@wipcomputer/wip-ldm-os 0.4.73-alpha.13 → 0.4.73-alpha.14

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.
@@ -1,22 +1,109 @@
1
1
  // server.mjs: Hosted MCP server for wip.computer
2
2
  // MCP Streamable HTTP transport at /mcp, health check at /health.
3
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).
4
6
 
5
- import { randomUUID } from "node:crypto";
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";
6
11
  import { createServer } from "node:http";
7
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
13
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
14
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
10
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 ─────────────────────────────────────────────────────────
11
25
 
12
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();
13
73
 
14
- // Prototype: one hardcoded test key. Later: database / Agent Pay.
15
- const API_KEYS = { "ck-test-001": "test-agent" };
74
+ // Challenge store: challengeId -> { challenge, type, userId, expires }
75
+ // Short-lived, in-memory only. Cleared on restart.
76
+ const challenges = {};
16
77
 
17
- // Session ID -> { transport, server, identity }
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 }
18
83
  const sessions = {};
19
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
+
20
107
  function authenticate(req) {
21
108
  const auth = req.headers["authorization"];
22
109
  if (!auth?.startsWith("Bearer ")) return null;
@@ -26,13 +113,25 @@ function authenticate(req) {
26
113
 
27
114
  function readBody(req) {
28
115
  return new Promise((resolve, reject) => {
116
+ const timer = setTimeout(() => reject(new Error("Request body read timeout")), MAX_REQUEST_BODY_MS);
29
117
  const chunks = [];
30
118
  req.on("data", (c) => chunks.push(c));
31
119
  req.on("end", () => {
120
+ clearTimeout(timer);
32
121
  try { const raw = Buffer.concat(chunks).toString(); resolve(raw ? JSON.parse(raw) : undefined); }
33
122
  catch (e) { reject(e); }
34
123
  });
35
- req.on("error", reject);
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); });
36
135
  });
37
136
  }
38
137
 
@@ -41,6 +140,11 @@ function json(res, status, body) {
41
140
  res.end(JSON.stringify(body));
42
141
  }
43
142
 
143
+ function htmlResponse(res, status, body) {
144
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
145
+ res.end(body);
146
+ }
147
+
44
148
  function rpcError(res, status, code, message) {
45
149
  json(res, status, { jsonrpc: "2.0", error: { code, message }, id: null });
46
150
  }
@@ -52,12 +156,1262 @@ function cors(res) {
52
156
  res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
53
157
  }
54
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
+
55
1408
  async function handlePost(req, res, identity) {
56
1409
  const sid = req.headers["mcp-session-id"];
57
1410
  let body;
58
1411
  try { body = await readBody(req); } catch { rpcError(res, 400, -32700, "Parse error"); return; }
59
1412
 
60
1413
  if (sid && sessions[sid]) {
1414
+ touchSession(sid);
61
1415
  await sessions[sid].transport.handleRequest(req, res, body);
62
1416
  return;
63
1417
  }
@@ -66,15 +1420,15 @@ async function handlePost(req, res, identity) {
66
1420
  const transport = new StreamableHTTPServerTransport({
67
1421
  sessionIdGenerator: () => randomUUID(),
68
1422
  onsessioninitialized: (id) => {
69
- sessions[id] = { transport, server: mcpServer, identity };
70
- console.log(`Session created: ${id} (agent: ${identity.agentId})`);
1423
+ sessions[id] = { transport, server: mcpServer, identity, lastActivity: Date.now() };
1424
+ console.log("Session created: " + id + " (agent: " + identity.agentId + ")");
71
1425
  },
72
1426
  });
73
1427
  transport.onclose = () => {
74
1428
  const id = transport.sessionId;
75
- if (id && sessions[id]) { console.log(`Session closed: ${id}`); delete sessions[id]; }
1429
+ if (id && sessions[id]) { console.log("Session closed: " + id); delete sessions[id]; }
76
1430
  };
77
- const mcpServer = new McpServer({ name: "wip-mcp", version: "0.1.0" });
1431
+ const mcpServer = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
78
1432
  registerTools(mcpServer, () => identity);
79
1433
  await mcpServer.connect(transport);
80
1434
  await transport.handleRequest(req, res, body);
@@ -87,21 +1441,150 @@ async function handlePost(req, res, identity) {
87
1441
  async function handleGetOrDelete(req, res) {
88
1442
  const sid = req.headers["mcp-session-id"];
89
1443
  if (!sid || !sessions[sid]) { rpcError(res, 400, -32000, "Invalid or missing session ID"); return; }
1444
+ touchSession(sid);
90
1445
  await sessions[sid].transport.handleRequest(req, res);
91
1446
  }
92
1447
 
1448
+ // ---------- HTTP server ----------
1449
+
93
1450
  const httpServer = createServer(async (req, res) => {
94
1451
  cors(res);
95
1452
  if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
96
1453
 
97
- if (req.method === "GET" && req.url === "/health") {
98
- json(res, 200, { ok: true, server: "wip-mcp", version: "0.1.0", sessions: Object.keys(sessions).length, uptime: process.uptime() });
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);
99
1511
  return;
100
1512
  }
101
1513
 
102
- if (req.url === "/mcp") {
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") {
103
1564
  const identity = authenticate(req);
104
- if (!identity && req.method === "POST") { json(res, 401, { error: "Unauthorized. Provide Bearer ck-... token." }); return; }
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
+ }
105
1588
  try {
106
1589
  if (req.method === "POST") await handlePost(req, res, identity);
107
1590
  else if (req.method === "GET" || req.method === "DELETE") await handleGetOrDelete(req, res);
@@ -116,15 +1599,25 @@ const httpServer = createServer(async (req, res) => {
116
1599
  json(res, 404, { error: "Not found" });
117
1600
  });
118
1601
 
119
- httpServer.listen(PORT, "0.0.0.0", () => {
120
- console.log(`wip-mcp listening on 0.0.0.0:${PORT}`);
121
- console.log(`Health: http://localhost:${PORT}/health`);
122
- console.log(`MCP: http://localhost:${PORT}/mcp`);
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");
123
1612
  });
124
1613
 
125
1614
  async function shutdown() {
126
1615
  console.log("Shutting down...");
127
- for (const sid of Object.keys(sessions)) { try { await sessions[sid].transport.close(); } catch {} delete sessions[sid]; }
1616
+ clearInterval(cleanupTimer);
1617
+ for (const sid of Object.keys(sessions)) {
1618
+ try { await sessions[sid].transport.close(); } catch {}
1619
+ delete sessions[sid];
1620
+ }
128
1621
  httpServer.close();
129
1622
  process.exit(0);
130
1623
  }