@wipcomputer/wip-ldm-os 0.4.73-alpha.2 → 0.4.73-alpha.20

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 (38) hide show
  1. package/bin/ldm.js +80 -29
  2. package/dist/bridge/{chunk-LF7EMFBY.js → chunk-24DJYS7Z.js} +95 -48
  3. package/dist/bridge/cli.js +1 -1
  4. package/dist/bridge/core.d.ts +1 -0
  5. package/dist/bridge/core.js +1 -1
  6. package/dist/bridge/mcp-server.js +18 -4
  7. package/dist/bridge/openclaw.d.ts +5 -0
  8. package/dist/bridge/openclaw.js +9 -0
  9. package/docs/doc-pipeline/README.md +74 -0
  10. package/docs/doc-pipeline/TECHNICAL.md +79 -0
  11. package/lib/deploy.mjs +66 -10
  12. package/lib/detect.mjs +20 -6
  13. package/package.json +2 -2
  14. package/shared/docs/how-install-works.md.tmpl +22 -2
  15. package/shared/docs/how-releases-work.md.tmpl +57 -43
  16. package/shared/rules/git-conventions.md +3 -3
  17. package/shared/rules/release-pipeline.md +1 -1
  18. package/shared/rules/security.md +1 -1
  19. package/shared/rules/workspace-boundaries.md +1 -1
  20. package/shared/rules/writing-style.md +1 -1
  21. package/src/bridge/core.ts +113 -53
  22. package/src/bridge/mcp-server.ts +35 -4
  23. package/src/bridge/openclaw.ts +14 -0
  24. package/src/hooks/inbox-check-hook.mjs +176 -0
  25. package/src/hosted-mcp/demo/agent.html +300 -0
  26. package/src/hosted-mcp/demo/agent.txt +84 -0
  27. package/src/hosted-mcp/demo/fallback.jpg +0 -0
  28. package/src/hosted-mcp/demo/footer.js +16 -0
  29. package/src/hosted-mcp/demo/index.html +1291 -0
  30. package/src/hosted-mcp/demo/privacy.html +230 -0
  31. package/src/hosted-mcp/demo/sprites.jpg +0 -0
  32. package/src/hosted-mcp/demo/sprites.png +0 -0
  33. package/src/hosted-mcp/demo/tos.html +205 -0
  34. package/src/hosted-mcp/deploy.sh +70 -0
  35. package/src/hosted-mcp/inbox.mjs +64 -0
  36. package/src/hosted-mcp/package.json +21 -0
  37. package/src/hosted-mcp/server.mjs +1625 -0
  38. package/src/hosted-mcp/tools.mjs +73 -0
@@ -0,0 +1,1625 @@
1
+ // server.mjs: Hosted MCP server for wip.computer
2
+ // MCP Streamable HTTP transport at /mcp, health check at /health.
3
+ // Auth: Bearer ck-... API key maps to an agent ID.
4
+ // OAuth 2.0: Minimal flow for Claude iOS custom connector.
5
+ // WebAuthn: Passkey-based signup/login (replaces agent name text form).
6
+
7
+ import { randomUUID, randomBytes, createHash } from "node:crypto";
8
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { createServer } from "node:http";
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
14
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
15
+ import { registerTools } from "./tools.mjs";
16
+ import {
17
+ generateRegistrationOptions,
18
+ verifyRegistrationResponse,
19
+ generateAuthenticationOptions,
20
+ verifyAuthenticationResponse,
21
+ } from "@simplewebauthn/server";
22
+ import QRCode from "qrcode";
23
+
24
+ // ── Settings ─────────────────────────────────────────────────────────
25
+
26
+ const PORT = parseInt(process.env.MCP_PORT || "18800", 10);
27
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
28
+ const SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
29
+ const OAUTH_CODE_EXPIRY_MS = 10 * 60 * 1000;
30
+ const MAX_REQUEST_BODY_MS = 30_000;
31
+ const SERVER_VERSION = "0.2.0";
32
+ const SERVER_NAME = "wip-mcp";
33
+ const SERVER_BIND = "0.0.0.0";
34
+ const ISSUER_URL = "https://wip.computer";
35
+ const MCP_RESOURCE_URL = "https://wip.computer/mcp";
36
+
37
+ // WebAuthn relying party config
38
+ const RP_NAME = "Memory Crystal";
39
+ const RP_ID = "wip.computer";
40
+ const RP_ORIGIN = "https://wip.computer";
41
+
42
+ // ── Data files ───────────────────────────────────────────────────────
43
+
44
+ const __dirname = dirname(fileURLToPath(import.meta.url));
45
+ const TOKEN_FILE = join(__dirname, "tokens.json");
46
+ const PASSKEY_FILE = join(__dirname, "passkeys.json");
47
+
48
+ // API keys: key -> agentId
49
+ const API_KEYS = {
50
+ "ck-test-001": "test-agent",
51
+ "ck-e04df46877aa3672e21c4e33149bacc4": "cc-mini",
52
+ "ck-f1986e957e21cbb40dc100bc05dc78ec": "lesa",
53
+ "ck-c2849eef903407c877bc6e79bf8794aa": "parker",
54
+ };
55
+
56
+ // Load persisted tokens
57
+ function loadTokens() {
58
+ try { return JSON.parse(readFileSync(TOKEN_FILE, "utf8")); } catch { return {}; }
59
+ }
60
+ function saveTokens() {
61
+ writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n");
62
+ }
63
+ Object.assign(API_KEYS, loadTokens());
64
+
65
+ // Passkey storage: array of { credentialId, publicKey, counter, userId, agentId, apiKey, createdAt, transports }
66
+ function loadPasskeys() {
67
+ try { return JSON.parse(readFileSync(PASSKEY_FILE, "utf8")); } catch { return []; }
68
+ }
69
+ function savePasskeys() {
70
+ writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n");
71
+ }
72
+ const passkeys = loadPasskeys();
73
+
74
+ // Challenge store: challengeId -> { challenge, type, userId, expires }
75
+ // Short-lived, in-memory only. Cleared on restart.
76
+ const challenges = {};
77
+
78
+ // Agent QR auth challenges: challengeId -> { qrBuffer, status, token, agentId, expires }
79
+ const agentAuthChallenges = {};
80
+ const AGENT_AUTH_EXPIRY_MS = 5 * 60 * 1000;
81
+
82
+ // Session ID -> { transport, server, identity, lastActivity }
83
+ const sessions = {};
84
+
85
+ // ---------- OAuth 2.0 in-memory stores ----------
86
+ const oauthClients = {};
87
+ const oauthCodes = {};
88
+
89
+ const OAUTH_METADATA = {
90
+ issuer: ISSUER_URL,
91
+ authorization_endpoint: ISSUER_URL + "/oauth/authorize",
92
+ token_endpoint: ISSUER_URL + "/oauth/token",
93
+ registration_endpoint: ISSUER_URL + "/oauth/register",
94
+ response_types_supported: ["code"],
95
+ grant_types_supported: ["authorization_code"],
96
+ code_challenge_methods_supported: ["S256"],
97
+ token_endpoint_auth_methods_supported: ["none"],
98
+ };
99
+
100
+ const PROTECTED_RESOURCE = {
101
+ resource: MCP_RESOURCE_URL,
102
+ authorization_servers: [ISSUER_URL],
103
+ };
104
+
105
+ // ---------- Helpers ----------
106
+
107
+ function authenticate(req) {
108
+ const auth = req.headers["authorization"];
109
+ if (!auth?.startsWith("Bearer ")) return null;
110
+ const key = auth.slice(7).trim();
111
+ return API_KEYS[key] ? { agentId: API_KEYS[key], apiKey: key } : null;
112
+ }
113
+
114
+ function readBody(req) {
115
+ return new Promise((resolve, reject) => {
116
+ const timer = setTimeout(() => reject(new Error("Request body read timeout")), MAX_REQUEST_BODY_MS);
117
+ const chunks = [];
118
+ req.on("data", (c) => chunks.push(c));
119
+ req.on("end", () => {
120
+ clearTimeout(timer);
121
+ try { const raw = Buffer.concat(chunks).toString(); resolve(raw ? JSON.parse(raw) : undefined); }
122
+ catch (e) { reject(e); }
123
+ });
124
+ req.on("error", (e) => { clearTimeout(timer); reject(e); });
125
+ });
126
+ }
127
+
128
+ function readBodyRaw(req) {
129
+ return new Promise((resolve, reject) => {
130
+ const timer = setTimeout(() => reject(new Error("Request body read timeout")), MAX_REQUEST_BODY_MS);
131
+ const chunks = [];
132
+ req.on("data", (c) => chunks.push(c));
133
+ req.on("end", () => { clearTimeout(timer); resolve(Buffer.concat(chunks).toString()); });
134
+ req.on("error", (e) => { clearTimeout(timer); reject(e); });
135
+ });
136
+ }
137
+
138
+ function json(res, status, body) {
139
+ res.writeHead(status, { "Content-Type": "application/json" });
140
+ res.end(JSON.stringify(body));
141
+ }
142
+
143
+ function htmlResponse(res, status, body) {
144
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
145
+ res.end(body);
146
+ }
147
+
148
+ function rpcError(res, status, code, message) {
149
+ json(res, status, { jsonrpc: "2.0", error: { code, message }, id: null });
150
+ }
151
+
152
+ function cors(res) {
153
+ res.setHeader("Access-Control-Allow-Origin", "*");
154
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
155
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, Last-Event-ID");
156
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
157
+ }
158
+
159
+ function generateApiKey() {
160
+ return "ck-" + randomUUID().replace(/-/g, "");
161
+ }
162
+
163
+ function parseUrl(reqUrl) {
164
+ return new URL(reqUrl, "http://localhost");
165
+ }
166
+
167
+ function esc(s) {
168
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
169
+ }
170
+
171
+ function sanitizeUsername(raw) {
172
+ if (!raw || typeof raw !== "string") return null;
173
+ const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30);
174
+ return cleaned.length > 0 ? cleaned : null;
175
+ }
176
+
177
+ // ---------- Session cleanup ----------
178
+
179
+ function touchSession(sid) {
180
+ if (sessions[sid]) sessions[sid].lastActivity = Date.now();
181
+ }
182
+
183
+ function cleanupStaleSessions() {
184
+ const now = Date.now();
185
+ let cleaned = 0;
186
+ for (const sid of Object.keys(sessions)) {
187
+ const age = now - (sessions[sid].lastActivity || 0);
188
+ if (age > SESSION_TIMEOUT_MS) {
189
+ try { sessions[sid].transport.close(); } catch {}
190
+ delete sessions[sid];
191
+ cleaned++;
192
+ }
193
+ }
194
+ if (cleaned > 0) {
195
+ console.log("Session cleanup: removed " + cleaned + " stale session(s). Active: " + Object.keys(sessions).length);
196
+ }
197
+ }
198
+
199
+ const cleanupTimer = setInterval(cleanupStaleSessions, SESSION_CLEANUP_INTERVAL_MS);
200
+ cleanupTimer.unref();
201
+
202
+ function cleanupExpiredCodes() {
203
+ const now = Date.now();
204
+ for (const code of Object.keys(oauthCodes)) {
205
+ if (now > oauthCodes[code].expires) delete oauthCodes[code];
206
+ }
207
+ }
208
+
209
+ function cleanupExpiredChallenges() {
210
+ const now = Date.now();
211
+ for (const id of Object.keys(challenges)) {
212
+ if (now > challenges[id].expires) delete challenges[id];
213
+ }
214
+ for (const id of Object.keys(agentAuthChallenges)) {
215
+ if (now > agentAuthChallenges[id].expires) delete agentAuthChallenges[id];
216
+ }
217
+ }
218
+
219
+ // ---------- Shared HTML / CSS ----------
220
+
221
+ const PAGE_STYLES = `
222
+ * { margin: 0; padding: 0; box-sizing: border-box; }
223
+ body {
224
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
225
+ background: #0a0a0a; color: #e0e0e0;
226
+ display: flex; align-items: center; justify-content: center;
227
+ min-height: 100vh; padding: 20px;
228
+ }
229
+ .card {
230
+ background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
231
+ padding: 40px; max-width: 400px; width: 100%; text-align: center;
232
+ }
233
+ .crystal { font-size: 48px; margin-bottom: 16px; }
234
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
235
+ .subtitle { color: #888; font-size: 14px; margin-bottom: 24px; }
236
+ .btn {
237
+ display: block; width: 100%; padding: 14px; border: none; border-radius: 8px;
238
+ font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s;
239
+ margin-bottom: 12px; text-decoration: none; text-align: center;
240
+ }
241
+ .btn-primary { background: #7c5cbf; color: white; }
242
+ .btn-primary:hover { background: #6a4dab; }
243
+ .btn-secondary { background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; }
244
+ .btn-secondary:hover { background: #333; }
245
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
246
+ .divider { color: #555; font-size: 13px; margin: 8px 0 16px; }
247
+ .footer { margin-top: 24px; font-size: 12px; color: #555; }
248
+ .status { margin-top: 16px; font-size: 14px; padding: 12px; border-radius: 8px; display: none; }
249
+ .status.success { display: block; background: #1a2e1a; color: #4caf50; border: 1px solid #2e4a2e; }
250
+ .status.error { display: block; background: #2e1a1a; color: #ef5350; border: 1px solid #4a2e2e; }
251
+ .status.loading { display: block; background: #1a1a2e; color: #7c5cbf; border: 1px solid #2e2e4a; }
252
+ .link { color: #7c5cbf; text-decoration: none; font-size: 13px; }
253
+ .link:hover { text-decoration: underline; }
254
+ `;
255
+
256
+ function pageShell(title, bodyContent) {
257
+ return '<!DOCTYPE html>\n<html lang="en">\n<head>\n'
258
+ + '<meta charset="utf-8">\n'
259
+ + '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
260
+ + '<title>' + esc(title) + '</title>\n'
261
+ + '<style>' + PAGE_STYLES + '</style>\n'
262
+ + '</head>\n<body>\n' + bodyContent + '\n</body>\n</html>';
263
+ }
264
+
265
+ // ---------- Shared WebAuthn JS helpers (inlined into pages) ----------
266
+
267
+ const WEBAUTHN_HELPERS = `
268
+ function b64urlToBytes(b64url) {
269
+ const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
270
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
271
+ const bin = atob(b64 + pad);
272
+ return Uint8Array.from(bin, c => c.charCodeAt(0));
273
+ }
274
+ function bytesToB64url(bytes) {
275
+ let bin = "";
276
+ for (const b of new Uint8Array(bytes)) bin += String.fromCharCode(b);
277
+ return btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
278
+ }
279
+ function setStatus(msg, type) {
280
+ const el = document.getElementById("status");
281
+ el.textContent = msg;
282
+ el.className = "status " + type;
283
+ }
284
+ `;
285
+
286
+ // ---------- WebAuthn route handlers ----------
287
+
288
+ // POST /webauthn/register-options
289
+ async function handleRegisterOptions(req, res) {
290
+ cleanupExpiredChallenges();
291
+
292
+ let body;
293
+ try { body = await readBody(req); } catch { body = {}; }
294
+
295
+ // Accept optional username from request body
296
+ const username = sanitizeUsername(body?.username);
297
+
298
+ const userId = randomBytes(16);
299
+ const userIdB64 = userId.toString("base64url");
300
+
301
+ const userName = username || ("user-" + userIdB64.slice(0, 8));
302
+ const displayName = username || "Memory Crystal User";
303
+
304
+ let options;
305
+ try {
306
+ options = await generateRegistrationOptions({
307
+ rpName: RP_NAME,
308
+ rpID: RP_ID,
309
+ userName: userName,
310
+ userDisplayName: displayName,
311
+ userID: userId,
312
+ attestationType: "none",
313
+ authenticatorSelection: {
314
+ authenticatorAttachment: "platform",
315
+ userVerification: "required",
316
+ residentKey: "required",
317
+ },
318
+ supportedAlgorithmIDs: [-7, -257],
319
+ });
320
+ } catch (err) {
321
+ console.error("WebAuthn register-options error:", err);
322
+ json(res, 500, { error: "Failed to generate registration options" });
323
+ return;
324
+ }
325
+
326
+ const challengeId = randomUUID();
327
+ challenges[challengeId] = {
328
+ challenge: options.challenge,
329
+ type: "registration",
330
+ userId: userIdB64,
331
+ username: username,
332
+ expires: Date.now() + 120000,
333
+ };
334
+
335
+ json(res, 200, { challengeId, options });
336
+ }
337
+
338
+ // POST /webauthn/register-verify
339
+ async function handleRegisterVerify(req, res) {
340
+ let body;
341
+ try { body = await readBody(req); } catch { json(res, 400, { error: "Invalid request body" }); return; }
342
+
343
+ const { challengeId, credential } = body || {};
344
+ if (!challengeId || !credential) {
345
+ json(res, 400, { error: "Missing challengeId or credential" });
346
+ return;
347
+ }
348
+
349
+ const stored = challenges[challengeId];
350
+ if (!stored || stored.type !== "registration") {
351
+ json(res, 400, { error: "Invalid or expired challenge" });
352
+ return;
353
+ }
354
+ if (Date.now() > stored.expires) {
355
+ delete challenges[challengeId];
356
+ json(res, 400, { error: "Challenge expired" });
357
+ return;
358
+ }
359
+
360
+ delete challenges[challengeId];
361
+
362
+ let verification;
363
+ try {
364
+ verification = await verifyRegistrationResponse({
365
+ response: credential,
366
+ expectedChallenge: stored.challenge,
367
+ expectedOrigin: RP_ORIGIN,
368
+ expectedRPID: RP_ID,
369
+ requireUserVerification: true,
370
+ });
371
+ } catch (err) {
372
+ console.error("WebAuthn register-verify error:", err);
373
+ json(res, 400, { error: "Verification failed: " + err.message });
374
+ return;
375
+ }
376
+
377
+ if (!verification.verified || !verification.registrationInfo) {
378
+ json(res, 400, { error: "Registration verification failed" });
379
+ return;
380
+ }
381
+
382
+ const { credential: cred, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
383
+
384
+ // Use provided username as agentId, or fall back to passkey-<id>
385
+ const agentId = stored.username || ("passkey-" + stored.userId.slice(0, 12));
386
+ const apiKey = generateApiKey();
387
+
388
+ const entry = {
389
+ credentialId: cred.id,
390
+ publicKey: Buffer.from(cred.publicKey).toString("base64url"),
391
+ counter: cred.counter,
392
+ userId: stored.userId,
393
+ agentId,
394
+ apiKey,
395
+ deviceType: credentialDeviceType,
396
+ backedUp: credentialBackedUp,
397
+ transports: credential.response?.transports || [],
398
+ createdAt: new Date().toISOString(),
399
+ };
400
+ passkeys.push(entry);
401
+ savePasskeys();
402
+
403
+ API_KEYS[apiKey] = agentId;
404
+ saveTokens();
405
+
406
+ console.log("WebAuthn: registered passkey for agent '" + agentId + "' (credId: " + cred.id.slice(0, 16) + "...)");
407
+
408
+ json(res, 200, { success: true, agentId, apiKey });
409
+ }
410
+
411
+ // POST /webauthn/auth-options
412
+ async function handleAuthOptions(req, res) {
413
+ cleanupExpiredChallenges();
414
+
415
+ let options;
416
+ try {
417
+ options = await generateAuthenticationOptions({
418
+ rpID: RP_ID,
419
+ userVerification: "required",
420
+ });
421
+ } catch (err) {
422
+ console.error("WebAuthn auth-options error:", err);
423
+ json(res, 500, { error: "Failed to generate authentication options" });
424
+ return;
425
+ }
426
+
427
+ const challengeId = randomUUID();
428
+ challenges[challengeId] = {
429
+ challenge: options.challenge,
430
+ type: "authentication",
431
+ expires: Date.now() + 120000,
432
+ };
433
+
434
+ json(res, 200, { challengeId, options });
435
+ }
436
+
437
+ // POST /webauthn/auth-verify
438
+ async function handleAuthVerify(req, res) {
439
+ let body;
440
+ try { body = await readBody(req); } catch { json(res, 400, { error: "Invalid request body" }); return; }
441
+
442
+ const { challengeId, credential } = body || {};
443
+ if (!challengeId || !credential) {
444
+ json(res, 400, { error: "Missing challengeId or credential" });
445
+ return;
446
+ }
447
+
448
+ const stored = challenges[challengeId];
449
+ if (!stored || stored.type !== "authentication") {
450
+ json(res, 400, { error: "Invalid or expired challenge" });
451
+ return;
452
+ }
453
+ if (Date.now() > stored.expires) {
454
+ delete challenges[challengeId];
455
+ json(res, 400, { error: "Challenge expired" });
456
+ return;
457
+ }
458
+
459
+ delete challenges[challengeId];
460
+
461
+ const credId = credential.id;
462
+ const entry = passkeys.find((p) => p.credentialId === credId);
463
+ if (!entry) {
464
+ json(res, 400, { error: "Unknown credential. Please create an account first." });
465
+ return;
466
+ }
467
+
468
+ let verification;
469
+ try {
470
+ verification = await verifyAuthenticationResponse({
471
+ response: credential,
472
+ expectedChallenge: stored.challenge,
473
+ expectedOrigin: RP_ORIGIN,
474
+ expectedRPID: RP_ID,
475
+ requireUserVerification: true,
476
+ credential: {
477
+ id: entry.credentialId,
478
+ publicKey: Uint8Array.from(Buffer.from(entry.publicKey, "base64url")),
479
+ counter: entry.counter,
480
+ transports: entry.transports || [],
481
+ },
482
+ });
483
+ } catch (err) {
484
+ console.error("WebAuthn auth-verify error:", err);
485
+ json(res, 400, { error: "Authentication failed: " + err.message });
486
+ return;
487
+ }
488
+
489
+ if (!verification.verified) {
490
+ json(res, 400, { error: "Authentication verification failed" });
491
+ return;
492
+ }
493
+
494
+ entry.counter = verification.authenticationInfo.newCounter;
495
+ savePasskeys();
496
+
497
+ console.log("WebAuthn: authenticated agent '" + entry.agentId + "'");
498
+
499
+ json(res, 200, { success: true, agentId: entry.agentId, apiKey: entry.apiKey });
500
+ }
501
+
502
+ // ---------- Page handlers ----------
503
+
504
+ function handleSignupPage(req, res) {
505
+ const body = '<div class="card">\n'
506
+ + '<div class="crystal">\u{1F48E}</div>\n'
507
+ + '<h1>Create your account</h1>\n'
508
+ + '<p class="subtitle">Memory Crystal ... wip.computer</p>\n'
509
+ + '<button class="btn btn-primary" id="createBtn" onclick="createPasskey()">Create Passkey</button>\n'
510
+ + '<div id="status" class="status"></div>\n'
511
+ + '<p class="footer"><a href="/login" class="link">Already have an account? Sign in</a></p>\n'
512
+ + '<p class="footer">Learning Dreaming Machines</p>\n'
513
+ + '</div>\n'
514
+ + '<script>\n'
515
+ + WEBAUTHN_HELPERS
516
+ + 'async function createPasskey() {\n'
517
+ + ' const btn = document.getElementById("createBtn");\n'
518
+ + ' btn.disabled = true;\n'
519
+ + ' setStatus("Preparing...", "loading");\n'
520
+ + ' try {\n'
521
+ + ' const optRes = await fetch("/webauthn/register-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
522
+ + ' const { challengeId, options } = await optRes.json();\n'
523
+ + ' if (!options) throw new Error("Server returned no options");\n'
524
+ + ' options.challenge = b64urlToBytes(options.challenge);\n'
525
+ + ' options.user.id = b64urlToBytes(options.user.id);\n'
526
+ + ' if (options.excludeCredentials) {\n'
527
+ + ' options.excludeCredentials = options.excludeCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
528
+ + ' }\n'
529
+ + ' setStatus("Waiting for biometric...", "loading");\n'
530
+ + ' const credential = await navigator.credentials.create({ publicKey: options });\n'
531
+ + ' const reqBody = {\n'
532
+ + ' challengeId,\n'
533
+ + ' credential: {\n'
534
+ + ' id: credential.id,\n'
535
+ + ' rawId: bytesToB64url(credential.rawId),\n'
536
+ + ' type: credential.type,\n'
537
+ + ' response: {\n'
538
+ + ' attestationObject: bytesToB64url(credential.response.attestationObject),\n'
539
+ + ' clientDataJSON: bytesToB64url(credential.response.clientDataJSON),\n'
540
+ + ' transports: credential.response.getTransports ? credential.response.getTransports() : [],\n'
541
+ + ' },\n'
542
+ + ' },\n'
543
+ + ' };\n'
544
+ + ' setStatus("Verifying...", "loading");\n'
545
+ + ' const verRes = await fetch("/webauthn/register-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
546
+ + ' const result = await verRes.json();\n'
547
+ + ' if (result.success) {\n'
548
+ + ' setStatus("Account created. You can close this page.", "success");\n'
549
+ + ' btn.textContent = "Done";\n'
550
+ + ' } else {\n'
551
+ + ' setStatus(result.error || "Registration failed", "error");\n'
552
+ + ' btn.disabled = false;\n'
553
+ + ' }\n'
554
+ + ' } catch (err) {\n'
555
+ + ' if (err.name === "NotAllowedError") {\n'
556
+ + ' setStatus("Cancelled. Try again when ready.", "error");\n'
557
+ + ' } else {\n'
558
+ + ' setStatus("Error: " + err.message, "error");\n'
559
+ + ' }\n'
560
+ + ' btn.disabled = false;\n'
561
+ + ' }\n'
562
+ + '}\n'
563
+ + '</script>';
564
+
565
+ htmlResponse(res, 200, pageShell("Create Account - Memory Crystal", body));
566
+ }
567
+
568
+ function handleLoginPage(req, res) {
569
+ const body = '<div class="card">\n'
570
+ + '<div class="crystal">\u{1F48E}</div>\n'
571
+ + '<h1>Sign in</h1>\n'
572
+ + '<p class="subtitle">Memory Crystal ... wip.computer</p>\n'
573
+ + '<button class="btn btn-primary" id="signInBtn" onclick="signIn()">Sign in with Passkey</button>\n'
574
+ + '<div id="status" class="status"></div>\n'
575
+ + '<p class="footer"><a href="/signup" class="link">Need an account? Create one</a></p>\n'
576
+ + '<p class="footer">Learning Dreaming Machines</p>\n'
577
+ + '</div>\n'
578
+ + '<script>\n'
579
+ + WEBAUTHN_HELPERS
580
+ + 'async function signIn() {\n'
581
+ + ' const btn = document.getElementById("signInBtn");\n'
582
+ + ' btn.disabled = true;\n'
583
+ + ' setStatus("Preparing...", "loading");\n'
584
+ + ' try {\n'
585
+ + ' const optRes = await fetch("/webauthn/auth-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
586
+ + ' const { challengeId, options } = await optRes.json();\n'
587
+ + ' if (!options) throw new Error("Server returned no options");\n'
588
+ + ' options.challenge = b64urlToBytes(options.challenge);\n'
589
+ + ' if (options.allowCredentials) {\n'
590
+ + ' options.allowCredentials = options.allowCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
591
+ + ' }\n'
592
+ + ' setStatus("Waiting for biometric...", "loading");\n'
593
+ + ' const assertion = await navigator.credentials.get({ publicKey: options });\n'
594
+ + ' const reqBody = {\n'
595
+ + ' challengeId,\n'
596
+ + ' credential: {\n'
597
+ + ' id: assertion.id,\n'
598
+ + ' rawId: bytesToB64url(assertion.rawId),\n'
599
+ + ' type: assertion.type,\n'
600
+ + ' response: {\n'
601
+ + ' authenticatorData: bytesToB64url(assertion.response.authenticatorData),\n'
602
+ + ' clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),\n'
603
+ + ' signature: bytesToB64url(assertion.response.signature),\n'
604
+ + ' userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,\n'
605
+ + ' },\n'
606
+ + ' },\n'
607
+ + ' };\n'
608
+ + ' setStatus("Verifying...", "loading");\n'
609
+ + ' const verRes = await fetch("/webauthn/auth-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
610
+ + ' const result = await verRes.json();\n'
611
+ + ' if (result.success) {\n'
612
+ + ' setStatus("Signed in as " + result.agentId + ". You can close this page.", "success");\n'
613
+ + ' btn.textContent = "Done";\n'
614
+ + ' } else {\n'
615
+ + ' setStatus(result.error || "Authentication failed", "error");\n'
616
+ + ' btn.disabled = false;\n'
617
+ + ' }\n'
618
+ + ' } catch (err) {\n'
619
+ + ' if (err.name === "NotAllowedError") {\n'
620
+ + ' setStatus("Cancelled. Try again when ready.", "error");\n'
621
+ + ' } else {\n'
622
+ + ' setStatus("Error: " + err.message, "error");\n'
623
+ + ' }\n'
624
+ + ' btn.disabled = false;\n'
625
+ + ' }\n'
626
+ + '}\n'
627
+ + '</script>';
628
+
629
+ htmlResponse(res, 200, pageShell("Sign In - Memory Crystal", body));
630
+ }
631
+
632
+ // ---------- OAuth route handlers ----------
633
+
634
+ function handleOAuthDiscovery(req, res) {
635
+ json(res, 200, OAUTH_METADATA);
636
+ }
637
+
638
+ function handleProtectedResource(req, res) {
639
+ json(res, 200, PROTECTED_RESOURCE);
640
+ }
641
+
642
+ async function handleOAuthRegister(req, res) {
643
+ let body;
644
+ try { body = await readBody(req); } catch { json(res, 400, { error: "invalid_request" }); return; }
645
+
646
+ const clientId = randomUUID();
647
+ const client = {
648
+ client_id: clientId,
649
+ redirect_uris: body?.redirect_uris || [],
650
+ client_name: body?.client_name || "unknown",
651
+ created: Date.now(),
652
+ };
653
+ oauthClients[clientId] = client;
654
+ console.log("OAuth: registered client " + clientId + " (" + client.client_name + ")");
655
+
656
+ json(res, 201, {
657
+ client_id: clientId,
658
+ client_name: client.client_name,
659
+ redirect_uris: client.redirect_uris,
660
+ grant_types: ["authorization_code"],
661
+ response_types: ["code"],
662
+ token_endpoint_auth_method: "none",
663
+ });
664
+ }
665
+
666
+ function handleOAuthAuthorize(req, res) {
667
+ const url = parseUrl(req.url);
668
+ const clientId = url.searchParams.get("client_id") || "";
669
+ const responseType = url.searchParams.get("response_type");
670
+ const redirectUri = url.searchParams.get("redirect_uri") || "";
671
+ const state = url.searchParams.get("state") || "";
672
+ const codeChallenge = url.searchParams.get("code_challenge") || "";
673
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "S256";
674
+
675
+ if (responseType !== "code") {
676
+ htmlResponse(res, 400, pageShell("Error", '<div class="card"><h1>Error</h1><p class="subtitle">Unsupported response_type</p></div>'));
677
+ return;
678
+ }
679
+ if (!redirectUri) {
680
+ htmlResponse(res, 400, pageShell("Error", '<div class="card"><h1>Error</h1><p class="subtitle">Missing redirect_uri</p></div>'));
681
+ return;
682
+ }
683
+
684
+ // Auto-register client
685
+ if (clientId && !oauthClients[clientId]) {
686
+ oauthClients[clientId] = { client_id: clientId, redirect_uris: [redirectUri], client_name: "auto", created: Date.now() };
687
+ }
688
+
689
+ // Encode OAuth params for the JS to use after WebAuthn
690
+ const oauthParams = JSON.stringify({
691
+ client_id: clientId,
692
+ redirect_uri: redirectUri,
693
+ state: state,
694
+ code_challenge: codeChallenge,
695
+ code_challenge_method: codeChallengeMethod,
696
+ });
697
+
698
+ const pageBody = '<div class="card">\n'
699
+ + '<div class="crystal">\u{1F48E}</div>\n'
700
+ + '<h1>Connect to Memory Crystal</h1>\n'
701
+ + '<p class="subtitle">wip.computer MCP server</p>\n'
702
+ + '<button class="btn btn-primary" id="signInBtn" onclick="doAuth()">Sign In</button>\n'
703
+ + '<div class="divider">or</div>\n'
704
+ + '<button class="btn btn-secondary" id="createBtn" onclick="doRegister()">Create Account</button>\n'
705
+ + '<div id="status" class="status"></div>\n'
706
+ + '<p class="footer">Learning Dreaming Machines</p>\n'
707
+ + '</div>\n'
708
+ + '<script>\n'
709
+ + WEBAUTHN_HELPERS
710
+ + 'const oauthParams = ' + oauthParams + ';\n'
711
+ + 'function disableButtons() {\n'
712
+ + ' document.getElementById("signInBtn").disabled = true;\n'
713
+ + ' document.getElementById("createBtn").disabled = true;\n'
714
+ + '}\n'
715
+ + 'function enableButtons() {\n'
716
+ + ' document.getElementById("signInBtn").disabled = false;\n'
717
+ + ' document.getElementById("createBtn").disabled = false;\n'
718
+ + '}\n'
719
+ + 'function completeOAuth(agentId) {\n'
720
+ + ' setStatus("Connecting...", "loading");\n'
721
+ + ' const form = document.createElement("form");\n'
722
+ + ' form.method = "POST";\n'
723
+ + ' form.action = "/oauth/authorize/submit";\n'
724
+ + ' const fields = {\n'
725
+ + ' client_id: oauthParams.client_id,\n'
726
+ + ' redirect_uri: oauthParams.redirect_uri,\n'
727
+ + ' state: oauthParams.state,\n'
728
+ + ' code_challenge: oauthParams.code_challenge,\n'
729
+ + ' code_challenge_method: oauthParams.code_challenge_method,\n'
730
+ + ' agent_name: agentId,\n'
731
+ + ' };\n'
732
+ + ' for (const [k, v] of Object.entries(fields)) {\n'
733
+ + ' const input = document.createElement("input");\n'
734
+ + ' input.type = "hidden";\n'
735
+ + ' input.name = k;\n'
736
+ + ' input.value = v;\n'
737
+ + ' form.appendChild(input);\n'
738
+ + ' }\n'
739
+ + ' document.body.appendChild(form);\n'
740
+ + ' form.submit();\n'
741
+ + '}\n'
742
+ + 'async function doRegister() {\n'
743
+ + ' disableButtons();\n'
744
+ + ' setStatus("Preparing...", "loading");\n'
745
+ + ' try {\n'
746
+ + ' const optRes = await fetch("/webauthn/register-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
747
+ + ' const { challengeId, options } = await optRes.json();\n'
748
+ + ' if (!options) throw new Error("Server returned no options");\n'
749
+ + ' options.challenge = b64urlToBytes(options.challenge);\n'
750
+ + ' options.user.id = b64urlToBytes(options.user.id);\n'
751
+ + ' if (options.excludeCredentials) {\n'
752
+ + ' options.excludeCredentials = options.excludeCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
753
+ + ' }\n'
754
+ + ' setStatus("Waiting for biometric...", "loading");\n'
755
+ + ' const credential = await navigator.credentials.create({ publicKey: options });\n'
756
+ + ' const reqBody = {\n'
757
+ + ' challengeId,\n'
758
+ + ' credential: {\n'
759
+ + ' id: credential.id,\n'
760
+ + ' rawId: bytesToB64url(credential.rawId),\n'
761
+ + ' type: credential.type,\n'
762
+ + ' response: {\n'
763
+ + ' attestationObject: bytesToB64url(credential.response.attestationObject),\n'
764
+ + ' clientDataJSON: bytesToB64url(credential.response.clientDataJSON),\n'
765
+ + ' transports: credential.response.getTransports ? credential.response.getTransports() : [],\n'
766
+ + ' },\n'
767
+ + ' },\n'
768
+ + ' };\n'
769
+ + ' setStatus("Verifying...", "loading");\n'
770
+ + ' const verRes = await fetch("/webauthn/register-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
771
+ + ' const result = await verRes.json();\n'
772
+ + ' if (result.success) {\n'
773
+ + ' completeOAuth(result.agentId);\n'
774
+ + ' } else {\n'
775
+ + ' setStatus(result.error || "Registration failed", "error");\n'
776
+ + ' enableButtons();\n'
777
+ + ' }\n'
778
+ + ' } catch (err) {\n'
779
+ + ' if (err.name === "NotAllowedError") {\n'
780
+ + ' setStatus("Cancelled. Try again when ready.", "error");\n'
781
+ + ' } else {\n'
782
+ + ' setStatus("Error: " + err.message, "error");\n'
783
+ + ' }\n'
784
+ + ' enableButtons();\n'
785
+ + ' }\n'
786
+ + '}\n'
787
+ + 'async function doAuth() {\n'
788
+ + ' disableButtons();\n'
789
+ + ' setStatus("Preparing...", "loading");\n'
790
+ + ' try {\n'
791
+ + ' const optRes = await fetch("/webauthn/auth-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
792
+ + ' const { challengeId, options } = await optRes.json();\n'
793
+ + ' if (!options) throw new Error("Server returned no options");\n'
794
+ + ' options.challenge = b64urlToBytes(options.challenge);\n'
795
+ + ' if (options.allowCredentials) {\n'
796
+ + ' options.allowCredentials = options.allowCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
797
+ + ' }\n'
798
+ + ' setStatus("Waiting for biometric...", "loading");\n'
799
+ + ' const assertion = await navigator.credentials.get({ publicKey: options });\n'
800
+ + ' const reqBody = {\n'
801
+ + ' challengeId,\n'
802
+ + ' credential: {\n'
803
+ + ' id: assertion.id,\n'
804
+ + ' rawId: bytesToB64url(assertion.rawId),\n'
805
+ + ' type: assertion.type,\n'
806
+ + ' response: {\n'
807
+ + ' authenticatorData: bytesToB64url(assertion.response.authenticatorData),\n'
808
+ + ' clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),\n'
809
+ + ' signature: bytesToB64url(assertion.response.signature),\n'
810
+ + ' userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,\n'
811
+ + ' },\n'
812
+ + ' },\n'
813
+ + ' };\n'
814
+ + ' setStatus("Verifying...", "loading");\n'
815
+ + ' const verRes = await fetch("/webauthn/auth-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
816
+ + ' const result = await verRes.json();\n'
817
+ + ' if (result.success) {\n'
818
+ + ' completeOAuth(result.agentId);\n'
819
+ + ' } else {\n'
820
+ + ' setStatus(result.error || "Authentication failed", "error");\n'
821
+ + ' enableButtons();\n'
822
+ + ' }\n'
823
+ + ' } catch (err) {\n'
824
+ + ' if (err.name === "NotAllowedError") {\n'
825
+ + ' setStatus("Cancelled. Try again when ready.", "error");\n'
826
+ + ' } else {\n'
827
+ + ' setStatus("Error: " + err.message, "error");\n'
828
+ + ' }\n'
829
+ + ' enableButtons();\n'
830
+ + ' }\n'
831
+ + '}\n'
832
+ + '</script>';
833
+
834
+ htmlResponse(res, 200, pageShell("Connect to Memory Crystal", pageBody));
835
+ }
836
+
837
+ async function handleOAuthAuthorizeSubmit(req, res) {
838
+ const raw = await readBodyRaw(req);
839
+ const params = new URLSearchParams(raw);
840
+ const clientId = params.get("client_id");
841
+ const redirectUri = params.get("redirect_uri");
842
+ const state = params.get("state");
843
+ const codeChallenge = params.get("code_challenge");
844
+ const codeChallengeMethod = params.get("code_challenge_method") || "S256";
845
+ const agentName = params.get("agent_name") || "unknown";
846
+
847
+ cleanupExpiredCodes();
848
+
849
+ const code = randomUUID();
850
+ oauthCodes[code] = {
851
+ client_id: clientId,
852
+ redirect_uri: redirectUri,
853
+ code_challenge: codeChallenge,
854
+ code_challenge_method: codeChallengeMethod,
855
+ agent_name: agentName.trim().toLowerCase(),
856
+ expires: Date.now() + OAUTH_CODE_EXPIRY_MS,
857
+ };
858
+
859
+ console.log("OAuth: issued code for agent '" + agentName + "' (client: " + clientId + ")");
860
+
861
+ const redirect = new URL(redirectUri);
862
+ redirect.searchParams.set("code", code);
863
+ if (state) redirect.searchParams.set("state", state);
864
+
865
+ res.writeHead(302, { Location: redirect.toString() });
866
+ res.end();
867
+ }
868
+
869
+ async function handleOAuthToken(req, res) {
870
+ let raw;
871
+ try { raw = await readBodyRaw(req); } catch { json(res, 400, { error: "invalid_request" }); return; }
872
+
873
+ const params = new URLSearchParams(raw);
874
+ const grantType = params.get("grant_type");
875
+ const code = params.get("code");
876
+ const redirectUri = params.get("redirect_uri");
877
+ const codeVerifier = params.get("code_verifier");
878
+
879
+ if (grantType !== "authorization_code") {
880
+ json(res, 400, { error: "unsupported_grant_type" });
881
+ return;
882
+ }
883
+
884
+ const stored = oauthCodes[code];
885
+ if (!stored) {
886
+ json(res, 400, { error: "invalid_grant", error_description: "Unknown or expired code" });
887
+ return;
888
+ }
889
+
890
+ delete oauthCodes[code];
891
+
892
+ if (Date.now() > stored.expires) {
893
+ json(res, 400, { error: "invalid_grant", error_description: "Code expired" });
894
+ return;
895
+ }
896
+
897
+ if (redirectUri && redirectUri !== stored.redirect_uri) {
898
+ json(res, 400, { error: "invalid_grant", error_description: "redirect_uri mismatch" });
899
+ return;
900
+ }
901
+
902
+ if (stored.code_challenge && codeVerifier) {
903
+ const expected = createHash("sha256").update(codeVerifier).digest("base64url");
904
+ if (expected !== stored.code_challenge) {
905
+ json(res, 400, { error: "invalid_grant", error_description: "PKCE verification failed" });
906
+ return;
907
+ }
908
+ }
909
+
910
+ // Check if agent already has an API key (from passkey registration)
911
+ const agentId = stored.agent_name || "oauth-user";
912
+ let apiKey;
913
+
914
+ const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);
915
+ if (existingKey) {
916
+ apiKey = existingKey[0];
917
+ } else {
918
+ apiKey = generateApiKey();
919
+ API_KEYS[apiKey] = agentId;
920
+ saveTokens();
921
+ }
922
+
923
+ console.log("OAuth: issued token for agent '" + agentId + "' (key: " + apiKey.slice(0, 10) + "...)");
924
+
925
+ json(res, 200, {
926
+ access_token: apiKey,
927
+ token_type: "Bearer",
928
+ scope: "mcp",
929
+ });
930
+ }
931
+
932
+ // ---------- Agent QR Auth handlers ----------
933
+
934
+ // GET /demo/api/agent-auth?agent=NAME&message=TEXT ... generate a QR challenge for an agent
935
+ async function handleAgentAuthStart(req, res) {
936
+ cleanupExpiredChallenges();
937
+ const url = parseUrl(req.url);
938
+ const agentName = (url.searchParams.get("agent") || "").trim().slice(0, 60);
939
+ const agentMessage = (url.searchParams.get("message") || "").trim().slice(0, 200);
940
+ const challengeId = randomUUID();
941
+ const approveUrl = ISSUER_URL + "/approve?c=" + challengeId;
942
+ const qrBuffer = await QRCode.toBuffer(approveUrl, { type: "png", width: 400, margin: 2 });
943
+ agentAuthChallenges[challengeId] = {
944
+ qrBuffer,
945
+ status: "pending",
946
+ token: null,
947
+ agentId: null,
948
+ agentName: agentName || null,
949
+ agentMessage: agentMessage || null,
950
+ expires: Date.now() + AGENT_AUTH_EXPIRY_MS,
951
+ };
952
+ console.log("Agent QR auth: created challenge " + challengeId.slice(0, 8) + "..." + (agentName ? " (agent: " + agentName + ")" : ""));
953
+ json(res, 200, { challengeId, approveUrl, qrUrl: "/demo/api/agent-auth/qr?c=" + challengeId });
954
+ }
955
+
956
+ // GET /demo/api/agent-auth/qr?c=XXX ... serve QR code PNG
957
+ function handleAgentAuthQR(req, res) {
958
+ const url = parseUrl(req.url);
959
+ const c = url.searchParams.get("c");
960
+ const entry = agentAuthChallenges[c];
961
+ if (!entry || Date.now() > entry.expires) {
962
+ json(res, 404, { error: "Challenge not found or expired" });
963
+ return;
964
+ }
965
+ res.writeHead(200, { "Content-Type": "image/png", "Content-Length": entry.qrBuffer.length });
966
+ res.end(entry.qrBuffer);
967
+ }
968
+
969
+ // GET /demo/api/agent-auth/status?c=XXX ... poll for approval
970
+ function handleAgentAuthStatus(req, res) {
971
+ const url = parseUrl(req.url);
972
+ const c = url.searchParams.get("c");
973
+ const entry = agentAuthChallenges[c];
974
+ if (!entry || Date.now() > entry.expires) {
975
+ json(res, 404, { error: "Challenge not found or expired" });
976
+ return;
977
+ }
978
+ if (entry.status === "approved") {
979
+ json(res, 200, { status: "approved", token: entry.token, agentId: entry.agentId });
980
+ delete agentAuthChallenges[c]; // one-time use
981
+ } else {
982
+ json(res, 200, { status: "pending" });
983
+ }
984
+ }
985
+
986
+ // GET /approve?c=XXX ... page the human sees when authorizing an agent
987
+ function handleApprovePage(req, res) {
988
+ const url = parseUrl(req.url);
989
+ let challengeId = url.searchParams.get("c") || "";
990
+ let entry = agentAuthChallenges[challengeId];
991
+
992
+ // If no challenge ID but agent params provided, create challenge on the fly
993
+ const agentParam = (url.searchParams.get("agent") || "").trim().slice(0, 60);
994
+ const messageParam = (url.searchParams.get("message") || "").trim().slice(0, 200);
995
+ if (!entry && agentParam) {
996
+ challengeId = randomUUID();
997
+ agentAuthChallenges[challengeId] = {
998
+ qrBuffer: null,
999
+ status: "pending",
1000
+ token: null,
1001
+ agentId: null,
1002
+ agentName: agentParam,
1003
+ agentMessage: messageParam || null,
1004
+ expires: Date.now() + AGENT_AUTH_EXPIRY_MS,
1005
+ };
1006
+ entry = agentAuthChallenges[challengeId];
1007
+ console.log("Approve page: created inline challenge " + challengeId.slice(0, 8) + "... for agent: " + agentParam);
1008
+ }
1009
+
1010
+ const expired = !entry || Date.now() > entry.expires;
1011
+
1012
+ const APPROVE_STYLES = `
1013
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
1014
+ body {
1015
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
1016
+ background: #FFFDF5; color: #1a1a1a;
1017
+ -webkit-text-size-adjust: 100%; -webkit-font-smoothing: antialiased;
1018
+ }
1019
+ .login-page {
1020
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
1021
+ min-height: 100vh; min-height: 100dvh; padding: 24px;
1022
+ }
1023
+ .login-card {
1024
+ position: relative; max-width: 380px; width: 100%; text-align: center;
1025
+ }
1026
+ .login-title {
1027
+ font-size: 22px; font-weight: 600; letter-spacing: 0.5px; margin-bottom: 8px;
1028
+ }
1029
+ .login-byline {
1030
+ font-size: 14px; color: #8a8580; margin-bottom: 32px; letter-spacing: 0.2px;
1031
+ }
1032
+ .info-section { text-align: left; margin-bottom: 20px; }
1033
+ .info-section h2 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #1a1a1a; }
1034
+ .info-section ul { list-style: none; padding: 0; margin: 0; }
1035
+ .info-section ul li { font-size: 13px; color: #8a8580; line-height: 1.6; padding-left: 16px; position: relative; }
1036
+ .info-section ul li::before { content: "\\2022"; position: absolute; left: 0; color: #c0bbb5; }
1037
+ .info-section.safe ul li::before { color: #2E7D32; }
1038
+ .revoke-note { font-size: 13px; color: #8a8580; margin-bottom: 28px; }
1039
+ .btn {
1040
+ display: block; width: 100%; padding: 16px; border: none; border-radius: 12px;
1041
+ font-size: 16px; font-weight: 600; cursor: pointer; transition: background 0.15s, transform 0.1s;
1042
+ font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
1043
+ -webkit-tap-highlight-color: transparent;
1044
+ }
1045
+ .btn:active { transform: scale(0.98); }
1046
+ .btn-primary { background: #0033FF; color: white; margin-bottom: 12px; }
1047
+ .btn-primary:hover { background: #0033FF; }
1048
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
1049
+ .create-link { font-size: 13px; color: #8a8580; cursor: pointer; text-decoration: none; }
1050
+ .create-link:hover { color: #1a1a1a; }
1051
+ .login-status { margin-top: 16px; font-size: 14px; padding: 12px 16px; border-radius: 10px; display: none; text-align: center; }
1052
+ .login-status.show { display: block; }
1053
+ .login-status.loading { background: #E8EEFF; color: #0033FF; }
1054
+ .login-status.error { background: #FFF0F0; color: #D32F2F; }
1055
+ .login-status.success { background: #F0FFF4; color: #2E7D32; }
1056
+ .success-check { font-size: 48px; margin-bottom: 16px; }
1057
+ `;
1058
+
1059
+ // Shared sprite JS for rotating nav icon
1060
+ const SPRITE_JS = 'var SPRITE_COLS = 8, SPRITE_ROWS = 3, SPRITE_TOTAL = 24;\n'
1061
+ + 'function makeIconHTML(size) {\n'
1062
+ + ' var idx = Math.floor(Math.random() * SPRITE_TOTAL);\n'
1063
+ + ' var col = idx % SPRITE_COLS, row = Math.floor(idx / SPRITE_COLS);\n'
1064
+ + ' var bx = (col / (SPRITE_COLS - 1)) * 100, by = (row / (SPRITE_ROWS - 1)) * 100;\n'
1065
+ + ' return \'<div style="width:\' + size + \'px;height:\' + size + \'px;overflow:hidden;"><div style="width:100%;height:100%;background:url(/demo/sprites.png);background-size:\' + (SPRITE_COLS * 100) + \'% \' + (SPRITE_ROWS * 100) + \'%;background-position:\' + bx + \'% \' + by + \'%;"></div></div>\';\n'
1066
+ + '}\n'
1067
+ + 'var loginIcon = document.getElementById("loginIcon");\n'
1068
+ + 'if (loginIcon) loginIcon.innerHTML = makeIconHTML(28);\n'
1069
+ + 'var rotateIdx = Math.floor(Math.random() * SPRITE_TOTAL);\n'
1070
+ + 'setInterval(function() {\n'
1071
+ + ' var el = document.getElementById("loginIcon"); if (!el) return;\n'
1072
+ + ' rotateIdx = (rotateIdx + 1) % SPRITE_TOTAL;\n'
1073
+ + ' var col = rotateIdx % SPRITE_COLS, row = Math.floor(rotateIdx / SPRITE_COLS);\n'
1074
+ + ' var bx = (col / (SPRITE_COLS - 1)) * 100, by = (row / (SPRITE_ROWS - 1)) * 100;\n'
1075
+ + ' el.innerHTML = \'<div style="width:28px;height:28px;overflow:hidden;transition:opacity 0.5s;"><div style="width:100%;height:100%;background:url(/demo/sprites.png);background-size:\' + (SPRITE_COLS * 100) + \'% \' + (SPRITE_ROWS * 100) + \'%;background-position:\' + bx + \'% \' + by + \'%;"></div></div>\';\n'
1076
+ + '}, 6000);\n';
1077
+
1078
+ if (expired) {
1079
+ const html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
1080
+ + '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
1081
+ + '<title>Expired - Kaleidoscope</title><style>' + APPROVE_STYLES + '</style></head><body>'
1082
+ + '<div class="login-page"><div class="login-card">'
1083
+ + '<h1 class="login-title"><span id="loginIcon" style="display:inline-block;vertical-align:middle;margin-right:8px;margin-bottom:3px;"></span>Kaleidoscope</h1>'
1084
+ + '<p class="login-byline">Every AI. One experience.</p>'
1085
+ + '<h2 style="font-size:18px;font-weight:600;margin-bottom:12px;">Link Expired</h2>'
1086
+ + '<p style="font-size:14px;color:#8a8580;line-height:1.5;">This authorization link has expired. Ask your agent to generate a new one.</p>'
1087
+ + '</div>'
1088
+ + '<div id="kscope-footer" style="margin-top:48px;text-align:center;"></div>'
1089
+ + '</div></div>'
1090
+ + '<script src="/demo/footer.js"></script>'
1091
+ + '<script>\n' + SPRITE_JS + '</script></body></html>';
1092
+ htmlResponse(res, 200, html);
1093
+ return;
1094
+ }
1095
+
1096
+ const html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
1097
+ + '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
1098
+ + '<title>Authorize Agent - Kaleidoscope</title><style>' + APPROVE_STYLES + '</style></head><body>'
1099
+ + '<div class="login-page"><div class="login-card">'
1100
+ + '<h1 class="login-title"><span id="loginIcon" style="display:inline-block;vertical-align:middle;margin-right:8px;margin-bottom:3px;"></span>Kaleidoscope</h1>'
1101
+ + '<p class="login-byline">Every AI. One experience.</p>'
1102
+ + '<div id="authSection">'
1103
+ + '<h2 style="font-size:18px;font-weight:600;margin-bottom:' + (entry.agentName ? '16' : '24') + 'px;">Authorize Agent Access</h2>'
1104
+ + (entry.agentName ? '<div style="background:#F5F3ED;border:1px solid #E0DDD6;border-radius:12px;padding:16px 20px;margin-bottom:12px;text-align:left;"><div style="font-size:12px;color:#8a8580;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Agent</div><div style="font-weight:600;">' + entry.agentName.replace(/</g, '&lt;') + '</div></div>' : '')
1105
+ + (entry.agentMessage ? '<div style="background:#F5F3ED;border:1px solid #E0DDD6;border-radius:12px;padding:16px 20px;margin-bottom:24px;text-align:left;"><div style="font-size:12px;color:#8a8580;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Passphrase</div><div style="font-weight:600;">' + entry.agentMessage.replace(/</g, '&lt;') + '</div></div>' : '')
1106
+ + '<div class="info-section"><h2>What they get:</h2><ul>'
1107
+ + '<li>A session token to use your account</li>'
1108
+ + '<li>Access to your wallet balance</li>'
1109
+ + '<li>Ability to generate images, send messages, search memory</li>'
1110
+ + '</ul></div>'
1111
+ + '<div class="info-section safe"><h2>What they don\'t get:</h2><ul>'
1112
+ + '<li>Your passkey (never leaves your device)</li>'
1113
+ + '<li>Your biometric data (stays on your device)</li>'
1114
+ + '<li>Permanent access (session expires)</li>'
1115
+ + '</ul></div>'
1116
+ + '<p class="revoke-note">You can revoke access anytime.</p>'
1117
+ + '<button class="btn btn-primary" id="authBtn" onclick="doAuthorize()">\ud83e\udec6 Authorize</button>'
1118
+ + '<div style="margin-top:8px;text-align:center;">'
1119
+ + '<a class="create-link" id="createLink" onclick="doCreateAndAuthorize()">New here? Create an account first...</a>'
1120
+ + '</div>'
1121
+ + '</div>'
1122
+ + '<div id="successSection" style="display:none;">'
1123
+ + '<div class="success-check">\u2713</div>'
1124
+ + '<h2 style="font-size:18px;font-weight:600;margin-bottom:12px;">Authorized</h2>'
1125
+ + '<p style="font-size:14px;color:#8a8580;line-height:1.5;margin-bottom:20px;">Send this token to your agent:</p>'
1126
+ + '<div style="position:relative;background:#F5F3ED;border:1px solid #E0DDD6;border-radius:12px;padding:16px 48px 16px 20px;margin-bottom:12px;"><span id="tokenDisplay" style="font-family:monospace;font-size:13px;word-break:break-all;user-select:all;-webkit-user-select:all;cursor:text;"></span><button onclick="navigator.clipboard.writeText(document.getElementById(\'tokenDisplay\').textContent)" style="position:absolute;top:12px;right:12px;background:none;border:none;padding:6px;cursor:pointer;color:#8a8580;opacity:0.5;"><svg width=\\"16\\" height=\\"16\\" viewBox=\\"0 0 16 16\\" fill=\\"none\\" stroke=\\"currentColor\\" stroke-width=\\"1.5\\" stroke-linecap=\\"round\\" stroke-linejoin=\\"round\\"><rect x=\\"5.5\\" y=\\"5.5\\" width=\\"8\\" height=\\"8\\" rx=\\"1.5\\"/><path d=\\"M10.5 5.5V3.5C10.5 2.67 9.83 2 9 2H3.5C2.67 2 2 2.67 2 3.5V9C2 9.83 2.67 10.5 3.5 10.5H5.5\\"/></svg></button></div>'
1127
+ + '<p style="font-size:13px;color:#8a8580;">Your agent uses this as: Authorization: Bearer [token]</p>'
1128
+ + '</div>'
1129
+ + '<div class="login-status" id="status"></div>'
1130
+ + '</div>'
1131
+ + '<div id="kscope-footer" style="margin-top:48px;text-align:center;"></div>'
1132
+ + '</div></div>'
1133
+ + '<script src="/demo/footer.js"></script>'
1134
+ + '<script>\n'
1135
+ + 'var CHALLENGE_ID = ' + JSON.stringify(challengeId) + ';\n'
1136
+ + SPRITE_JS
1137
+ + 'function setStatus(msg, type) {\n'
1138
+ + ' var el = document.getElementById("status");\n'
1139
+ + ' el.textContent = msg; el.className = "login-status show " + type;\n'
1140
+ + '}\n'
1141
+ + 'function b64urlToBytes(b64url) {\n'
1142
+ + ' var b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");\n'
1143
+ + ' var pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));\n'
1144
+ + ' var bin = atob(b64 + pad);\n'
1145
+ + ' return Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });\n'
1146
+ + '}\n'
1147
+ + 'function bytesToB64url(bytes) {\n'
1148
+ + ' var bin = ""; var arr = new Uint8Array(bytes);\n'
1149
+ + ' for (var i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);\n'
1150
+ + ' return btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/g, "");\n'
1151
+ + '}\n'
1152
+ + 'async function approveAgent(agentId, apiKey) {\n'
1153
+ + ' setStatus("Approving agent access...", "loading");\n'
1154
+ + ' var approveRes = await fetch("/demo/api/agent-auth/approve", {\n'
1155
+ + ' method: "POST", headers: { "Content-Type": "application/json" },\n'
1156
+ + ' body: JSON.stringify({ challengeId: CHALLENGE_ID, agentId: agentId, apiKey: apiKey })\n'
1157
+ + ' });\n'
1158
+ + ' var approveData = await approveRes.json();\n'
1159
+ + ' if (approveData.ok) {\n'
1160
+ + ' document.getElementById("authSection").style.display = "none";\n'
1161
+ + ' document.getElementById("successSection").style.display = "block";\n'
1162
+ + ' document.getElementById("tokenDisplay").textContent = apiKey;\n'
1163
+ + ' document.getElementById("status").className = "login-status";\n'
1164
+ + ' } else {\n'
1165
+ + ' throw new Error(approveData.error || "Failed to approve");\n'
1166
+ + ' }\n'
1167
+ + '}\n'
1168
+ + 'async function doAuthorize() {\n'
1169
+ + ' var btn = document.getElementById("authBtn"); btn.disabled = true;\n'
1170
+ + ' setStatus("Preparing...", "loading");\n'
1171
+ + ' try {\n'
1172
+ + ' var optRes = await fetch("/webauthn/auth-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
1173
+ + ' var optData = await optRes.json();\n'
1174
+ + ' var challengeId = optData.challengeId;\n'
1175
+ + ' var options = optData.options;\n'
1176
+ + ' if (!options) throw new Error("Server returned no options");\n'
1177
+ + ' options.challenge = b64urlToBytes(options.challenge);\n'
1178
+ + ' if (options.allowCredentials) {\n'
1179
+ + ' options.allowCredentials = options.allowCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });\n'
1180
+ + ' }\n'
1181
+ + ' setStatus("Waiting for biometric...", "loading");\n'
1182
+ + ' var assertion = await navigator.credentials.get({ publicKey: options });\n'
1183
+ + ' var reqBody = {\n'
1184
+ + ' challengeId: challengeId,\n'
1185
+ + ' credential: {\n'
1186
+ + ' id: assertion.id, rawId: bytesToB64url(assertion.rawId), type: assertion.type,\n'
1187
+ + ' response: {\n'
1188
+ + ' authenticatorData: bytesToB64url(assertion.response.authenticatorData),\n'
1189
+ + ' clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),\n'
1190
+ + ' signature: bytesToB64url(assertion.response.signature),\n'
1191
+ + ' userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,\n'
1192
+ + ' },\n'
1193
+ + ' },\n'
1194
+ + ' };\n'
1195
+ + ' setStatus("Verifying...", "loading");\n'
1196
+ + ' var verRes = await fetch("/webauthn/auth-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
1197
+ + ' var result = await verRes.json();\n'
1198
+ + ' if (!result.success) { setStatus(result.error || "Authentication failed", "error"); btn.disabled = false; return; }\n'
1199
+ + ' await approveAgent(result.agentId, result.apiKey);\n'
1200
+ + ' } catch (err) {\n'
1201
+ + ' if (err.name === "NotAllowedError") { setStatus("Cancelled. Try again when ready.", "error"); }\n'
1202
+ + ' else { setStatus("Error: " + err.message, "error"); }\n'
1203
+ + ' btn.disabled = false;\n'
1204
+ + ' }\n'
1205
+ + '}\n'
1206
+ + 'async function doCreateAndAuthorize() {\n'
1207
+ + ' var btn = document.getElementById("authBtn"); btn.disabled = true;\n'
1208
+ + ' document.getElementById("createLink").style.display = "none";\n'
1209
+ + ' setStatus("Creating your account...", "loading");\n'
1210
+ + ' try {\n'
1211
+ + ' var optRes = await fetch("/webauthn/register-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
1212
+ + ' var optData = await optRes.json();\n'
1213
+ + ' var challengeId = optData.challengeId;\n'
1214
+ + ' var options = optData.options;\n'
1215
+ + ' if (!options) throw new Error("Server returned no options");\n'
1216
+ + ' options.challenge = b64urlToBytes(options.challenge);\n'
1217
+ + ' options.user.id = b64urlToBytes(options.user.id);\n'
1218
+ + ' if (options.excludeCredentials) {\n'
1219
+ + ' options.excludeCredentials = options.excludeCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });\n'
1220
+ + ' }\n'
1221
+ + ' setStatus("Waiting for biometric...", "loading");\n'
1222
+ + ' var credential = await navigator.credentials.create({ publicKey: options });\n'
1223
+ + ' var reqBody = {\n'
1224
+ + ' challengeId: challengeId,\n'
1225
+ + ' credential: {\n'
1226
+ + ' id: credential.id, rawId: bytesToB64url(credential.rawId), type: credential.type,\n'
1227
+ + ' response: {\n'
1228
+ + ' attestationObject: bytesToB64url(credential.response.attestationObject),\n'
1229
+ + ' clientDataJSON: bytesToB64url(credential.response.clientDataJSON),\n'
1230
+ + ' transports: credential.response.getTransports ? credential.response.getTransports() : [],\n'
1231
+ + ' },\n'
1232
+ + ' },\n'
1233
+ + ' };\n'
1234
+ + ' setStatus("Verifying...", "loading");\n'
1235
+ + ' var verRes = await fetch("/webauthn/register-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
1236
+ + ' var result = await verRes.json();\n'
1237
+ + ' if (!result.success) { setStatus(result.error || "Registration failed", "error"); btn.disabled = false; document.getElementById("createLink").style.display = ""; return; }\n'
1238
+ + ' await approveAgent(result.agentId, result.apiKey);\n'
1239
+ + ' } catch (err) {\n'
1240
+ + ' if (err.name === "NotAllowedError") { setStatus("Cancelled. Try again when ready.", "error"); }\n'
1241
+ + ' else { setStatus("Error: " + err.message, "error"); }\n'
1242
+ + ' btn.disabled = false;\n'
1243
+ + ' document.getElementById("createLink").style.display = "";\n'
1244
+ + ' }\n'
1245
+ + '}\n'
1246
+ + '</script></body></html>';
1247
+
1248
+ htmlResponse(res, 200, html);
1249
+ }
1250
+
1251
+ // POST /demo/api/agent-auth/approve ... called by the approve page after successful passkey auth
1252
+ function handleAgentAuthApprove(req, res) {
1253
+ readBody(req).then(function(body) {
1254
+ const { challengeId, agentId, apiKey } = body || {};
1255
+ const entry = agentAuthChallenges[challengeId];
1256
+ if (!entry || Date.now() > entry.expires) {
1257
+ json(res, 404, { error: "Challenge not found or expired" });
1258
+ return;
1259
+ }
1260
+ if (entry.status === "approved") {
1261
+ json(res, 400, { error: "Already approved" });
1262
+ return;
1263
+ }
1264
+ entry.status = "approved";
1265
+ entry.token = apiKey;
1266
+ entry.agentId = agentId;
1267
+ console.log("Agent QR auth: approved challenge " + challengeId.slice(0, 8) + "... for agent '" + agentId + "'");
1268
+ json(res, 200, { ok: true });
1269
+ }).catch(function() {
1270
+ json(res, 400, { error: "Invalid request" });
1271
+ });
1272
+ }
1273
+
1274
+ // ---------- Demo API handlers ----------
1275
+
1276
+ // ── Wallet tracking (per agent, persisted to file) ──
1277
+ const WALLET_FILE = join(dirname(fileURLToPath(import.meta.url)), "wallets.json");
1278
+ const IMAGE_COST_CENTS = 1; // $0.01
1279
+ const INITIAL_BALANCE_CENTS = 500; // $5.00
1280
+
1281
+ function loadWallets() { try { return JSON.parse(readFileSync(WALLET_FILE, "utf8")); } catch { return {}; } }
1282
+ function saveWallets(w) { writeFileSync(WALLET_FILE, JSON.stringify(w, null, 2) + "\n"); }
1283
+ function getBalance(agentId) { const w = loadWallets(); return w[agentId] !== undefined ? w[agentId] : INITIAL_BALANCE_CENTS; }
1284
+ function deductBalance(agentId, cents) {
1285
+ const w = loadWallets();
1286
+ if (w[agentId] === undefined) w[agentId] = INITIAL_BALANCE_CENTS;
1287
+ w[agentId] = Math.max(0, w[agentId] - cents);
1288
+ saveWallets(w);
1289
+ return w[agentId];
1290
+ }
1291
+ function formatCents(c) { return "$" + (c / 100).toFixed(2); }
1292
+
1293
+ // POST /demo/api/analyze-photo
1294
+ // Sends a base64 image to OpenAI GPT-4o vision to extract colors/mood.
1295
+ async function handleDemoAnalyzePhoto(req, res) {
1296
+ const identity = authenticate(req);
1297
+ if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
1298
+
1299
+ try {
1300
+ const body = await readBody(req);
1301
+ const image = body?.image;
1302
+ if (!image || typeof image !== "string" || !image.startsWith("data:image/")) {
1303
+ json(res, 400, { error: "Missing or invalid base64 image" });
1304
+ return;
1305
+ }
1306
+
1307
+ const OPENAI_KEY = process.env.OPENAI_API_KEY || "";
1308
+ if (!OPENAI_KEY) {
1309
+ json(res, 503, { error: "Vision analysis not configured" });
1310
+ return;
1311
+ }
1312
+
1313
+ const oaiRes = await fetch("https://api.openai.com/v1/chat/completions", {
1314
+ method: "POST",
1315
+ headers: {
1316
+ "Content-Type": "application/json",
1317
+ "Authorization": "Bearer " + OPENAI_KEY,
1318
+ },
1319
+ body: JSON.stringify({
1320
+ model: "gpt-4o",
1321
+ max_tokens: 80,
1322
+ messages: [
1323
+ {
1324
+ role: "user",
1325
+ content: [
1326
+ {
1327
+ type: "text",
1328
+ text: "List only the 5 most dominant COLOR NAMES in this image, separated by commas. Example: warm amber, deep brown, soft cream, golden yellow, muted gray. Do NOT describe objects, people, faces, or shapes. ONLY color names. Nothing else.",
1329
+ },
1330
+ {
1331
+ type: "image_url",
1332
+ image_url: { url: image },
1333
+ },
1334
+ ],
1335
+ },
1336
+ ],
1337
+ }),
1338
+ });
1339
+
1340
+ const oaiData = await oaiRes.json();
1341
+ const description = oaiData.choices?.[0]?.message?.content?.trim();
1342
+
1343
+ if (!description) {
1344
+ console.error("Vision analysis: no description returned", oaiData.error || "");
1345
+ json(res, 502, { error: "Vision analysis returned no description" });
1346
+ return;
1347
+ }
1348
+
1349
+ console.log("Demo: vision analysis for agent '" + identity.agentId + "': " + description);
1350
+ json(res, 200, { description });
1351
+ } catch (err) {
1352
+ console.error("Demo analyze-photo error:", err.message);
1353
+ json(res, 500, { error: "Internal error" });
1354
+ }
1355
+ }
1356
+
1357
+ // POST /demo/api/imagine
1358
+ async function handleDemoImagine(req, res) {
1359
+ const identity = authenticate(req);
1360
+ if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
1361
+
1362
+ try {
1363
+ const body = await readBody(req);
1364
+ const prompt = body?.prompt || "kaleidoscope";
1365
+
1366
+ const XAI_KEY = process.env.XAI_API_KEY || "";
1367
+ if (!XAI_KEY) {
1368
+ json(res, 503, { error: "Image generation not configured" });
1369
+ return;
1370
+ }
1371
+
1372
+ const grokRes = await fetch("https://api.x.ai/v1/images/generations", {
1373
+ method: "POST",
1374
+ headers: {
1375
+ "Content-Type": "application/json",
1376
+ "Authorization": "Bearer " + XAI_KEY,
1377
+ },
1378
+ body: JSON.stringify({
1379
+ model: "grok-imagine-image",
1380
+ prompt: prompt,
1381
+ n: 1,
1382
+ }),
1383
+ });
1384
+
1385
+ const grokData = await grokRes.json();
1386
+ if (grokData.error) {
1387
+ json(res, 502, { error: grokData.error.message || "Image generation failed" });
1388
+ return;
1389
+ }
1390
+
1391
+ const imageUrl = grokData.data?.[0]?.url;
1392
+ if (!imageUrl) {
1393
+ json(res, 502, { error: "No image returned" });
1394
+ return;
1395
+ }
1396
+
1397
+ const newBalance = deductBalance(identity.agentId, IMAGE_COST_CENTS);
1398
+ console.log("Demo: generated image for agent '" + identity.agentId + "' (balance: " + formatCents(newBalance) + ")");
1399
+ json(res, 200, { url: imageUrl, prompt: prompt, cost: formatCents(IMAGE_COST_CENTS), balance: formatCents(newBalance) });
1400
+ } catch (err) {
1401
+ console.error("Demo imagine error:", err.message);
1402
+ json(res, 500, { error: "Internal error" });
1403
+ }
1404
+ }
1405
+
1406
+ // ---------- MCP handlers ----------
1407
+
1408
+ async function handlePost(req, res, identity) {
1409
+ const sid = req.headers["mcp-session-id"];
1410
+ let body;
1411
+ try { body = await readBody(req); } catch { rpcError(res, 400, -32700, "Parse error"); return; }
1412
+
1413
+ if (sid && sessions[sid]) {
1414
+ touchSession(sid);
1415
+ await sessions[sid].transport.handleRequest(req, res, body);
1416
+ return;
1417
+ }
1418
+
1419
+ if (!sid && isInitializeRequest(body)) {
1420
+ const transport = new StreamableHTTPServerTransport({
1421
+ sessionIdGenerator: () => randomUUID(),
1422
+ onsessioninitialized: (id) => {
1423
+ sessions[id] = { transport, server: mcpServer, identity, lastActivity: Date.now() };
1424
+ console.log("Session created: " + id + " (agent: " + identity.agentId + ")");
1425
+ },
1426
+ });
1427
+ transport.onclose = () => {
1428
+ const id = transport.sessionId;
1429
+ if (id && sessions[id]) { console.log("Session closed: " + id); delete sessions[id]; }
1430
+ };
1431
+ const mcpServer = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
1432
+ registerTools(mcpServer, () => identity);
1433
+ await mcpServer.connect(transport);
1434
+ await transport.handleRequest(req, res, body);
1435
+ return;
1436
+ }
1437
+
1438
+ rpcError(res, 400, -32000, "Bad request: missing or invalid session");
1439
+ }
1440
+
1441
+ async function handleGetOrDelete(req, res) {
1442
+ const sid = req.headers["mcp-session-id"];
1443
+ if (!sid || !sessions[sid]) { rpcError(res, 400, -32000, "Invalid or missing session ID"); return; }
1444
+ touchSession(sid);
1445
+ await sessions[sid].transport.handleRequest(req, res);
1446
+ }
1447
+
1448
+ // ---------- HTTP server ----------
1449
+
1450
+ const httpServer = createServer(async (req, res) => {
1451
+ cors(res);
1452
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
1453
+
1454
+ const url = parseUrl(req.url);
1455
+ const path = url.pathname;
1456
+
1457
+ // Health check
1458
+ if (req.method === "GET" && path === "/health") {
1459
+ json(res, 200, {
1460
+ ok: true, server: SERVER_NAME, version: SERVER_VERSION,
1461
+ sessions: Object.keys(sessions).length,
1462
+ passkeys: passkeys.length,
1463
+ uptime: process.uptime(),
1464
+ });
1465
+ return;
1466
+ }
1467
+
1468
+ // --- Static pages ---
1469
+
1470
+ if (req.method === "GET" && path === "/signup") {
1471
+ handleSignupPage(req, res);
1472
+ return;
1473
+ }
1474
+
1475
+ if (req.method === "GET" && path === "/login") {
1476
+ handleLoginPage(req, res);
1477
+ return;
1478
+ }
1479
+
1480
+ // --- WebAuthn API ---
1481
+
1482
+ if (req.method === "POST" && path === "/webauthn/register-options") {
1483
+ await handleRegisterOptions(req, res);
1484
+ return;
1485
+ }
1486
+
1487
+ if (req.method === "POST" && path === "/webauthn/register-verify") {
1488
+ await handleRegisterVerify(req, res);
1489
+ return;
1490
+ }
1491
+
1492
+ if (req.method === "POST" && path === "/webauthn/auth-options") {
1493
+ await handleAuthOptions(req, res);
1494
+ return;
1495
+ }
1496
+
1497
+ if (req.method === "POST" && path === "/webauthn/auth-verify") {
1498
+ await handleAuthVerify(req, res);
1499
+ return;
1500
+ }
1501
+
1502
+ // --- OAuth 2.0 / Well-Known ---
1503
+
1504
+ if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") {
1505
+ handleOAuthDiscovery(req, res);
1506
+ return;
1507
+ }
1508
+
1509
+ if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") {
1510
+ handleProtectedResource(req, res);
1511
+ return;
1512
+ }
1513
+
1514
+ if (req.method === "POST" && path === "/oauth/register") {
1515
+ await handleOAuthRegister(req, res);
1516
+ return;
1517
+ }
1518
+
1519
+ if (req.method === "GET" && path === "/oauth/authorize") {
1520
+ handleOAuthAuthorize(req, res);
1521
+ return;
1522
+ }
1523
+
1524
+ if (req.method === "POST" && path === "/oauth/authorize/submit") {
1525
+ await handleOAuthAuthorizeSubmit(req, res);
1526
+ return;
1527
+ }
1528
+
1529
+ if (req.method === "POST" && path === "/oauth/token") {
1530
+ await handleOAuthToken(req, res);
1531
+ return;
1532
+ }
1533
+
1534
+ // --- Agent QR Auth ---
1535
+
1536
+ if (req.method === "GET" && path === "/demo/api/agent-auth") {
1537
+ await handleAgentAuthStart(req, res);
1538
+ return;
1539
+ }
1540
+
1541
+ if (req.method === "GET" && path === "/demo/api/agent-auth/qr") {
1542
+ handleAgentAuthQR(req, res);
1543
+ return;
1544
+ }
1545
+
1546
+ if (req.method === "GET" && path === "/demo/api/agent-auth/status") {
1547
+ handleAgentAuthStatus(req, res);
1548
+ return;
1549
+ }
1550
+
1551
+ if (req.method === "POST" && path === "/demo/api/agent-auth/approve") {
1552
+ handleAgentAuthApprove(req, res);
1553
+ return;
1554
+ }
1555
+
1556
+ if (req.method === "GET" && path === "/approve") {
1557
+ handleApprovePage(req, res);
1558
+ return;
1559
+ }
1560
+
1561
+ // --- Demo API ---
1562
+
1563
+ if (req.method === "GET" && path === "/demo/api/wallet") {
1564
+ const identity = authenticate(req);
1565
+ if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
1566
+ json(res, 200, { balance: formatCents(getBalance(identity.agentId)), cost: formatCents(IMAGE_COST_CENTS) });
1567
+ return;
1568
+ }
1569
+
1570
+ if (req.method === "POST" && path === "/demo/api/analyze-photo") {
1571
+ await handleDemoAnalyzePhoto(req, res);
1572
+ return;
1573
+ }
1574
+
1575
+ if (req.method === "POST" && path === "/demo/api/imagine") {
1576
+ await handleDemoImagine(req, res);
1577
+ return;
1578
+ }
1579
+
1580
+ // --- MCP ---
1581
+
1582
+ if (path === "/mcp") {
1583
+ const identity = authenticate(req);
1584
+ if (!identity && req.method === "POST") {
1585
+ json(res, 401, { error: "Unauthorized. Provide Bearer ck-... token." });
1586
+ return;
1587
+ }
1588
+ try {
1589
+ if (req.method === "POST") await handlePost(req, res, identity);
1590
+ else if (req.method === "GET" || req.method === "DELETE") await handleGetOrDelete(req, res);
1591
+ else rpcError(res, 405, -32000, "Method not allowed");
1592
+ } catch (err) {
1593
+ console.error("MCP error:", err);
1594
+ if (!res.headersSent) rpcError(res, 500, -32603, "Internal server error");
1595
+ }
1596
+ return;
1597
+ }
1598
+
1599
+ json(res, 404, { error: "Not found" });
1600
+ });
1601
+
1602
+ httpServer.listen(PORT, SERVER_BIND, () => {
1603
+ console.log(SERVER_NAME + " v" + SERVER_VERSION + " listening on " + SERVER_BIND + ":" + PORT);
1604
+ console.log("Health: http://localhost:" + PORT + "/health");
1605
+ console.log("MCP: http://localhost:" + PORT + "/mcp");
1606
+ console.log("OAuth: http://localhost:" + PORT + "/.well-known/oauth-authorization-server");
1607
+ console.log("Signup: http://localhost:" + PORT + "/signup");
1608
+ console.log("Login: http://localhost:" + PORT + "/login");
1609
+ console.log("Demo: http://localhost:" + PORT + "/demo/");
1610
+ console.log("Passkeys stored: " + passkeys.length);
1611
+ console.log("Session timeout: " + (SESSION_TIMEOUT_MS / 60000) + " min");
1612
+ });
1613
+
1614
+ async function shutdown() {
1615
+ console.log("Shutting down...");
1616
+ clearInterval(cleanupTimer);
1617
+ for (const sid of Object.keys(sessions)) {
1618
+ try { await sessions[sid].transport.close(); } catch {}
1619
+ delete sessions[sid];
1620
+ }
1621
+ httpServer.close();
1622
+ process.exit(0);
1623
+ }
1624
+ process.on("SIGINT", shutdown);
1625
+ process.on("SIGTERM", shutdown);