@wipcomputer/wip-ldm-os 0.4.73-alpha.8 → 0.4.74
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.
- package/LICENSE +52 -0
- package/SKILL.md +8 -1
- package/bin/ldm.js +587 -82
- package/dist/bridge/chunk-3RG5ZIWI.js +10 -0
- package/dist/bridge/{chunk-LF7EMFBY.js → chunk-7NH6JBIO.js} +127 -49
- package/dist/bridge/cli.js +2 -1
- package/dist/bridge/core.d.ts +13 -1
- package/dist/bridge/core.js +4 -1
- package/dist/bridge/mcp-server.js +52 -7
- package/dist/bridge/openclaw.d.ts +5 -0
- package/dist/bridge/openclaw.js +11 -0
- package/docs/bridge/TECHNICAL.md +86 -0
- package/docs/doc-pipeline/README.md +74 -0
- package/docs/doc-pipeline/TECHNICAL.md +79 -0
- package/lib/deploy.mjs +175 -13
- package/lib/detect.mjs +20 -6
- package/package.json +2 -2
- package/shared/docs/README.md.tmpl +2 -2
- package/shared/docs/how-releases-work.md.tmpl +3 -1
- package/shared/docs/how-worktrees-work.md.tmpl +12 -7
- package/shared/rules/git-conventions.md +3 -3
- package/shared/rules/release-pipeline.md +1 -1
- package/shared/rules/security.md +1 -1
- package/shared/rules/workspace-boundaries.md +1 -1
- package/shared/rules/writing-style.md +1 -1
- package/shared/templates/claude-md-level1.md +7 -3
- package/src/bridge/core.ts +160 -56
- package/src/bridge/mcp-server.ts +93 -8
- package/src/bridge/openclaw.ts +14 -0
- package/src/hooks/inbox-check-hook.mjs +232 -0
- package/src/hooks/inbox-rewake-hook.mjs +388 -0
- package/src/hosted-mcp/.env.example +3 -0
- package/src/hosted-mcp/demo/agent.html +300 -0
- package/src/hosted-mcp/demo/agent.txt +84 -0
- package/src/hosted-mcp/demo/fallback.jpg +0 -0
- package/src/hosted-mcp/demo/footer.js +74 -0
- package/src/hosted-mcp/demo/index.html +1303 -0
- package/src/hosted-mcp/demo/login.html +548 -0
- package/src/hosted-mcp/demo/privacy.html +223 -0
- package/src/hosted-mcp/demo/sprites.jpg +0 -0
- package/src/hosted-mcp/demo/sprites.png +0 -0
- package/src/hosted-mcp/demo/tos.html +198 -0
- package/src/hosted-mcp/deploy.sh +70 -0
- package/src/hosted-mcp/ecosystem.config.cjs +14 -0
- package/src/hosted-mcp/inbox.mjs +64 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +205 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +230 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +98 -0
- package/src/hosted-mcp/nginx/mcp-server.conf +17 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +45 -0
- package/src/hosted-mcp/package-lock.json +2092 -0
- package/src/hosted-mcp/package.json +23 -0
- package/src/hosted-mcp/prisma/migrations/20260406233014_init/migration.sql +68 -0
- package/src/hosted-mcp/prisma/migrations/migration_lock.toml +3 -0
- package/src/hosted-mcp/prisma/schema.prisma +57 -0
- package/src/hosted-mcp/prisma.config.ts +14 -0
- package/src/hosted-mcp/server.mjs +2093 -0
- package/src/hosted-mcp/shared/kaleidoscope.css +139 -0
- package/src/hosted-mcp/shared/kaleidoscope.js +192 -0
- package/src/hosted-mcp/tools.mjs +73 -0
- package/templates/hooks/pre-commit +5 -0
|
@@ -0,0 +1,2093 @@
|
|
|
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 { PrismaClient } from "@prisma/client";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
16
|
+
import { registerTools } from "./tools.mjs";
|
|
17
|
+
import {
|
|
18
|
+
generateRegistrationOptions,
|
|
19
|
+
verifyRegistrationResponse,
|
|
20
|
+
generateAuthenticationOptions,
|
|
21
|
+
verifyAuthenticationResponse,
|
|
22
|
+
} from "@simplewebauthn/server";
|
|
23
|
+
import QRCode from "qrcode";
|
|
24
|
+
|
|
25
|
+
// ── Settings ─────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const PORT = parseInt(process.env.MCP_PORT || "18800", 10);
|
|
28
|
+
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
29
|
+
const SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
30
|
+
const OAUTH_CODE_EXPIRY_MS = 10 * 60 * 1000;
|
|
31
|
+
const MAX_REQUEST_BODY_MS = 30_000;
|
|
32
|
+
const SERVER_VERSION = "0.2.0";
|
|
33
|
+
const SERVER_NAME = "wip-mcp";
|
|
34
|
+
const SERVER_BIND = "0.0.0.0";
|
|
35
|
+
const ISSUER_URL = "https://wip.computer";
|
|
36
|
+
const MCP_RESOURCE_URL = "https://wip.computer/mcp";
|
|
37
|
+
|
|
38
|
+
// WebAuthn relying party config
|
|
39
|
+
const RP_NAME = "Memory Crystal";
|
|
40
|
+
const RP_ID = "wip.computer";
|
|
41
|
+
const RP_ORIGIN = "https://wip.computer";
|
|
42
|
+
|
|
43
|
+
// ── Data layer ──────────────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// Primary: Postgres via Prisma (production).
|
|
46
|
+
// Fallback: JSON files (if DATABASE_URL is not set, e.g. local dev without Postgres).
|
|
47
|
+
//
|
|
48
|
+
// The demo and all API endpoints use the db.* functions below.
|
|
49
|
+
// They try Prisma first, fall back to JSON if Prisma isn't available.
|
|
50
|
+
|
|
51
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const TOKEN_FILE = join(__dirname, "tokens.json");
|
|
53
|
+
const PASSKEY_FILE = join(__dirname, "passkeys.json");
|
|
54
|
+
const WALLET_FILE_LEGACY = join(__dirname, "wallets.json");
|
|
55
|
+
|
|
56
|
+
// Initialize Prisma (may fail if DATABASE_URL not set)
|
|
57
|
+
let prisma = null;
|
|
58
|
+
let usePrisma = false;
|
|
59
|
+
try {
|
|
60
|
+
prisma = new PrismaClient();
|
|
61
|
+
await prisma.$connect();
|
|
62
|
+
usePrisma = true;
|
|
63
|
+
console.log("Database: Postgres via Prisma");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.log("Database: JSON files (Prisma not available: " + err.message + ")");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ── API Keys ────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
// Hardcoded defaults (always available, even without DB)
|
|
71
|
+
const DEFAULT_API_KEYS = {
|
|
72
|
+
"ck-test-001": "test-agent",
|
|
73
|
+
"ck-e04df46877aa3672e21c4e33149bacc4": "cc-mini",
|
|
74
|
+
"ck-f1986e957e21cbb40dc100bc05dc78ec": "lesa",
|
|
75
|
+
"ck-c2849eef903407c877bc6e79bf8794aa": "parker",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// In-memory cache (populated from DB or JSON on boot)
|
|
79
|
+
const API_KEYS = { ...DEFAULT_API_KEYS };
|
|
80
|
+
|
|
81
|
+
// Load from JSON (fallback)
|
|
82
|
+
function loadTokensFromFile() {
|
|
83
|
+
try { return JSON.parse(readFileSync(TOKEN_FILE, "utf8")); } catch { return {}; }
|
|
84
|
+
}
|
|
85
|
+
Object.assign(API_KEYS, loadTokensFromFile());
|
|
86
|
+
|
|
87
|
+
async function saveApiKey(key, agentId) {
|
|
88
|
+
API_KEYS[key] = agentId;
|
|
89
|
+
if (usePrisma) {
|
|
90
|
+
try {
|
|
91
|
+
await prisma.apiKey.upsert({
|
|
92
|
+
where: { key },
|
|
93
|
+
update: { agentId },
|
|
94
|
+
create: { key, agentId },
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error("Prisma saveApiKey error:", err.message);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Always write JSON as backup
|
|
101
|
+
try { writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n"); } catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Passkeys ────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
// In-memory array (populated from DB or JSON on boot)
|
|
107
|
+
let passkeys = [];
|
|
108
|
+
|
|
109
|
+
function loadPasskeysFromFile() {
|
|
110
|
+
try { return JSON.parse(readFileSync(PASSKEY_FILE, "utf8")); } catch { return []; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadPasskeysFromDb() {
|
|
114
|
+
if (!usePrisma) return loadPasskeysFromFile();
|
|
115
|
+
try {
|
|
116
|
+
const creds = await prisma.credential.findMany({ include: { user: true } });
|
|
117
|
+
return creds.map(c => ({
|
|
118
|
+
credentialId: c.id,
|
|
119
|
+
publicKey: Buffer.from(c.publicKey).toString("base64url"),
|
|
120
|
+
counter: c.counter,
|
|
121
|
+
userId: c.userId,
|
|
122
|
+
agentId: c.user?.name ? "passkey-" + c.user.name.slice(0, 12) : "unknown",
|
|
123
|
+
createdAt: c.createdAt.toISOString(),
|
|
124
|
+
transports: c.transports || [],
|
|
125
|
+
}));
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error("Prisma loadPasskeys error:", err.message);
|
|
128
|
+
return loadPasskeysFromFile();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function savePasskey(entry) {
|
|
133
|
+
passkeys.push(entry);
|
|
134
|
+
if (usePrisma) {
|
|
135
|
+
try {
|
|
136
|
+
// Ensure user exists
|
|
137
|
+
let user = await prisma.user.findUnique({ where: { id: entry.userId } });
|
|
138
|
+
if (!user) {
|
|
139
|
+
user = await prisma.user.create({
|
|
140
|
+
data: { id: entry.userId, name: entry.agentId || "user" },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
await prisma.credential.create({
|
|
144
|
+
data: {
|
|
145
|
+
id: entry.credentialId,
|
|
146
|
+
userId: entry.userId,
|
|
147
|
+
publicKey: Buffer.from(entry.publicKey, "base64url"),
|
|
148
|
+
counter: entry.counter || 0,
|
|
149
|
+
transports: entry.transports || [],
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error("Prisma savePasskey error:", err.message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Always write JSON as backup
|
|
157
|
+
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function updatePasskeyCounter(credentialId, newCounter) {
|
|
161
|
+
const entry = passkeys.find(p => p.credentialId === credentialId);
|
|
162
|
+
if (entry) entry.counter = newCounter;
|
|
163
|
+
if (usePrisma) {
|
|
164
|
+
try {
|
|
165
|
+
await prisma.credential.update({
|
|
166
|
+
where: { id: credentialId },
|
|
167
|
+
data: { counter: newCounter },
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error("Prisma updateCounter error:", err.message);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Boot: load passkeys
|
|
177
|
+
passkeys = await loadPasskeysFromDb();
|
|
178
|
+
|
|
179
|
+
// Challenge store: challengeId -> { challenge, type, userId, expires }
|
|
180
|
+
// Short-lived, in-memory only. Cleared on restart.
|
|
181
|
+
const challenges = {};
|
|
182
|
+
|
|
183
|
+
// Agent QR auth challenges: challengeId -> { qrBuffer, status, token, agentId, expires }
|
|
184
|
+
const agentAuthChallenges = {};
|
|
185
|
+
const AGENT_AUTH_EXPIRY_MS = 5 * 60 * 1000;
|
|
186
|
+
|
|
187
|
+
// QR login sessions (Chrome fallback): sessionId -> { qrBuffer, status, agentId, apiKey, handle, expires }
|
|
188
|
+
const qrLoginSessions = {};
|
|
189
|
+
const QR_LOGIN_EXPIRY_MS = 5 * 60 * 1000;
|
|
190
|
+
|
|
191
|
+
// Session ID -> { transport, server, identity, lastActivity }
|
|
192
|
+
const sessions = {};
|
|
193
|
+
|
|
194
|
+
// ---------- OAuth 2.0 in-memory stores ----------
|
|
195
|
+
const oauthClients = {};
|
|
196
|
+
const oauthCodes = {};
|
|
197
|
+
|
|
198
|
+
const OAUTH_METADATA = {
|
|
199
|
+
issuer: ISSUER_URL,
|
|
200
|
+
authorization_endpoint: ISSUER_URL + "/oauth/authorize",
|
|
201
|
+
token_endpoint: ISSUER_URL + "/oauth/token",
|
|
202
|
+
registration_endpoint: ISSUER_URL + "/oauth/register",
|
|
203
|
+
response_types_supported: ["code"],
|
|
204
|
+
grant_types_supported: ["authorization_code"],
|
|
205
|
+
code_challenge_methods_supported: ["S256"],
|
|
206
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const PROTECTED_RESOURCE = {
|
|
210
|
+
resource: MCP_RESOURCE_URL,
|
|
211
|
+
authorization_servers: [ISSUER_URL],
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// ---------- Helpers ----------
|
|
215
|
+
|
|
216
|
+
function authenticate(req) {
|
|
217
|
+
const auth = req.headers["authorization"];
|
|
218
|
+
if (!auth?.startsWith("Bearer ")) return null;
|
|
219
|
+
const key = auth.slice(7).trim();
|
|
220
|
+
return API_KEYS[key] ? { agentId: API_KEYS[key], apiKey: key } : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function readBody(req) {
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
const timer = setTimeout(() => reject(new Error("Request body read timeout")), MAX_REQUEST_BODY_MS);
|
|
226
|
+
const chunks = [];
|
|
227
|
+
req.on("data", (c) => chunks.push(c));
|
|
228
|
+
req.on("end", () => {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
try { const raw = Buffer.concat(chunks).toString(); resolve(raw ? JSON.parse(raw) : undefined); }
|
|
231
|
+
catch (e) { reject(e); }
|
|
232
|
+
});
|
|
233
|
+
req.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function readBodyRaw(req) {
|
|
238
|
+
return new Promise((resolve, reject) => {
|
|
239
|
+
const timer = setTimeout(() => reject(new Error("Request body read timeout")), MAX_REQUEST_BODY_MS);
|
|
240
|
+
const chunks = [];
|
|
241
|
+
req.on("data", (c) => chunks.push(c));
|
|
242
|
+
req.on("end", () => { clearTimeout(timer); resolve(Buffer.concat(chunks).toString()); });
|
|
243
|
+
req.on("error", (e) => { clearTimeout(timer); reject(e); });
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function json(res, status, body) {
|
|
248
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
249
|
+
res.end(JSON.stringify(body));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function htmlResponse(res, status, body) {
|
|
253
|
+
res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
|
254
|
+
res.end(body);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function rpcError(res, status, code, message) {
|
|
258
|
+
json(res, status, { jsonrpc: "2.0", error: { code, message }, id: null });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function cors(res) {
|
|
262
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
263
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
264
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, Last-Event-ID");
|
|
265
|
+
res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function generateApiKey() {
|
|
269
|
+
return "ck-" + randomUUID().replace(/-/g, "");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseUrl(reqUrl) {
|
|
273
|
+
return new URL(reqUrl, "http://localhost");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function esc(s) {
|
|
277
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sanitizeUsername(raw) {
|
|
281
|
+
if (!raw || typeof raw !== "string") return null;
|
|
282
|
+
const cleaned = raw.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30);
|
|
283
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------- Session cleanup ----------
|
|
287
|
+
|
|
288
|
+
function touchSession(sid) {
|
|
289
|
+
if (sessions[sid]) sessions[sid].lastActivity = Date.now();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function cleanupStaleSessions() {
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
let cleaned = 0;
|
|
295
|
+
for (const sid of Object.keys(sessions)) {
|
|
296
|
+
const age = now - (sessions[sid].lastActivity || 0);
|
|
297
|
+
if (age > SESSION_TIMEOUT_MS) {
|
|
298
|
+
try { sessions[sid].transport.close(); } catch {}
|
|
299
|
+
delete sessions[sid];
|
|
300
|
+
cleaned++;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (cleaned > 0) {
|
|
304
|
+
console.log("Session cleanup: removed " + cleaned + " stale session(s). Active: " + Object.keys(sessions).length);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const cleanupTimer = setInterval(cleanupStaleSessions, SESSION_CLEANUP_INTERVAL_MS);
|
|
309
|
+
cleanupTimer.unref();
|
|
310
|
+
|
|
311
|
+
function cleanupExpiredCodes() {
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
for (const code of Object.keys(oauthCodes)) {
|
|
314
|
+
if (now > oauthCodes[code].expires) delete oauthCodes[code];
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function cleanupExpiredChallenges() {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
for (const id of Object.keys(challenges)) {
|
|
321
|
+
if (now > challenges[id].expires) delete challenges[id];
|
|
322
|
+
}
|
|
323
|
+
for (const id of Object.keys(agentAuthChallenges)) {
|
|
324
|
+
if (now > agentAuthChallenges[id].expires) delete agentAuthChallenges[id];
|
|
325
|
+
}
|
|
326
|
+
for (const id of Object.keys(qrLoginSessions)) {
|
|
327
|
+
if (now > qrLoginSessions[id].expires) delete qrLoginSessions[id];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------- Shared HTML / CSS ----------
|
|
332
|
+
|
|
333
|
+
const PAGE_STYLES = `
|
|
334
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
335
|
+
body {
|
|
336
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
337
|
+
background: #0a0a0a; color: #e0e0e0;
|
|
338
|
+
display: flex; align-items: center; justify-content: center;
|
|
339
|
+
min-height: 100vh; padding: 20px;
|
|
340
|
+
}
|
|
341
|
+
.card {
|
|
342
|
+
background: #1a1a1a; border: 1px solid #333; border-radius: 12px;
|
|
343
|
+
padding: 40px; max-width: 400px; width: 100%; text-align: center;
|
|
344
|
+
}
|
|
345
|
+
.crystal { font-size: 48px; margin-bottom: 16px; }
|
|
346
|
+
h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
|
347
|
+
.subtitle { color: #888; font-size: 14px; margin-bottom: 24px; }
|
|
348
|
+
.btn {
|
|
349
|
+
display: block; width: 100%; padding: 14px; border: none; border-radius: 8px;
|
|
350
|
+
font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s;
|
|
351
|
+
margin-bottom: 12px; text-decoration: none; text-align: center;
|
|
352
|
+
}
|
|
353
|
+
.btn-primary { background: #7c5cbf; color: white; }
|
|
354
|
+
.btn-primary:hover { background: #6a4dab; }
|
|
355
|
+
.btn-secondary { background: #2a2a2a; color: #e0e0e0; border: 1px solid #444; }
|
|
356
|
+
.btn-secondary:hover { background: #333; }
|
|
357
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
358
|
+
.divider { color: #555; font-size: 13px; margin: 8px 0 16px; }
|
|
359
|
+
.footer { margin-top: 24px; font-size: 12px; color: #555; }
|
|
360
|
+
.status { margin-top: 16px; font-size: 14px; padding: 12px; border-radius: 8px; display: none; }
|
|
361
|
+
.status.success { display: block; background: #1a2e1a; color: #4caf50; border: 1px solid #2e4a2e; }
|
|
362
|
+
.status.error { display: block; background: #2e1a1a; color: #ef5350; border: 1px solid #4a2e2e; }
|
|
363
|
+
.status.loading { display: block; background: #1a1a2e; color: #7c5cbf; border: 1px solid #2e2e4a; }
|
|
364
|
+
.link { color: #7c5cbf; text-decoration: none; font-size: 13px; }
|
|
365
|
+
.link:hover { text-decoration: underline; }
|
|
366
|
+
`;
|
|
367
|
+
|
|
368
|
+
function pageShell(title, bodyContent) {
|
|
369
|
+
return '<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
|
370
|
+
+ '<meta charset="utf-8">\n'
|
|
371
|
+
+ '<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
|
372
|
+
+ '<title>' + esc(title) + '</title>\n'
|
|
373
|
+
+ '<style>' + PAGE_STYLES + '</style>\n'
|
|
374
|
+
+ '</head>\n<body>\n' + bodyContent + '\n</body>\n</html>';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------- Shared WebAuthn JS helpers (inlined into pages) ----------
|
|
378
|
+
|
|
379
|
+
const WEBAUTHN_HELPERS = `
|
|
380
|
+
function b64urlToBytes(b64url) {
|
|
381
|
+
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
382
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
|
|
383
|
+
const bin = atob(b64 + pad);
|
|
384
|
+
return Uint8Array.from(bin, c => c.charCodeAt(0));
|
|
385
|
+
}
|
|
386
|
+
function bytesToB64url(bytes) {
|
|
387
|
+
let bin = "";
|
|
388
|
+
for (const b of new Uint8Array(bytes)) bin += String.fromCharCode(b);
|
|
389
|
+
return btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
|
|
390
|
+
}
|
|
391
|
+
function setStatus(msg, type) {
|
|
392
|
+
const el = document.getElementById("status");
|
|
393
|
+
el.textContent = msg;
|
|
394
|
+
el.className = "status " + type;
|
|
395
|
+
}
|
|
396
|
+
`;
|
|
397
|
+
|
|
398
|
+
// ---------- WebAuthn route handlers ----------
|
|
399
|
+
|
|
400
|
+
// POST /webauthn/register-options
|
|
401
|
+
async function handleRegisterOptions(req, res) {
|
|
402
|
+
cleanupExpiredChallenges();
|
|
403
|
+
|
|
404
|
+
let body;
|
|
405
|
+
try { body = await readBody(req); } catch { body = {}; }
|
|
406
|
+
|
|
407
|
+
// Accept optional username from request body
|
|
408
|
+
const username = sanitizeUsername(body?.username);
|
|
409
|
+
|
|
410
|
+
const userId = randomBytes(16);
|
|
411
|
+
const userIdB64 = userId.toString("base64url");
|
|
412
|
+
|
|
413
|
+
const userName = username || ("user-" + userIdB64.slice(0, 8));
|
|
414
|
+
const displayName = username || "Memory Crystal User";
|
|
415
|
+
|
|
416
|
+
let options;
|
|
417
|
+
try {
|
|
418
|
+
options = await generateRegistrationOptions({
|
|
419
|
+
rpName: RP_NAME,
|
|
420
|
+
rpID: RP_ID,
|
|
421
|
+
userName: userName,
|
|
422
|
+
userDisplayName: displayName,
|
|
423
|
+
userID: userId,
|
|
424
|
+
attestationType: "none",
|
|
425
|
+
authenticatorSelection: {
|
|
426
|
+
authenticatorAttachment: "platform",
|
|
427
|
+
userVerification: "required",
|
|
428
|
+
residentKey: "required",
|
|
429
|
+
},
|
|
430
|
+
supportedAlgorithmIDs: [-7, -257],
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
433
|
+
console.error("WebAuthn register-options error:", err);
|
|
434
|
+
json(res, 500, { error: "Failed to generate registration options" });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const challengeId = randomUUID();
|
|
439
|
+
challenges[challengeId] = {
|
|
440
|
+
challenge: options.challenge,
|
|
441
|
+
type: "registration",
|
|
442
|
+
userId: userIdB64,
|
|
443
|
+
username: username,
|
|
444
|
+
expires: Date.now() + 120000,
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
json(res, 200, { challengeId, options });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// POST /webauthn/register-verify
|
|
451
|
+
async function handleRegisterVerify(req, res) {
|
|
452
|
+
let body;
|
|
453
|
+
try { body = await readBody(req); } catch { json(res, 400, { error: "Invalid request body" }); return; }
|
|
454
|
+
|
|
455
|
+
const { challengeId, credential } = body || {};
|
|
456
|
+
if (!challengeId || !credential) {
|
|
457
|
+
json(res, 400, { error: "Missing challengeId or credential" });
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const stored = challenges[challengeId];
|
|
462
|
+
if (!stored || stored.type !== "registration") {
|
|
463
|
+
json(res, 400, { error: "Invalid or expired challenge" });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (Date.now() > stored.expires) {
|
|
467
|
+
delete challenges[challengeId];
|
|
468
|
+
json(res, 400, { error: "Challenge expired" });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
delete challenges[challengeId];
|
|
473
|
+
|
|
474
|
+
let verification;
|
|
475
|
+
try {
|
|
476
|
+
verification = await verifyRegistrationResponse({
|
|
477
|
+
response: credential,
|
|
478
|
+
expectedChallenge: stored.challenge,
|
|
479
|
+
expectedOrigin: RP_ORIGIN,
|
|
480
|
+
expectedRPID: RP_ID,
|
|
481
|
+
requireUserVerification: true,
|
|
482
|
+
});
|
|
483
|
+
} catch (err) {
|
|
484
|
+
console.error("WebAuthn register-verify error:", err);
|
|
485
|
+
json(res, 400, { error: "Verification failed: " + err.message });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
490
|
+
json(res, 400, { error: "Registration verification failed" });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const { credential: cred, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
495
|
+
|
|
496
|
+
// Use provided username as agentId, or fall back to passkey-<id>
|
|
497
|
+
const agentId = stored.username || ("passkey-" + stored.userId.slice(0, 12));
|
|
498
|
+
const apiKey = generateApiKey();
|
|
499
|
+
|
|
500
|
+
const entry = {
|
|
501
|
+
credentialId: cred.id,
|
|
502
|
+
publicKey: Buffer.from(cred.publicKey).toString("base64url"),
|
|
503
|
+
counter: cred.counter,
|
|
504
|
+
userId: stored.userId,
|
|
505
|
+
agentId,
|
|
506
|
+
apiKey,
|
|
507
|
+
deviceType: credentialDeviceType,
|
|
508
|
+
backedUp: credentialBackedUp,
|
|
509
|
+
transports: credential.response?.transports || [],
|
|
510
|
+
createdAt: new Date().toISOString(),
|
|
511
|
+
};
|
|
512
|
+
await savePasskey(entry);
|
|
513
|
+
await saveApiKey(apiKey, agentId);
|
|
514
|
+
|
|
515
|
+
console.log("WebAuthn: registered passkey for agent '" + agentId + "' (credId: " + cred.id.slice(0, 16) + "...)");
|
|
516
|
+
|
|
517
|
+
json(res, 200, { success: true, agentId, apiKey });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// POST /webauthn/auth-options
|
|
521
|
+
async function handleAuthOptions(req, res) {
|
|
522
|
+
cleanupExpiredChallenges();
|
|
523
|
+
|
|
524
|
+
let options;
|
|
525
|
+
try {
|
|
526
|
+
options = await generateAuthenticationOptions({
|
|
527
|
+
rpID: RP_ID,
|
|
528
|
+
userVerification: "required",
|
|
529
|
+
});
|
|
530
|
+
} catch (err) {
|
|
531
|
+
console.error("WebAuthn auth-options error:", err);
|
|
532
|
+
json(res, 500, { error: "Failed to generate authentication options" });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const challengeId = randomUUID();
|
|
537
|
+
challenges[challengeId] = {
|
|
538
|
+
challenge: options.challenge,
|
|
539
|
+
type: "authentication",
|
|
540
|
+
expires: Date.now() + 120000,
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
json(res, 200, { challengeId, options });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// POST /webauthn/auth-verify
|
|
547
|
+
async function handleAuthVerify(req, res) {
|
|
548
|
+
let body;
|
|
549
|
+
try { body = await readBody(req); } catch { json(res, 400, { error: "Invalid request body" }); return; }
|
|
550
|
+
|
|
551
|
+
const { challengeId, credential } = body || {};
|
|
552
|
+
if (!challengeId || !credential) {
|
|
553
|
+
json(res, 400, { error: "Missing challengeId or credential" });
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const stored = challenges[challengeId];
|
|
558
|
+
if (!stored || stored.type !== "authentication") {
|
|
559
|
+
json(res, 400, { error: "Invalid or expired challenge" });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (Date.now() > stored.expires) {
|
|
563
|
+
delete challenges[challengeId];
|
|
564
|
+
json(res, 400, { error: "Challenge expired" });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
delete challenges[challengeId];
|
|
569
|
+
|
|
570
|
+
const credId = credential.id;
|
|
571
|
+
const entry = passkeys.find((p) => p.credentialId === credId);
|
|
572
|
+
if (!entry) {
|
|
573
|
+
json(res, 400, { error: "Unknown credential. Please create an account first." });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let verification;
|
|
578
|
+
try {
|
|
579
|
+
verification = await verifyAuthenticationResponse({
|
|
580
|
+
response: credential,
|
|
581
|
+
expectedChallenge: stored.challenge,
|
|
582
|
+
expectedOrigin: RP_ORIGIN,
|
|
583
|
+
expectedRPID: RP_ID,
|
|
584
|
+
requireUserVerification: true,
|
|
585
|
+
credential: {
|
|
586
|
+
id: entry.credentialId,
|
|
587
|
+
publicKey: Uint8Array.from(Buffer.from(entry.publicKey, "base64url")),
|
|
588
|
+
counter: entry.counter,
|
|
589
|
+
transports: entry.transports || [],
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
} catch (err) {
|
|
593
|
+
console.error("WebAuthn auth-verify error:", err);
|
|
594
|
+
json(res, 400, { error: "Authentication failed: " + err.message });
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!verification.verified) {
|
|
599
|
+
json(res, 400, { error: "Authentication verification failed" });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
entry.counter = verification.authenticationInfo.newCounter;
|
|
604
|
+
await updatePasskeyCounter(entry.credentialId, entry.counter);
|
|
605
|
+
|
|
606
|
+
console.log("WebAuthn: authenticated agent '" + entry.agentId + "'");
|
|
607
|
+
|
|
608
|
+
json(res, 200, { success: true, agentId: entry.agentId, apiKey: entry.apiKey });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------- Page handlers ----------
|
|
612
|
+
|
|
613
|
+
function handleSignupPage(req, res) {
|
|
614
|
+
const body = '<div class="card">\n'
|
|
615
|
+
+ '<div class="crystal">\u{1F48E}</div>\n'
|
|
616
|
+
+ '<h1>Create your account</h1>\n'
|
|
617
|
+
+ '<p class="subtitle">Memory Crystal ... wip.computer</p>\n'
|
|
618
|
+
+ '<button class="btn btn-primary" id="createBtn" onclick="createPasskey()">Create Passkey</button>\n'
|
|
619
|
+
+ '<div id="status" class="status"></div>\n'
|
|
620
|
+
+ '<p class="footer"><a href="/login" class="link">Already have an account? Sign in</a></p>\n'
|
|
621
|
+
+ '<p class="footer">Learning Dreaming Machines</p>\n'
|
|
622
|
+
+ '</div>\n'
|
|
623
|
+
+ '<script>\n'
|
|
624
|
+
+ WEBAUTHN_HELPERS
|
|
625
|
+
+ 'async function createPasskey() {\n'
|
|
626
|
+
+ ' const btn = document.getElementById("createBtn");\n'
|
|
627
|
+
+ ' btn.disabled = true;\n'
|
|
628
|
+
+ ' setStatus("Preparing...", "loading");\n'
|
|
629
|
+
+ ' try {\n'
|
|
630
|
+
+ ' const optRes = await fetch("/webauthn/register-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
|
|
631
|
+
+ ' const { challengeId, options } = await optRes.json();\n'
|
|
632
|
+
+ ' if (!options) throw new Error("Server returned no options");\n'
|
|
633
|
+
+ ' options.challenge = b64urlToBytes(options.challenge);\n'
|
|
634
|
+
+ ' options.user.id = b64urlToBytes(options.user.id);\n'
|
|
635
|
+
+ ' if (options.excludeCredentials) {\n'
|
|
636
|
+
+ ' options.excludeCredentials = options.excludeCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
|
|
637
|
+
+ ' }\n'
|
|
638
|
+
+ ' setStatus("Waiting for biometric...", "loading");\n'
|
|
639
|
+
+ ' const credential = await navigator.credentials.create({ publicKey: options });\n'
|
|
640
|
+
+ ' const reqBody = {\n'
|
|
641
|
+
+ ' challengeId,\n'
|
|
642
|
+
+ ' credential: {\n'
|
|
643
|
+
+ ' id: credential.id,\n'
|
|
644
|
+
+ ' rawId: bytesToB64url(credential.rawId),\n'
|
|
645
|
+
+ ' type: credential.type,\n'
|
|
646
|
+
+ ' response: {\n'
|
|
647
|
+
+ ' attestationObject: bytesToB64url(credential.response.attestationObject),\n'
|
|
648
|
+
+ ' clientDataJSON: bytesToB64url(credential.response.clientDataJSON),\n'
|
|
649
|
+
+ ' transports: credential.response.getTransports ? credential.response.getTransports() : [],\n'
|
|
650
|
+
+ ' },\n'
|
|
651
|
+
+ ' },\n'
|
|
652
|
+
+ ' };\n'
|
|
653
|
+
+ ' setStatus("Verifying...", "loading");\n'
|
|
654
|
+
+ ' const verRes = await fetch("/webauthn/register-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
|
|
655
|
+
+ ' const result = await verRes.json();\n'
|
|
656
|
+
+ ' if (result.success) {\n'
|
|
657
|
+
+ ' setStatus("Account created. You can close this page.", "success");\n'
|
|
658
|
+
+ ' btn.textContent = "Done";\n'
|
|
659
|
+
+ ' } else {\n'
|
|
660
|
+
+ ' setStatus(result.error || "Registration failed", "error");\n'
|
|
661
|
+
+ ' btn.disabled = false;\n'
|
|
662
|
+
+ ' }\n'
|
|
663
|
+
+ ' } catch (err) {\n'
|
|
664
|
+
+ ' if (err.name === "NotAllowedError") {\n'
|
|
665
|
+
+ ' setStatus("Cancelled. Try again when ready.", "error");\n'
|
|
666
|
+
+ ' } else {\n'
|
|
667
|
+
+ ' setStatus("Error: " + err.message, "error");\n'
|
|
668
|
+
+ ' }\n'
|
|
669
|
+
+ ' btn.disabled = false;\n'
|
|
670
|
+
+ ' }\n'
|
|
671
|
+
+ '}\n'
|
|
672
|
+
+ '</script>';
|
|
673
|
+
|
|
674
|
+
htmlResponse(res, 200, pageShell("Create Account - Memory Crystal", body));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function handleLoginPage(req, res) {
|
|
678
|
+
const body = '<div class="card">\n'
|
|
679
|
+
+ '<div class="crystal">\u{1F48E}</div>\n'
|
|
680
|
+
+ '<h1>Sign in</h1>\n'
|
|
681
|
+
+ '<p class="subtitle">Memory Crystal ... wip.computer</p>\n'
|
|
682
|
+
+ '<button class="btn btn-primary" id="signInBtn" onclick="signIn()">Sign in with Passkey</button>\n'
|
|
683
|
+
+ '<div id="status" class="status"></div>\n'
|
|
684
|
+
+ '<p class="footer"><a href="/signup" class="link">Need an account? Create one</a></p>\n'
|
|
685
|
+
+ '<p class="footer">Learning Dreaming Machines</p>\n'
|
|
686
|
+
+ '</div>\n'
|
|
687
|
+
+ '<script>\n'
|
|
688
|
+
+ WEBAUTHN_HELPERS
|
|
689
|
+
+ 'async function signIn() {\n'
|
|
690
|
+
+ ' const btn = document.getElementById("signInBtn");\n'
|
|
691
|
+
+ ' btn.disabled = true;\n'
|
|
692
|
+
+ ' setStatus("Preparing...", "loading");\n'
|
|
693
|
+
+ ' try {\n'
|
|
694
|
+
+ ' const optRes = await fetch("/webauthn/auth-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
|
|
695
|
+
+ ' const { challengeId, options } = await optRes.json();\n'
|
|
696
|
+
+ ' if (!options) throw new Error("Server returned no options");\n'
|
|
697
|
+
+ ' options.challenge = b64urlToBytes(options.challenge);\n'
|
|
698
|
+
+ ' if (options.allowCredentials) {\n'
|
|
699
|
+
+ ' options.allowCredentials = options.allowCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
|
|
700
|
+
+ ' }\n'
|
|
701
|
+
+ ' setStatus("Waiting for biometric...", "loading");\n'
|
|
702
|
+
+ ' const assertion = await navigator.credentials.get({ publicKey: options });\n'
|
|
703
|
+
+ ' const reqBody = {\n'
|
|
704
|
+
+ ' challengeId,\n'
|
|
705
|
+
+ ' credential: {\n'
|
|
706
|
+
+ ' id: assertion.id,\n'
|
|
707
|
+
+ ' rawId: bytesToB64url(assertion.rawId),\n'
|
|
708
|
+
+ ' type: assertion.type,\n'
|
|
709
|
+
+ ' response: {\n'
|
|
710
|
+
+ ' authenticatorData: bytesToB64url(assertion.response.authenticatorData),\n'
|
|
711
|
+
+ ' clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),\n'
|
|
712
|
+
+ ' signature: bytesToB64url(assertion.response.signature),\n'
|
|
713
|
+
+ ' userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,\n'
|
|
714
|
+
+ ' },\n'
|
|
715
|
+
+ ' },\n'
|
|
716
|
+
+ ' };\n'
|
|
717
|
+
+ ' setStatus("Verifying...", "loading");\n'
|
|
718
|
+
+ ' const verRes = await fetch("/webauthn/auth-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
|
|
719
|
+
+ ' const result = await verRes.json();\n'
|
|
720
|
+
+ ' if (result.success) {\n'
|
|
721
|
+
+ ' setStatus("Signed in as " + result.agentId + ". You can close this page.", "success");\n'
|
|
722
|
+
+ ' btn.textContent = "Done";\n'
|
|
723
|
+
+ ' } else {\n'
|
|
724
|
+
+ ' setStatus(result.error || "Authentication failed", "error");\n'
|
|
725
|
+
+ ' btn.disabled = false;\n'
|
|
726
|
+
+ ' }\n'
|
|
727
|
+
+ ' } catch (err) {\n'
|
|
728
|
+
+ ' if (err.name === "NotAllowedError") {\n'
|
|
729
|
+
+ ' setStatus("Cancelled. Try again when ready.", "error");\n'
|
|
730
|
+
+ ' } else {\n'
|
|
731
|
+
+ ' setStatus("Error: " + err.message, "error");\n'
|
|
732
|
+
+ ' }\n'
|
|
733
|
+
+ ' btn.disabled = false;\n'
|
|
734
|
+
+ ' }\n'
|
|
735
|
+
+ '}\n'
|
|
736
|
+
+ '</script>';
|
|
737
|
+
|
|
738
|
+
htmlResponse(res, 200, pageShell("Sign In - Memory Crystal", body));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ---------- OAuth route handlers ----------
|
|
742
|
+
|
|
743
|
+
function handleOAuthDiscovery(req, res) {
|
|
744
|
+
json(res, 200, OAUTH_METADATA);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function handleProtectedResource(req, res) {
|
|
748
|
+
json(res, 200, PROTECTED_RESOURCE);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function handleOAuthRegister(req, res) {
|
|
752
|
+
let body;
|
|
753
|
+
try { body = await readBody(req); } catch { json(res, 400, { error: "invalid_request" }); return; }
|
|
754
|
+
|
|
755
|
+
const clientId = randomUUID();
|
|
756
|
+
const client = {
|
|
757
|
+
client_id: clientId,
|
|
758
|
+
redirect_uris: body?.redirect_uris || [],
|
|
759
|
+
client_name: body?.client_name || "unknown",
|
|
760
|
+
created: Date.now(),
|
|
761
|
+
};
|
|
762
|
+
oauthClients[clientId] = client;
|
|
763
|
+
console.log("OAuth: registered client " + clientId + " (" + client.client_name + ")");
|
|
764
|
+
|
|
765
|
+
json(res, 201, {
|
|
766
|
+
client_id: clientId,
|
|
767
|
+
client_name: client.client_name,
|
|
768
|
+
redirect_uris: client.redirect_uris,
|
|
769
|
+
grant_types: ["authorization_code"],
|
|
770
|
+
response_types: ["code"],
|
|
771
|
+
token_endpoint_auth_method: "none",
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function handleOAuthAuthorize(req, res) {
|
|
776
|
+
const url = parseUrl(req.url);
|
|
777
|
+
const clientId = url.searchParams.get("client_id") || "";
|
|
778
|
+
const responseType = url.searchParams.get("response_type");
|
|
779
|
+
const redirectUri = url.searchParams.get("redirect_uri") || "";
|
|
780
|
+
const state = url.searchParams.get("state") || "";
|
|
781
|
+
const codeChallenge = url.searchParams.get("code_challenge") || "";
|
|
782
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "S256";
|
|
783
|
+
|
|
784
|
+
if (responseType !== "code") {
|
|
785
|
+
htmlResponse(res, 400, pageShell("Error", '<div class="card"><h1>Error</h1><p class="subtitle">Unsupported response_type</p></div>'));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!redirectUri) {
|
|
789
|
+
htmlResponse(res, 400, pageShell("Error", '<div class="card"><h1>Error</h1><p class="subtitle">Missing redirect_uri</p></div>'));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Auto-register client
|
|
794
|
+
if (clientId && !oauthClients[clientId]) {
|
|
795
|
+
oauthClients[clientId] = { client_id: clientId, redirect_uris: [redirectUri], client_name: "auto", created: Date.now() };
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Encode OAuth params for the JS to use after WebAuthn
|
|
799
|
+
const oauthParams = JSON.stringify({
|
|
800
|
+
client_id: clientId,
|
|
801
|
+
redirect_uri: redirectUri,
|
|
802
|
+
state: state,
|
|
803
|
+
code_challenge: codeChallenge,
|
|
804
|
+
code_challenge_method: codeChallengeMethod,
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
const pageBody = '<div class="card">\n'
|
|
808
|
+
+ '<div class="crystal">\u{1F48E}</div>\n'
|
|
809
|
+
+ '<h1>Connect to Memory Crystal</h1>\n'
|
|
810
|
+
+ '<p class="subtitle">wip.computer MCP server</p>\n'
|
|
811
|
+
+ '<button class="btn btn-primary" id="signInBtn" onclick="doAuth()">Sign In</button>\n'
|
|
812
|
+
+ '<div class="divider">or</div>\n'
|
|
813
|
+
+ '<button class="btn btn-secondary" id="createBtn" onclick="doRegister()">Create Account</button>\n'
|
|
814
|
+
+ '<div id="status" class="status"></div>\n'
|
|
815
|
+
+ '<p class="footer">Learning Dreaming Machines</p>\n'
|
|
816
|
+
+ '</div>\n'
|
|
817
|
+
+ '<script>\n'
|
|
818
|
+
+ WEBAUTHN_HELPERS
|
|
819
|
+
+ 'const oauthParams = ' + oauthParams + ';\n'
|
|
820
|
+
+ 'function disableButtons() {\n'
|
|
821
|
+
+ ' document.getElementById("signInBtn").disabled = true;\n'
|
|
822
|
+
+ ' document.getElementById("createBtn").disabled = true;\n'
|
|
823
|
+
+ '}\n'
|
|
824
|
+
+ 'function enableButtons() {\n'
|
|
825
|
+
+ ' document.getElementById("signInBtn").disabled = false;\n'
|
|
826
|
+
+ ' document.getElementById("createBtn").disabled = false;\n'
|
|
827
|
+
+ '}\n'
|
|
828
|
+
+ 'function completeOAuth(agentId) {\n'
|
|
829
|
+
+ ' setStatus("Connecting...", "loading");\n'
|
|
830
|
+
+ ' const form = document.createElement("form");\n'
|
|
831
|
+
+ ' form.method = "POST";\n'
|
|
832
|
+
+ ' form.action = "/oauth/authorize/submit";\n'
|
|
833
|
+
+ ' const fields = {\n'
|
|
834
|
+
+ ' client_id: oauthParams.client_id,\n'
|
|
835
|
+
+ ' redirect_uri: oauthParams.redirect_uri,\n'
|
|
836
|
+
+ ' state: oauthParams.state,\n'
|
|
837
|
+
+ ' code_challenge: oauthParams.code_challenge,\n'
|
|
838
|
+
+ ' code_challenge_method: oauthParams.code_challenge_method,\n'
|
|
839
|
+
+ ' agent_name: agentId,\n'
|
|
840
|
+
+ ' };\n'
|
|
841
|
+
+ ' for (const [k, v] of Object.entries(fields)) {\n'
|
|
842
|
+
+ ' const input = document.createElement("input");\n'
|
|
843
|
+
+ ' input.type = "hidden";\n'
|
|
844
|
+
+ ' input.name = k;\n'
|
|
845
|
+
+ ' input.value = v;\n'
|
|
846
|
+
+ ' form.appendChild(input);\n'
|
|
847
|
+
+ ' }\n'
|
|
848
|
+
+ ' document.body.appendChild(form);\n'
|
|
849
|
+
+ ' form.submit();\n'
|
|
850
|
+
+ '}\n'
|
|
851
|
+
+ 'async function doRegister() {\n'
|
|
852
|
+
+ ' disableButtons();\n'
|
|
853
|
+
+ ' setStatus("Preparing...", "loading");\n'
|
|
854
|
+
+ ' try {\n'
|
|
855
|
+
+ ' const optRes = await fetch("/webauthn/register-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
|
|
856
|
+
+ ' const { challengeId, options } = await optRes.json();\n'
|
|
857
|
+
+ ' if (!options) throw new Error("Server returned no options");\n'
|
|
858
|
+
+ ' options.challenge = b64urlToBytes(options.challenge);\n'
|
|
859
|
+
+ ' options.user.id = b64urlToBytes(options.user.id);\n'
|
|
860
|
+
+ ' if (options.excludeCredentials) {\n'
|
|
861
|
+
+ ' options.excludeCredentials = options.excludeCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
|
|
862
|
+
+ ' }\n'
|
|
863
|
+
+ ' setStatus("Waiting for biometric...", "loading");\n'
|
|
864
|
+
+ ' const credential = await navigator.credentials.create({ publicKey: options });\n'
|
|
865
|
+
+ ' const reqBody = {\n'
|
|
866
|
+
+ ' challengeId,\n'
|
|
867
|
+
+ ' credential: {\n'
|
|
868
|
+
+ ' id: credential.id,\n'
|
|
869
|
+
+ ' rawId: bytesToB64url(credential.rawId),\n'
|
|
870
|
+
+ ' type: credential.type,\n'
|
|
871
|
+
+ ' response: {\n'
|
|
872
|
+
+ ' attestationObject: bytesToB64url(credential.response.attestationObject),\n'
|
|
873
|
+
+ ' clientDataJSON: bytesToB64url(credential.response.clientDataJSON),\n'
|
|
874
|
+
+ ' transports: credential.response.getTransports ? credential.response.getTransports() : [],\n'
|
|
875
|
+
+ ' },\n'
|
|
876
|
+
+ ' },\n'
|
|
877
|
+
+ ' };\n'
|
|
878
|
+
+ ' setStatus("Verifying...", "loading");\n'
|
|
879
|
+
+ ' const verRes = await fetch("/webauthn/register-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
|
|
880
|
+
+ ' const result = await verRes.json();\n'
|
|
881
|
+
+ ' if (result.success) {\n'
|
|
882
|
+
+ ' completeOAuth(result.agentId);\n'
|
|
883
|
+
+ ' } else {\n'
|
|
884
|
+
+ ' setStatus(result.error || "Registration failed", "error");\n'
|
|
885
|
+
+ ' enableButtons();\n'
|
|
886
|
+
+ ' }\n'
|
|
887
|
+
+ ' } catch (err) {\n'
|
|
888
|
+
+ ' if (err.name === "NotAllowedError") {\n'
|
|
889
|
+
+ ' setStatus("Cancelled. Try again when ready.", "error");\n'
|
|
890
|
+
+ ' } else {\n'
|
|
891
|
+
+ ' setStatus("Error: " + err.message, "error");\n'
|
|
892
|
+
+ ' }\n'
|
|
893
|
+
+ ' enableButtons();\n'
|
|
894
|
+
+ ' }\n'
|
|
895
|
+
+ '}\n'
|
|
896
|
+
+ 'async function doAuth() {\n'
|
|
897
|
+
+ ' disableButtons();\n'
|
|
898
|
+
+ ' setStatus("Preparing...", "loading");\n'
|
|
899
|
+
+ ' try {\n'
|
|
900
|
+
+ ' const optRes = await fetch("/webauthn/auth-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
|
|
901
|
+
+ ' const { challengeId, options } = await optRes.json();\n'
|
|
902
|
+
+ ' if (!options) throw new Error("Server returned no options");\n'
|
|
903
|
+
+ ' options.challenge = b64urlToBytes(options.challenge);\n'
|
|
904
|
+
+ ' if (options.allowCredentials) {\n'
|
|
905
|
+
+ ' options.allowCredentials = options.allowCredentials.map(c => ({ ...c, id: b64urlToBytes(c.id) }));\n'
|
|
906
|
+
+ ' }\n'
|
|
907
|
+
+ ' setStatus("Waiting for biometric...", "loading");\n'
|
|
908
|
+
+ ' const assertion = await navigator.credentials.get({ publicKey: options });\n'
|
|
909
|
+
+ ' const reqBody = {\n'
|
|
910
|
+
+ ' challengeId,\n'
|
|
911
|
+
+ ' credential: {\n'
|
|
912
|
+
+ ' id: assertion.id,\n'
|
|
913
|
+
+ ' rawId: bytesToB64url(assertion.rawId),\n'
|
|
914
|
+
+ ' type: assertion.type,\n'
|
|
915
|
+
+ ' response: {\n'
|
|
916
|
+
+ ' authenticatorData: bytesToB64url(assertion.response.authenticatorData),\n'
|
|
917
|
+
+ ' clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),\n'
|
|
918
|
+
+ ' signature: bytesToB64url(assertion.response.signature),\n'
|
|
919
|
+
+ ' userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,\n'
|
|
920
|
+
+ ' },\n'
|
|
921
|
+
+ ' },\n'
|
|
922
|
+
+ ' };\n'
|
|
923
|
+
+ ' setStatus("Verifying...", "loading");\n'
|
|
924
|
+
+ ' const verRes = await fetch("/webauthn/auth-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
|
|
925
|
+
+ ' const result = await verRes.json();\n'
|
|
926
|
+
+ ' if (result.success) {\n'
|
|
927
|
+
+ ' completeOAuth(result.agentId);\n'
|
|
928
|
+
+ ' } else {\n'
|
|
929
|
+
+ ' setStatus(result.error || "Authentication failed", "error");\n'
|
|
930
|
+
+ ' enableButtons();\n'
|
|
931
|
+
+ ' }\n'
|
|
932
|
+
+ ' } catch (err) {\n'
|
|
933
|
+
+ ' if (err.name === "NotAllowedError") {\n'
|
|
934
|
+
+ ' setStatus("Cancelled. Try again when ready.", "error");\n'
|
|
935
|
+
+ ' } else {\n'
|
|
936
|
+
+ ' setStatus("Error: " + err.message, "error");\n'
|
|
937
|
+
+ ' }\n'
|
|
938
|
+
+ ' enableButtons();\n'
|
|
939
|
+
+ ' }\n'
|
|
940
|
+
+ '}\n'
|
|
941
|
+
+ '</script>';
|
|
942
|
+
|
|
943
|
+
htmlResponse(res, 200, pageShell("Connect to Memory Crystal", pageBody));
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function handleOAuthAuthorizeSubmit(req, res) {
|
|
947
|
+
const raw = await readBodyRaw(req);
|
|
948
|
+
const params = new URLSearchParams(raw);
|
|
949
|
+
const clientId = params.get("client_id");
|
|
950
|
+
const redirectUri = params.get("redirect_uri");
|
|
951
|
+
const state = params.get("state");
|
|
952
|
+
const codeChallenge = params.get("code_challenge");
|
|
953
|
+
const codeChallengeMethod = params.get("code_challenge_method") || "S256";
|
|
954
|
+
const agentName = params.get("agent_name") || "unknown";
|
|
955
|
+
|
|
956
|
+
cleanupExpiredCodes();
|
|
957
|
+
|
|
958
|
+
const code = randomUUID();
|
|
959
|
+
oauthCodes[code] = {
|
|
960
|
+
client_id: clientId,
|
|
961
|
+
redirect_uri: redirectUri,
|
|
962
|
+
code_challenge: codeChallenge,
|
|
963
|
+
code_challenge_method: codeChallengeMethod,
|
|
964
|
+
agent_name: agentName.trim().toLowerCase(),
|
|
965
|
+
expires: Date.now() + OAUTH_CODE_EXPIRY_MS,
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
console.log("OAuth: issued code for agent '" + agentName + "' (client: " + clientId + ")");
|
|
969
|
+
|
|
970
|
+
const redirect = new URL(redirectUri);
|
|
971
|
+
redirect.searchParams.set("code", code);
|
|
972
|
+
if (state) redirect.searchParams.set("state", state);
|
|
973
|
+
|
|
974
|
+
res.writeHead(302, { Location: redirect.toString() });
|
|
975
|
+
res.end();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function handleOAuthToken(req, res) {
|
|
979
|
+
let raw;
|
|
980
|
+
try { raw = await readBodyRaw(req); } catch { json(res, 400, { error: "invalid_request" }); return; }
|
|
981
|
+
|
|
982
|
+
const params = new URLSearchParams(raw);
|
|
983
|
+
const grantType = params.get("grant_type");
|
|
984
|
+
const code = params.get("code");
|
|
985
|
+
const redirectUri = params.get("redirect_uri");
|
|
986
|
+
const codeVerifier = params.get("code_verifier");
|
|
987
|
+
|
|
988
|
+
if (grantType !== "authorization_code") {
|
|
989
|
+
json(res, 400, { error: "unsupported_grant_type" });
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const stored = oauthCodes[code];
|
|
994
|
+
if (!stored) {
|
|
995
|
+
json(res, 400, { error: "invalid_grant", error_description: "Unknown or expired code" });
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
delete oauthCodes[code];
|
|
1000
|
+
|
|
1001
|
+
if (Date.now() > stored.expires) {
|
|
1002
|
+
json(res, 400, { error: "invalid_grant", error_description: "Code expired" });
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (redirectUri && redirectUri !== stored.redirect_uri) {
|
|
1007
|
+
json(res, 400, { error: "invalid_grant", error_description: "redirect_uri mismatch" });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
if (stored.code_challenge && codeVerifier) {
|
|
1012
|
+
const expected = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
1013
|
+
if (expected !== stored.code_challenge) {
|
|
1014
|
+
json(res, 400, { error: "invalid_grant", error_description: "PKCE verification failed" });
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// Check if agent already has an API key (from passkey registration)
|
|
1020
|
+
const agentId = stored.agent_name || "oauth-user";
|
|
1021
|
+
let apiKey;
|
|
1022
|
+
|
|
1023
|
+
const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);
|
|
1024
|
+
if (existingKey) {
|
|
1025
|
+
apiKey = existingKey[0];
|
|
1026
|
+
} else {
|
|
1027
|
+
apiKey = generateApiKey();
|
|
1028
|
+
await saveApiKey(apiKey, agentId);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
console.log("OAuth: issued token for agent '" + agentId + "' (key: " + apiKey.slice(0, 10) + "...)");
|
|
1032
|
+
|
|
1033
|
+
json(res, 200, {
|
|
1034
|
+
access_token: apiKey,
|
|
1035
|
+
token_type: "Bearer",
|
|
1036
|
+
scope: "mcp",
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// ---------- Agent QR Auth handlers ----------
|
|
1041
|
+
|
|
1042
|
+
// GET /demo/api/agent-auth?agent=NAME&message=TEXT ... generate a QR challenge for an agent
|
|
1043
|
+
async function handleAgentAuthStart(req, res) {
|
|
1044
|
+
cleanupExpiredChallenges();
|
|
1045
|
+
const url = parseUrl(req.url);
|
|
1046
|
+
const agentName = (url.searchParams.get("agent") || "").trim().slice(0, 60);
|
|
1047
|
+
const agentMessage = (url.searchParams.get("message") || "").trim().slice(0, 200);
|
|
1048
|
+
const challengeId = randomUUID();
|
|
1049
|
+
const approveUrl = ISSUER_URL + "/approve?c=" + challengeId;
|
|
1050
|
+
const qrBuffer = await QRCode.toBuffer(approveUrl, { type: "png", width: 400, margin: 2 });
|
|
1051
|
+
agentAuthChallenges[challengeId] = {
|
|
1052
|
+
qrBuffer,
|
|
1053
|
+
status: "pending",
|
|
1054
|
+
token: null,
|
|
1055
|
+
agentId: null,
|
|
1056
|
+
agentName: agentName || null,
|
|
1057
|
+
agentMessage: agentMessage || null,
|
|
1058
|
+
expires: Date.now() + AGENT_AUTH_EXPIRY_MS,
|
|
1059
|
+
};
|
|
1060
|
+
console.log("Agent QR auth: created challenge " + challengeId.slice(0, 8) + "..." + (agentName ? " (agent: " + agentName + ")" : ""));
|
|
1061
|
+
json(res, 200, { challengeId, approveUrl, qrUrl: "/demo/api/agent-auth/qr?c=" + challengeId });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// GET /demo/api/agent-auth/qr?c=XXX ... serve QR code PNG
|
|
1065
|
+
function handleAgentAuthQR(req, res) {
|
|
1066
|
+
const url = parseUrl(req.url);
|
|
1067
|
+
const c = url.searchParams.get("c");
|
|
1068
|
+
const entry = agentAuthChallenges[c];
|
|
1069
|
+
if (!entry || Date.now() > entry.expires) {
|
|
1070
|
+
json(res, 404, { error: "Challenge not found or expired" });
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Length": entry.qrBuffer.length });
|
|
1074
|
+
res.end(entry.qrBuffer);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// GET /demo/api/agent-auth/status?c=XXX ... poll for approval
|
|
1078
|
+
function handleAgentAuthStatus(req, res) {
|
|
1079
|
+
const url = parseUrl(req.url);
|
|
1080
|
+
const c = url.searchParams.get("c");
|
|
1081
|
+
const entry = agentAuthChallenges[c];
|
|
1082
|
+
if (!entry || Date.now() > entry.expires) {
|
|
1083
|
+
json(res, 404, { error: "Challenge not found or expired" });
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (entry.status === "approved") {
|
|
1087
|
+
json(res, 200, { status: "approved", token: entry.token, agentId: entry.agentId });
|
|
1088
|
+
delete agentAuthChallenges[c]; // one-time use
|
|
1089
|
+
} else {
|
|
1090
|
+
json(res, 200, { status: "pending" });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// GET /approve?c=XXX ... page the human sees when authorizing an agent
|
|
1095
|
+
function handleApprovePage(req, res) {
|
|
1096
|
+
const url = parseUrl(req.url);
|
|
1097
|
+
let challengeId = url.searchParams.get("c") || "";
|
|
1098
|
+
let entry = agentAuthChallenges[challengeId];
|
|
1099
|
+
|
|
1100
|
+
// If no challenge ID but agent params provided, create challenge on the fly
|
|
1101
|
+
const agentParam = (url.searchParams.get("agent") || "").trim().slice(0, 60);
|
|
1102
|
+
const messageParam = (url.searchParams.get("message") || "").trim().slice(0, 200);
|
|
1103
|
+
if (!entry && agentParam) {
|
|
1104
|
+
challengeId = randomUUID();
|
|
1105
|
+
agentAuthChallenges[challengeId] = {
|
|
1106
|
+
qrBuffer: null,
|
|
1107
|
+
status: "pending",
|
|
1108
|
+
token: null,
|
|
1109
|
+
agentId: null,
|
|
1110
|
+
agentName: agentParam,
|
|
1111
|
+
agentMessage: messageParam || null,
|
|
1112
|
+
expires: Date.now() + AGENT_AUTH_EXPIRY_MS,
|
|
1113
|
+
};
|
|
1114
|
+
entry = agentAuthChallenges[challengeId];
|
|
1115
|
+
console.log("Approve page: created inline challenge " + challengeId.slice(0, 8) + "... for agent: " + agentParam);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const expired = !entry || Date.now() > entry.expires;
|
|
1119
|
+
|
|
1120
|
+
const APPROVE_STYLES = `
|
|
1121
|
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1122
|
+
body {
|
|
1123
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
|
1124
|
+
background: #FFFDF5; color: #1a1a1a;
|
|
1125
|
+
-webkit-text-size-adjust: 100%; -webkit-font-smoothing: antialiased;
|
|
1126
|
+
}
|
|
1127
|
+
.login-page {
|
|
1128
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
1129
|
+
min-height: 100vh; min-height: 100dvh; padding: 24px;
|
|
1130
|
+
}
|
|
1131
|
+
.login-card {
|
|
1132
|
+
position: relative; max-width: 380px; width: 100%; text-align: center;
|
|
1133
|
+
}
|
|
1134
|
+
.login-title {
|
|
1135
|
+
font-size: 22px; font-weight: 600; letter-spacing: 0.5px; margin-bottom: 8px;
|
|
1136
|
+
}
|
|
1137
|
+
.login-byline {
|
|
1138
|
+
font-size: 14px; color: #8a8580; margin-bottom: 32px; letter-spacing: 0.2px;
|
|
1139
|
+
}
|
|
1140
|
+
.info-section { text-align: left; margin-bottom: 20px; }
|
|
1141
|
+
.info-section h2 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: #1a1a1a; }
|
|
1142
|
+
.info-section ul { list-style: none; padding: 0; margin: 0; }
|
|
1143
|
+
.info-section ul li { font-size: 13px; color: #8a8580; line-height: 1.6; padding-left: 16px; position: relative; }
|
|
1144
|
+
.info-section ul li::before { content: "\\2022"; position: absolute; left: 0; color: #c0bbb5; }
|
|
1145
|
+
.info-section.safe ul li::before { color: #2E7D32; }
|
|
1146
|
+
.revoke-note { font-size: 13px; color: #8a8580; margin-bottom: 28px; }
|
|
1147
|
+
.btn {
|
|
1148
|
+
display: block; width: 100%; padding: 16px; border: none; border-radius: 12px;
|
|
1149
|
+
font-size: 16px; font-weight: 600; cursor: pointer; transition: background 0.15s, transform 0.1s;
|
|
1150
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
|
|
1151
|
+
-webkit-tap-highlight-color: transparent;
|
|
1152
|
+
}
|
|
1153
|
+
.btn:active { transform: scale(0.98); }
|
|
1154
|
+
.btn-primary { background: #0033FF; color: white; margin-bottom: 12px; }
|
|
1155
|
+
.btn-primary:hover { background: #0033FF; }
|
|
1156
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
|
1157
|
+
.create-link { font-size: 13px; color: #8a8580; cursor: pointer; text-decoration: none; }
|
|
1158
|
+
.create-link:hover { color: #1a1a1a; }
|
|
1159
|
+
.login-status { margin-top: 16px; font-size: 14px; padding: 12px 16px; border-radius: 10px; display: none; text-align: center; }
|
|
1160
|
+
.login-status.show { display: block; }
|
|
1161
|
+
.login-status.loading { background: #E8EEFF; color: #0033FF; }
|
|
1162
|
+
.login-status.error { background: #FFF0F0; color: #D32F2F; }
|
|
1163
|
+
.login-status.success { background: #F0FFF4; color: #2E7D32; }
|
|
1164
|
+
.success-check { font-size: 48px; margin-bottom: 16px; }
|
|
1165
|
+
`;
|
|
1166
|
+
|
|
1167
|
+
// Shared sprite JS for rotating nav icon
|
|
1168
|
+
const SPRITE_JS = 'var SPRITE_COLS = 8, SPRITE_ROWS = 3, SPRITE_TOTAL = 24;\n'
|
|
1169
|
+
+ 'function makeIconHTML(size) {\n'
|
|
1170
|
+
+ ' var idx = Math.floor(Math.random() * SPRITE_TOTAL);\n'
|
|
1171
|
+
+ ' var col = idx % SPRITE_COLS, row = Math.floor(idx / SPRITE_COLS);\n'
|
|
1172
|
+
+ ' var bx = (col / (SPRITE_COLS - 1)) * 100, by = (row / (SPRITE_ROWS - 1)) * 100;\n'
|
|
1173
|
+
+ ' 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'
|
|
1174
|
+
+ '}\n'
|
|
1175
|
+
+ 'var loginIcon = document.getElementById("loginIcon");\n'
|
|
1176
|
+
+ 'if (loginIcon) loginIcon.innerHTML = makeIconHTML(28);\n'
|
|
1177
|
+
+ 'var rotateIdx = Math.floor(Math.random() * SPRITE_TOTAL);\n'
|
|
1178
|
+
+ 'setInterval(function() {\n'
|
|
1179
|
+
+ ' var el = document.getElementById("loginIcon"); if (!el) return;\n'
|
|
1180
|
+
+ ' rotateIdx = (rotateIdx + 1) % SPRITE_TOTAL;\n'
|
|
1181
|
+
+ ' var col = rotateIdx % SPRITE_COLS, row = Math.floor(rotateIdx / SPRITE_COLS);\n'
|
|
1182
|
+
+ ' var bx = (col / (SPRITE_COLS - 1)) * 100, by = (row / (SPRITE_ROWS - 1)) * 100;\n'
|
|
1183
|
+
+ ' 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'
|
|
1184
|
+
+ '}, 6000);\n';
|
|
1185
|
+
|
|
1186
|
+
if (expired) {
|
|
1187
|
+
const html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
|
|
1188
|
+
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
|
|
1189
|
+
+ '<title>Expired - Kaleidoscope</title><style>' + APPROVE_STYLES + '</style></head><body>'
|
|
1190
|
+
+ '<div class="login-page"><div class="login-card">'
|
|
1191
|
+
+ '<h1 class="login-title"><span id="loginIcon" style="display:inline-block;vertical-align:middle;margin-right:8px;margin-bottom:3px;"></span>Kaleidoscope</h1>'
|
|
1192
|
+
+ '<p class="login-byline">Every AI. One experience.</p>'
|
|
1193
|
+
+ '<h2 style="font-size:18px;font-weight:600;margin-bottom:12px;">Link Expired</h2>'
|
|
1194
|
+
+ '<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>'
|
|
1195
|
+
+ '</div>'
|
|
1196
|
+
+ '<div id="kscope-footer" style="margin-top:48px;text-align:center;"></div>'
|
|
1197
|
+
+ '</div></div>'
|
|
1198
|
+
+ '<script src="/demo/footer.js"></script>'
|
|
1199
|
+
+ '<script>\n' + SPRITE_JS + '</script></body></html>';
|
|
1200
|
+
htmlResponse(res, 200, html);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const html = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
|
|
1205
|
+
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
|
|
1206
|
+
+ '<title>Authorize Agent - Kaleidoscope</title><style>' + APPROVE_STYLES + '</style></head><body>'
|
|
1207
|
+
+ '<div class="login-page"><div class="login-card">'
|
|
1208
|
+
+ '<h1 class="login-title"><span id="loginIcon" style="display:inline-block;vertical-align:middle;margin-right:8px;margin-bottom:3px;"></span>Kaleidoscope</h1>'
|
|
1209
|
+
+ '<p class="login-byline">Every AI. One experience.</p>'
|
|
1210
|
+
+ '<div id="authSection">'
|
|
1211
|
+
+ '<h2 style="font-size:18px;font-weight:600;margin-bottom:' + (entry.agentName ? '16' : '24') + 'px;">Authorize Agent Access</h2>'
|
|
1212
|
+
+ (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, '<') + '</div></div>' : '')
|
|
1213
|
+
+ (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, '<') + '</div></div>' : '')
|
|
1214
|
+
+ '<div class="info-section"><h2>What they get:</h2><ul>'
|
|
1215
|
+
+ '<li>A session token to use your account</li>'
|
|
1216
|
+
+ '<li>Access to your wallet balance</li>'
|
|
1217
|
+
+ '<li>Ability to generate images, send messages, search memory</li>'
|
|
1218
|
+
+ '</ul></div>'
|
|
1219
|
+
+ '<div class="info-section safe"><h2>What they don\'t get:</h2><ul>'
|
|
1220
|
+
+ '<li>Your passkey (never leaves your device)</li>'
|
|
1221
|
+
+ '<li>Your biometric data (stays on your device)</li>'
|
|
1222
|
+
+ '<li>Permanent access (session expires)</li>'
|
|
1223
|
+
+ '</ul></div>'
|
|
1224
|
+
+ '<p class="revoke-note">You can revoke access anytime.</p>'
|
|
1225
|
+
+ '<button class="btn btn-primary" id="authBtn" onclick="doAuthorize()">\ud83e\udec6 Authorize</button>'
|
|
1226
|
+
+ '<div style="margin-top:8px;text-align:center;">'
|
|
1227
|
+
+ '<a class="create-link" id="createLink" onclick="doCreateAndAuthorize()">New here? Create an account first...</a>'
|
|
1228
|
+
+ '</div>'
|
|
1229
|
+
+ '</div>'
|
|
1230
|
+
+ '<div id="successSection" style="display:none;">'
|
|
1231
|
+
+ '<div class="success-check">\u2713</div>'
|
|
1232
|
+
+ '<h2 style="font-size:18px;font-weight:600;margin-bottom:12px;">Authorized</h2>'
|
|
1233
|
+
+ '<p style="font-size:14px;color:#8a8580;line-height:1.5;margin-bottom:20px;">Send this token to your agent:</p>'
|
|
1234
|
+
+ '<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>'
|
|
1235
|
+
+ '<p style="font-size:13px;color:#8a8580;">Your agent uses this as: Authorization: Bearer [token]</p>'
|
|
1236
|
+
+ '</div>'
|
|
1237
|
+
+ '<div class="login-status" id="status"></div>'
|
|
1238
|
+
+ '</div>'
|
|
1239
|
+
+ '<div id="kscope-footer" style="margin-top:48px;text-align:center;"></div>'
|
|
1240
|
+
+ '</div></div>'
|
|
1241
|
+
+ '<script src="/demo/footer.js"></script>'
|
|
1242
|
+
+ '<script>\n'
|
|
1243
|
+
+ 'var CHALLENGE_ID = ' + JSON.stringify(challengeId) + ';\n'
|
|
1244
|
+
+ SPRITE_JS
|
|
1245
|
+
+ 'function setStatus(msg, type) {\n'
|
|
1246
|
+
+ ' var el = document.getElementById("status");\n'
|
|
1247
|
+
+ ' el.textContent = msg; el.className = "login-status show " + type;\n'
|
|
1248
|
+
+ '}\n'
|
|
1249
|
+
+ 'function b64urlToBytes(b64url) {\n'
|
|
1250
|
+
+ ' var b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");\n'
|
|
1251
|
+
+ ' var pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));\n'
|
|
1252
|
+
+ ' var bin = atob(b64 + pad);\n'
|
|
1253
|
+
+ ' return Uint8Array.from(bin, function(c) { return c.charCodeAt(0); });\n'
|
|
1254
|
+
+ '}\n'
|
|
1255
|
+
+ 'function bytesToB64url(bytes) {\n'
|
|
1256
|
+
+ ' var bin = ""; var arr = new Uint8Array(bytes);\n'
|
|
1257
|
+
+ ' for (var i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);\n'
|
|
1258
|
+
+ ' return btoa(bin).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/g, "");\n'
|
|
1259
|
+
+ '}\n'
|
|
1260
|
+
+ 'async function approveAgent(agentId, apiKey) {\n'
|
|
1261
|
+
+ ' setStatus("Approving agent access...", "loading");\n'
|
|
1262
|
+
+ ' var approveRes = await fetch("/demo/api/agent-auth/approve", {\n'
|
|
1263
|
+
+ ' method: "POST", headers: { "Content-Type": "application/json" },\n'
|
|
1264
|
+
+ ' body: JSON.stringify({ challengeId: CHALLENGE_ID, agentId: agentId, apiKey: apiKey })\n'
|
|
1265
|
+
+ ' });\n'
|
|
1266
|
+
+ ' var approveData = await approveRes.json();\n'
|
|
1267
|
+
+ ' if (approveData.ok) {\n'
|
|
1268
|
+
+ ' document.getElementById("authSection").style.display = "none";\n'
|
|
1269
|
+
+ ' document.getElementById("successSection").style.display = "block";\n'
|
|
1270
|
+
+ ' document.getElementById("tokenDisplay").textContent = apiKey;\n'
|
|
1271
|
+
+ ' document.getElementById("status").className = "login-status";\n'
|
|
1272
|
+
+ ' } else {\n'
|
|
1273
|
+
+ ' throw new Error(approveData.error || "Failed to approve");\n'
|
|
1274
|
+
+ ' }\n'
|
|
1275
|
+
+ '}\n'
|
|
1276
|
+
+ 'async function doAuthorize() {\n'
|
|
1277
|
+
+ ' var btn = document.getElementById("authBtn"); btn.disabled = true;\n'
|
|
1278
|
+
+ ' setStatus("Preparing...", "loading");\n'
|
|
1279
|
+
+ ' try {\n'
|
|
1280
|
+
+ ' var optRes = await fetch("/webauthn/auth-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
|
|
1281
|
+
+ ' var optData = await optRes.json();\n'
|
|
1282
|
+
+ ' var challengeId = optData.challengeId;\n'
|
|
1283
|
+
+ ' var options = optData.options;\n'
|
|
1284
|
+
+ ' if (!options) throw new Error("Server returned no options");\n'
|
|
1285
|
+
+ ' options.challenge = b64urlToBytes(options.challenge);\n'
|
|
1286
|
+
+ ' if (options.allowCredentials) {\n'
|
|
1287
|
+
+ ' options.allowCredentials = options.allowCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });\n'
|
|
1288
|
+
+ ' }\n'
|
|
1289
|
+
+ ' setStatus("Waiting for biometric...", "loading");\n'
|
|
1290
|
+
+ ' var assertion = await navigator.credentials.get({ publicKey: options });\n'
|
|
1291
|
+
+ ' var reqBody = {\n'
|
|
1292
|
+
+ ' challengeId: challengeId,\n'
|
|
1293
|
+
+ ' credential: {\n'
|
|
1294
|
+
+ ' id: assertion.id, rawId: bytesToB64url(assertion.rawId), type: assertion.type,\n'
|
|
1295
|
+
+ ' response: {\n'
|
|
1296
|
+
+ ' authenticatorData: bytesToB64url(assertion.response.authenticatorData),\n'
|
|
1297
|
+
+ ' clientDataJSON: bytesToB64url(assertion.response.clientDataJSON),\n'
|
|
1298
|
+
+ ' signature: bytesToB64url(assertion.response.signature),\n'
|
|
1299
|
+
+ ' userHandle: assertion.response.userHandle ? bytesToB64url(assertion.response.userHandle) : null,\n'
|
|
1300
|
+
+ ' },\n'
|
|
1301
|
+
+ ' },\n'
|
|
1302
|
+
+ ' };\n'
|
|
1303
|
+
+ ' setStatus("Verifying...", "loading");\n'
|
|
1304
|
+
+ ' var verRes = await fetch("/webauthn/auth-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
|
|
1305
|
+
+ ' var result = await verRes.json();\n'
|
|
1306
|
+
+ ' if (!result.success) { setStatus(result.error || "Authentication failed", "error"); btn.disabled = false; return; }\n'
|
|
1307
|
+
+ ' await approveAgent(result.agentId, result.apiKey);\n'
|
|
1308
|
+
+ ' } catch (err) {\n'
|
|
1309
|
+
+ ' if (err.name === "NotAllowedError") { setStatus("Cancelled. Try again when ready.", "error"); }\n'
|
|
1310
|
+
+ ' else { setStatus("Error: " + err.message, "error"); }\n'
|
|
1311
|
+
+ ' btn.disabled = false;\n'
|
|
1312
|
+
+ ' }\n'
|
|
1313
|
+
+ '}\n'
|
|
1314
|
+
+ 'async function doCreateAndAuthorize() {\n'
|
|
1315
|
+
+ ' var btn = document.getElementById("authBtn"); btn.disabled = true;\n'
|
|
1316
|
+
+ ' document.getElementById("createLink").style.display = "none";\n'
|
|
1317
|
+
+ ' setStatus("Creating your account...", "loading");\n'
|
|
1318
|
+
+ ' try {\n'
|
|
1319
|
+
+ ' var optRes = await fetch("/webauthn/register-options", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });\n'
|
|
1320
|
+
+ ' var optData = await optRes.json();\n'
|
|
1321
|
+
+ ' var challengeId = optData.challengeId;\n'
|
|
1322
|
+
+ ' var options = optData.options;\n'
|
|
1323
|
+
+ ' if (!options) throw new Error("Server returned no options");\n'
|
|
1324
|
+
+ ' options.challenge = b64urlToBytes(options.challenge);\n'
|
|
1325
|
+
+ ' options.user.id = b64urlToBytes(options.user.id);\n'
|
|
1326
|
+
+ ' if (options.excludeCredentials) {\n'
|
|
1327
|
+
+ ' options.excludeCredentials = options.excludeCredentials.map(function(c) { return Object.assign({}, c, { id: b64urlToBytes(c.id) }); });\n'
|
|
1328
|
+
+ ' }\n'
|
|
1329
|
+
+ ' setStatus("Waiting for biometric...", "loading");\n'
|
|
1330
|
+
+ ' var credential = await navigator.credentials.create({ publicKey: options });\n'
|
|
1331
|
+
+ ' var reqBody = {\n'
|
|
1332
|
+
+ ' challengeId: challengeId,\n'
|
|
1333
|
+
+ ' credential: {\n'
|
|
1334
|
+
+ ' id: credential.id, rawId: bytesToB64url(credential.rawId), type: credential.type,\n'
|
|
1335
|
+
+ ' response: {\n'
|
|
1336
|
+
+ ' attestationObject: bytesToB64url(credential.response.attestationObject),\n'
|
|
1337
|
+
+ ' clientDataJSON: bytesToB64url(credential.response.clientDataJSON),\n'
|
|
1338
|
+
+ ' transports: credential.response.getTransports ? credential.response.getTransports() : [],\n'
|
|
1339
|
+
+ ' },\n'
|
|
1340
|
+
+ ' },\n'
|
|
1341
|
+
+ ' };\n'
|
|
1342
|
+
+ ' setStatus("Verifying...", "loading");\n'
|
|
1343
|
+
+ ' var verRes = await fetch("/webauthn/register-verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(reqBody) });\n'
|
|
1344
|
+
+ ' var result = await verRes.json();\n'
|
|
1345
|
+
+ ' if (!result.success) { setStatus(result.error || "Registration failed", "error"); btn.disabled = false; document.getElementById("createLink").style.display = ""; return; }\n'
|
|
1346
|
+
+ ' await approveAgent(result.agentId, result.apiKey);\n'
|
|
1347
|
+
+ ' } catch (err) {\n'
|
|
1348
|
+
+ ' if (err.name === "NotAllowedError") { setStatus("Cancelled. Try again when ready.", "error"); }\n'
|
|
1349
|
+
+ ' else { setStatus("Error: " + err.message, "error"); }\n'
|
|
1350
|
+
+ ' btn.disabled = false;\n'
|
|
1351
|
+
+ ' document.getElementById("createLink").style.display = "";\n'
|
|
1352
|
+
+ ' }\n'
|
|
1353
|
+
+ '}\n'
|
|
1354
|
+
+ '</script></body></html>';
|
|
1355
|
+
|
|
1356
|
+
htmlResponse(res, 200, html);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// POST /demo/api/agent-auth/approve ... called by the approve page after successful passkey auth
|
|
1360
|
+
function handleAgentAuthApprove(req, res) {
|
|
1361
|
+
readBody(req).then(function(body) {
|
|
1362
|
+
const { challengeId, agentId, apiKey } = body || {};
|
|
1363
|
+
const entry = agentAuthChallenges[challengeId];
|
|
1364
|
+
if (!entry || Date.now() > entry.expires) {
|
|
1365
|
+
json(res, 404, { error: "Challenge not found or expired" });
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
if (entry.status === "approved") {
|
|
1369
|
+
json(res, 400, { error: "Already approved" });
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
entry.status = "approved";
|
|
1373
|
+
entry.token = apiKey;
|
|
1374
|
+
entry.agentId = agentId;
|
|
1375
|
+
console.log("Agent QR auth: approved challenge " + challengeId.slice(0, 8) + "... for agent '" + agentId + "'");
|
|
1376
|
+
json(res, 200, { ok: true });
|
|
1377
|
+
}).catch(function() {
|
|
1378
|
+
json(res, 400, { error: "Invalid request" });
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// ---------- QR Login (Chrome fallback) ----------
|
|
1383
|
+
|
|
1384
|
+
// POST /api/qr-login ... create a QR login session
|
|
1385
|
+
async function handleQrLoginStart(req, res) {
|
|
1386
|
+
cleanupExpiredChallenges();
|
|
1387
|
+
const body = await readBody(req).catch(() => ({}));
|
|
1388
|
+
const handle = ((body && body.handle) || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30);
|
|
1389
|
+
const mode = ((body && body.mode) || "register") === "signin" ? "signin" : "register";
|
|
1390
|
+
const sessionId = randomUUID();
|
|
1391
|
+
const loginUrl = ISSUER_URL + "/login?s=" + sessionId + "&m=" + mode + (handle ? "&h=" + encodeURIComponent(handle) : "");
|
|
1392
|
+
const qrBuffer = await QRCode.toBuffer(loginUrl, { type: "png", width: 400, margin: 2 });
|
|
1393
|
+
qrLoginSessions[sessionId] = {
|
|
1394
|
+
qrBuffer,
|
|
1395
|
+
status: "pending",
|
|
1396
|
+
agentId: null,
|
|
1397
|
+
apiKey: null,
|
|
1398
|
+
handle: handle || null,
|
|
1399
|
+
expires: Date.now() + QR_LOGIN_EXPIRY_MS,
|
|
1400
|
+
};
|
|
1401
|
+
console.log("QR login: created session " + sessionId.slice(0, 8) + "...");
|
|
1402
|
+
json(res, 200, { sessionId, qrUrl: "/api/qr-login/qr?s=" + sessionId });
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// GET /api/qr-login/qr?s=XXX ... serve QR code PNG
|
|
1406
|
+
function handleQrLoginQR(req, res) {
|
|
1407
|
+
const url = parseUrl(req.url);
|
|
1408
|
+
const s = url.searchParams.get("s");
|
|
1409
|
+
const entry = qrLoginSessions[s];
|
|
1410
|
+
if (!entry || Date.now() > entry.expires) {
|
|
1411
|
+
json(res, 404, { error: "Session not found or expired" });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
res.writeHead(200, { "Content-Type": "image/png", "Content-Length": entry.qrBuffer.length });
|
|
1415
|
+
res.end(entry.qrBuffer);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// GET /api/qr-login/status?s=XXX ... poll for completion
|
|
1419
|
+
function handleQrLoginStatus(req, res) {
|
|
1420
|
+
const url = parseUrl(req.url);
|
|
1421
|
+
const s = url.searchParams.get("s");
|
|
1422
|
+
const entry = qrLoginSessions[s];
|
|
1423
|
+
if (!entry || Date.now() > entry.expires) {
|
|
1424
|
+
json(res, 404, { error: "Session not found or expired" });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (entry.status === "approved") {
|
|
1428
|
+
json(res, 200, { status: "approved", agentId: entry.agentId, apiKey: entry.apiKey });
|
|
1429
|
+
delete qrLoginSessions[s]; // one-time use
|
|
1430
|
+
} else {
|
|
1431
|
+
json(res, 200, { status: "pending" });
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// POST /api/qr-login/approve ... phone calls after passkey created
|
|
1436
|
+
function handleQrLoginApprove(req, res) {
|
|
1437
|
+
readBody(req).then(function(body) {
|
|
1438
|
+
const { sessionId, agentId, apiKey } = body || {};
|
|
1439
|
+
const entry = qrLoginSessions[sessionId];
|
|
1440
|
+
if (!entry || Date.now() > entry.expires) {
|
|
1441
|
+
json(res, 404, { error: "Session not found or expired" });
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (entry.status === "approved") {
|
|
1445
|
+
json(res, 400, { error: "Already approved" });
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
entry.status = "approved";
|
|
1449
|
+
entry.agentId = agentId;
|
|
1450
|
+
entry.apiKey = apiKey;
|
|
1451
|
+
console.log("QR login: approved session " + sessionId.slice(0, 8) + "... for '" + agentId + "'");
|
|
1452
|
+
json(res, 200, { ok: true });
|
|
1453
|
+
}).catch(function() {
|
|
1454
|
+
json(res, 400, { error: "Invalid request" });
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ---------- Demo API handlers ----------
|
|
1459
|
+
|
|
1460
|
+
// ── Wallet tracking (per agent) ──
|
|
1461
|
+
const IMAGE_COST_CENTS = 1; // $0.01
|
|
1462
|
+
const INITIAL_BALANCE_CENTS = 500; // $5.00
|
|
1463
|
+
|
|
1464
|
+
// JSON fallback for wallets
|
|
1465
|
+
const WALLET_FILE = join(dirname(fileURLToPath(import.meta.url)), "wallets.json");
|
|
1466
|
+
function loadWalletsFromFile() { try { return JSON.parse(readFileSync(WALLET_FILE, "utf8")); } catch { return {}; } }
|
|
1467
|
+
function saveWalletsToFile(w) { try { writeFileSync(WALLET_FILE, JSON.stringify(w, null, 2) + "\n"); } catch {} }
|
|
1468
|
+
|
|
1469
|
+
async function getBalance(agentId) {
|
|
1470
|
+
if (usePrisma) {
|
|
1471
|
+
try {
|
|
1472
|
+
const wallet = await prisma.wallet.findFirst({ where: { userId: agentId } });
|
|
1473
|
+
return wallet ? wallet.balance : INITIAL_BALANCE_CENTS;
|
|
1474
|
+
} catch {}
|
|
1475
|
+
}
|
|
1476
|
+
const w = loadWalletsFromFile();
|
|
1477
|
+
return w[agentId] !== undefined ? w[agentId] : INITIAL_BALANCE_CENTS;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
async function deductBalance(agentId, cents) {
|
|
1481
|
+
if (usePrisma) {
|
|
1482
|
+
try {
|
|
1483
|
+
let wallet = await prisma.wallet.findFirst({ where: { userId: agentId } });
|
|
1484
|
+
if (!wallet) {
|
|
1485
|
+
wallet = await prisma.wallet.create({
|
|
1486
|
+
data: { userId: agentId, balance: INITIAL_BALANCE_CENTS },
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
const newBalance = Math.max(0, wallet.balance - cents);
|
|
1490
|
+
await prisma.wallet.update({ where: { id: wallet.id }, data: { balance: newBalance } });
|
|
1491
|
+
return newBalance;
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
console.error("Prisma deductBalance error:", err.message);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
// JSON fallback
|
|
1497
|
+
const w = loadWalletsFromFile();
|
|
1498
|
+
if (w[agentId] === undefined) w[agentId] = INITIAL_BALANCE_CENTS;
|
|
1499
|
+
w[agentId] = Math.max(0, w[agentId] - cents);
|
|
1500
|
+
saveWalletsToFile(w);
|
|
1501
|
+
return w[agentId];
|
|
1502
|
+
}
|
|
1503
|
+
function formatCents(c) { return "$" + (c / 100).toFixed(2); }
|
|
1504
|
+
|
|
1505
|
+
// POST /demo/api/analyze-photo
|
|
1506
|
+
// Sends a base64 image to OpenAI GPT-4o vision to extract colors/mood.
|
|
1507
|
+
async function handleDemoAnalyzePhoto(req, res) {
|
|
1508
|
+
const identity = authenticate(req);
|
|
1509
|
+
if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
|
|
1510
|
+
|
|
1511
|
+
try {
|
|
1512
|
+
const body = await readBody(req);
|
|
1513
|
+
const image = body?.image;
|
|
1514
|
+
if (!image || typeof image !== "string" || !image.startsWith("data:image/")) {
|
|
1515
|
+
json(res, 400, { error: "Missing or invalid base64 image" });
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const OPENAI_KEY = process.env.OPENAI_API_KEY || "";
|
|
1520
|
+
if (!OPENAI_KEY) {
|
|
1521
|
+
json(res, 503, { error: "Vision analysis not configured" });
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const oaiRes = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1526
|
+
method: "POST",
|
|
1527
|
+
headers: {
|
|
1528
|
+
"Content-Type": "application/json",
|
|
1529
|
+
"Authorization": "Bearer " + OPENAI_KEY,
|
|
1530
|
+
},
|
|
1531
|
+
body: JSON.stringify({
|
|
1532
|
+
model: "gpt-4o",
|
|
1533
|
+
max_tokens: 80,
|
|
1534
|
+
messages: [
|
|
1535
|
+
{
|
|
1536
|
+
role: "user",
|
|
1537
|
+
content: [
|
|
1538
|
+
{
|
|
1539
|
+
type: "text",
|
|
1540
|
+
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.",
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
type: "image_url",
|
|
1544
|
+
image_url: { url: image },
|
|
1545
|
+
},
|
|
1546
|
+
],
|
|
1547
|
+
},
|
|
1548
|
+
],
|
|
1549
|
+
}),
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
const oaiData = await oaiRes.json();
|
|
1553
|
+
const description = oaiData.choices?.[0]?.message?.content?.trim();
|
|
1554
|
+
|
|
1555
|
+
if (!description) {
|
|
1556
|
+
console.error("Vision analysis: no description returned", oaiData.error || "");
|
|
1557
|
+
json(res, 502, { error: "Vision analysis returned no description" });
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
console.log("Demo: vision analysis for agent '" + identity.agentId + "': " + description);
|
|
1562
|
+
json(res, 200, { description });
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
console.error("Demo analyze-photo error:", err.message);
|
|
1565
|
+
json(res, 500, { error: "Internal error" });
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// POST /demo/api/imagine
|
|
1570
|
+
async function handleDemoImagine(req, res) {
|
|
1571
|
+
const identity = authenticate(req);
|
|
1572
|
+
if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
|
|
1573
|
+
|
|
1574
|
+
try {
|
|
1575
|
+
const body = await readBody(req);
|
|
1576
|
+
const prompt = body?.prompt || "kaleidoscope";
|
|
1577
|
+
|
|
1578
|
+
const XAI_KEY = process.env.XAI_API_KEY || "";
|
|
1579
|
+
if (!XAI_KEY) {
|
|
1580
|
+
json(res, 503, { error: "Image generation not configured" });
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const grokRes = await fetch("https://api.x.ai/v1/images/generations", {
|
|
1585
|
+
method: "POST",
|
|
1586
|
+
headers: {
|
|
1587
|
+
"Content-Type": "application/json",
|
|
1588
|
+
"Authorization": "Bearer " + XAI_KEY,
|
|
1589
|
+
},
|
|
1590
|
+
body: JSON.stringify({
|
|
1591
|
+
model: "grok-imagine-image",
|
|
1592
|
+
prompt: prompt,
|
|
1593
|
+
n: 1,
|
|
1594
|
+
}),
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
const grokData = await grokRes.json();
|
|
1598
|
+
if (grokData.error) {
|
|
1599
|
+
json(res, 502, { error: grokData.error.message || "Image generation failed" });
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const imageUrl = grokData.data?.[0]?.url;
|
|
1604
|
+
if (!imageUrl) {
|
|
1605
|
+
json(res, 502, { error: "No image returned" });
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const newBalance = await deductBalance(identity.agentId, IMAGE_COST_CENTS);
|
|
1610
|
+
console.log("Demo: generated image for agent '" + identity.agentId + "' (balance: " + formatCents(newBalance) + ")");
|
|
1611
|
+
json(res, 200, { url: imageUrl, prompt: prompt, cost: formatCents(IMAGE_COST_CENTS), balance: formatCents(newBalance) });
|
|
1612
|
+
} catch (err) {
|
|
1613
|
+
console.error("Demo imagine error:", err.message);
|
|
1614
|
+
json(res, 500, { error: "Internal error" });
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// ---------- MCP handlers ----------
|
|
1619
|
+
|
|
1620
|
+
async function handlePost(req, res, identity) {
|
|
1621
|
+
const sid = req.headers["mcp-session-id"];
|
|
1622
|
+
let body;
|
|
1623
|
+
try { body = await readBody(req); } catch { rpcError(res, 400, -32700, "Parse error"); return; }
|
|
1624
|
+
|
|
1625
|
+
if (sid && sessions[sid]) {
|
|
1626
|
+
touchSession(sid);
|
|
1627
|
+
await sessions[sid].transport.handleRequest(req, res, body);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
if (!sid && isInitializeRequest(body)) {
|
|
1632
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1633
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1634
|
+
onsessioninitialized: (id) => {
|
|
1635
|
+
sessions[id] = { transport, server: mcpServer, identity, lastActivity: Date.now() };
|
|
1636
|
+
console.log("Session created: " + id + " (agent: " + identity.agentId + ")");
|
|
1637
|
+
},
|
|
1638
|
+
});
|
|
1639
|
+
transport.onclose = () => {
|
|
1640
|
+
const id = transport.sessionId;
|
|
1641
|
+
if (id && sessions[id]) { console.log("Session closed: " + id); delete sessions[id]; }
|
|
1642
|
+
};
|
|
1643
|
+
const mcpServer = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
1644
|
+
registerTools(mcpServer, () => identity);
|
|
1645
|
+
await mcpServer.connect(transport);
|
|
1646
|
+
await transport.handleRequest(req, res, body);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
rpcError(res, 400, -32000, "Bad request: missing or invalid session");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
async function handleGetOrDelete(req, res) {
|
|
1654
|
+
const sid = req.headers["mcp-session-id"];
|
|
1655
|
+
if (!sid || !sessions[sid]) { rpcError(res, 400, -32000, "Invalid or missing session ID"); return; }
|
|
1656
|
+
touchSession(sid);
|
|
1657
|
+
await sessions[sid].transport.handleRequest(req, res);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// ---------- HTTP server ----------
|
|
1661
|
+
|
|
1662
|
+
// ── Device Pairing (Bridge Phase A) ─────────────────────────────────
|
|
1663
|
+
//
|
|
1664
|
+
// Flow:
|
|
1665
|
+
// 1. CLI runs `ldm pair`, calls POST /api/pair/request with a code
|
|
1666
|
+
// 2. Server stores the pending pairing (code -> device info)
|
|
1667
|
+
// 3. User goes to kaleidoscope.wip.computer/pair, signs in with passkey
|
|
1668
|
+
// 4. User enters the code, calls POST /api/pair/approve
|
|
1669
|
+
// 5. Server matches code, generates a device token, marks as approved
|
|
1670
|
+
// 6. CLI polls GET /api/pair/status?code=X, gets the token
|
|
1671
|
+
// 7. CLI stores token at ~/.ldm/auth/kaleidoscope.json
|
|
1672
|
+
//
|
|
1673
|
+
// Codes expire after 120 seconds. Approved tokens persist on the server
|
|
1674
|
+
// in a device registry (paired-devices.json).
|
|
1675
|
+
|
|
1676
|
+
const PAIR_CODE_EXPIRY_MS = 120_000;
|
|
1677
|
+
const PAIRED_DEVICES_FILE = join(__dirname, "paired-devices.json");
|
|
1678
|
+
|
|
1679
|
+
// In-memory pending pairings: code -> { deviceName, agentId, createdAt, approved, token, userId, userName }
|
|
1680
|
+
const pendingPairings = new Map();
|
|
1681
|
+
|
|
1682
|
+
// Load persisted device registry
|
|
1683
|
+
function loadPairedDevices() {
|
|
1684
|
+
try { return JSON.parse(readFileSync(PAIRED_DEVICES_FILE, "utf8")); } catch { return []; }
|
|
1685
|
+
}
|
|
1686
|
+
function savePairedDevices(devices) {
|
|
1687
|
+
writeFileSync(PAIRED_DEVICES_FILE, JSON.stringify(devices, null, 2) + "\n");
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Word lists for human-readable codes
|
|
1691
|
+
const PAIR_WORDS = [
|
|
1692
|
+
"BLUE", "RED", "GREEN", "GOLD", "GRAY", "PINK", "DARK", "WARM", "COLD", "WILD",
|
|
1693
|
+
"FISH", "BIRD", "WOLF", "BEAR", "DEER", "HAWK", "FROG", "LYNX", "DOVE", "CROW",
|
|
1694
|
+
"OAK", "ELM", "ASH", "FIG", "IVY", "YEW", "BAY", "FIR", "RYE", "RUM",
|
|
1695
|
+
];
|
|
1696
|
+
|
|
1697
|
+
function generatePairCode() {
|
|
1698
|
+
const w1 = PAIR_WORDS[Math.floor(Math.random() * 10)]; // color
|
|
1699
|
+
const w2 = PAIR_WORDS[10 + Math.floor(Math.random() * 10)]; // animal
|
|
1700
|
+
const num = String(Math.floor(1000 + Math.random() * 9000)); // 4 digits
|
|
1701
|
+
return `${w1}-${w2}-${num}`;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// Clean expired pairings every 30s
|
|
1705
|
+
setInterval(() => {
|
|
1706
|
+
const now = Date.now();
|
|
1707
|
+
for (const [code, p] of pendingPairings) {
|
|
1708
|
+
if (now - p.createdAt > PAIR_CODE_EXPIRY_MS && !p.approved) {
|
|
1709
|
+
pendingPairings.delete(code);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}, 30_000);
|
|
1713
|
+
|
|
1714
|
+
async function handlePairRequest(req, res) {
|
|
1715
|
+
const body = await readBody(req);
|
|
1716
|
+
const { code, deviceName, agentId } = body || {};
|
|
1717
|
+
|
|
1718
|
+
if (!code || typeof code !== "string") {
|
|
1719
|
+
json(res, 400, { error: "Missing code" });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Store as pending
|
|
1724
|
+
pendingPairings.set(code.toUpperCase(), {
|
|
1725
|
+
deviceName: deviceName || "unknown",
|
|
1726
|
+
agentId: agentId || "cc-mini",
|
|
1727
|
+
createdAt: Date.now(),
|
|
1728
|
+
approved: false,
|
|
1729
|
+
token: null,
|
|
1730
|
+
userId: null,
|
|
1731
|
+
userName: null,
|
|
1732
|
+
});
|
|
1733
|
+
|
|
1734
|
+
json(res, 200, { ok: true, code: code.toUpperCase(), expiresIn: PAIR_CODE_EXPIRY_MS / 1000 });
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
async function handlePairApprove(req, res) {
|
|
1738
|
+
const body = await readBody(req);
|
|
1739
|
+
const { code, userId, userName } = body || {};
|
|
1740
|
+
|
|
1741
|
+
if (!code || typeof code !== "string") {
|
|
1742
|
+
json(res, 400, { error: "Missing code" });
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
const upper = code.toUpperCase();
|
|
1747
|
+
const pending = pendingPairings.get(upper);
|
|
1748
|
+
|
|
1749
|
+
if (!pending) {
|
|
1750
|
+
json(res, 404, { error: "Code not found or expired. Run ldm pair again." });
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if (Date.now() - pending.createdAt > PAIR_CODE_EXPIRY_MS) {
|
|
1755
|
+
pendingPairings.delete(upper);
|
|
1756
|
+
json(res, 410, { error: "Code expired. Run ldm pair again." });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Generate device token
|
|
1761
|
+
const token = "dk-" + randomBytes(32).toString("hex");
|
|
1762
|
+
|
|
1763
|
+
// Mark as approved
|
|
1764
|
+
pending.approved = true;
|
|
1765
|
+
pending.token = token;
|
|
1766
|
+
pending.userId = userId || "unknown";
|
|
1767
|
+
pending.userName = userName || "User";
|
|
1768
|
+
|
|
1769
|
+
// Persist to device registry
|
|
1770
|
+
if (usePrisma) {
|
|
1771
|
+
try {
|
|
1772
|
+
await prisma.device.create({
|
|
1773
|
+
data: {
|
|
1774
|
+
token,
|
|
1775
|
+
deviceName: pending.deviceName,
|
|
1776
|
+
agentId: pending.agentId,
|
|
1777
|
+
userId: pending.userId,
|
|
1778
|
+
pairedAt: new Date(),
|
|
1779
|
+
},
|
|
1780
|
+
});
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
console.error("Prisma device save error:", err.message);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
// JSON backup
|
|
1786
|
+
try {
|
|
1787
|
+
const devices = loadPairedDevices();
|
|
1788
|
+
devices.push({
|
|
1789
|
+
token,
|
|
1790
|
+
deviceName: pending.deviceName,
|
|
1791
|
+
agentId: pending.agentId,
|
|
1792
|
+
userId: pending.userId,
|
|
1793
|
+
userName: pending.userName,
|
|
1794
|
+
pairedAt: new Date().toISOString(),
|
|
1795
|
+
});
|
|
1796
|
+
savePairedDevices(devices);
|
|
1797
|
+
} catch {}
|
|
1798
|
+
|
|
1799
|
+
json(res, 200, {
|
|
1800
|
+
paired: true,
|
|
1801
|
+
deviceName: pending.deviceName,
|
|
1802
|
+
token, // returned to the approve page so it can confirm
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function handlePairStatus(req, res, url) {
|
|
1807
|
+
const code = (url.searchParams?.get("code") || url.query?.code || "").toUpperCase();
|
|
1808
|
+
|
|
1809
|
+
if (!code) {
|
|
1810
|
+
json(res, 400, { error: "Missing code parameter" });
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const pending = pendingPairings.get(code);
|
|
1815
|
+
|
|
1816
|
+
if (!pending) {
|
|
1817
|
+
json(res, 404, { error: "Code not found or expired" });
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
if (!pending.approved) {
|
|
1822
|
+
json(res, 202, { status: "pending", message: "Waiting for approval..." });
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Approved. Return token. Clean up.
|
|
1827
|
+
pendingPairings.delete(code);
|
|
1828
|
+
json(res, 200, {
|
|
1829
|
+
status: "approved",
|
|
1830
|
+
token: pending.token,
|
|
1831
|
+
userId: pending.userId,
|
|
1832
|
+
userName: pending.userName,
|
|
1833
|
+
});
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const httpServer = createServer(async (req, res) => {
|
|
1837
|
+
cors(res);
|
|
1838
|
+
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
|
1839
|
+
|
|
1840
|
+
const url = parseUrl(req.url);
|
|
1841
|
+
const path = url.pathname;
|
|
1842
|
+
|
|
1843
|
+
// Health check
|
|
1844
|
+
if (req.method === "GET" && path === "/health") {
|
|
1845
|
+
json(res, 200, {
|
|
1846
|
+
ok: true, server: SERVER_NAME, version: SERVER_VERSION,
|
|
1847
|
+
database: usePrisma ? "postgres" : "json",
|
|
1848
|
+
sessions: Object.keys(sessions).length,
|
|
1849
|
+
passkeys: passkeys.length,
|
|
1850
|
+
uptime: process.uptime(),
|
|
1851
|
+
});
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// --- Shared assets (Kaleidoscope template system) ---
|
|
1856
|
+
|
|
1857
|
+
if (req.method === "GET" && path.startsWith("/shared/")) {
|
|
1858
|
+
const filePath = join(__dirname, path);
|
|
1859
|
+
try {
|
|
1860
|
+
const content = readFileSync(filePath, "utf8");
|
|
1861
|
+
const ext = path.split(".").pop();
|
|
1862
|
+
const mimeTypes = { css: "text/css", js: "text/javascript", html: "text/html" };
|
|
1863
|
+
res.writeHead(200, { "Content-Type": (mimeTypes[ext] || "text/plain") + "; charset=utf-8" });
|
|
1864
|
+
res.end(content);
|
|
1865
|
+
} catch { json(res, 404, { error: "Not found" }); }
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// --- Static pages ---
|
|
1870
|
+
|
|
1871
|
+
if (req.method === "GET" && path === "/signup") {
|
|
1872
|
+
handleSignupPage(req, res);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
if (req.method === "GET" && (path === "/login" || path === "/login/")) {
|
|
1877
|
+
// Serve the production login page (Kaleidoscope design)
|
|
1878
|
+
try {
|
|
1879
|
+
const loginHtml = readFileSync(join(__dirname, "demo", "login.html"), "utf8");
|
|
1880
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1881
|
+
res.end(loginHtml);
|
|
1882
|
+
} catch {
|
|
1883
|
+
// Fallback to old server-rendered login
|
|
1884
|
+
handleLoginPage(req, res);
|
|
1885
|
+
}
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// --- Legal pages ---
|
|
1890
|
+
|
|
1891
|
+
if (req.method === "GET" && (path === "/legal/privacy/en-ww/" || path === "/legal/privacy/en-ww")) {
|
|
1892
|
+
try {
|
|
1893
|
+
const html = readFileSync(join(__dirname, "legal", "privacy", "en-ww", "index.html"), "utf8");
|
|
1894
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1895
|
+
res.end(html);
|
|
1896
|
+
} catch { json(res, 404, { error: "Not found" }); }
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
if (req.method === "GET" && path === "/legal/internet-services/terms/site.html") {
|
|
1901
|
+
try {
|
|
1902
|
+
const html = readFileSync(join(__dirname, "legal", "internet-services", "terms", "site.html"), "utf8");
|
|
1903
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1904
|
+
res.end(html);
|
|
1905
|
+
} catch { json(res, 404, { error: "Not found" }); }
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// --- WebAuthn API ---
|
|
1910
|
+
|
|
1911
|
+
if (req.method === "POST" && path === "/webauthn/register-options") {
|
|
1912
|
+
await handleRegisterOptions(req, res);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (req.method === "POST" && path === "/webauthn/register-verify") {
|
|
1917
|
+
await handleRegisterVerify(req, res);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
if (req.method === "POST" && path === "/webauthn/auth-options") {
|
|
1922
|
+
await handleAuthOptions(req, res);
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
if (req.method === "POST" && path === "/webauthn/auth-verify") {
|
|
1927
|
+
await handleAuthVerify(req, res);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// --- OAuth 2.0 / Well-Known ---
|
|
1932
|
+
|
|
1933
|
+
if (req.method === "GET" && path === "/.well-known/oauth-authorization-server") {
|
|
1934
|
+
handleOAuthDiscovery(req, res);
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
if (req.method === "GET" && path === "/.well-known/oauth-protected-resource") {
|
|
1939
|
+
handleProtectedResource(req, res);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
if (req.method === "POST" && path === "/oauth/register") {
|
|
1944
|
+
await handleOAuthRegister(req, res);
|
|
1945
|
+
return;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
if (req.method === "GET" && path === "/oauth/authorize") {
|
|
1949
|
+
handleOAuthAuthorize(req, res);
|
|
1950
|
+
return;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
if (req.method === "POST" && path === "/oauth/authorize/submit") {
|
|
1954
|
+
await handleOAuthAuthorizeSubmit(req, res);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
if (req.method === "POST" && path === "/oauth/token") {
|
|
1959
|
+
await handleOAuthToken(req, res);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// --- Agent QR Auth ---
|
|
1964
|
+
|
|
1965
|
+
if (req.method === "GET" && path === "/demo/api/agent-auth") {
|
|
1966
|
+
await handleAgentAuthStart(req, res);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
if (req.method === "GET" && path === "/demo/api/agent-auth/qr") {
|
|
1971
|
+
handleAgentAuthQR(req, res);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (req.method === "GET" && path === "/demo/api/agent-auth/status") {
|
|
1976
|
+
handleAgentAuthStatus(req, res);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
if (req.method === "POST" && path === "/demo/api/agent-auth/approve") {
|
|
1981
|
+
handleAgentAuthApprove(req, res);
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
if (req.method === "GET" && path === "/approve") {
|
|
1986
|
+
handleApprovePage(req, res);
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// --- QR Login (Chrome fallback) ---
|
|
1991
|
+
|
|
1992
|
+
if (req.method === "POST" && path === "/api/qr-login") {
|
|
1993
|
+
await handleQrLoginStart(req, res);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
if (req.method === "GET" && path === "/api/qr-login/qr") {
|
|
1998
|
+
handleQrLoginQR(req, res);
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
if (req.method === "GET" && path === "/api/qr-login/status") {
|
|
2003
|
+
handleQrLoginStatus(req, res);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
if (req.method === "POST" && path === "/api/qr-login/approve") {
|
|
2008
|
+
handleQrLoginApprove(req, res);
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// --- Demo API ---
|
|
2013
|
+
|
|
2014
|
+
if (req.method === "GET" && path === "/demo/api/wallet") {
|
|
2015
|
+
const identity = authenticate(req);
|
|
2016
|
+
if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
|
|
2017
|
+
json(res, 200, { balance: formatCents(await getBalance(identity.agentId)), cost: formatCents(IMAGE_COST_CENTS) });
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
if (req.method === "POST" && path === "/demo/api/analyze-photo") {
|
|
2022
|
+
await handleDemoAnalyzePhoto(req, res);
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
if (req.method === "POST" && path === "/demo/api/imagine") {
|
|
2027
|
+
await handleDemoImagine(req, res);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// --- MCP ---
|
|
2032
|
+
|
|
2033
|
+
if (path === "/mcp") {
|
|
2034
|
+
const identity = authenticate(req);
|
|
2035
|
+
if (!identity && req.method === "POST") {
|
|
2036
|
+
json(res, 401, { error: "Unauthorized. Provide Bearer ck-... token." });
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
try {
|
|
2040
|
+
if (req.method === "POST") await handlePost(req, res, identity);
|
|
2041
|
+
else if (req.method === "GET" || req.method === "DELETE") await handleGetOrDelete(req, res);
|
|
2042
|
+
else rpcError(res, 405, -32000, "Method not allowed");
|
|
2043
|
+
} catch (err) {
|
|
2044
|
+
console.error("MCP error:", err);
|
|
2045
|
+
if (!res.headersSent) rpcError(res, 500, -32603, "Internal server error");
|
|
2046
|
+
}
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
// --- Device Pairing API (Bridge Phase A) ---
|
|
2051
|
+
|
|
2052
|
+
if (req.method === "POST" && path === "/api/pair/request") {
|
|
2053
|
+
await handlePairRequest(req, res);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
if (req.method === "POST" && path === "/api/pair/approve") {
|
|
2058
|
+
await handlePairApprove(req, res);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
if (req.method === "GET" && path === "/api/pair/status") {
|
|
2063
|
+
handlePairStatus(req, res, url);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
json(res, 404, { error: "Not found" });
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
httpServer.listen(PORT, SERVER_BIND, () => {
|
|
2071
|
+
console.log(SERVER_NAME + " v" + SERVER_VERSION + " listening on " + SERVER_BIND + ":" + PORT);
|
|
2072
|
+
console.log("Health: http://localhost:" + PORT + "/health");
|
|
2073
|
+
console.log("MCP: http://localhost:" + PORT + "/mcp");
|
|
2074
|
+
console.log("OAuth: http://localhost:" + PORT + "/.well-known/oauth-authorization-server");
|
|
2075
|
+
console.log("Signup: http://localhost:" + PORT + "/signup");
|
|
2076
|
+
console.log("Login: http://localhost:" + PORT + "/login");
|
|
2077
|
+
console.log("Demo: http://localhost:" + PORT + "/demo/");
|
|
2078
|
+
console.log("Passkeys stored: " + passkeys.length);
|
|
2079
|
+
console.log("Session timeout: " + (SESSION_TIMEOUT_MS / 60000) + " min");
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
async function shutdown() {
|
|
2083
|
+
console.log("Shutting down...");
|
|
2084
|
+
clearInterval(cleanupTimer);
|
|
2085
|
+
for (const sid of Object.keys(sessions)) {
|
|
2086
|
+
try { await sessions[sid].transport.close(); } catch {}
|
|
2087
|
+
delete sessions[sid];
|
|
2088
|
+
}
|
|
2089
|
+
httpServer.close();
|
|
2090
|
+
process.exit(0);
|
|
2091
|
+
}
|
|
2092
|
+
process.on("SIGINT", shutdown);
|
|
2093
|
+
process.on("SIGTERM", shutdown);
|