@dupecom/botcha-cloudflare 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +74 -9
  2. package/dist/agent-auth.d.ts +129 -0
  3. package/dist/agent-auth.d.ts.map +1 -0
  4. package/dist/agent-auth.js +210 -0
  5. package/dist/agents.d.ts +10 -0
  6. package/dist/agents.d.ts.map +1 -1
  7. package/dist/agents.js +51 -1
  8. package/dist/app-gate.d.ts +6 -0
  9. package/dist/app-gate.d.ts.map +1 -0
  10. package/dist/app-gate.js +69 -0
  11. package/dist/apps.d.ts +9 -0
  12. package/dist/apps.d.ts.map +1 -1
  13. package/dist/apps.js +26 -0
  14. package/dist/dashboard/account.d.ts +63 -0
  15. package/dist/dashboard/account.d.ts.map +1 -0
  16. package/dist/dashboard/account.js +488 -0
  17. package/dist/dashboard/api.js +15 -68
  18. package/dist/dashboard/auth.d.ts.map +1 -1
  19. package/dist/dashboard/auth.js +14 -14
  20. package/dist/dashboard/docs.d.ts.map +1 -1
  21. package/dist/dashboard/docs.js +146 -3
  22. package/dist/dashboard/layout.d.ts.map +1 -1
  23. package/dist/dashboard/layout.js +2 -2
  24. package/dist/dashboard/mcp-setup.d.ts +15 -0
  25. package/dist/dashboard/mcp-setup.d.ts.map +1 -0
  26. package/dist/dashboard/mcp-setup.js +391 -0
  27. package/dist/dashboard/showcase.d.ts +6 -10
  28. package/dist/dashboard/showcase.d.ts.map +1 -1
  29. package/dist/dashboard/showcase.js +67 -991
  30. package/dist/dashboard/whitepaper.d.ts.map +1 -1
  31. package/dist/dashboard/whitepaper.js +42 -4
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +452 -52
  35. package/dist/mcp.d.ts +20 -0
  36. package/dist/mcp.d.ts.map +1 -0
  37. package/dist/mcp.js +1290 -0
  38. package/dist/oauth-agent.d.ts +130 -0
  39. package/dist/oauth-agent.d.ts.map +1 -0
  40. package/dist/oauth-agent.js +194 -0
  41. package/dist/static.d.ts +732 -1
  42. package/dist/static.d.ts.map +1 -1
  43. package/dist/static.js +646 -2
  44. package/dist/tap-a2a-routes.d.ts +355 -0
  45. package/dist/tap-a2a-routes.d.ts.map +1 -0
  46. package/dist/tap-a2a-routes.js +475 -0
  47. package/dist/tap-a2a.d.ts +199 -0
  48. package/dist/tap-a2a.d.ts.map +1 -0
  49. package/dist/tap-a2a.js +502 -0
  50. package/dist/tap-agents.d.ts +15 -0
  51. package/dist/tap-agents.d.ts.map +1 -1
  52. package/dist/tap-agents.js +31 -1
  53. package/dist/tap-ans-routes.d.ts +302 -0
  54. package/dist/tap-ans-routes.d.ts.map +1 -0
  55. package/dist/tap-ans-routes.js +535 -0
  56. package/dist/tap-ans.d.ts +241 -0
  57. package/dist/tap-ans.d.ts.map +1 -0
  58. package/dist/tap-ans.js +481 -0
  59. package/dist/tap-delegation-routes.d.ts.map +1 -1
  60. package/dist/tap-delegation-routes.js +11 -0
  61. package/dist/tap-did.d.ts +140 -0
  62. package/dist/tap-did.d.ts.map +1 -0
  63. package/dist/tap-did.js +262 -0
  64. package/dist/tap-oidca-routes.d.ts +383 -0
  65. package/dist/tap-oidca-routes.d.ts.map +1 -0
  66. package/dist/tap-oidca-routes.js +597 -0
  67. package/dist/tap-oidca.d.ts +288 -0
  68. package/dist/tap-oidca.d.ts.map +1 -0
  69. package/dist/tap-oidca.js +461 -0
  70. package/dist/tap-routes.d.ts +24 -8
  71. package/dist/tap-routes.d.ts.map +1 -1
  72. package/dist/tap-routes.js +169 -23
  73. package/dist/tap-vc-routes.d.ts +358 -0
  74. package/dist/tap-vc-routes.d.ts.map +1 -0
  75. package/dist/tap-vc-routes.js +367 -0
  76. package/dist/tap-vc.d.ts +125 -0
  77. package/dist/tap-vc.d.ts.map +1 -0
  78. package/dist/tap-vc.js +245 -0
  79. package/dist/tap-x402-routes.d.ts +89 -0
  80. package/dist/tap-x402-routes.d.ts.map +1 -0
  81. package/dist/tap-x402-routes.js +579 -0
  82. package/dist/tap-x402.d.ts +222 -0
  83. package/dist/tap-x402.d.ts.map +1 -0
  84. package/dist/tap-x402.js +546 -0
  85. package/dist/webhooks.d.ts +99 -0
  86. package/dist/webhooks.d.ts.map +1 -0
  87. package/dist/webhooks.js +642 -0
  88. package/package.json +3 -1
package/dist/apps.js CHANGED
@@ -10,6 +10,21 @@
10
10
  * - Email→app_id reverse index for recovery lookups
11
11
  * - Secret rotation with email notification
12
12
  */
13
+ // ============ ERRORS ============
14
+ /**
15
+ * Thrown when attempting to register an app with an email that's already in use.
16
+ * Callers should return a 409 Conflict with recovery instructions.
17
+ */
18
+ export class EmailAlreadyRegisteredError extends Error {
19
+ email;
20
+ existing_app_id;
21
+ constructor(email, existing_app_id) {
22
+ super(`Email ${email} is already registered to app ${existing_app_id}`);
23
+ this.name = 'EmailAlreadyRegisteredError';
24
+ this.email = email;
25
+ this.existing_app_id = existing_app_id;
26
+ }
27
+ }
13
28
  // ============ CRYPTO UTILITIES ============
14
29
  /**
15
30
  * Generate a crypto-random app ID
@@ -83,6 +98,17 @@ export function generateVerificationCode() {
83
98
  * @returns {app_id, name, app_secret, email, email_verified, verification_required}
84
99
  */
85
100
  export async function createApp(kv, email, name) {
101
+ // Unique email constraint: one app per email
102
+ const existingAppId = await kv.get(`email:${email.toLowerCase()}`, 'text');
103
+ if (existingAppId) {
104
+ // Verify the app still exists (not an orphaned index)
105
+ const existingApp = await kv.get(`app:${existingAppId}`, 'text');
106
+ if (existingApp) {
107
+ throw new EmailAlreadyRegisteredError(email, existingAppId);
108
+ }
109
+ // Orphaned index — clean it up and proceed with creation
110
+ await kv.delete(`email:${email.toLowerCase()}`);
111
+ }
86
112
  const app_id = generateAppId();
87
113
  const app_secret = generateAppSecret();
88
114
  const secret_hash = await hashSecret(app_secret);
@@ -0,0 +1,63 @@
1
+ /**
2
+ * BOTCHA Account Page
3
+ *
4
+ * Accessible at GET /account (dashboard session required).
5
+ * Content-negotiated:
6
+ * Accept: application/json → structured JSON (for agents)
7
+ * Accept: text/html → rendered HTML page (for humans)
8
+ *
9
+ * Shows:
10
+ * - App info (app_id, email, created_at, rate_limit, email_verified)
11
+ * - Agent list with per-agent reputation score + TAP status
12
+ * - Links to dashboard, docs, magic link re-generation
13
+ */
14
+ import type { Context } from 'hono';
15
+ type Bindings = {
16
+ APPS: import('../challenges').KVNamespace;
17
+ AGENTS: import('../challenges').KVNamespace;
18
+ JWT_SECRET: string;
19
+ BOTCHA_VERSION: string;
20
+ };
21
+ type Variables = {
22
+ dashboardAppId?: string;
23
+ };
24
+ export declare function handleAccountJson(c: Context<{
25
+ Bindings: Bindings;
26
+ Variables: Variables;
27
+ }>): Promise<Response & import("hono").TypedResponse<{
28
+ success: true;
29
+ app: {
30
+ app_id: string;
31
+ email: string | null;
32
+ email_verified: boolean;
33
+ created_at: number | null;
34
+ rate_limit: number | null;
35
+ };
36
+ agents: {
37
+ agent_id: any;
38
+ name: any;
39
+ operator: any;
40
+ created_at: any;
41
+ tap_enabled: boolean;
42
+ provider: any;
43
+ oauth_authorized: boolean;
44
+ reputation: {
45
+ score: number;
46
+ tier: import("../tap-reputation").ReputationTier;
47
+ event_count: number;
48
+ } | null;
49
+ }[];
50
+ links: {
51
+ account: string;
52
+ dashboard: string;
53
+ docs: string;
54
+ openapi: string;
55
+ ai_txt: string;
56
+ };
57
+ }, import("hono/utils/http-status").ContentfulStatusCode, "json">>;
58
+ export declare function handleAccountPage(c: Context<{
59
+ Bindings: Bindings;
60
+ Variables: Variables;
61
+ }>): Promise<Response>;
62
+ export {};
63
+ //# sourceMappingURL=account.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account.d.ts","sourceRoot":"","sources":["../../src/dashboard/account.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAIpC,KAAK,QAAQ,GAAG;IACd,IAAI,EAAE,OAAO,eAAe,EAAE,WAAW,CAAC;IAC1C,MAAM,EAAE,OAAO,eAAe,EAAE,WAAW,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,KAAK,SAAS,GAAG;IACf,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAqDF,wBAAsB,iBAAiB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mEAqB/F;AA0BD,wBAAsB,iBAAiB,CAAC,CAAC,EAAE,OAAO,CAAC;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC,qBA6Y/F"}
@@ -0,0 +1,488 @@
1
+ /**
2
+ * BOTCHA Account Page
3
+ *
4
+ * Accessible at GET /account (dashboard session required).
5
+ * Content-negotiated:
6
+ * Accept: application/json → structured JSON (for agents)
7
+ * Accept: text/html → rendered HTML page (for humans)
8
+ *
9
+ * Shows:
10
+ * - App info (app_id, email, created_at, rate_limit, email_verified)
11
+ * - Agent list with per-agent reputation score + TAP status
12
+ * - Links to dashboard, docs, magic link re-generation
13
+ */
14
+ import { DASHBOARD_CSS } from './styles';
15
+ // ============ DATA FETCHING ============
16
+ async function fetchAccountData(c) {
17
+ const appId = c.get('dashboardAppId');
18
+ const env = c.env;
19
+ // BOTCHA_BASE_URL is set in .dev.vars for local dev (http://localhost:8787)
20
+ // and in wrangler.toml [vars] for production (https://botcha.ai).
21
+ const baseUrl = c.env.BOTCHA_BASE_URL ?? new URL(c.req.url).origin;
22
+ // Parallel fetches
23
+ const [appRaw, agentsRaw] = await Promise.allSettled([
24
+ import('../apps').then(m => m.getApp(env.APPS, appId)),
25
+ import('../agents').then(m => m.listAgents(env.AGENTS, appId)),
26
+ ]);
27
+ const app = appRaw.status === 'fulfilled' ? appRaw.value : null;
28
+ const agents = agentsRaw.status === 'fulfilled' ? (agentsRaw.value ?? []) : [];
29
+ // Fetch reputation for each agent (parallel, fail-open)
30
+ // getReputationScore requires (sessions, agents, agentId, appId)
31
+ // It only works for TAP-registered agents; returns { success, score? }
32
+ const reputations = await Promise.allSettled(agents.map((agent) => import('../tap-reputation').then(m => m.getReputationScore(env.AGENTS, env.AGENTS, agent.agent_id, appId))));
33
+ const agentsWithRep = agents.map((agent, i) => {
34
+ const result = reputations[i].status === 'fulfilled' ? reputations[i].value : null;
35
+ const rep = result?.success && result?.score ? result.score : null;
36
+ return {
37
+ agent_id: agent.agent_id,
38
+ name: agent.name,
39
+ operator: agent.operator ?? null,
40
+ created_at: agent.created_at,
41
+ tap_enabled: Boolean(agent.tap_enabled),
42
+ provider: agent.provider ?? null,
43
+ oauth_authorized: Boolean(agent.oauth_authorized_at),
44
+ reputation: rep
45
+ ? { score: rep.score, tier: rep.tier, event_count: rep.event_count }
46
+ : null,
47
+ };
48
+ });
49
+ return { appId, app, agents: agentsWithRep, baseUrl };
50
+ }
51
+ // ============ JSON HANDLER (agents) ============
52
+ export async function handleAccountJson(c) {
53
+ const { appId, app, agents, baseUrl } = await fetchAccountData(c);
54
+ return c.json({
55
+ success: true,
56
+ app: {
57
+ app_id: appId,
58
+ email: app?.email ?? null,
59
+ email_verified: app?.email_verified ?? false,
60
+ created_at: app?.created_at ?? null,
61
+ rate_limit: app?.rate_limit ?? null,
62
+ },
63
+ agents,
64
+ links: {
65
+ account: `${baseUrl}/account`,
66
+ dashboard: `${baseUrl}/dashboard`,
67
+ docs: `${baseUrl}/docs`,
68
+ openapi: `${baseUrl}/openapi.json`,
69
+ ai_txt: `${baseUrl}/ai.txt`,
70
+ },
71
+ });
72
+ }
73
+ // ============ HTML PAGE (humans) ============
74
+ function reputationBar(score) {
75
+ const s = score ?? 500;
76
+ // score 0-1000, bar 0-100%
77
+ const pct = Math.round(s / 10);
78
+ const color = s >= 700 ? '#22c55e' : s >= 400 ? '#f59e0b' : '#ef4444';
79
+ return `<div style="background:#e5e7eb;border-radius:4px;height:6px;width:100%;margin-top:4px;">
80
+ <div style="background:${color};height:6px;border-radius:4px;width:${pct}%;transition:width 0.3s;"></div>
81
+ </div>`;
82
+ }
83
+ function tierBadge(tier) {
84
+ const colors = {
85
+ trusted: '#22c55e',
86
+ good: '#84cc16',
87
+ neutral: '#f59e0b',
88
+ caution: '#f97316',
89
+ restricted: '#ef4444',
90
+ };
91
+ const c = colors[tier] ?? '#9ca3af';
92
+ return `<span style="background:${c}22;color:${c};border:1px solid ${c}66;border-radius:3px;padding:1px 6px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;">${tier}</span>`;
93
+ }
94
+ export async function handleAccountPage(c) {
95
+ const { appId, app, agents, baseUrl } = await fetchAccountData(c);
96
+ const version = c.env.BOTCHA_VERSION ?? '0.15.0';
97
+ const agentRows = agents.length === 0
98
+ ? `<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:32px 24px;">No agents yet. Click <strong style="color:#6b7280;">+ Add Agent</strong> above to get a prompt you can paste into your AI agent.</td></tr>`
99
+ : agents.map(a => `
100
+ <tr id="row-${a.agent_id}">
101
+ <td style="font-family:monospace;font-size:12px;color:#6b7280;">${a.agent_id}</td>
102
+ <td>${a.name ?? '—'}</td>
103
+ <td>${a.operator ?? '—'}</td>
104
+ <td>
105
+ ${a.reputation
106
+ ? `${reputationBar(a.reputation.score)}<span style="font-size:11px;color:#6b7280;">${a.reputation.score} &middot; ${tierBadge(a.reputation.tier)} &middot; ${a.reputation.event_count} events</span>`
107
+ : '<span style="color:#9ca3af;font-size:12px;">no data</span>'}
108
+ </td>
109
+ <td style="text-align:center;">
110
+ <button class="btn-action" onclick="toggleReidentify('${a.agent_id}')" style="color:${a.oauth_authorized ? '#22c55e' : a.tap_enabled ? '#22c55e' : '#d1d5db'};" title="Click for re-identification instructions">
111
+ ${a.oauth_authorized ? '✓ OAuth' : a.tap_enabled ? '✓ TAP' + (a.provider ? ' · ' + a.provider : '') : '— no auth'}
112
+ </button>
113
+ </td>
114
+ <td style="text-align:right;white-space:nowrap;">
115
+ ${a.tap_enabled
116
+ ? `<button class="btn-action warn" onclick="toggleRotateKey('${a.agent_id}')" title="Lost your private key? Rotate to a new keypair using your app_secret">↺ rotate</button>`
117
+ : ''}
118
+ <button class="btn-action danger" onclick="deleteAgent('${a.agent_id}')" title="Delete agent">✕</button>
119
+ </td>
120
+ </tr>
121
+ ${a.tap_enabled ? `
122
+ <tr id="reidentify-${a.agent_id}" style="display:none;">
123
+ <td colspan="6" style="padding:0 12px 16px 12px;background:#f9fafb;border-bottom:1px solid var(--border);">
124
+ <div style="font-size:12px;color:#374151;line-height:1.7;padding-top:12px;">
125
+ <strong style="font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:#6b7280;">How to re-identify in a new session</strong>
126
+ ${a.oauth_authorized ? `
127
+ <p style="margin:8px 0 4px;">This agent is OAuth-authorized. At the start of a new conversation, tell your agent:</p>
128
+ <div style="background:#fff;border:1px solid var(--border);border-radius:2px;padding:10px 12px;font-family:monospace;font-size:12px;color:#374151;margin-bottom:8px;">
129
+ You are agent <strong>${a.agent_id}</strong> on my BOTCHA account (${baseUrl}). Your refresh token is <strong>&lt;paste brt_... token&gt;</strong>. Re-identify yourself before doing anything else.
130
+ </div>
131
+ <p style="margin:4px 0 0;color:#6b7280;font-size:12px;">The agent calls <code style="font-size:11px;">POST /v1/agents/auth/refresh</code> — no challenge, no signing, instant.</p>
132
+ <button onclick="revokeOAuth('${a.agent_id}')" class="btn-action danger" style="margin-top:8px;">Revoke OAuth</button>
133
+ ` : a.provider ? `
134
+ <p style="margin:8px 0 4px;">This agent is bound to your <strong>${a.provider}</strong> API key. At the start of a new conversation, just tell your agent:</p>
135
+ <div style="background:#fff;border:1px solid var(--border);border-radius:2px;padding:10px 12px;font-family:monospace;font-size:12px;color:#374151;margin-bottom:8px;">
136
+ You are agent <strong>${a.agent_id}</strong> on my BOTCHA account (${baseUrl}). App ID is ${appId}. Re-identify yourself before doing anything else.
137
+ </div>
138
+ <p style="margin:4px 0 0;color:#6b7280;font-size:12px;">The agent uses its <code style="font-size:11px;">${a.provider.toUpperCase()}_API_KEY</code> environment variable — no extra secret needed. It calls <code style="font-size:11px;">POST /v1/agents/auth/provider</code> automatically.</p>
139
+ ` : a.oauth_authorized ? '' : `
140
+ <p style="margin:8px 0 4px;">At the start of a new conversation, tell your agent:</p>
141
+ <div style="background:#fff;border:1px solid var(--border);border-radius:2px;padding:10px 12px;font-family:monospace;font-size:12px;color:#374151;margin-bottom:8px;">
142
+ You are agent <strong>${a.agent_id}</strong> on my BOTCHA account (${baseUrl}). Your TAP private key is <strong>&lt;paste tapk_... key&gt;</strong>. Re-identify yourself before doing anything else.
143
+ </div>
144
+ <p style="margin:4px 0 4px;font-size:11px;color:#d97706;"><strong>Tip:</strong> Bind your provider API key to skip the tapk_ secret entirely — use the ↺ rotate button to register a provider binding.</p>
145
+ `}
146
+ <p style="margin:8px 0 0;color:#9ca3af;font-size:11px;">The identity JWT contains your agent_id claim — proving this is the same agent, not a fresh anonymous session.</p>
147
+ </div>
148
+ </td>
149
+ </tr>
150
+ <tr id="rotatekey-${a.agent_id}" style="display:none;">
151
+ <td colspan="6" style="padding:0 12px 16px 12px;background:#fffbeb;border-bottom:1px solid #fde68a;">
152
+ <div style="font-size:12px;color:#374151;line-height:1.7;padding-top:12px;">
153
+ <strong style="font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:#92400e;">Lost your private key? Rotate to a new keypair</strong>
154
+ <p style="margin:8px 0 4px;color:#6b7280;">Your <code style="font-size:11px;">app_secret</code> is the recovery anchor. As long as you have it, you can issue a new keypair to this agent without losing its <code style="font-size:11px;">agent_id</code> or reputation. Tell your agent:</p>
155
+ <div style="background:#fff;border:1px solid #fde68a;border-radius:2px;padding:10px 12px;font-family:monospace;font-size:12px;color:#374151;margin-bottom:8px;">
156
+ My BOTCHA agent ${a.agent_id} lost its private key. Rotate its keypair using app_secret &lt;paste app_secret&gt; for app ${appId} at ${baseUrl}. Generate a new Ed25519 keypair, call POST ${baseUrl}/v1/agents/${a.agent_id}/tap/rotate-key?app_id=${appId} with header x-app-secret: &lt;app_secret&gt; and body {"public_key":"&lt;raw 32-byte base64&gt;","signature_algorithm":"ed25519"}, then give me the new private key to save.
157
+ </div>
158
+ <p style="margin:4px 0 0;color:#9ca3af;font-size:11px;">The agent generates a new keypair locally, registers the new public key, and returns the new private key for you to store. The old key is invalidated immediately.</p>
159
+ </div>
160
+ </td>
161
+ </tr>` : ''}
162
+ `).join('');
163
+ const html = `<!DOCTYPE html>
164
+ <html lang="en">
165
+ <head>
166
+ <meta charset="utf-8" />
167
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
168
+ <title>Account — BOTCHA</title>
169
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
170
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
171
+ <style>${DASHBOARD_CSS}
172
+ .account-grid { display:grid; grid-template-columns:1fr 1fr; gap:20px; margin-bottom:24px; }
173
+ @media(max-width:640px){ .account-grid{ grid-template-columns:1fr; } }
174
+ .kv-row { display:flex; justify-content:space-between; align-items:center; padding:8px 0; border-bottom:1px solid #f3f4f6; font-size:13px; }
175
+ .kv-row:last-child { border-bottom:none; }
176
+ .kv-label { color:#6b7280; font-weight:500; }
177
+ .kv-value { font-family:monospace; color:#111827; word-break:break-all; text-align:right; max-width:60%; }
178
+ .agents-table { width:100%; border-collapse:collapse; font-size:13px; }
179
+ .agents-table th { text-align:left; padding:8px 12px; background:#f9fafb; border-bottom:2px solid #e5e7eb; font-weight:600; color:#374151; font-size:11px; text-transform:uppercase; letter-spacing:0.05em; }
180
+ .agents-table td { padding:12px; border-bottom:1px solid #f3f4f6; vertical-align:top; }
181
+ .agents-table tr:last-child td { border-bottom:none; }
182
+ .btn-action { background:none; border:none; color:#9ca3af; font-size:11px; font-weight:500; cursor:pointer; padding:3px 7px; border-radius:3px; font-family:var(--font); letter-spacing:0.03em; transition:color 0.15s,background 0.15s; white-space:nowrap; }
183
+ .btn-action:hover { color:#374151; background:#f3f4f6; }
184
+ .btn-action.danger:hover { color:#ef4444; background:#fef2f2; }
185
+ .btn-action.warn:hover { color:#d97706; background:#fffbeb; }
186
+ .verified-badge { color:#22c55e; font-size:11px; }
187
+ .unverified-badge { color:#f59e0b; font-size:11px; }
188
+ </style>
189
+ </head>
190
+ <body>
191
+ <nav class="dashboard-nav">
192
+ <div class="nav-container">
193
+ <a href="/dashboard" class="nav-logo">BOTCHA</a>
194
+ <span class="nav-app-id">${appId}</span>
195
+ <a href="/account" class="nav-link" style="font-weight:600;">Account</a>
196
+ <a href="/dashboard" class="nav-link">Analytics</a>
197
+ <a href="/dashboard/logout" class="nav-link">Logout</a>
198
+ </div>
199
+ </nav>
200
+ <main class="dashboard-main">
201
+ <div style="max-width:900px;margin:0 auto;padding:32px 16px;">
202
+ <h1 style="font-size:20px;font-weight:700;margin:0 0 4px;">Account</h1>
203
+ <p style="color:#6b7280;font-size:13px;margin:0 0 28px;">App details, registered agents, and reputation scores.</p>
204
+
205
+ <div class="account-grid">
206
+ <!-- App Info -->
207
+ <div class="card">
208
+ <div class="card-header"><h3><span class="card-title">App</span></h3></div>
209
+ <div class="card-body"><div class="card-inner">
210
+ <div class="kv-row">
211
+ <span class="kv-label">app_id</span>
212
+ <span class="kv-value">${appId}</span>
213
+ </div>
214
+ <div class="kv-row">
215
+ <span class="kv-label">email</span>
216
+ <span class="kv-value">${app?.email ?? '—'}
217
+ ${app?.email_verified
218
+ ? '<span class="verified-badge"> ✓ verified</span>'
219
+ : '<span class="unverified-badge"> ⚠ unverified</span>'}
220
+ </span>
221
+ </div>
222
+ <div class="kv-row">
223
+ <span class="kv-label">created</span>
224
+ <span class="kv-value">${app?.created_at ? new Date(app.created_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : '—'}</span>
225
+ </div>
226
+ <div class="kv-row">
227
+ <span class="kv-label">rate limit</span>
228
+ <span class="kv-value">${app?.rate_limit ?? '—'} req/min</span>
229
+ </div>
230
+ <div class="kv-row" style="align-items:flex-start;flex-direction:column;gap:6px;padding-top:12px;border-top:1px solid #f3f4f6;margin-top:4px;">
231
+ <span class="kv-label">app_secret</span>
232
+ <span style="font-size:12px;color:#6b7280;line-height:1.6;">
233
+ Your <code style="font-size:11px;">app_secret</code> was shown once when you created this app.
234
+ It's the master credential for your account — store it in a password manager.<br>
235
+ <strong style="color:#374151;">Lost it?</strong>
236
+ ${app?.email_verified
237
+ ? `Send a recovery code to <strong>${app?.email}</strong>:`
238
+ : 'Verify your email first, then you can recover via email.'}
239
+ </span>
240
+ ${app?.email_verified ? `
241
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
242
+ <button class="btn-action" onclick="recoverSecret(this)" style="border:1px solid var(--border);">
243
+ Email recovery code →
244
+ </button>
245
+ <span id="recover-status" style="font-size:11px;color:#6b7280;"></span>
246
+ </div>
247
+ <div id="recover-form" style="display:none;margin-top:6px;width:100%;">
248
+ <p style="font-size:12px;color:#6b7280;margin:0 0 6px;">Enter the 6-digit code from your email:</p>
249
+ <div style="display:flex;gap:6px;align-items:center;">
250
+ <input id="recover-code" type="text" maxlength="6" placeholder="123456"
251
+ style="font-family:var(--font);font-size:13px;padding:4px 8px;border:1px solid var(--border);border-radius:3px;width:90px;background:var(--bg);" />
252
+ <button class="btn-action" onclick="submitRecovery()" style="border:1px solid var(--border);">Rotate secret</button>
253
+ </div>
254
+ <p id="recover-result" style="font-size:12px;margin:8px 0 0;word-break:break-all;"></p>
255
+ </div>` : ''}
256
+ </div>
257
+ </div></div>
258
+ </div>
259
+
260
+ <!-- Quick Links -->
261
+ <div class="card">
262
+ <div class="card-header"><h3><span class="card-title">Links</span></h3></div>
263
+ <div class="card-body"><div class="card-inner">
264
+ <div class="kv-row">
265
+ <span class="kv-label">Analytics</span>
266
+ <span class="kv-value"><a href="/dashboard">Dashboard →</a></span>
267
+ </div>
268
+ <div class="kv-row">
269
+ <span class="kv-label">API Docs</span>
270
+ <span class="kv-value"><a href="/docs">Docs →</a></span>
271
+ </div>
272
+ <div class="kv-row">
273
+ <span class="kv-label">OpenAPI</span>
274
+ <span class="kv-value"><a href="/openapi.json">openapi.json →</a></span>
275
+ </div>
276
+ <div class="kv-row">
277
+ <span class="kv-label">Agent discovery</span>
278
+ <span class="kv-value"><a href="/ai.txt">ai.txt →</a></span>
279
+ </div>
280
+ <div class="kv-row">
281
+ <span class="kv-label">Whitepaper</span>
282
+ <span class="kv-value"><a href="/whitepaper">TAP whitepaper →</a></span>
283
+ </div>
284
+ </div></div>
285
+ </div>
286
+ </div>
287
+
288
+ <!-- Agents Table -->
289
+ <div class="card">
290
+ <div class="card-header">
291
+ <h3 style="display:flex;align-items:center;justify-content:space-between;width:100%;">
292
+ <span>
293
+ <span class="card-title">Agents</span>
294
+ <span class="badge-inline section-badge">${agents.length} registered</span>
295
+ </span>
296
+ <button
297
+ onclick="toggleAddAgent()"
298
+ class="btn-action"
299
+ style="border:1px solid var(--border);color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;"
300
+ >+ Add Agent</button>
301
+ </h3>
302
+ </div>
303
+
304
+ <!-- Add Agent prompt panel (hidden by default) -->
305
+ <div id="add-agent-panel" style="display:none;border-bottom:1px solid var(--border);">
306
+ <div style="padding:16px 20px;">
307
+
308
+ <!-- Hidden full prompt (copied to clipboard) -->
309
+ <span id="agent-prompt" style="display:none;">Register as a BOTCHA agent. Base URL: ${baseUrl}. App ID: ${appId}. Do all steps automatically: 1) GET ${baseUrl}/v1/token?app_id=${appId} — solve the SHA256 speed challenge in-process, then POST ${baseUrl}/v1/token/verify with {"id":"<challenge_id>","answers":["<hash1>",...],"app_id":"${appId}"}. Save access_token as your Bearer token. 2) POST ${baseUrl}/v1/agents/register — Authorization: Bearer <access_token>, body {"name":"<your model name>","operator":"<human name or org>"}. Save agent_id. 3) Generate an Ed25519 keypair. Export public key as raw 32 bytes base64 (strip 12-byte SPKI header). 4) POST ${baseUrl}/v1/agents/register/tap?app_id=${appId} — Authorization: Bearer <access_token>, body {"agent_id":"<from step 2>","name":"<your model name>","public_key":"<raw 32-byte base64>","signature_algorithm":"ed25519","capabilities":[{"action":"browse"},{"action":"search"}]}. 5) Tell the human: agent_id, public key fingerprint, and TAP private key — prefix it with tapk_ so it looks like tapk_ABC123...== — they must save this, it is the only way to re-identify you. BOTCHA never stores it. IMPORTANT: the TAP private key (tapk_...) is NOT the same as the app_secret (sk_...). Label them clearly.</span>
310
+
311
+ <!-- Human-readable breakdown -->
312
+ <div style="font-size:12px;color:#6b7280;margin-bottom:12px;">
313
+ Copy this prompt and paste it into your AI agent — it will handle all steps automatically.
314
+ </div>
315
+
316
+ <div style="background:var(--bg-raised);border:1px solid var(--border);border-radius:2px;padding:14px 16px;">
317
+ <ol style="margin:0;padding:0 0 0 18px;font-size:12px;color:#374151;line-height:1.8;font-family:var(--font);">
318
+ <li><strong>Solve speed challenge</strong> — <code style="font-size:11px;">GET /v1/token?app_id=${appId}</code>, compute SHA256 answers in-process, verify with <code style="font-size:11px;">POST /v1/token/verify</code> → <code style="font-size:11px;">access_token</code></li>
319
+ <li><strong>Register identity</strong> — <code style="font-size:11px;">POST /v1/agents/register</code> with Bearer token → <code style="font-size:11px;">agent_id</code></li>
320
+ <li><strong>Generate Ed25519 keypair</strong> — raw 32-byte public key as base64</li>
321
+ <li><strong>Register keypair</strong> — <code style="font-size:11px;">POST /v1/agents/register/tap</code> with <code style="font-size:11px;">agent_id</code> + public key</li>
322
+ <li><strong>Report back</strong> — agent_id, key fingerprint, and private key for you to save</li>
323
+ </ol>
324
+ <div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);">
325
+ <button onclick="copyAgentPrompt()" type="button"
326
+ style="display:inline-flex;align-items:center;gap:6px;font-family:var(--font);font-size:11px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:0;text-transform:uppercase;letter-spacing:0.1em;"
327
+ onmouseover="this.style.color='#111827'" onmouseout="this.style.color='var(--text-muted)'">
328
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square"><rect x="9" y="9" width="13" height="13"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
329
+ <span id="agent-copy-text">Copy prompt</span>
330
+ </button>
331
+ </div>
332
+ </div>
333
+
334
+ </div>
335
+ </div>
336
+
337
+ <div class="card-body"><div class="card-inner" style="padding:0;overflow-x:auto;">
338
+ <table class="agents-table">
339
+ <thead>
340
+ <tr>
341
+ <th>Agent ID</th>
342
+ <th>Name</th>
343
+ <th>Operator</th>
344
+ <th>Reputation</th>
345
+ <th style="text-align:center;">TAP</th>
346
+ <th></th>
347
+ </tr>
348
+ </thead>
349
+ <tbody>${agentRows}</tbody>
350
+ </table>
351
+ </div></div>
352
+ </div>
353
+
354
+ <script>
355
+ var _newSecret = '';
356
+ function copySecret() {
357
+ navigator.clipboard.writeText(_newSecret).then(function() {
358
+ var btn = document.getElementById('copy-secret-btn');
359
+ if (btn) { btn.textContent = 'Copied!'; setTimeout(function(){ btn.textContent = 'Copy'; }, 2500); }
360
+ });
361
+ }
362
+ async function recoverSecret(btn) {
363
+ var status = document.getElementById('recover-status');
364
+ btn.disabled = true;
365
+ status.textContent = 'Sending…';
366
+ try {
367
+ await fetch('/v1/auth/recover', {
368
+ method: 'POST',
369
+ headers: {'Content-Type':'application/json'},
370
+ body: JSON.stringify({email: '${app?.email ?? ''}'})
371
+ });
372
+ status.textContent = 'Code sent — check your email.';
373
+ document.getElementById('recover-form').style.display = 'block';
374
+ } catch(e) {
375
+ status.textContent = 'Failed to send. Try again.';
376
+ btn.disabled = false;
377
+ }
378
+ }
379
+ async function submitRecovery() {
380
+ var code = document.getElementById('recover-code').value.trim();
381
+ var result = document.getElementById('recover-result');
382
+ if (!code) { result.textContent = 'Enter the 6-digit code.'; return; }
383
+ result.textContent = 'Verifying…';
384
+ try {
385
+ const loginRes = await fetch('/dashboard/code', {
386
+ method: 'POST',
387
+ headers: {'Content-Type':'application/json'},
388
+ body: JSON.stringify({code})
389
+ });
390
+ if (!loginRes.ok) {
391
+ const d = await loginRes.json();
392
+ result.textContent = 'Invalid code: ' + (d.message || d.error || loginRes.status);
393
+ return;
394
+ }
395
+ result.textContent = 'Rotating secret…';
396
+ const rotateRes = await fetch('/v1/apps/${appId}/rotate-secret', {
397
+ method: 'POST',
398
+ headers: {'Content-Type':'application/json'},
399
+ body: JSON.stringify({app_id: '${appId}'})
400
+ });
401
+ const rotateData = await rotateRes.json();
402
+ if (rotateRes.ok && rotateData.app_secret) {
403
+ _newSecret = rotateData.app_secret;
404
+ result.innerHTML =
405
+ '<strong style="color:#22c55e;">New app_secret issued.</strong> Save this now — it will not be shown again:<br>' +
406
+ '<code id="new-secret-val" style="display:block;margin-top:8px;padding:8px 10px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:3px;font-size:12px;word-break:break-all;color:#111827;"></code>' +
407
+ '<button id="copy-secret-btn" onclick="copySecret()" class="btn-action" style="margin-top:6px;border:1px solid var(--border);">Copy</button>';
408
+ document.getElementById('new-secret-val').textContent = rotateData.app_secret;
409
+ } else {
410
+ result.textContent = 'Rotation failed: ' + (rotateData.message || rotateData.error || rotateRes.status);
411
+ }
412
+ } catch(e) {
413
+ result.textContent = 'Failed. Try again.';
414
+ }
415
+ }
416
+ function toggleAddAgent() {
417
+ var panel = document.getElementById('add-agent-panel');
418
+ panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
419
+ }
420
+ function toggleReidentify(agentId) {
421
+ var row = document.getElementById('reidentify-' + agentId);
422
+ if (row) row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
423
+ }
424
+ async function revokeOAuth(agentId) {
425
+ if (!confirm('Revoke OAuth for ' + agentId + '? The agent will need to re-authorize.')) return;
426
+ const res = await fetch('/v1/oauth/revoke', {
427
+ method: 'POST', headers: {'Content-Type':'application/json'},
428
+ body: JSON.stringify({agent_id: agentId, app_id: '${appId}'})
429
+ });
430
+ if (res.ok) { location.reload(); }
431
+ else { const d = await res.json(); alert('Failed: ' + (d.error || res.status)); }
432
+ }
433
+ function toggleRotateKey(agentId) {
434
+ var row = document.getElementById('rotatekey-' + agentId);
435
+ if (row) row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
436
+ }
437
+ function copyAgentPrompt() {
438
+ var text = document.getElementById('agent-prompt').textContent.trim();
439
+ navigator.clipboard.writeText(text).then(function() {
440
+ var label = document.getElementById('agent-copy-text');
441
+ label.textContent = 'Copied!';
442
+ setTimeout(function() { label.textContent = 'Copy prompt'; }, 2500);
443
+ });
444
+ }
445
+ async function deleteAgent(agentId) {
446
+ if (!confirm('Delete agent ' + agentId + '? This cannot be undone.')) return;
447
+ const res = await fetch('/v1/agents/' + agentId, { method: 'DELETE' });
448
+ if (res.ok) {
449
+ var row = document.getElementById('row-' + agentId);
450
+ if (row) row.remove();
451
+ var badge = document.querySelector('.section-badge');
452
+ if (badge) {
453
+ var n = document.querySelectorAll('[id^="row-"]').length;
454
+ badge.textContent = n + ' registered';
455
+ }
456
+ } else {
457
+ var data = await res.json();
458
+ alert('Failed to delete: ' + (data.error || res.status));
459
+ }
460
+ }
461
+ </script>
462
+ </div>
463
+ </main>
464
+ <footer class="global-footer">
465
+ <div class="global-footer-inner">
466
+ <a href="/account" class="global-footer-dashboard">Account</a>
467
+ <div class="global-footer-links">
468
+ <span>v${version}</span>
469
+ <span class="global-footer-sep">&middot;</span>
470
+ <a href="https://botcha.ai">botcha.ai</a>
471
+ <span class="global-footer-sep">&middot;</span>
472
+ <a href="/docs">Docs</a>
473
+ <span class="global-footer-sep">&middot;</span>
474
+ <a href="/whitepaper">Whitepaper</a>
475
+ <span class="global-footer-sep">&middot;</span>
476
+ <a href="/openapi.json">OpenAPI</a>
477
+ <span class="global-footer-sep">&middot;</span>
478
+ <a href="/ai.txt">ai.txt</a>
479
+ <span class="global-footer-sep">&middot;</span>
480
+ <a href="https://github.com/dupe-com/botcha">GitHub</a>
481
+ </div>
482
+ <div class="global-footer-legal">&copy; ${new Date().getFullYear()} <a href="https://dupe.com">Dupe.com</a></div>
483
+ </div>
484
+ </footer>
485
+ </body>
486
+ </html>`;
487
+ return c.html(html);
488
+ }