@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  // server.mjs: Hosted MCP server for wip.computer
2
2
  // MCP Streamable HTTP transport at /mcp, health check at /health.
3
- // Auth: Bearer ck-... API key maps to an agent ID.
3
+ // Auth: Bearer ck-... API key maps to an immutable tenant ID.
4
4
  // OAuth 2.0: Minimal flow for Claude iOS custom connector.
5
5
  // WebAuthn: Passkey-based signup/login (replaces agent name text form).
6
6
 
@@ -27,6 +27,31 @@ import { parse as parseUrlQs } from "node:querystring";
27
27
  // ── Settings ─────────────────────────────────────────────────────────
28
28
 
29
29
  const PORT = parseInt(process.env.MCP_PORT || "18800", 10);
30
+ // Dev mode: opt-in to JSON-file fallbacks for the data layer and to
31
+ // reading tokens/passkeys from local JSON files. Production must run
32
+ // without this flag set (production fails closed when Prisma is
33
+ // unavailable, and never reads/writes the local JSON token files).
34
+ // Tracked by ai/product/bugs/security/2026-04-28--cc-mini--vps-hosted-mcp-audit.md (F-002, F-005a).
35
+ const DEV_MODE = process.env.LDM_HOSTED_MCP_DEV_MODE === "1";
36
+ // WebSocket Origin allowlist (F-003 in the VPS hosted-mcp audit).
37
+ // Browser-borne web WS upgrades must present an Origin from this list.
38
+ // Comma-separated env var; default is the production origin.
39
+ // Daemon WS upgrades (CLI / agent connections) are NOT gated on Origin
40
+ // because they do not send a browser Origin header.
41
+ const WS_ORIGIN_ALLOWLIST = (process.env.LDM_HOSTED_MCP_WS_ORIGIN_ALLOWLIST || "https://wip.computer")
42
+ .split(",")
43
+ .map(s => s.trim())
44
+ .filter(Boolean);
45
+
46
+ function isWsOriginAllowed(origin) {
47
+ if (!origin) return false;
48
+ return WS_ORIGIN_ALLOWLIST.includes(origin);
49
+ }
50
+ // F-001: WS URL-token fallback (browser sends ?token=ck-... on upgrade).
51
+ // Default off in production. Set LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN=1 to
52
+ // allow the legacy back-compat path. Independent of any other dev flag
53
+ // so that this can be toggled without enabling other dev-mode behavior.
54
+ const ALLOW_WS_URL_TOKEN = process.env.LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN === "1";
30
55
  const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
31
56
  const SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
32
57
  const OAUTH_CODE_EXPIRY_MS = 10 * 60 * 1000;
@@ -44,18 +69,19 @@ const RP_ORIGIN = "https://wip.computer";
44
69
 
45
70
  // ── Data layer ──────────────────────────────────────────────────────
46
71
  //
47
- // Primary: Postgres via Prisma (production).
48
- // Fallback: JSON files (if DATABASE_URL is not set, e.g. local dev without Postgres).
72
+ // Production: Postgres via Prisma is the canonical store. If Prisma
73
+ // cannot connect, the server refuses to start (F-005a).
49
74
  //
50
- // The demo and all API endpoints use the db.* functions below.
51
- // They try Prisma first, fall back to JSON if Prisma isn't available.
75
+ // Dev mode (LDM_HOSTED_MCP_DEV_MODE=1): JSON files are used as a
76
+ // fallback for tokens and passkeys when Prisma is unavailable, and
77
+ // are also seeded into the in-memory cache on boot. Production must
78
+ // not set this flag.
52
79
 
53
80
  const __dirname = dirname(fileURLToPath(import.meta.url));
54
81
  const TOKEN_FILE = join(__dirname, "tokens.json");
55
82
  const PASSKEY_FILE = join(__dirname, "passkeys.json");
56
83
  const WALLET_FILE_LEGACY = join(__dirname, "wallets.json");
57
84
 
58
- // Initialize Prisma (may fail if DATABASE_URL not set)
59
85
  let prisma = null;
60
86
  let usePrisma = false;
61
87
  try {
@@ -64,30 +90,105 @@ try {
64
90
  usePrisma = true;
65
91
  console.log("Database: Postgres via Prisma");
66
92
  } catch (err) {
67
- console.log("Database: JSON files (Prisma not available: " + err.message + ")");
93
+ if (!DEV_MODE) {
94
+ console.error("FATAL: Prisma unavailable; refusing to start.");
95
+ console.error("Cause: " + err.message);
96
+ console.error("Set LDM_HOSTED_MCP_DEV_MODE=1 to allow the JSON fallback (dev only).");
97
+ process.exit(1);
98
+ }
99
+ console.warn("Database: JSON files (DEV MODE; Prisma not available: " + err.message + ")");
68
100
  }
69
101
 
70
102
  // ── API Keys ────────────────────────────────────────────────────────
103
+ //
104
+ // Hardcoded production defaults removed (F-002). Production keys live
105
+ // in the Postgres ApiKey table and are loaded on boot. In DEV_MODE,
106
+ // the local tokens.json file is also seeded into the in-memory cache.
71
107
 
72
- // Hardcoded defaults (always available, even without DB)
73
- const DEFAULT_API_KEYS = {
74
- "ck-test-001": "test-agent",
75
- "ck-e04df46877aa3672e21c4e33149bacc4": "cc-mini",
76
- "ck-f1986e957e21cbb40dc100bc05dc78ec": "lesa",
77
- "ck-c2849eef903407c877bc6e79bf8794aa": "parker",
78
- };
108
+ const API_KEYS = {};
109
+ const API_KEY_HANDLES = {};
110
+ const ACCOUNT_TENANT_PREFIX = "acct:";
111
+ const LEGACY_API_KEY_TENANT_PREFIX = "key:";
112
+ const OAUTH_API_KEY_TENANT_PREFIX = "oauth:";
113
+ const RESERVED_AGENT_HANDLES = new Set([
114
+ "parker-smoke-test",
115
+ ]);
116
+
117
+ function isInternalTenantId(id) {
118
+ return typeof id === "string"
119
+ && (id.startsWith(ACCOUNT_TENANT_PREFIX)
120
+ || id.startsWith(LEGACY_API_KEY_TENANT_PREFIX)
121
+ || id.startsWith(OAUTH_API_KEY_TENANT_PREFIX));
122
+ }
123
+
124
+ function accountTenantIdForUserId(userId) {
125
+ return ACCOUNT_TENANT_PREFIX + userId;
126
+ }
127
+
128
+ function legacyTenantIdForApiKey(key) {
129
+ return LEGACY_API_KEY_TENANT_PREFIX + createHash("sha256").update(key).digest("base64url").slice(0, 32);
130
+ }
131
+
132
+ function oauthTenantIdForApiKey(key) {
133
+ return OAUTH_API_KEY_TENANT_PREFIX + createHash("sha256").update(key).digest("base64url").slice(0, 32);
134
+ }
135
+
136
+ function rememberApiKeyInMemory(key, tenantId, handle = null) {
137
+ API_KEYS[key] = tenantId;
138
+ if (handle) API_KEY_HANDLES[key] = handle;
139
+ else delete API_KEY_HANDLES[key];
140
+ }
141
+
142
+ function rememberLoadedApiKey(key, storedAgentId) {
143
+ const tenantId = isInternalTenantId(storedAgentId) ? storedAgentId : legacyTenantIdForApiKey(key);
144
+ const handle = isInternalTenantId(storedAgentId) ? null : storedAgentId;
145
+ rememberApiKeyInMemory(key, tenantId, handle);
146
+ }
79
147
 
80
- // In-memory cache (populated from DB or JSON on boot)
81
- const API_KEYS = { ...DEFAULT_API_KEYS };
148
+ function identityForApiKey(key) {
149
+ const tenantId = API_KEYS[key];
150
+ if (!tenantId) return null;
151
+ return {
152
+ agentId: tenantId,
153
+ tenantId,
154
+ handle: API_KEY_HANDLES[key] || tenantId,
155
+ apiKey: key,
156
+ };
157
+ }
82
158
 
83
- // Load from JSON (fallback)
84
159
  function loadTokensFromFile() {
85
- try { return JSON.parse(readFileSync(TOKEN_FILE, "utf8")); } catch { return {}; }
160
+ let rows = {};
161
+ try { rows = JSON.parse(readFileSync(TOKEN_FILE, "utf8")); } catch { return; }
162
+ for (const [key, storedAgentId] of Object.entries(rows)) {
163
+ rememberLoadedApiKey(key, storedAgentId);
164
+ }
86
165
  }
87
- Object.assign(API_KEYS, loadTokensFromFile());
88
166
 
89
- async function saveApiKey(key, agentId) {
90
- API_KEYS[key] = agentId;
167
+ async function loadApiKeysFromDb() {
168
+ if (!usePrisma) return;
169
+ try {
170
+ const rows = await prisma.apiKey.findMany();
171
+ for (const row of rows) rememberLoadedApiKey(row.key, row.agentId);
172
+ } catch (err) {
173
+ if (!DEV_MODE) {
174
+ console.error("FATAL: Prisma loadApiKeys failed; refusing to start.");
175
+ console.error("Cause: " + err.message);
176
+ process.exit(1);
177
+ }
178
+ console.error("Prisma loadApiKeys error (DEV_MODE):", err.message);
179
+ }
180
+ }
181
+
182
+ if (DEV_MODE) {
183
+ loadTokensFromFile();
184
+ }
185
+ await loadApiKeysFromDb();
186
+
187
+ async function saveApiKey(key, agentId, { handle = null } = {}) {
188
+ // Persist before advertising in memory: a newly issued key must not
189
+ // become valid in the in-memory cache if the canonical store did not
190
+ // accept it. Otherwise the key would work for the lifetime of the
191
+ // process and disappear on restart.
91
192
  if (usePrisma) {
92
193
  try {
93
194
  await prisma.apiKey.upsert({
@@ -97,10 +198,17 @@ async function saveApiKey(key, agentId) {
97
198
  });
98
199
  } catch (err) {
99
200
  console.error("Prisma saveApiKey error:", err.message);
201
+ if (!DEV_MODE) throw new Error("saveApiKey persistence failed: " + err.message);
100
202
  }
203
+ } else if (!DEV_MODE) {
204
+ // Production should never reach here (boot exits if Prisma is
205
+ // unavailable), but guard explicitly.
206
+ throw new Error("saveApiKey called without Prisma in production");
207
+ }
208
+ rememberApiKeyInMemory(key, agentId, handle);
209
+ if (DEV_MODE) {
210
+ try { writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n"); } catch {}
101
211
  }
102
- // Always write JSON as backup
103
- try { writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n"); } catch {}
104
212
  }
105
213
 
106
214
  // ── Passkeys ────────────────────────────────────────────────────────
@@ -113,33 +221,72 @@ function loadPasskeysFromFile() {
113
221
  }
114
222
 
115
223
  async function loadPasskeysFromDb() {
116
- if (!usePrisma) return loadPasskeysFromFile();
224
+ if (!usePrisma) {
225
+ return DEV_MODE ? loadPasskeysFromFile() : [];
226
+ }
117
227
  try {
118
228
  const creds = await prisma.credential.findMany({ include: { user: true } });
119
- return creds.map(c => ({
120
- credentialId: c.id,
121
- publicKey: Buffer.from(c.publicKey).toString("base64url"),
122
- counter: c.counter,
123
- userId: c.userId,
124
- agentId: c.user?.name ? "passkey-" + c.user.name.slice(0, 12) : "unknown",
125
- createdAt: c.createdAt.toISOString(),
126
- transports: c.transports || [],
127
- }));
229
+ const handleUserIds = new Map();
230
+ for (const c of creds) {
231
+ const handle = c.user?.name || (c.userId ? "user-" + c.userId.slice(0, 8) : "unknown");
232
+ if (!handleUserIds.has(handle)) handleUserIds.set(handle, new Set());
233
+ handleUserIds.get(handle).add(c.userId);
234
+ }
235
+ const out = [];
236
+ for (const c of creds) {
237
+ const handle = c.user?.name || (c.userId ? "user-" + c.userId.slice(0, 8) : "unknown");
238
+ const agentId = accountTenantIdForUserId(c.userId);
239
+ let apiKey = null;
240
+ for (const [key, tenantId] of Object.entries(API_KEYS)) {
241
+ if (tenantId === agentId || (handleUserIds.get(handle)?.size === 1 && API_KEY_HANDLES[key] === handle)) {
242
+ apiKey = key;
243
+ break;
244
+ }
245
+ }
246
+ if (apiKey) {
247
+ API_KEY_HANDLES[apiKey] = handle;
248
+ if (API_KEYS[apiKey] !== agentId) {
249
+ try {
250
+ await saveApiKey(apiKey, agentId, { handle });
251
+ console.log("loadPasskeysFromDb: migrated API key tenant for handle '" + handle + "' to immutable account id");
252
+ } catch (err) {
253
+ console.error("loadPasskeysFromDb: failed to migrate API key tenant for handle '" + handle + "':", err.message);
254
+ if (!DEV_MODE) throw err;
255
+ }
256
+ }
257
+ } else if (handle !== "unknown") {
258
+ console.warn("loadPasskeysFromDb: no ApiKey row for account tenant '" + agentId + "'; auth-verify will mint on next successful login");
259
+ }
260
+ out.push({
261
+ credentialId: c.id,
262
+ publicKey: Buffer.from(c.publicKey).toString("base64url"),
263
+ counter: c.counter,
264
+ userId: c.userId,
265
+ agentId,
266
+ handle,
267
+ apiKey,
268
+ createdAt: c.createdAt.toISOString(),
269
+ transports: c.transports || [],
270
+ });
271
+ }
272
+ return out;
128
273
  } catch (err) {
129
274
  console.error("Prisma loadPasskeys error:", err.message);
130
- return loadPasskeysFromFile();
275
+ return DEV_MODE ? loadPasskeysFromFile() : [];
131
276
  }
132
277
  }
133
278
 
134
279
  async function savePasskey(entry) {
135
- passkeys.push(entry);
280
+ // Persist before pushing to in-memory: a passkey must not exist in
281
+ // memory if it was never persisted, or it would authenticate for the
282
+ // lifetime of the process and disappear on restart.
136
283
  if (usePrisma) {
137
284
  try {
138
285
  // Ensure user exists
139
286
  let user = await prisma.user.findUnique({ where: { id: entry.userId } });
140
287
  if (!user) {
141
288
  user = await prisma.user.create({
142
- data: { id: entry.userId, name: entry.agentId || "user" },
289
+ data: { id: entry.userId, name: entry.handle || "user" },
143
290
  });
144
291
  }
145
292
  await prisma.credential.create({
@@ -153,15 +300,22 @@ async function savePasskey(entry) {
153
300
  });
154
301
  } catch (err) {
155
302
  console.error("Prisma savePasskey error:", err.message);
303
+ if (!DEV_MODE) throw new Error("savePasskey persistence failed: " + err.message);
156
304
  }
305
+ } else if (!DEV_MODE) {
306
+ throw new Error("savePasskey called without Prisma in production");
307
+ }
308
+ passkeys.push(entry);
309
+ if (DEV_MODE) {
310
+ try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
157
311
  }
158
- // Always write JSON as backup
159
- try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
160
312
  }
161
313
 
162
314
  async function updatePasskeyCounter(credentialId, newCounter) {
163
- const entry = passkeys.find(p => p.credentialId === credentialId);
164
- if (entry) entry.counter = newCounter;
315
+ // Persist before updating in-memory. The counter is the WebAuthn
316
+ // replay-protection state; advancing it in memory while the DB row
317
+ // stays behind would let a replayed assertion validate after a
318
+ // restart re-loaded the stale counter.
165
319
  if (usePrisma) {
166
320
  try {
167
321
  await prisma.credential.update({
@@ -170,9 +324,16 @@ async function updatePasskeyCounter(credentialId, newCounter) {
170
324
  });
171
325
  } catch (err) {
172
326
  console.error("Prisma updateCounter error:", err.message);
327
+ if (!DEV_MODE) throw new Error("updatePasskeyCounter persistence failed: " + err.message);
173
328
  }
329
+ } else if (!DEV_MODE) {
330
+ throw new Error("updatePasskeyCounter called without Prisma in production");
331
+ }
332
+ const entry = passkeys.find(p => p.credentialId === credentialId);
333
+ if (entry) entry.counter = newCounter;
334
+ if (DEV_MODE) {
335
+ try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
174
336
  }
175
- try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
176
337
  }
177
338
 
178
339
  // Boot: load passkeys
@@ -219,7 +380,7 @@ function authenticate(req) {
219
380
  const auth = req.headers["authorization"];
220
381
  if (!auth?.startsWith("Bearer ")) return null;
221
382
  const key = auth.slice(7).trim();
222
- return API_KEYS[key] ? { agentId: API_KEYS[key], apiKey: key } : null;
383
+ return identityForApiKey(key);
223
384
  }
224
385
 
225
386
  function readBody(req) {
@@ -275,6 +436,85 @@ function parseUrl(reqUrl) {
275
436
  return new URL(reqUrl, "http://localhost");
276
437
  }
277
438
 
439
+ // ── Rate limiting (F-008 in the VPS hosted-mcp audit) ───────────────
440
+ //
441
+ // Per-IP, per-bucket fixed-window counter. In-process Map; resets on
442
+ // restart. nginx-side limit_req would be more durable but harder to
443
+ // scope per route; in-process keeps the policy with the code that
444
+ // mints/validates the auth tokens. Defaults are conservative; tune via
445
+ // env. Stale entries are pruned periodically so memory stays bounded.
446
+ //
447
+ // Buckets:
448
+ // mint ... endpoints that issue a credential or ticket
449
+ // validate ... endpoints that consume / verify a credential
450
+ // status ... poll-friendly endpoints (higher limit)
451
+
452
+ const RATE_LIMIT_BUCKETS = {
453
+ mint: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_MINT || "30", 10), windowMs: 60_000 },
454
+ validate: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_VALIDATE || "60", 10), windowMs: 60_000 },
455
+ status: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_STATUS || "120", 10), windowMs: 60_000 },
456
+ };
457
+
458
+ const rateLimitState = new Map(); // key: "<bucket>:<ip>" -> { count, windowStart }
459
+
460
+ function getClientIp(req) {
461
+ // Prefer X-Real-IP (nginx overwrites on proxy hop, harder to spoof
462
+ // through the proxy). Fall back to the LAST entry in X-Forwarded-For
463
+ // (nginx appends $remote_addr via proxy_add_x_forwarded_for, so the
464
+ // last entry is the real client IP from nginx's perspective; the
465
+ // first entries are attacker-controlled). Last fallback: socket.
466
+ const xRealIp = req.headers["x-real-ip"];
467
+ if (typeof xRealIp === "string" && xRealIp.length > 0) return xRealIp.trim();
468
+ const xff = req.headers["x-forwarded-for"];
469
+ if (typeof xff === "string" && xff.length > 0) {
470
+ const parts = xff.split(",").map(s => s.trim()).filter(Boolean);
471
+ if (parts.length > 0) return parts[parts.length - 1];
472
+ }
473
+ return req.socket?.remoteAddress || "unknown";
474
+ }
475
+
476
+ function rateLimitCheck(req, bucket) {
477
+ const config = RATE_LIMIT_BUCKETS[bucket];
478
+ if (!config) return { ok: true };
479
+ const ip = getClientIp(req);
480
+ const key = bucket + ":" + ip;
481
+ const now = Date.now();
482
+ const entry = rateLimitState.get(key);
483
+ if (!entry || now - entry.windowStart > config.windowMs) {
484
+ rateLimitState.set(key, { count: 1, windowStart: now });
485
+ return { ok: true };
486
+ }
487
+ entry.count += 1;
488
+ if (entry.count > config.limit) {
489
+ const retryAfterSec = Math.max(1, Math.ceil((config.windowMs - (now - entry.windowStart)) / 1000));
490
+ return { ok: false, retryAfterSec };
491
+ }
492
+ return { ok: true };
493
+ }
494
+
495
+ // Returns true if the request is allowed. If limited, writes 429 and
496
+ // returns false; the caller must `return` immediately on false.
497
+ function applyRateLimit(req, res, bucket) {
498
+ const result = rateLimitCheck(req, bucket);
499
+ if (!result.ok) {
500
+ res.setHeader("Retry-After", String(result.retryAfterSec));
501
+ json(res, 429, { error: "rate_limit_exceeded", error_description: "Too many requests. Retry after " + result.retryAfterSec + "s." });
502
+ console.warn("rate-limit hit:", bucket, getClientIp(req), req.method, req.url?.split("?")[0]);
503
+ return false;
504
+ }
505
+ return true;
506
+ }
507
+
508
+ // Keep memory bounded: drop entries older than 2 windows.
509
+ setInterval(() => {
510
+ const now = Date.now();
511
+ for (const [key, entry] of rateLimitState) {
512
+ if (now - entry.windowStart > 2 * 60_000) {
513
+ rateLimitState.delete(key);
514
+ }
515
+ }
516
+ }, 5 * 60_000).unref();
517
+
278
518
  function esc(s) {
279
519
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
280
520
  }
@@ -285,6 +525,15 @@ function sanitizeUsername(raw) {
285
525
  return cleaned.length > 0 ? cleaned : null;
286
526
  }
287
527
 
528
+ async function isUsernameTaken(username) {
529
+ if (!username) return false;
530
+ if (usePrisma) {
531
+ const existing = await prisma.user.findFirst({ where: { name: username } });
532
+ return !!existing;
533
+ }
534
+ return passkeys.some((entry) => entry.handle === username || entry.agentId === username);
535
+ }
536
+
288
537
  // ---------- Session cleanup ----------
289
538
 
290
539
  function touchSession(sid) {
@@ -408,6 +657,14 @@ async function handleRegisterOptions(req, res) {
408
657
 
409
658
  // Accept optional username from request body
410
659
  const username = sanitizeUsername(body?.username);
660
+ if (username && RESERVED_AGENT_HANDLES.has(username)) {
661
+ json(res, 409, { error: "reserved_handle", error_description: "This handle is reserved." });
662
+ return;
663
+ }
664
+ if (username && await isUsernameTaken(username)) {
665
+ json(res, 409, { error: "handle_taken", error_description: "This handle is already in use." });
666
+ return;
667
+ }
411
668
 
412
669
  const userId = randomBytes(16);
413
670
  const userIdB64 = userId.toString("base64url");
@@ -495,8 +752,16 @@ async function handleRegisterVerify(req, res) {
495
752
 
496
753
  const { credential: cred, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
497
754
 
498
- // Use provided username as agentId, or fall back to passkey-<id>
499
- const agentId = stored.username || ("passkey-" + stored.userId.slice(0, 12));
755
+ // Internal tenancy is the immutable WebAuthn user id. The chosen
756
+ // username is display metadata only and never owns a relay namespace.
757
+ const agentId = accountTenantIdForUserId(stored.userId);
758
+ // credentialLabel matches the userName passed to
759
+ // generateRegistrationOptions in handleRegisterOptions, which is what
760
+ // iOS Passwords / 1Password show next to the saved passkey. The
761
+ // welcome view should display this, not agentId. Auth semantics are
762
+ // unchanged; only the user-facing label is aligned with the saved
763
+ // credential.
764
+ const credentialLabel = stored.username || ("user-" + stored.userId.slice(0, 8));
500
765
  const apiKey = generateApiKey();
501
766
 
502
767
  const entry = {
@@ -505,18 +770,25 @@ async function handleRegisterVerify(req, res) {
505
770
  counter: cred.counter,
506
771
  userId: stored.userId,
507
772
  agentId,
773
+ handle: credentialLabel,
508
774
  apiKey,
509
775
  deviceType: credentialDeviceType,
510
776
  backedUp: credentialBackedUp,
511
777
  transports: credential.response?.transports || [],
512
778
  createdAt: new Date().toISOString(),
513
779
  };
514
- await savePasskey(entry);
515
- await saveApiKey(apiKey, agentId);
780
+ try {
781
+ await savePasskey(entry);
782
+ await saveApiKey(apiKey, agentId, { handle: credentialLabel });
783
+ } catch (err) {
784
+ console.error("Persistence failure during passkey registration:", err.message);
785
+ json(res, 500, { error: "persistence_failure", error_description: "Could not persist credentials. Try again." });
786
+ return;
787
+ }
516
788
 
517
- console.log("WebAuthn: registered passkey for agent '" + agentId + "' (credId: " + cred.id.slice(0, 16) + "...)");
789
+ console.log("WebAuthn: registered passkey for tenant '" + agentId + "' handle '" + credentialLabel + "' (credId: " + cred.id.slice(0, 16) + "...)");
518
790
 
519
- json(res, 200, { success: true, agentId, apiKey });
791
+ json(res, 200, { success: true, agentId: credentialLabel, tenantId: agentId, apiKey, credentialLabel });
520
792
  }
521
793
 
522
794
  // POST /webauthn/auth-options
@@ -602,12 +874,53 @@ async function handleAuthVerify(req, res) {
602
874
  return;
603
875
  }
604
876
 
605
- entry.counter = verification.authenticationInfo.newCounter;
606
- await updatePasskeyCounter(entry.credentialId, entry.counter);
877
+ // Persist new counter before mutating in-memory entry. updatePasskeyCounter
878
+ // performs the in-memory update only on success, so the in-memory counter
879
+ // stays consistent with the DB and replay protection holds across restarts.
880
+ try {
881
+ await updatePasskeyCounter(entry.credentialId, verification.authenticationInfo.newCounter);
882
+ } catch (err) {
883
+ console.error("Persistence failure during passkey counter update:", err.message);
884
+ json(res, 500, { error: "persistence_failure", error_description: "Could not persist counter. Try again." });
885
+ return;
886
+ }
887
+
888
+ let credentialLabel = entry.handle;
889
+ if (!credentialLabel && entry.agentId && entry.agentId.startsWith("passkey-")) {
890
+ credentialLabel = (typeof entry.userId === "string" && entry.userId.length >= 8)
891
+ ? "user-" + entry.userId.slice(0, 8)
892
+ : entry.agentId;
893
+ } else if (!credentialLabel && !isInternalTenantId(entry.agentId)) {
894
+ credentialLabel = entry.agentId;
895
+ } else if (!credentialLabel && typeof entry.userId === "string" && entry.userId.length >= 8) {
896
+ credentialLabel = "user-" + entry.userId.slice(0, 8);
897
+ } else if (!credentialLabel) {
898
+ credentialLabel = "you";
899
+ }
900
+
901
+ // Recovery path: a passkey reloaded from Postgres after a restart may
902
+ // have entry.apiKey = null if no ApiKey row was found for its agent
903
+ // at boot. Mint a fresh ck- now so the login response always carries
904
+ // a usable token. Without this, the browser would store
905
+ // sessionStorage.wip_api_key = null and Remote Control would 401 on
906
+ // /bootstrap and /ws-ticket.
907
+ if (!entry.apiKey) {
908
+ const newKey = generateApiKey();
909
+ try {
910
+ await saveApiKey(newKey, entry.agentId, { handle: credentialLabel });
911
+ } catch (err) {
912
+ console.error("Persistence failure minting recovery key for tenant '" + entry.agentId + "':", err.message);
913
+ json(res, 500, { error: "persistence_failure", error_description: "Could not mint API key. Try again." });
914
+ return;
915
+ }
916
+ entry.apiKey = newKey;
917
+ entry.handle = credentialLabel;
918
+ console.log("WebAuthn: minted recovery key for tenant '" + entry.agentId + "' (key: " + newKey.slice(0, 10) + "...)");
919
+ }
607
920
 
608
- console.log("WebAuthn: authenticated agent '" + entry.agentId + "'");
921
+ console.log("WebAuthn: authenticated tenant '" + entry.agentId + "' handle '" + credentialLabel + "'");
609
922
 
610
- json(res, 200, { success: true, agentId: entry.agentId, apiKey: entry.apiKey });
923
+ json(res, 200, { success: true, agentId: credentialLabel, tenantId: entry.agentId, apiKey: entry.apiKey, credentialLabel });
611
924
  }
612
925
 
613
926
  // ---------- Page handlers ----------
@@ -1018,19 +1331,18 @@ async function handleOAuthToken(req, res) {
1018
1331
  }
1019
1332
  }
1020
1333
 
1021
- // Check if agent already has an API key (from passkey registration)
1022
- const agentId = stored.agent_name || "oauth-user";
1023
- let apiKey;
1024
-
1025
- const existingKey = Object.entries(API_KEYS).find(([k, v]) => v === agentId);
1026
- if (existingKey) {
1027
- apiKey = existingKey[0];
1028
- } else {
1029
- apiKey = generateApiKey();
1030
- await saveApiKey(apiKey, agentId);
1334
+ const agentHandle = stored.agent_name || "oauth-user";
1335
+ const apiKey = generateApiKey();
1336
+ const agentId = oauthTenantIdForApiKey(apiKey);
1337
+ try {
1338
+ await saveApiKey(apiKey, agentId, { handle: agentHandle });
1339
+ } catch (err) {
1340
+ console.error("Persistence failure during OAuth token issuance:", err.message);
1341
+ json(res, 500, { error: "server_error", error_description: "Could not issue token. Try again." });
1342
+ return;
1031
1343
  }
1032
1344
 
1033
- console.log("OAuth: issued token for agent '" + agentId + "' (key: " + apiKey.slice(0, 10) + "...)");
1345
+ console.log("OAuth: issued token for tenant '" + agentId + "' handle '" + agentHandle + "' (key: " + apiKey.slice(0, 10) + "...)");
1034
1346
 
1035
1347
  json(res, 200, {
1036
1348
  access_token: apiKey,
@@ -1383,12 +1695,53 @@ function handleAgentAuthApprove(req, res) {
1383
1695
 
1384
1696
  // ---------- QR Login (Chrome fallback) ----------
1385
1697
 
1698
+ // `next` whitelist for the QR login flow. Two shapes are allowed; both
1699
+ // land the user on a known phone-side surface after successful sign-in.
1700
+ // Anything else is silently dropped. `next` is NOT a general redirect
1701
+ // primitive.
1702
+ //
1703
+ // 1. PAIR_NEXT_REGEX: /pair/<CODE> using the daemon's real alphabet
1704
+ // (CODEX_PAIR_ALPHABET, length 6, L IS included; I/O/0/1 excluded).
1705
+ // See plan ai/product/plans-prds/codex-remote-control/
1706
+ // 2026-04-30--cc-mini--pair-via-login-qr-flow.md constraints C1,
1707
+ // C8, and round-5. Per C8 the URL fallback for this shape is
1708
+ // mobile-only (desktop must not become the pairing authority).
1709
+ //
1710
+ // 2. REMOTE_CONTROL_NEXT_REGEX: /codex-remote-control/<UUID> for the
1711
+ // Kaleidoscope phone-side remote-control thread surface. Standard
1712
+ // ?next semantics; allowed on both desktop and mobile (this is
1713
+ // navigation continuation, not authority transfer).
1714
+ const PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
1715
+ const REMOTE_CONTROL_NEXT_REGEX = /^\/codex-remote-control\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1716
+
1717
+ function sanitizeCrcPairNext(raw) {
1718
+ if (typeof raw !== "string") return null;
1719
+ // Single decode; reject if a second decode would still differ.
1720
+ let decoded;
1721
+ try { decoded = decodeURIComponent(raw); } catch { return null; }
1722
+ // Catch double-encoded payloads.
1723
+ if (decoded !== raw && /%/.test(decoded)) return null;
1724
+ if (!PAIR_NEXT_REGEX.test(decoded) && !REMOTE_CONTROL_NEXT_REGEX.test(decoded)) return null;
1725
+ return decoded;
1726
+ }
1727
+
1386
1728
  // POST /api/qr-login ... create a QR login session
1387
1729
  async function handleQrLoginStart(req, res) {
1388
1730
  cleanupExpiredChallenges();
1389
1731
  const body = await readBody(req).catch(() => ({}));
1390
1732
  const handle = ((body && body.handle) || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30);
1391
1733
  const mode = ((body && body.mode) || "register") === "signin" ? "signin" : "register";
1734
+ // Validate `next` strictly. Invalid next is silently dropped, not
1735
+ // 400'd, so legacy callers still work.
1736
+ //
1737
+ // Only /pair/<CODE> next triggers pair-mode (C6 strip on desktop
1738
+ // status, C8 desktop-no-redirect, the "phone is the actor" model).
1739
+ // /codex-remote-control/<UUID> is a normal post-login continuation:
1740
+ // desktop status returns the full login response (apiKey, handle,
1741
+ // next) so the desktop poll can authenticate and redirect on its
1742
+ // own. The phone also gets next via approve, so both ends can act.
1743
+ const next = sanitizeCrcPairNext(body && body.next);
1744
+ const purpose = (next && PAIR_NEXT_REGEX.test(next)) ? "pair" : null;
1392
1745
  const sessionId = randomUUID();
1393
1746
  const loginUrl = ISSUER_URL + "/login?s=" + sessionId + "&m=" + mode + (handle ? "&h=" + encodeURIComponent(handle) : "");
1394
1747
  const qrBuffer = await QRCode.toBuffer(loginUrl, { type: "png", width: 400, margin: 2 });
@@ -1399,8 +1752,10 @@ async function handleQrLoginStart(req, res) {
1399
1752
  apiKey: null,
1400
1753
  handle: handle || null,
1401
1754
  expires: Date.now() + QR_LOGIN_EXPIRY_MS,
1755
+ purpose, // "pair" | null
1756
+ next: next || null, // sanitized `/pair/<CODE>` or null
1402
1757
  };
1403
- console.log("QR login: created session " + sessionId.slice(0, 8) + "...");
1758
+ console.log("QR login: created session " + sessionId.slice(0, 8) + "..." + (purpose === "pair" ? " (pair-mode)" : ""));
1404
1759
  json(res, 200, { sessionId, qrUrl: "/api/qr-login/qr?s=" + sessionId });
1405
1760
  }
1406
1761
 
@@ -1418,6 +1773,12 @@ function handleQrLoginQR(req, res) {
1418
1773
  }
1419
1774
 
1420
1775
  // GET /api/qr-login/status?s=XXX ... poll for completion
1776
+ //
1777
+ // Response shape depends on `purpose`:
1778
+ // - Pair-mode (purpose === "pair"): {status, agentId} only on approved.
1779
+ // NEVER returns apiKey or next to the desktop. Phone receives next via
1780
+ // /api/qr-login/approve. Per plan C6 round 4.
1781
+ // - Legacy login mode: {status, agentId, apiKey} on approved (unchanged).
1421
1782
  function handleQrLoginStatus(req, res) {
1422
1783
  const url = parseUrl(req.url);
1423
1784
  const s = url.searchParams.get("s");
@@ -1427,7 +1788,28 @@ function handleQrLoginStatus(req, res) {
1427
1788
  return;
1428
1789
  }
1429
1790
  if (entry.status === "approved") {
1430
- json(res, 200, { status: "approved", agentId: entry.agentId, apiKey: entry.apiKey });
1791
+ if (entry.purpose === "pair") {
1792
+ // Pair-mode (purpose === "pair", next === /pair/<CODE>):
1793
+ // desktop gets ONLY a display label. No apiKey. No next. Plan
1794
+ // C6 round 4. Desktop never becomes the pairing authority.
1795
+ json(res, 200, { status: "approved", agentId: entry.agentId });
1796
+ } else {
1797
+ // Legacy login mode OR codex-remote-control continuation
1798
+ // (purpose === null). Desktop gets full identity to render the
1799
+ // welcome view OR redirect to next on its own poll.
1800
+ // credentialLabel matches the saved-passkey label (see
1801
+ // register-verify / auth-verify). next is included only if a
1802
+ // sanitized non-pair-mode next was set on the session
1803
+ // (currently /codex-remote-control/<UUID>); legacy login
1804
+ // sessions without next get next === null.
1805
+ json(res, 200, {
1806
+ status: "approved",
1807
+ agentId: entry.agentId,
1808
+ apiKey: entry.apiKey,
1809
+ credentialLabel: entry.credentialLabel || null,
1810
+ next: entry.next || null,
1811
+ });
1812
+ }
1431
1813
  delete qrLoginSessions[s]; // one-time use
1432
1814
  } else {
1433
1815
  json(res, 200, { status: "pending" });
@@ -1435,9 +1817,13 @@ function handleQrLoginStatus(req, res) {
1435
1817
  }
1436
1818
 
1437
1819
  // POST /api/qr-login/approve ... phone calls after passkey created
1820
+ //
1821
+ // In pair-mode, the response includes the sanitized `next` so the phone
1822
+ // can location.replace(next) into /pair/<CODE>. Legacy login mode returns
1823
+ // {ok: true} unchanged.
1438
1824
  function handleQrLoginApprove(req, res) {
1439
1825
  readBody(req).then(function(body) {
1440
- const { sessionId, agentId, apiKey } = body || {};
1826
+ const { sessionId, agentId, apiKey, credentialLabel } = body || {};
1441
1827
  const entry = qrLoginSessions[sessionId];
1442
1828
  if (!entry || Date.now() > entry.expires) {
1443
1829
  json(res, 404, { error: "Session not found or expired" });
@@ -1450,8 +1836,21 @@ function handleQrLoginApprove(req, res) {
1450
1836
  entry.status = "approved";
1451
1837
  entry.agentId = agentId;
1452
1838
  entry.apiKey = apiKey;
1453
- console.log("QR login: approved session " + sessionId.slice(0, 8) + "... for '" + agentId + "'");
1454
- json(res, 200, { ok: true });
1839
+ // Phone-side passes the label it received from register-verify /
1840
+ // auth-verify so the desktop can show the same string the user
1841
+ // just saved on their phone. Optional for back-compat.
1842
+ entry.credentialLabel = (typeof credentialLabel === "string" && credentialLabel.length <= 64) ? credentialLabel : null;
1843
+ console.log("QR login: approved session " + sessionId.slice(0, 8) + "... for '" + agentId + "'" + (entry.purpose === "pair" ? " (pair-mode)" : (entry.next ? " (next=" + entry.next + ")" : "")));
1844
+ // Phone receives next on approve regardless of purpose, so the
1845
+ // phone can redirect to either /pair/<CODE> (pair-mode, phone is
1846
+ // the actor) or /codex-remote-control/<UUID> (continuation, phone
1847
+ // can act). Desktop's separate behavior (strip vs full response)
1848
+ // is handled in handleQrLoginStatus.
1849
+ if (entry.next) {
1850
+ json(res, 200, { ok: true, next: entry.next });
1851
+ } else {
1852
+ json(res, 200, { ok: true });
1853
+ }
1455
1854
  }).catch(function() {
1456
1855
  json(res, 400, { error: "Invalid request" });
1457
1856
  });
@@ -1876,13 +2275,28 @@ const httpServer = createServer(async (req, res) => {
1876
2275
  }
1877
2276
 
1878
2277
  if (req.method === "GET" && (path === "/login" || path === "/login/")) {
1879
- // Serve the new app/ login (two-path: this device or QR-from-phone).
2278
+ // Production /login owns its own file at app/kaleidoscope-login.html.
2279
+ //
2280
+ // Earlier this route served demo/login.html, which made production
2281
+ // auth depend on a file under demo/. That coupling is wrong: demo/
2282
+ // is the demo site's domain, not production. The canonical
2283
+ // Kaleidoscope login HTML now lives under app/, where production
2284
+ // owns it.
2285
+ //
2286
+ // Fallback chain (defense in depth):
2287
+ // 1. app/kaleidoscope-login.html ... canonical production file.
2288
+ // 2. demo/login.html ... legacy fallback during the
2289
+ // transition; will be removed in a follow-up once the
2290
+ // production file is verified live.
2291
+ // 3. handleLoginPage ... server-rendered last resort.
2292
+ //
2293
+ // /login/app continues to serve the developed app/login.html flow
2294
+ // (see the next handler).
1880
2295
  try {
1881
- const loginHtml = readFileSync(join(__dirname, "app", "login.html"), "utf8");
2296
+ const html = readFileSync(join(__dirname, "app", "kaleidoscope-login.html"), "utf8");
1882
2297
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1883
- res.end(loginHtml);
2298
+ res.end(html);
1884
2299
  } catch {
1885
- // Fallback to legacy demo login, then server-rendered.
1886
2300
  try {
1887
2301
  const legacy = readFileSync(join(__dirname, "demo", "login.html"), "utf8");
1888
2302
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
@@ -1894,6 +2308,22 @@ const httpServer = createServer(async (req, res) => {
1894
2308
  return;
1895
2309
  }
1896
2310
 
2311
+ if (req.method === "GET" && (path === "/login/app" || path === "/login/app/")) {
2312
+ // Explicit non-primary route for the app/login.html flow (the
2313
+ // newer two-path "this device or QR-from-phone" copy). This
2314
+ // exists so the developed flow stays reachable without hijacking
2315
+ // /login. If app/login.html is not present, return 404 rather
2316
+ // than silently falling back to the canonical /login page.
2317
+ try {
2318
+ const loginHtml = readFileSync(join(__dirname, "app", "login.html"), "utf8");
2319
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2320
+ res.end(loginHtml);
2321
+ } catch {
2322
+ json(res, 404, { error: "Not found" });
2323
+ }
2324
+ return;
2325
+ }
2326
+
1897
2327
  // --- Legal pages ---
1898
2328
 
1899
2329
  if (req.method === "GET" && (path === "/legal/privacy/en-ww/" || path === "/legal/privacy/en-ww")) {
@@ -1917,21 +2347,25 @@ const httpServer = createServer(async (req, res) => {
1917
2347
  // --- WebAuthn API ---
1918
2348
 
1919
2349
  if (req.method === "POST" && path === "/webauthn/register-options") {
2350
+ if (!applyRateLimit(req, res, "mint")) return;
1920
2351
  await handleRegisterOptions(req, res);
1921
2352
  return;
1922
2353
  }
1923
2354
 
1924
2355
  if (req.method === "POST" && path === "/webauthn/register-verify") {
2356
+ if (!applyRateLimit(req, res, "validate")) return;
1925
2357
  await handleRegisterVerify(req, res);
1926
2358
  return;
1927
2359
  }
1928
2360
 
1929
2361
  if (req.method === "POST" && path === "/webauthn/auth-options") {
2362
+ if (!applyRateLimit(req, res, "mint")) return;
1930
2363
  await handleAuthOptions(req, res);
1931
2364
  return;
1932
2365
  }
1933
2366
 
1934
2367
  if (req.method === "POST" && path === "/webauthn/auth-verify") {
2368
+ if (!applyRateLimit(req, res, "validate")) return;
1935
2369
  await handleAuthVerify(req, res);
1936
2370
  return;
1937
2371
  }
@@ -1949,6 +2383,7 @@ const httpServer = createServer(async (req, res) => {
1949
2383
  }
1950
2384
 
1951
2385
  if (req.method === "POST" && path === "/oauth/register") {
2386
+ if (!applyRateLimit(req, res, "mint")) return;
1952
2387
  await handleOAuthRegister(req, res);
1953
2388
  return;
1954
2389
  }
@@ -1959,11 +2394,13 @@ const httpServer = createServer(async (req, res) => {
1959
2394
  }
1960
2395
 
1961
2396
  if (req.method === "POST" && path === "/oauth/authorize/submit") {
2397
+ if (!applyRateLimit(req, res, "validate")) return;
1962
2398
  await handleOAuthAuthorizeSubmit(req, res);
1963
2399
  return;
1964
2400
  }
1965
2401
 
1966
2402
  if (req.method === "POST" && path === "/oauth/token") {
2403
+ if (!applyRateLimit(req, res, "mint")) return;
1967
2404
  await handleOAuthToken(req, res);
1968
2405
  return;
1969
2406
  }
@@ -1971,21 +2408,25 @@ const httpServer = createServer(async (req, res) => {
1971
2408
  // --- Agent QR Auth ---
1972
2409
 
1973
2410
  if (req.method === "GET" && path === "/demo/api/agent-auth") {
2411
+ if (!applyRateLimit(req, res, "mint")) return;
1974
2412
  await handleAgentAuthStart(req, res);
1975
2413
  return;
1976
2414
  }
1977
2415
 
1978
2416
  if (req.method === "GET" && path === "/demo/api/agent-auth/qr") {
2417
+ if (!applyRateLimit(req, res, "status")) return;
1979
2418
  handleAgentAuthQR(req, res);
1980
2419
  return;
1981
2420
  }
1982
2421
 
1983
2422
  if (req.method === "GET" && path === "/demo/api/agent-auth/status") {
2423
+ if (!applyRateLimit(req, res, "status")) return;
1984
2424
  handleAgentAuthStatus(req, res);
1985
2425
  return;
1986
2426
  }
1987
2427
 
1988
2428
  if (req.method === "POST" && path === "/demo/api/agent-auth/approve") {
2429
+ if (!applyRateLimit(req, res, "validate")) return;
1989
2430
  handleAgentAuthApprove(req, res);
1990
2431
  return;
1991
2432
  }
@@ -2017,21 +2458,25 @@ const httpServer = createServer(async (req, res) => {
2017
2458
  // --- QR Login (Chrome fallback) ---
2018
2459
 
2019
2460
  if (req.method === "POST" && path === "/api/qr-login") {
2461
+ if (!applyRateLimit(req, res, "mint")) return;
2020
2462
  await handleQrLoginStart(req, res);
2021
2463
  return;
2022
2464
  }
2023
2465
 
2024
2466
  if (req.method === "GET" && path === "/api/qr-login/qr") {
2467
+ if (!applyRateLimit(req, res, "status")) return;
2025
2468
  handleQrLoginQR(req, res);
2026
2469
  return;
2027
2470
  }
2028
2471
 
2029
2472
  if (req.method === "GET" && path === "/api/qr-login/status") {
2473
+ if (!applyRateLimit(req, res, "status")) return;
2030
2474
  handleQrLoginStatus(req, res);
2031
2475
  return;
2032
2476
  }
2033
2477
 
2034
2478
  if (req.method === "POST" && path === "/api/qr-login/approve") {
2479
+ if (!applyRateLimit(req, res, "validate")) return;
2035
2480
  handleQrLoginApprove(req, res);
2036
2481
  return;
2037
2482
  }
@@ -2094,32 +2539,38 @@ const httpServer = createServer(async (req, res) => {
2094
2539
  // --- Codex Relay (codex-daemon ↔ phone) ---
2095
2540
 
2096
2541
  if (req.method === "POST" && path === "/api/codex-relay/pair-init") {
2542
+ if (!applyRateLimit(req, res, "mint")) return;
2097
2543
  await handleCodexPairInit(req, res);
2098
2544
  return;
2099
2545
  }
2100
2546
 
2101
2547
  if (req.method === "GET" && path.startsWith("/api/codex-relay/pair-status/")) {
2548
+ if (!applyRateLimit(req, res, "status")) return;
2102
2549
  handleCodexPairStatus(req, res, path.slice("/api/codex-relay/pair-status/".length));
2103
2550
  return;
2104
2551
  }
2105
2552
 
2106
2553
  if (req.method === "POST" && path === "/api/codex-relay/pair-complete") {
2554
+ if (!applyRateLimit(req, res, "validate")) return;
2107
2555
  await handleCodexPairComplete(req, res);
2108
2556
  return;
2109
2557
  }
2110
2558
 
2111
2559
  if (req.method === "GET" && path === "/api/codex-relay/state") {
2560
+ if (!applyRateLimit(req, res, "status")) return;
2112
2561
  handleCodexRelayState(req, res);
2113
2562
  return;
2114
2563
  }
2115
2564
 
2116
2565
  if (req.method === "GET" && path.startsWith("/api/codex-relay/bootstrap/")) {
2566
+ if (!applyRateLimit(req, res, "mint")) return;
2117
2567
  const tid = decodeURIComponent(path.slice("/api/codex-relay/bootstrap/".length));
2118
2568
  handleCodexBootstrap(req, res, tid);
2119
2569
  return;
2120
2570
  }
2121
2571
 
2122
2572
  if (req.method === "POST" && path === "/api/codex-relay/ws-ticket") {
2573
+ if (!applyRateLimit(req, res, "mint")) return;
2123
2574
  await handleCodexWsTicket(req, res);
2124
2575
  return;
2125
2576
  }
@@ -2131,6 +2582,13 @@ const httpServer = createServer(async (req, res) => {
2131
2582
  return;
2132
2583
  }
2133
2584
 
2585
+ // /pair/<CODE> ... URL-first pair flow. Per plan C1 + round 5: real daemon
2586
+ // alphabet [ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}, length 6, L IS included.
2587
+ if (req.method === "GET" && /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(path)) {
2588
+ serveAppFile(res, "pair.html");
2589
+ return;
2590
+ }
2591
+
2134
2592
  // /:handle/codex-remote-control/:threadId
2135
2593
  const remoteControlMatch = path.match(/^\/([^/]+)\/codex-remote-control\/([^/]+)\/?$/);
2136
2594
  if (req.method === "GET" && remoteControlMatch) {
@@ -2151,21 +2609,22 @@ const httpServer = createServer(async (req, res) => {
2151
2609
  // ---------- Codex Relay (codex-daemon ↔ phone) ----------
2152
2610
  //
2153
2611
  // In-memory state. Pairing codes: 6-char, 5-min TTL. Daemons indexed by
2154
- // agentId (one daemon per agentId; new daemon kicks the old one). Web clients
2155
- // indexed by `agentId:threadId`. Server is a transparent passthrough between
2612
+ // immutable tenant id (one daemon per tenant; new daemon kicks the old one). Web clients
2613
+ // indexed by `tenantId:threadId`. Server is a transparent passthrough between
2156
2614
  // the daemon and the matching web client(s); thread routing is enforced
2157
2615
  // purely client-side via session.send/sessionId payloads.
2158
2616
 
2159
2617
  const CODEX_PAIR_EXPIRY_MS = 5 * 60 * 1000;
2160
2618
  const CODEX_PAIR_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
2161
- const codexPairings = {}; // pairing_id -> { code, status, expires, daemon_info, apiKey?, agentId?, daemon_public_key?, crypto_versions? }
2619
+ const codexPairings = {}; // pairing_id -> { code, status, expires, daemon_info, apiKey?, agentId?, handle?, daemon_public_key?, crypto_versions? }
2162
2620
  const codexPairingByCode = {}; // code -> pairing_id (only while pending)
2163
2621
  const codexDaemons = new Map(); // agentId -> ws
2164
- const codexWebClients = new Map(); // `${agentId}:${threadId}` -> ws
2622
+ const codexWebClients = new Map(); // `${agentId}:${threadId}` -> Set<ws>
2623
+ const codexE2eeSessionRoutes = new Map(); // `${agentId}:${e2eeSession}` -> { threadId, webKey, ws }
2165
2624
 
2166
2625
  // E2EE substrate (Phase 2.5).
2167
2626
  //
2168
- // codexDaemonPubkeys: per agentId, the most recently paired daemon's
2627
+ // codexDaemonPubkeys: per tenant id, the most recently paired daemon's
2169
2628
  // public key (P-256 SPKI base64url) + supported crypto versions +
2170
2629
  // registration timestamp. This is what the browser fetches via
2171
2630
  // bootstrap before opening an encrypted session.
@@ -2174,10 +2633,67 @@ const codexWebClients = new Map(); // `${agentId}:${threadId}` -> ws
2174
2633
  // ?token=ck-... in the browser WebSocket URL. Bound to a specific
2175
2634
  // (agentId, threadId) so a leaked ticket cannot drive a different
2176
2635
  // route, even by the same authenticated user.
2177
- const codexDaemonPubkeys = new Map(); // agentId -> { pubkey, crypto_versions, registered_at }
2636
+ const codexDaemonPubkeys = new Map(); // tenantId -> { pubkey, crypto_versions, registered_at }
2178
2637
  const codexRelayTickets = new Map(); // ticket -> { agentId, threadId, expires, used }
2179
2638
  const CODEX_RELAY_TICKET_TTL_MS = 60 * 1000; // 60s; browser must connect immediately
2180
2639
 
2640
+ function codexRelayKey(agentId, id) {
2641
+ return agentId + ":" + id;
2642
+ }
2643
+
2644
+ function isCodexE2eeEnvelope(envelope) {
2645
+ return !!(envelope && typeof envelope.type === "string" && envelope.type.startsWith("e2ee."));
2646
+ }
2647
+
2648
+ function registerCodexE2eeSessionRoute(agentId, e2eeSession, threadId, ws) {
2649
+ if (typeof e2eeSession !== "string" || !e2eeSession) return;
2650
+ if (typeof threadId !== "string" || !threadId) return;
2651
+ const webKey = codexRelayKey(agentId, threadId);
2652
+ codexE2eeSessionRoutes.set(codexRelayKey(agentId, e2eeSession), { threadId, webKey, ws });
2653
+ }
2654
+
2655
+ function addCodexWebClient(webKey, ws) {
2656
+ let clients = codexWebClients.get(webKey);
2657
+ if (!clients) {
2658
+ clients = new Set();
2659
+ codexWebClients.set(webKey, clients);
2660
+ }
2661
+ clients.add(ws);
2662
+ return clients.size;
2663
+ }
2664
+
2665
+ function removeCodexWebClient(webKey, ws) {
2666
+ const clients = codexWebClients.get(webKey);
2667
+ if (!clients) return 0;
2668
+ clients.delete(ws);
2669
+ if (clients.size === 0) {
2670
+ codexWebClients.delete(webKey);
2671
+ return 0;
2672
+ }
2673
+ return clients.size;
2674
+ }
2675
+
2676
+ function openCodexWebClientsForKey(webKey) {
2677
+ const clients = codexWebClients.get(webKey);
2678
+ if (!clients) return [];
2679
+ return [...clients].filter((webWs) => webWs.readyState === webWs.OPEN);
2680
+ }
2681
+
2682
+ function resolveCodexWebClientsForDaemonFrame(agentId, routeId) {
2683
+ const routed = codexE2eeSessionRoutes.get(codexRelayKey(agentId, routeId));
2684
+ if (routed && routed.ws && routed.ws.readyState === routed.ws.OPEN) return [routed.ws];
2685
+ return openCodexWebClientsForKey(codexRelayKey(agentId, routeId));
2686
+ }
2687
+
2688
+ function removeCodexE2eeRoutesForWeb(agentId, threadId, ws) {
2689
+ const webKey = codexRelayKey(agentId, threadId);
2690
+ for (const [routeKey, route] of codexE2eeSessionRoutes) {
2691
+ if (route.webKey === webKey && (!ws || route.ws === ws)) {
2692
+ codexE2eeSessionRoutes.delete(routeKey);
2693
+ }
2694
+ }
2695
+ }
2696
+
2181
2697
  function generateCodexPairingCode() {
2182
2698
  for (let attempt = 0; attempt < 100; attempt += 1) {
2183
2699
  let code = "";
@@ -2216,10 +2732,13 @@ async function handleCodexPairInit(req, res) {
2216
2732
  : null,
2217
2733
  };
2218
2734
  codexPairingByCode[code] = pairingId;
2735
+ // Per plan: web_url goes through /login first so the existing Kaleidoscope
2736
+ // QR + phone-passkey ceremony handles auth. After phone passkey, phone
2737
+ // (not desktop) redirects to /pair/<CODE> and completes pair-complete.
2219
2738
  json(res, 200, {
2220
2739
  code,
2221
2740
  pairing_id: pairingId,
2222
- web_url: ISSUER_URL + "/pair",
2741
+ web_url: ISSUER_URL + "/login?next=" + encodeURIComponent("/pair/" + code),
2223
2742
  expires_at: new Date(expires).toISOString(),
2224
2743
  });
2225
2744
  }
@@ -2232,7 +2751,7 @@ function handleCodexPairStatus(req, res, pairingId) {
2232
2751
  if (codexPairingByCode[p.code] === pairingId) delete codexPairingByCode[p.code];
2233
2752
  }
2234
2753
  if (p.status === "completed") {
2235
- json(res, 200, { status: "completed", api_key: p.apiKey, handle: p.agentId });
2754
+ json(res, 200, { status: "completed", api_key: p.apiKey, handle: p.handle || p.agentId });
2236
2755
  } else {
2237
2756
  json(res, 200, { status: p.status });
2238
2757
  }
@@ -2245,6 +2764,14 @@ async function handleCodexPairComplete(req, res) {
2245
2764
  try { body = await readBody(req); } catch { json(res, 400, { error: "bad request" }); return; }
2246
2765
  const code = (body && typeof body.code === "string") ? body.code.trim().toUpperCase() : "";
2247
2766
  if (!code) { json(res, 400, { error: "missing code" }); return; }
2767
+ // Defensive: reject codes that don't match the daemon's alphabet up front,
2768
+ // before the map lookup, so probe attempts get one uniform reject path
2769
+ // instead of leaking timing/shape info between "wrong-alphabet" and "valid
2770
+ // shape but unknown code." Per plan C3 + round 5.
2771
+ if (!/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(code)) {
2772
+ json(res, 404, { error: "invalid or already-used code" });
2773
+ return;
2774
+ }
2248
2775
  const pairingId = codexPairingByCode[code];
2249
2776
  if (!pairingId) { json(res, 404, { error: "invalid or already-used code" }); return; }
2250
2777
  const p = codexPairings[pairingId];
@@ -2255,6 +2782,7 @@ async function handleCodexPairComplete(req, res) {
2255
2782
  p.status = "completed";
2256
2783
  p.apiKey = identity.apiKey;
2257
2784
  p.agentId = identity.agentId;
2785
+ p.handle = identity.handle;
2258
2786
  // Phase 2.5: register the daemon's E2EE public key against the
2259
2787
  // authenticated handle. Replaces any previous key for this handle
2260
2788
  // (rotate-key implicitly happens here on a re-pair).
@@ -2267,15 +2795,15 @@ async function handleCodexPairComplete(req, res) {
2267
2795
  console.log("codex-relay: registered E2EE pubkey for " + identity.agentId);
2268
2796
  }
2269
2797
  delete codexPairingByCode[code];
2270
- console.log("codex-relay: paired daemon for " + identity.agentId);
2271
- json(res, 200, { ok: true, handle: identity.agentId });
2798
+ console.log("codex-relay: paired daemon for tenant " + identity.agentId + " handle " + identity.handle);
2799
+ json(res, 200, { ok: true, handle: identity.handle });
2272
2800
  }
2273
2801
 
2274
2802
  function handleCodexRelayState(req, res) {
2275
2803
  const identity = authenticate(req);
2276
2804
  if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
2277
2805
  json(res, 200, {
2278
- handle: identity.agentId,
2806
+ handle: identity.handle,
2279
2807
  daemon_online: codexDaemons.has(identity.agentId),
2280
2808
  });
2281
2809
  }
@@ -2291,7 +2819,7 @@ function handleCodexBootstrap(req, res, threadId) {
2291
2819
  const daemonOnline = codexDaemons.has(identity.agentId);
2292
2820
  const daemonKey = codexDaemonPubkeys.get(identity.agentId) || null;
2293
2821
  json(res, 200, {
2294
- handle: identity.agentId,
2822
+ handle: identity.handle,
2295
2823
  thread_id: threadId,
2296
2824
  daemon_online: daemonOnline,
2297
2825
  daemon_public_key: daemonKey ? daemonKey.pubkey : null,
@@ -2318,6 +2846,7 @@ async function handleCodexWsTicket(req, res) {
2318
2846
  const expires = Date.now() + CODEX_RELAY_TICKET_TTL_MS;
2319
2847
  codexRelayTickets.set(ticket, {
2320
2848
  agentId: identity.agentId,
2849
+ handle: identity.handle,
2321
2850
  threadId,
2322
2851
  expires,
2323
2852
  used: false,
@@ -2342,7 +2871,7 @@ function consumeCodexRelayTicket(ticket, threadId) {
2342
2871
  if (Date.now() > entry.expires) { codexRelayTickets.delete(ticket); return null; }
2343
2872
  if (entry.threadId !== threadId) return null; // bound to specific route
2344
2873
  entry.used = true;
2345
- return { agentId: entry.agentId };
2874
+ return { agentId: entry.agentId, handle: entry.handle || entry.agentId };
2346
2875
  }
2347
2876
 
2348
2877
  function serveAppFile(res, relPath) {
@@ -2368,23 +2897,64 @@ function serveAppFile(res, relPath) {
2368
2897
  }
2369
2898
  }
2370
2899
 
2371
- function authenticateWs(req) {
2900
+ // authenticateWs verifies a WS upgrade request against the API_KEYS
2901
+ // map. Default behavior is HEADER ONLY: Authorization: Bearer ck-...
2902
+ // is accepted; ?token=ck-... in the URL is ignored.
2903
+ //
2904
+ // Set { allowQueryToken: true } only on the explicit web-side
2905
+ // back-compat branch that runs inside ALLOW_WS_URL_TOKEN. Daemon
2906
+ // connections never enable this; CLI clients can always set
2907
+ // Authorization, and a daemon accepting URL-token would be a needless
2908
+ // attack surface (URL leaks via referrer / log scrape are not relevant
2909
+ // for daemons, but the asymmetry of "header-only on the daemon path"
2910
+ // keeps the policy clean and auditable).
2911
+ function authenticateWs(req, { allowQueryToken = false } = {}) {
2372
2912
  const auth = req.headers["authorization"];
2373
2913
  if (auth && auth.startsWith("Bearer ")) {
2374
2914
  const key = auth.slice(7).trim();
2375
- if (API_KEYS[key]) return { agentId: API_KEYS[key], apiKey: key };
2915
+ const identity = identityForApiKey(key);
2916
+ if (identity) return identity;
2376
2917
  }
2377
- // Browsers can't set Authorization on WebSocket(): accept ?token= fallback.
2918
+ if (!allowQueryToken) return null;
2919
+
2920
+ // Web back-compat path only. Browsers cannot set Authorization on a
2921
+ // WebSocket() handshake, so legacy clients put ck- in the URL.
2922
+ // parseUrl returns a WHATWG URL, which has no .query getter (only
2923
+ // .search/.searchParams). Strip the leading "?" off .search and
2924
+ // parse with querystring so the array/string handling below works.
2378
2925
  const u = parseUrl(req.url);
2379
- const qs = u.query ? parseUrlQs(u.query) : {};
2926
+ const qs = u.search ? parseUrlQs(u.search.slice(1)) : {};
2380
2927
  const tokenParam = Array.isArray(qs.token) ? qs.token[0] : qs.token;
2381
- if (typeof tokenParam === "string" && API_KEYS[tokenParam]) {
2382
- return { agentId: API_KEYS[tokenParam], apiKey: tokenParam };
2928
+ if (typeof tokenParam === "string") {
2929
+ return identityForApiKey(tokenParam);
2383
2930
  }
2384
2931
  return null;
2385
2932
  }
2386
2933
 
2387
- const codexRelayWss = new WebSocketServer({ noServer: true });
2934
+ // F-001: subprotocol-based WS auth for the codex-relay surface. The web
2935
+ // client sends Sec-WebSocket-Protocol: ldm-codex-relay.v1, ticket.<v>.
2936
+ // Server echoes only the protocol name (not the ticket-bearing entry).
2937
+ // Daemon connections do not use a subprotocol, so empty Sets pass.
2938
+ const codexRelayWss = new WebSocketServer({
2939
+ noServer: true,
2940
+ handleProtocols: (protocols /*, request */) => {
2941
+ if (!protocols || protocols.size === 0) return undefined;
2942
+ if (protocols.has("ldm-codex-relay.v1")) return "ldm-codex-relay.v1";
2943
+ return false;
2944
+ },
2945
+ });
2946
+
2947
+ function getTicketFromSubprotocol(req) {
2948
+ const header = req.headers["sec-websocket-protocol"];
2949
+ if (!header) return null;
2950
+ const tokens = header.split(",").map(s => s.trim()).filter(Boolean);
2951
+ for (const t of tokens) {
2952
+ if (t.startsWith("ticket.")) {
2953
+ return t.slice("ticket.".length);
2954
+ }
2955
+ }
2956
+ return null;
2957
+ }
2388
2958
 
2389
2959
  httpServer.on("upgrade", (req, socket, head) => {
2390
2960
  const u = parseUrl(req.url);
@@ -2393,21 +2963,64 @@ httpServer.on("upgrade", (req, socket, head) => {
2393
2963
  const isWeb = path.startsWith("/api/codex-relay/web/");
2394
2964
  if (!isDaemon && !isWeb) return; // let other listeners (or default) handle it
2395
2965
 
2966
+ // F-003: enforce Origin allowlist for browser-borne web upgrades.
2967
+ // Runs BEFORE ticket consumption / authenticateWs so a request from
2968
+ // a disallowed origin cannot burn a valid one-time ticket or trigger
2969
+ // an auth check side effect. Daemon path is exempt because CLI
2970
+ // clients do not send a browser Origin header. Requires nginx to
2971
+ // pass the Origin header through unchanged on the upgrade hop;
2972
+ // verified in Lane B B2 of the audit doc.
2973
+ if (isWeb) {
2974
+ const origin = req.headers["origin"];
2975
+ if (!isWsOriginAllowed(origin)) {
2976
+ console.warn("WS upgrade rejected: bad origin (" + (origin || "<none>") + ") for " + path);
2977
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
2978
+ socket.destroy();
2979
+ return;
2980
+ }
2981
+ }
2982
+
2396
2983
  // Daemon side keeps the existing Bearer ck- token auth.
2397
- // Web side: prefer single-use ?ticket= bound to the route; fall back
2398
- // to ?token=ck- for back-compat with the pre-2.5 alpha.
2984
+ // Web side: prefer ticket via Sec-WebSocket-Protocol (F-001), fall
2985
+ // back to ?ticket= query string. The legacy ?token=ck- URL fallback
2986
+ // is gated behind LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN=1; production
2987
+ // does not accept it.
2399
2988
  let identity = null;
2400
2989
  if (isDaemon) {
2401
- identity = authenticateWs(req);
2990
+ // Daemon connections must use Authorization: Bearer ck-. URL-token
2991
+ // is never accepted on the daemon path (allowQueryToken: false).
2992
+ identity = authenticateWs(req, { allowQueryToken: false });
2402
2993
  } else {
2403
2994
  const threadId = decodeURIComponent(path.slice("/api/codex-relay/web/".length));
2404
- const qs = u.query ? parseUrlQs(u.query) : {};
2405
- const ticketParam = Array.isArray(qs.ticket) ? qs.ticket[0] : qs.ticket;
2406
- if (typeof ticketParam === "string" && ticketParam) {
2407
- const consumed = consumeCodexRelayTicket(ticketParam, threadId);
2408
- if (consumed) identity = { agentId: consumed.agentId, viaTicket: true };
2995
+
2996
+ // Preferred path: ticket carried in Sec-WebSocket-Protocol. Avoids
2997
+ // URL/log/referrer exposure of the ticket value.
2998
+ const subTicket = getTicketFromSubprotocol(req);
2999
+ if (subTicket) {
3000
+ const consumed = consumeCodexRelayTicket(subTicket, threadId);
3001
+ if (consumed) identity = { agentId: consumed.agentId, handle: consumed.handle, viaTicket: true };
3002
+ }
3003
+
3004
+ // Back-compat: ?ticket= query string. Same single-use binding.
3005
+ if (!identity) {
3006
+ // parseUrl returns a WHATWG URL with .search/.searchParams, no
3007
+ // .query. Strip the leading "?" off .search and parse with
3008
+ // querystring so the array/string handling below still works.
3009
+ const qs = u.search ? parseUrlQs(u.search.slice(1)) : {};
3010
+ const ticketParam = Array.isArray(qs.ticket) ? qs.ticket[0] : qs.ticket;
3011
+ if (typeof ticketParam === "string" && ticketParam) {
3012
+ const consumed = consumeCodexRelayTicket(ticketParam, threadId);
3013
+ if (consumed) identity = { agentId: consumed.agentId, handle: consumed.handle, viaTicket: true };
3014
+ }
3015
+ }
3016
+
3017
+ // Legacy ?token=ck- URL fallback: dev/back-compat only. Production
3018
+ // refuses long-lived bearer in WS URLs (gate condition 2). The
3019
+ // URL-token branch in authenticateWs is gated by allowQueryToken,
3020
+ // and we only set it true here, only when ALLOW_WS_URL_TOKEN is on.
3021
+ if (!identity && ALLOW_WS_URL_TOKEN) {
3022
+ identity = authenticateWs(req, { allowQueryToken: true });
2409
3023
  }
2410
- if (!identity) identity = authenticateWs(req);
2411
3024
  }
2412
3025
 
2413
3026
  if (!identity) {
@@ -2422,14 +3035,58 @@ httpServer.on("upgrade", (req, socket, head) => {
2422
3035
  if (previous && previous !== ws) try { previous.close(4000, "replaced"); } catch {}
2423
3036
  codexDaemons.set(identity.agentId, ws);
2424
3037
  console.log("codex-relay: daemon online for " + identity.agentId);
3038
+ // F-001 per-thread isolation. Daemon -> web routing must NOT
3039
+ // fan out every frame to every same-agent web socket; that
3040
+ // breaks isolation when one user has multiple threads open.
3041
+ // Parse the OUTER envelope only to read the routing field
3042
+ // (session/sessionId). The encrypted ciphertext (or any inner
3043
+ // session.* payload) is never inspected, so gate 3a still
3044
+ // holds: the relay sees only routing metadata on the envelope.
3045
+ // No-session frames are an explicit allowlist (control/presence
3046
+ // types) or are dropped with a redacted warning. We never
3047
+ // broadcast unknown frames.
3048
+ const BROADCAST_TYPES = new Set([
3049
+ "presence",
3050
+ "presence.web",
3051
+ "presence.daemon",
3052
+ "daemon.online",
3053
+ "daemon.offline",
3054
+ ]);
2425
3055
  ws.on("message", (data) => {
2426
3056
  const text = data.toString();
2427
- const prefix = identity.agentId + ":";
2428
- for (const [key, webWs] of codexWebClients) {
2429
- if (key.startsWith(prefix) && webWs.readyState === webWs.OPEN) {
2430
- webWs.send(text);
3057
+ let envelope = null;
3058
+ try { envelope = JSON.parse(text); } catch {}
3059
+ const sessionId = envelope?.session || envelope?.sessionId || envelope?.threadId;
3060
+ if (sessionId) {
3061
+ const targets = resolveCodexWebClientsForDaemonFrame(identity.agentId, sessionId);
3062
+ for (const target of targets) {
3063
+ target.send(text);
3064
+ }
3065
+ // No matching web client: drop silently. Daemon-emitted
3066
+ // frames for a thread the user has not opened in any browser
3067
+ // are not interesting to fan out elsewhere.
3068
+ return;
3069
+ }
3070
+ const type = envelope?.type;
3071
+ if (type && BROADCAST_TYPES.has(type)) {
3072
+ // Allowlisted agent-level frame (presence, online status).
3073
+ // Fan out within agent, never across agents.
3074
+ const prefix = identity.agentId + ":";
3075
+ for (const [key, webClients] of codexWebClients) {
3076
+ if (key.startsWith(prefix)) {
3077
+ for (const webWs of webClients) {
3078
+ if (webWs.readyState === webWs.OPEN) webWs.send(text);
3079
+ }
3080
+ }
2431
3081
  }
3082
+ return;
2432
3083
  }
3084
+ // Parse failed, missing session, or unknown type: drop. Log a
3085
+ // redacted notice so the operator can see if a daemon is
3086
+ // emitting unrouteable frames. We never log envelope/payload
3087
+ // bytes; only the agent and the type (or "no-type").
3088
+ const typeMarker = type ? String(type).slice(0, 32) : "no-type";
3089
+ console.warn("codex-relay: dropped unroutable daemon frame for " + identity.agentId + " (type=" + typeMarker + ")");
2433
3090
  });
2434
3091
  ws.on("close", () => {
2435
3092
  if (codexDaemons.get(identity.agentId) === ws) {
@@ -2452,21 +3109,26 @@ httpServer.on("upgrade", (req, socket, head) => {
2452
3109
  return;
2453
3110
  }
2454
3111
  codexRelayWss.handleUpgrade(req, socket, head, (ws) => {
2455
- const key = identity.agentId + ":" + threadId;
2456
- const previous = codexWebClients.get(key);
2457
- if (previous && previous !== ws) try { previous.close(4000, "replaced"); } catch {}
2458
- codexWebClients.set(key, ws);
2459
- console.log("codex-relay: web online " + key);
3112
+ const key = codexRelayKey(identity.agentId, threadId);
3113
+ const clientCount = addCodexWebClient(key, ws);
3114
+ console.log("codex-relay: web online " + key + " clients=" + clientCount);
2460
3115
  ws.on("message", (data) => {
3116
+ const text = data.toString();
3117
+ let envelope = null;
3118
+ try { envelope = JSON.parse(text); } catch {}
3119
+ if (isCodexE2eeEnvelope(envelope) && envelope.session) {
3120
+ registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);
3121
+ }
2461
3122
  const daemonWs = codexDaemons.get(identity.agentId);
2462
3123
  if (daemonWs && daemonWs.readyState === daemonWs.OPEN) {
2463
- daemonWs.send(data.toString());
3124
+ daemonWs.send(text);
2464
3125
  } else {
2465
3126
  try { ws.send(JSON.stringify({ type: "error", message: "daemon offline" })); } catch {}
2466
3127
  }
2467
3128
  });
2468
3129
  ws.on("close", () => {
2469
- if (codexWebClients.get(key) === ws) codexWebClients.delete(key);
3130
+ removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);
3131
+ removeCodexWebClient(key, ws);
2470
3132
  });
2471
3133
  ws.on("error", (err) => {
2472
3134
  console.error("codex-relay web ws error:", err.message);
@@ -2476,6 +3138,7 @@ httpServer.on("upgrade", (req, socket, head) => {
2476
3138
 
2477
3139
  httpServer.listen(PORT, SERVER_BIND, () => {
2478
3140
  console.log(SERVER_NAME + " v" + SERVER_VERSION + " listening on " + SERVER_BIND + ":" + PORT);
3141
+ console.log("WS origin allowlist: " + WS_ORIGIN_ALLOWLIST.join(", "));
2479
3142
  console.log("Health: http://localhost:" + PORT + "/health");
2480
3143
  console.log("MCP: http://localhost:" + PORT + "/mcp");
2481
3144
  console.log("OAuth: http://localhost:" + PORT + "/.well-known/oauth-authorization-server");