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
1 change: 1 addition & 0 deletions .github/trigger-deploy
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions .github/trigger-test
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 67 additions & 0 deletions scripts/test-operator-wishes.js
Original file line number Diff line number Diff line change
@@ -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("&lt;script&gt;") && !html.includes("<script>alert"), "pre-escaped text passes through without double-encoding");
ok(!html.includes("&amp;lt;"), "no double-escaping of &lt; into &amp;lt;");

// --- privacy: gated + advertised as non-public, kept out of robots ---
ok(html.includes("AGENT402_OPERATOR_TOKEN"), "page states it is token-gated");
ok(/Not public/.test(html), "page is labelled not public");
const robots = robotsTxt(BASE);
ok(/^Disallow: \/__operator$/m.test(robots), "robots.txt disallows /__operator");

// --- empty state ---
const empty = operatorWishesPage(BASE, { distinctClusters: 0, totalWishes: 0, threshold: 5, qualifyMinSpanHours: 24, clusters: [] });
ok(/No wishes recorded yet/.test(empty), "empty board renders a friendly empty state");

console.log(`\n${pass} passed, ${fail} failed`);
if (fail) process.exit(1);
142 changes: 142 additions & 0 deletions src/operator-wishes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Token-gated /__operator/wishes dashboard — the full agent demand board,
// including single-source and below-threshold clusters the public never sees
// ranked. Same auth model and visual language as operator-leads.js:
// AGENT402_OPERATOR_TOKEN via Authorization: Bearer / X-Operator-Token header
// (preferred) or ?token= query (legacy, stripped from the URL on load).
//
// Why operator-only: the raw feed at /api/wishes is deliberately public (a
// demand beacon that pulls sellers in), and the paid demand-radar tool sells
// the analysis layer on top. This page is the OPERATOR's strategic read of the
// same data — every cluster, ranked, with the qualification verdict spelled
// out — so it lives behind the token, not on a public route.
//
// aggregate is the getWishesAggregate() output. Its `text` field is already
// esc()'d at the source, so it is inserted here without re-escaping (double-
// escaping would mangle the &amp; etc.). Every other value is numeric/ISO.
import { ledgerShell, ledgerFooterCompact, esc } from "./ledger-chrome.js";

const fmt = (iso) => String(iso || "").replace("T", " ").slice(0, 16) + "Z";

// A cluster's story at a glance: qualified (would auto-open an issue), or the
// reason it won't. Mirrors clusterQualifies in wish.js.
function verdict(c, threshold) {
if (c.qualified) return { label: "qualified", color: "#3E9B6E", note: "opens an issue" };
if (c.count < threshold) return { label: "below", color: "#8C8C8C", note: `needs ${threshold - c.count} more` };
const distinct = ["api", "mcp", "find-miss"].filter((s) => (c.sources?.[s] || 0) > 0).length;
if (distinct < 2) return { label: "single-source", color: "#c4a44e", note: "one surface, not corroborated" };
return { label: "held", color: "#c4a44e", note: "not yet sustained" };
}

export function operatorWishesPage(baseUrl, aggregate) {
const { distinctClusters = 0, totalWishes = 0, threshold = 5, qualifyMinSpanHours = 24, clusters = [] } = aggregate || {};
const atThreshold = clusters.filter((c) => c.count >= threshold);
const qualified = atThreshold.filter((c) => c.qualified);
const held = atThreshold.filter((c) => !c.qualified);

const summary = `<div class="ow-grid">
<div class="ow-stat"><div class="ow-k">Distinct clusters</div><div class="ow-v">${esc(distinctClusters)}</div></div>
<div class="ow-stat"><div class="ow-k">Total wishes</div><div class="ow-v">${esc(totalWishes)}</div></div>
<div class="ow-stat"><div class="ow-k">At threshold (${esc(threshold)})</div><div class="ow-v">${esc(atThreshold.length)}</div></div>
<div class="ow-stat"><div class="ow-k">Qualified</div><div class="ow-v" style="color:#3E9B6E">${esc(qualified.length)}</div></div>
<div class="ow-stat"><div class="ow-k">Held (spam/near-miss)</div><div class="ow-v" style="color:#c4a44e">${esc(held.length)}</div></div>
</div>`;

const rows = (clusters || []).map((c) => {
const v = verdict(c, threshold);
const s = c.sources || {};
const src = `${s.api || 0}/${s.mcp || 0}/${s["find-miss"] || 0}`;
return `<tr>
<td class="ow-mono ow-num">${esc(c.count)}</td>
<td><span class="ow-badge" style="color:${v.color};border-color:${v.color}55">${esc(v.label)}</span><div class="ow-note">${esc(v.note)}</div></td>
<td class="ow-mono ow-faint" title="api / mcp / find-miss">${esc(src)}</td>
<td class="ow-mono ow-small ow-faint">${esc(fmt(c.firstSeen))}<br>${esc(fmt(c.lastSeen))}</td>
<td class="ow-text">${c.text}</td>
</tr>`;
}).join("");

const table = clusters.length
? `<div class="ow-tbl-wrap"><table>
<thead><tr>
<th class="ow-num">Count</th><th>Verdict</th><th title="api / mcp / find-miss">a/m/f</th><th>First / last</th><th>Normalized request</th>
</tr></thead>
<tbody>${rows}</tbody>
</table></div>`
: `<p class="ow-empty">No wishes recorded yet. Demand lands here from POST /api/wish, MCP request_tool, and /api/find misses.</p>`;

const extraCss = `
.ow-wrap{max-width:1180px;margin:0 auto;padding:56px 30px}
.ow-h1{font-family:var(--font-body);font-weight:800;font-size:58px;line-height:.96;letter-spacing:-.03em;margin:0 0 6px}
.ow-sub{color:var(--muted);margin:0 0 22px;font-size:14px;line-height:1.55}
.ow-sub a{color:var(--accent);text-decoration:none}
.ow-sub a:hover{text-decoration:underline}
.ow-sub code{font-family:var(--font-mono);font-size:12px;background:var(--surface);color:var(--on-dark);padding:2px 7px;border:1.5px solid var(--ink)}
.ow-grid{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));margin:0 0 22px}
.ow-stat{background:var(--surface);border:1.5px solid var(--ink);padding:12px 16px}
.ow-stat .ow-k{color:var(--dk-muted);font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:.06em}
.ow-stat .ow-v{font-family:var(--font-mono);font-size:1.25rem;color:var(--on-dark);margin-top:2px}
.ow-tbl-wrap{background:var(--surface);border:1.5px solid var(--ink);overflow:hidden}
table{width:100%;border-collapse:collapse}
th{text-align:left;color:var(--dk-muted);font-weight:500;font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:.04em;padding:10px 14px;border-bottom:1px solid var(--dark-border);background:var(--ink-panel)}
td{padding:10px 14px;border-bottom:1px solid var(--dark-border);vertical-align:top;font-size:13px;color:var(--on-dark)}
tr:last-child td{border-bottom:0}
.ow-mono{font-family:var(--font-mono)}
.ow-num{text-align:right;width:64px}
.ow-small{font-size:12px}
.ow-faint{color:var(--dk-muted)}
.ow-badge{display:inline-block;border:1px solid var(--dark-border);padding:1px 10px;font-family:var(--font-mono);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
.ow-note{color:var(--dk-muted);font-size:11px;margin-top:3px}
.ow-text{max-width:520px;word-break:break-word}
.ow-empty{background:var(--card);border:1.5px solid var(--ink);padding:30px;text-align:center;color:var(--faint)}
@media(max-width:600px){.ow-h1{font-size:36px !important}}
`;

// Same operator auth script as the other __operator pages: capture ?token=
// into sessionStorage, strip it from the URL, and navigate between operator
// pages via fetch() so the token rides the Authorization header, never a URL.
const body = `
<div class="ow-wrap">
<h1 class="ow-h1">Agent demand</h1>
<p class="ow-sub">The full wish board, ranked — every cluster including single-source and below-threshold, which the public feed never shows ranked. A cluster auto-opens a GitHub issue only when <b>qualified</b> (count &ge; ${esc(threshold)} and either &ge;2 sources or sustained past ${esc(qualifyMinSpanHours)}h). Not public — gated by <code>AGENT402_OPERATOR_TOKEN</code>. <a href="/__operator" data-op-link>Back to operator</a></p>
${summary}
${table}
</div>
<script>
(function(){
var qs = new URLSearchParams(location.search);
if (qs.has('token')) {
try { sessionStorage.setItem('agent402-op-token', qs.get('token') || ''); } catch(_) {}
qs.delete('token');
var clean = location.pathname + (qs.toString() ? '?' + qs.toString() : '');
history.replaceState({}, document.title, clean);
}
var TOKEN = '';
try { TOKEN = sessionStorage.getItem('agent402-op-token') || ''; } catch(_) {}
document.querySelectorAll('a[data-op-link]').forEach(function(a){
a.addEventListener('click', function(e){
e.preventDefault();
fetch(a.getAttribute('href'), {
headers: TOKEN ? { 'Authorization': 'Bearer ' + TOKEN } : {},
cache: 'no-store',
})
.then(function(r){ return r.text(); })
.then(function(t){
document.open(); document.write(t); document.close();
history.pushState({}, '', a.getAttribute('href'));
})
.catch(function(){});
});
});
})();
</script>
${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,
});
}
2 changes: 1 addition & 1 deletion src/operator.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ td a:hover{color:var(--accent)}
<div class="op-wrap">

<h1 class="op-h1">Operator dashboard</h1>
<p class="op-sub">Per-tool usage, settlement split, and live activity. Auto-refreshes every 10s. Not public — gated by <code>AGENT402_OPERATOR_TOKEN</code>. <a href="/__operator/leads" data-op-link>Tollbooth leads</a></p>
<p class="op-sub">Per-tool usage, settlement split, and live activity. Auto-refreshes every 10s. Not public — gated by <code>AGENT402_OPERATOR_TOKEN</code>. <a href="/__operator/wishes" data-op-link>Agent demand</a> · <a href="/__operator/leads" data-op-link>Tollbooth leads</a></p>

<div class="op-grid">
<div class="op-stat"><div class="op-k">Total calls</div><div class="op-v" id="t-total">${esc(t.total ?? 0)}</div><div class="op-s">all tools, all rails</div></div>
Expand Down
5 changes: 4 additions & 1 deletion src/seo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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("<p>Not found.</p>");
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("<p>Not found.</p>");
const list = await listLeads({ limit: 200 });
Expand Down