@wipcomputer/wip-ldm-os 0.4.85-alpha.3 → 0.4.85-alpha.32
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/README.md +22 -2
- package/SKILL.md +136 -14
- package/bin/ldm.js +525 -75
- package/docs/universal-installer/SPEC.md +16 -3
- package/docs/universal-installer/TECHNICAL.md +4 -4
- package/lib/deploy.mjs +104 -20
- package/lib/detect.mjs +35 -4
- package/lib/registry-migrations.mjs +296 -0
- package/package.json +24 -3
- package/scripts/test-boot-hook-registration.mjs +136 -0
- package/scripts/test-boot-payload-trim.mjs +200 -0
- package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +129 -0
- package/scripts/test-crc-pair-login-flow.mjs +115 -0
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
- package/scripts/test-crc-websocket-abuse-limits.mjs +128 -0
- package/scripts/test-doctor-hook-dedupe.mjs +188 -0
- package/scripts/test-install-prompt-policy.mjs +84 -0
- package/scripts/test-installer-skill-directory.mjs +55 -0
- package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
- package/scripts/test-installer-target-self-update.mjs +131 -0
- package/scripts/test-kaleidoscope-onboarding-copy.mjs +170 -0
- package/scripts/test-kaleidoscope-public-stats-baseline.mjs +129 -0
- package/scripts/test-kaleidoscope-qr-authenticator-confirmation.mjs +89 -0
- package/scripts/test-ldm-status-concurrency.mjs +118 -0
- package/scripts/test-ldm-status-timeout.mjs +96 -0
- package/scripts/test-legacy-npm-sources-migration.mjs +460 -0
- package/scripts/test-readme-install-prompt.mjs +66 -0
- package/shared/boot/boot-config.json +18 -8
- package/shared/docs/dev-guide-wipcomputerinc.md.tmpl +5 -4
- package/shared/rules/security.md +4 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/boot/README.md +24 -1
- package/src/boot/boot-config.json +18 -8
- package/src/boot/boot-hook.mjs +118 -28
- package/src/boot/installer.mjs +33 -10
- package/src/hosted-mcp/.env.example +4 -0
- package/src/hosted-mcp/README.md +37 -0
- package/src/hosted-mcp/app/footer.js +74 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +1290 -0
- package/src/hosted-mcp/app/pair.html +165 -57
- package/src/hosted-mcp/app/sprites.png +0 -0
- package/src/hosted-mcp/app/wip-logo.png +0 -0
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
- package/src/hosted-mcp/codex-relay-ws-abuse-limits.mjs +140 -0
- package/src/hosted-mcp/demo/footer.js +2 -2
- package/src/hosted-mcp/demo/index.html +169 -51
- package/src/hosted-mcp/demo/login.html +390 -28
- package/src/hosted-mcp/demo/privacy.html +4 -214
- package/src/hosted-mcp/demo/tos.html +4 -189
- package/src/hosted-mcp/deploy.sh +308 -56
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/legal/internet-services/kaleidoscope/index.html +257 -0
- package/src/hosted-mcp/legal/internet-services/terms/site.html +224 -168
- package/src/hosted-mcp/legal/legal-footer.js +75 -0
- package/src/hosted-mcp/legal/legal.css +166 -0
- package/src/hosted-mcp/legal/privacy/en-ww/index.html +4 -221
- package/src/hosted-mcp/legal/privacy/index.html +253 -0
- package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
- package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
- package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
- package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
- package/src/hosted-mcp/server.mjs +1681 -166
|
@@ -1,11 +1,11 @@
|
|
|
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
|
|
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
|
|
|
7
7
|
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, accessSync, constants as fsConstants } from "node:fs";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import { createServer } from "node:http";
|
|
@@ -23,10 +23,49 @@ import {
|
|
|
23
23
|
import QRCode from "qrcode";
|
|
24
24
|
import { WebSocketServer } from "ws";
|
|
25
25
|
import { parse as parseUrlQs } from "node:querystring";
|
|
26
|
+
import {
|
|
27
|
+
buildCodexBootstrapPayload,
|
|
28
|
+
codexDaemonPubkeyFingerprint,
|
|
29
|
+
createCodexDaemonPubkeyRegistry,
|
|
30
|
+
evaluateCodexDaemonReconnectPubkey,
|
|
31
|
+
} from "./codex-relay-e2ee-registry.mjs";
|
|
32
|
+
import {
|
|
33
|
+
codexWsFrameByteLength,
|
|
34
|
+
createCodexWsAbuseLimitConfig,
|
|
35
|
+
createCodexWsConnectionGuard,
|
|
36
|
+
formatCodexWsLimitLog,
|
|
37
|
+
isCodexWsAgentDisabled,
|
|
38
|
+
} from "./codex-relay-ws-abuse-limits.mjs";
|
|
26
39
|
|
|
27
40
|
// ── Settings ─────────────────────────────────────────────────────────
|
|
28
41
|
|
|
29
42
|
const PORT = parseInt(process.env.MCP_PORT || "18800", 10);
|
|
43
|
+
// Dev mode: opt-in to JSON-file fallbacks for the data layer and to
|
|
44
|
+
// reading tokens/passkeys from local JSON files. Production must run
|
|
45
|
+
// without this flag set (production fails closed when Prisma is
|
|
46
|
+
// unavailable, and never reads/writes the local JSON token files).
|
|
47
|
+
// Tracked by ai/product/bugs/security/2026-04-28--cc-mini--vps-hosted-mcp-audit.md (F-002, F-005a).
|
|
48
|
+
const DEV_MODE = process.env.LDM_HOSTED_MCP_DEV_MODE === "1";
|
|
49
|
+
// WebSocket Origin allowlist (F-003 in the VPS hosted-mcp audit).
|
|
50
|
+
// Browser-borne web WS upgrades must present an Origin from this list.
|
|
51
|
+
// Comma-separated env var; default is the production origin.
|
|
52
|
+
// Daemon WS upgrades (CLI / agent connections) are NOT gated on Origin
|
|
53
|
+
// because they do not send a browser Origin header.
|
|
54
|
+
const WS_ORIGIN_ALLOWLIST = (process.env.LDM_HOSTED_MCP_WS_ORIGIN_ALLOWLIST || "https://wip.computer")
|
|
55
|
+
.split(",")
|
|
56
|
+
.map(s => s.trim())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
const CODEX_WS_ABUSE_LIMITS = createCodexWsAbuseLimitConfig(process.env);
|
|
59
|
+
|
|
60
|
+
function isWsOriginAllowed(origin) {
|
|
61
|
+
if (!origin) return false;
|
|
62
|
+
return WS_ORIGIN_ALLOWLIST.includes(origin);
|
|
63
|
+
}
|
|
64
|
+
// F-001: WS URL-token fallback (browser sends ?token=ck-... on upgrade).
|
|
65
|
+
// Default off in production. Set LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN=1 to
|
|
66
|
+
// allow the legacy back-compat path. Independent of any other dev flag
|
|
67
|
+
// so that this can be toggled without enabling other dev-mode behavior.
|
|
68
|
+
const ALLOW_WS_URL_TOKEN = process.env.LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN === "1";
|
|
30
69
|
const SESSION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
31
70
|
const SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
32
71
|
const OAUTH_CODE_EXPIRY_MS = 10 * 60 * 1000;
|
|
@@ -44,18 +83,19 @@ const RP_ORIGIN = "https://wip.computer";
|
|
|
44
83
|
|
|
45
84
|
// ── Data layer ──────────────────────────────────────────────────────
|
|
46
85
|
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
86
|
+
// Production: Postgres via Prisma is the canonical store. If Prisma
|
|
87
|
+
// cannot connect, the server refuses to start (F-005a).
|
|
49
88
|
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
89
|
+
// Dev mode (LDM_HOSTED_MCP_DEV_MODE=1): JSON files are used as a
|
|
90
|
+
// fallback for tokens and passkeys when Prisma is unavailable, and
|
|
91
|
+
// are also seeded into the in-memory cache on boot. Production must
|
|
92
|
+
// not set this flag.
|
|
52
93
|
|
|
53
94
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
54
95
|
const TOKEN_FILE = join(__dirname, "tokens.json");
|
|
55
96
|
const PASSKEY_FILE = join(__dirname, "passkeys.json");
|
|
56
97
|
const WALLET_FILE_LEGACY = join(__dirname, "wallets.json");
|
|
57
98
|
|
|
58
|
-
// Initialize Prisma (may fail if DATABASE_URL not set)
|
|
59
99
|
let prisma = null;
|
|
60
100
|
let usePrisma = false;
|
|
61
101
|
try {
|
|
@@ -64,30 +104,102 @@ try {
|
|
|
64
104
|
usePrisma = true;
|
|
65
105
|
console.log("Database: Postgres via Prisma");
|
|
66
106
|
} catch (err) {
|
|
67
|
-
|
|
107
|
+
if (!DEV_MODE) {
|
|
108
|
+
console.error("FATAL: Prisma unavailable; refusing to start.");
|
|
109
|
+
console.error("Cause: " + err.message);
|
|
110
|
+
console.error("Set LDM_HOSTED_MCP_DEV_MODE=1 to allow the JSON fallback (dev only).");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
console.warn("Database: JSON files (DEV MODE; Prisma not available: " + err.message + ")");
|
|
68
114
|
}
|
|
69
115
|
|
|
70
116
|
// ── API Keys ────────────────────────────────────────────────────────
|
|
117
|
+
//
|
|
118
|
+
// Hardcoded production defaults removed (F-002). Production keys live
|
|
119
|
+
// in the Postgres ApiKey table and are loaded on boot. In DEV_MODE,
|
|
120
|
+
// the local tokens.json file is also seeded into the in-memory cache.
|
|
71
121
|
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
122
|
+
const API_KEYS = {};
|
|
123
|
+
const API_KEY_HANDLES = {};
|
|
124
|
+
const ACCOUNT_TENANT_PREFIX = "acct:";
|
|
125
|
+
const LEGACY_API_KEY_TENANT_PREFIX = "key:";
|
|
126
|
+
const OAUTH_API_KEY_TENANT_PREFIX = "oauth:";
|
|
127
|
+
|
|
128
|
+
function isInternalTenantId(id) {
|
|
129
|
+
return typeof id === "string"
|
|
130
|
+
&& (id.startsWith(ACCOUNT_TENANT_PREFIX)
|
|
131
|
+
|| id.startsWith(LEGACY_API_KEY_TENANT_PREFIX)
|
|
132
|
+
|| id.startsWith(OAUTH_API_KEY_TENANT_PREFIX));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function accountTenantIdForUserId(userId) {
|
|
136
|
+
return ACCOUNT_TENANT_PREFIX + userId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function legacyTenantIdForApiKey(key) {
|
|
140
|
+
return LEGACY_API_KEY_TENANT_PREFIX + createHash("sha256").update(key).digest("base64url").slice(0, 32);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function oauthTenantIdForApiKey(key) {
|
|
144
|
+
return OAUTH_API_KEY_TENANT_PREFIX + createHash("sha256").update(key).digest("base64url").slice(0, 32);
|
|
145
|
+
}
|
|
79
146
|
|
|
80
|
-
|
|
81
|
-
|
|
147
|
+
function rememberApiKeyInMemory(key, tenantId, handle = null) {
|
|
148
|
+
API_KEYS[key] = tenantId;
|
|
149
|
+
if (handle) API_KEY_HANDLES[key] = handle;
|
|
150
|
+
else delete API_KEY_HANDLES[key];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function rememberLoadedApiKey(key, storedAgentId) {
|
|
154
|
+
const tenantId = isInternalTenantId(storedAgentId) ? storedAgentId : legacyTenantIdForApiKey(key);
|
|
155
|
+
const handle = isInternalTenantId(storedAgentId) ? null : storedAgentId;
|
|
156
|
+
rememberApiKeyInMemory(key, tenantId, handle);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function identityForApiKey(key) {
|
|
160
|
+
const tenantId = API_KEYS[key];
|
|
161
|
+
if (!tenantId) return null;
|
|
162
|
+
return {
|
|
163
|
+
agentId: tenantId,
|
|
164
|
+
tenantId,
|
|
165
|
+
handle: API_KEY_HANDLES[key] || tenantId,
|
|
166
|
+
apiKey: key,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
82
169
|
|
|
83
|
-
// Load from JSON (fallback)
|
|
84
170
|
function loadTokensFromFile() {
|
|
85
|
-
|
|
171
|
+
let rows = {};
|
|
172
|
+
try { rows = JSON.parse(readFileSync(TOKEN_FILE, "utf8")); } catch { return; }
|
|
173
|
+
for (const [key, storedAgentId] of Object.entries(rows)) {
|
|
174
|
+
rememberLoadedApiKey(key, storedAgentId);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function loadApiKeysFromDb() {
|
|
179
|
+
if (!usePrisma) return;
|
|
180
|
+
try {
|
|
181
|
+
const rows = await prisma.apiKey.findMany();
|
|
182
|
+
for (const row of rows) rememberLoadedApiKey(row.key, row.agentId);
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (!DEV_MODE) {
|
|
185
|
+
console.error("FATAL: Prisma loadApiKeys failed; refusing to start.");
|
|
186
|
+
console.error("Cause: " + err.message);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
console.error("Prisma loadApiKeys error (DEV_MODE):", err.message);
|
|
190
|
+
}
|
|
86
191
|
}
|
|
87
|
-
Object.assign(API_KEYS, loadTokensFromFile());
|
|
88
192
|
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
if (DEV_MODE) {
|
|
194
|
+
loadTokensFromFile();
|
|
195
|
+
}
|
|
196
|
+
await loadApiKeysFromDb();
|
|
197
|
+
|
|
198
|
+
async function saveApiKey(key, agentId, { handle = null } = {}) {
|
|
199
|
+
// Persist before advertising in memory: a newly issued key must not
|
|
200
|
+
// become valid in the in-memory cache if the canonical store did not
|
|
201
|
+
// accept it. Otherwise the key would work for the lifetime of the
|
|
202
|
+
// process and disappear on restart.
|
|
91
203
|
if (usePrisma) {
|
|
92
204
|
try {
|
|
93
205
|
await prisma.apiKey.upsert({
|
|
@@ -97,10 +209,17 @@ async function saveApiKey(key, agentId) {
|
|
|
97
209
|
});
|
|
98
210
|
} catch (err) {
|
|
99
211
|
console.error("Prisma saveApiKey error:", err.message);
|
|
212
|
+
if (!DEV_MODE) throw new Error("saveApiKey persistence failed: " + err.message);
|
|
100
213
|
}
|
|
214
|
+
} else if (!DEV_MODE) {
|
|
215
|
+
// Production should never reach here (boot exits if Prisma is
|
|
216
|
+
// unavailable), but guard explicitly.
|
|
217
|
+
throw new Error("saveApiKey called without Prisma in production");
|
|
218
|
+
}
|
|
219
|
+
rememberApiKeyInMemory(key, agentId, handle);
|
|
220
|
+
if (DEV_MODE) {
|
|
221
|
+
try { writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n"); } catch {}
|
|
101
222
|
}
|
|
102
|
-
// Always write JSON as backup
|
|
103
|
-
try { writeFileSync(TOKEN_FILE, JSON.stringify(API_KEYS, null, 2) + "\n"); } catch {}
|
|
104
223
|
}
|
|
105
224
|
|
|
106
225
|
// ── Passkeys ────────────────────────────────────────────────────────
|
|
@@ -113,33 +232,72 @@ function loadPasskeysFromFile() {
|
|
|
113
232
|
}
|
|
114
233
|
|
|
115
234
|
async function loadPasskeysFromDb() {
|
|
116
|
-
if (!usePrisma)
|
|
235
|
+
if (!usePrisma) {
|
|
236
|
+
return DEV_MODE ? loadPasskeysFromFile() : [];
|
|
237
|
+
}
|
|
117
238
|
try {
|
|
118
239
|
const creds = await prisma.credential.findMany({ include: { user: true } });
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
240
|
+
const handleUserIds = new Map();
|
|
241
|
+
for (const c of creds) {
|
|
242
|
+
const handle = c.user?.name || (c.userId ? "user-" + c.userId.slice(0, 8) : "unknown");
|
|
243
|
+
if (!handleUserIds.has(handle)) handleUserIds.set(handle, new Set());
|
|
244
|
+
handleUserIds.get(handle).add(c.userId);
|
|
245
|
+
}
|
|
246
|
+
const out = [];
|
|
247
|
+
for (const c of creds) {
|
|
248
|
+
const handle = c.user?.name || (c.userId ? "user-" + c.userId.slice(0, 8) : "unknown");
|
|
249
|
+
const agentId = accountTenantIdForUserId(c.userId);
|
|
250
|
+
let apiKey = null;
|
|
251
|
+
for (const [key, tenantId] of Object.entries(API_KEYS)) {
|
|
252
|
+
if (tenantId === agentId || (handleUserIds.get(handle)?.size === 1 && API_KEY_HANDLES[key] === handle)) {
|
|
253
|
+
apiKey = key;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (apiKey) {
|
|
258
|
+
API_KEY_HANDLES[apiKey] = handle;
|
|
259
|
+
if (API_KEYS[apiKey] !== agentId) {
|
|
260
|
+
try {
|
|
261
|
+
await saveApiKey(apiKey, agentId, { handle });
|
|
262
|
+
console.log("loadPasskeysFromDb: migrated API key tenant for handle '" + handle + "' to immutable account id");
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.error("loadPasskeysFromDb: failed to migrate API key tenant for handle '" + handle + "':", err.message);
|
|
265
|
+
if (!DEV_MODE) throw err;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} else if (handle !== "unknown") {
|
|
269
|
+
console.warn("loadPasskeysFromDb: no ApiKey row for account tenant '" + agentId + "'; auth-verify will mint on next successful login");
|
|
270
|
+
}
|
|
271
|
+
out.push({
|
|
272
|
+
credentialId: c.id,
|
|
273
|
+
publicKey: Buffer.from(c.publicKey).toString("base64url"),
|
|
274
|
+
counter: c.counter,
|
|
275
|
+
userId: c.userId,
|
|
276
|
+
agentId,
|
|
277
|
+
handle,
|
|
278
|
+
apiKey,
|
|
279
|
+
createdAt: c.createdAt.toISOString(),
|
|
280
|
+
transports: c.transports || [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
128
284
|
} catch (err) {
|
|
129
285
|
console.error("Prisma loadPasskeys error:", err.message);
|
|
130
|
-
return loadPasskeysFromFile();
|
|
286
|
+
return DEV_MODE ? loadPasskeysFromFile() : [];
|
|
131
287
|
}
|
|
132
288
|
}
|
|
133
289
|
|
|
134
290
|
async function savePasskey(entry) {
|
|
135
|
-
|
|
291
|
+
// Persist before pushing to in-memory: a passkey must not exist in
|
|
292
|
+
// memory if it was never persisted, or it would authenticate for the
|
|
293
|
+
// lifetime of the process and disappear on restart.
|
|
136
294
|
if (usePrisma) {
|
|
137
295
|
try {
|
|
138
296
|
// Ensure user exists
|
|
139
297
|
let user = await prisma.user.findUnique({ where: { id: entry.userId } });
|
|
140
298
|
if (!user) {
|
|
141
299
|
user = await prisma.user.create({
|
|
142
|
-
data: { id: entry.userId, name: entry.
|
|
300
|
+
data: { id: entry.userId, name: entry.handle || "user" },
|
|
143
301
|
});
|
|
144
302
|
}
|
|
145
303
|
await prisma.credential.create({
|
|
@@ -153,15 +311,22 @@ async function savePasskey(entry) {
|
|
|
153
311
|
});
|
|
154
312
|
} catch (err) {
|
|
155
313
|
console.error("Prisma savePasskey error:", err.message);
|
|
314
|
+
if (!DEV_MODE) throw new Error("savePasskey persistence failed: " + err.message);
|
|
156
315
|
}
|
|
316
|
+
} else if (!DEV_MODE) {
|
|
317
|
+
throw new Error("savePasskey called without Prisma in production");
|
|
318
|
+
}
|
|
319
|
+
passkeys.push(entry);
|
|
320
|
+
if (DEV_MODE) {
|
|
321
|
+
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
157
322
|
}
|
|
158
|
-
// Always write JSON as backup
|
|
159
|
-
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
160
323
|
}
|
|
161
324
|
|
|
162
325
|
async function updatePasskeyCounter(credentialId, newCounter) {
|
|
163
|
-
|
|
164
|
-
|
|
326
|
+
// Persist before updating in-memory. The counter is the WebAuthn
|
|
327
|
+
// replay-protection state; advancing it in memory while the DB row
|
|
328
|
+
// stays behind would let a replayed assertion validate after a
|
|
329
|
+
// restart re-loaded the stale counter.
|
|
165
330
|
if (usePrisma) {
|
|
166
331
|
try {
|
|
167
332
|
await prisma.credential.update({
|
|
@@ -170,9 +335,16 @@ async function updatePasskeyCounter(credentialId, newCounter) {
|
|
|
170
335
|
});
|
|
171
336
|
} catch (err) {
|
|
172
337
|
console.error("Prisma updateCounter error:", err.message);
|
|
338
|
+
if (!DEV_MODE) throw new Error("updatePasskeyCounter persistence failed: " + err.message);
|
|
173
339
|
}
|
|
340
|
+
} else if (!DEV_MODE) {
|
|
341
|
+
throw new Error("updatePasskeyCounter called without Prisma in production");
|
|
342
|
+
}
|
|
343
|
+
const entry = passkeys.find(p => p.credentialId === credentialId);
|
|
344
|
+
if (entry) entry.counter = newCounter;
|
|
345
|
+
if (DEV_MODE) {
|
|
346
|
+
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
174
347
|
}
|
|
175
|
-
try { writeFileSync(PASSKEY_FILE, JSON.stringify(passkeys, null, 2) + "\n"); } catch {}
|
|
176
348
|
}
|
|
177
349
|
|
|
178
350
|
// Boot: load passkeys
|
|
@@ -219,7 +391,7 @@ function authenticate(req) {
|
|
|
219
391
|
const auth = req.headers["authorization"];
|
|
220
392
|
if (!auth?.startsWith("Bearer ")) return null;
|
|
221
393
|
const key = auth.slice(7).trim();
|
|
222
|
-
return
|
|
394
|
+
return identityForApiKey(key);
|
|
223
395
|
}
|
|
224
396
|
|
|
225
397
|
function readBody(req) {
|
|
@@ -275,13 +447,92 @@ function parseUrl(reqUrl) {
|
|
|
275
447
|
return new URL(reqUrl, "http://localhost");
|
|
276
448
|
}
|
|
277
449
|
|
|
450
|
+
// ── Rate limiting (F-008 in the VPS hosted-mcp audit) ───────────────
|
|
451
|
+
//
|
|
452
|
+
// Per-IP, per-bucket fixed-window counter. In-process Map; resets on
|
|
453
|
+
// restart. nginx-side limit_req would be more durable but harder to
|
|
454
|
+
// scope per route; in-process keeps the policy with the code that
|
|
455
|
+
// mints/validates the auth tokens. Defaults are conservative; tune via
|
|
456
|
+
// env. Stale entries are pruned periodically so memory stays bounded.
|
|
457
|
+
//
|
|
458
|
+
// Buckets:
|
|
459
|
+
// mint ... endpoints that issue a credential or ticket
|
|
460
|
+
// validate ... endpoints that consume / verify a credential
|
|
461
|
+
// status ... poll-friendly endpoints (higher limit)
|
|
462
|
+
|
|
463
|
+
const RATE_LIMIT_BUCKETS = {
|
|
464
|
+
mint: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_MINT || "30", 10), windowMs: 60_000 },
|
|
465
|
+
validate: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_VALIDATE || "60", 10), windowMs: 60_000 },
|
|
466
|
+
status: { limit: parseInt(process.env.LDM_HOSTED_MCP_RL_STATUS || "120", 10), windowMs: 60_000 },
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const rateLimitState = new Map(); // key: "<bucket>:<ip>" -> { count, windowStart }
|
|
470
|
+
|
|
471
|
+
function getClientIp(req) {
|
|
472
|
+
// Prefer X-Real-IP (nginx overwrites on proxy hop, harder to spoof
|
|
473
|
+
// through the proxy). Fall back to the LAST entry in X-Forwarded-For
|
|
474
|
+
// (nginx appends $remote_addr via proxy_add_x_forwarded_for, so the
|
|
475
|
+
// last entry is the real client IP from nginx's perspective; the
|
|
476
|
+
// first entries are attacker-controlled). Last fallback: socket.
|
|
477
|
+
const xRealIp = req.headers["x-real-ip"];
|
|
478
|
+
if (typeof xRealIp === "string" && xRealIp.length > 0) return xRealIp.trim();
|
|
479
|
+
const xff = req.headers["x-forwarded-for"];
|
|
480
|
+
if (typeof xff === "string" && xff.length > 0) {
|
|
481
|
+
const parts = xff.split(",").map(s => s.trim()).filter(Boolean);
|
|
482
|
+
if (parts.length > 0) return parts[parts.length - 1];
|
|
483
|
+
}
|
|
484
|
+
return req.socket?.remoteAddress || "unknown";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function rateLimitCheck(req, bucket) {
|
|
488
|
+
const config = RATE_LIMIT_BUCKETS[bucket];
|
|
489
|
+
if (!config) return { ok: true };
|
|
490
|
+
const ip = getClientIp(req);
|
|
491
|
+
const key = bucket + ":" + ip;
|
|
492
|
+
const now = Date.now();
|
|
493
|
+
const entry = rateLimitState.get(key);
|
|
494
|
+
if (!entry || now - entry.windowStart > config.windowMs) {
|
|
495
|
+
rateLimitState.set(key, { count: 1, windowStart: now });
|
|
496
|
+
return { ok: true };
|
|
497
|
+
}
|
|
498
|
+
entry.count += 1;
|
|
499
|
+
if (entry.count > config.limit) {
|
|
500
|
+
const retryAfterSec = Math.max(1, Math.ceil((config.windowMs - (now - entry.windowStart)) / 1000));
|
|
501
|
+
return { ok: false, retryAfterSec };
|
|
502
|
+
}
|
|
503
|
+
return { ok: true };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Returns true if the request is allowed. If limited, writes 429 and
|
|
507
|
+
// returns false; the caller must `return` immediately on false.
|
|
508
|
+
function applyRateLimit(req, res, bucket) {
|
|
509
|
+
const result = rateLimitCheck(req, bucket);
|
|
510
|
+
if (!result.ok) {
|
|
511
|
+
res.setHeader("Retry-After", String(result.retryAfterSec));
|
|
512
|
+
json(res, 429, { error: "rate_limit_exceeded", error_description: "Too many requests. Retry after " + result.retryAfterSec + "s." });
|
|
513
|
+
console.warn("rate-limit hit:", bucket, getClientIp(req), req.method, req.url?.split("?")[0]);
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Keep memory bounded: drop entries older than 2 windows.
|
|
520
|
+
setInterval(() => {
|
|
521
|
+
const now = Date.now();
|
|
522
|
+
for (const [key, entry] of rateLimitState) {
|
|
523
|
+
if (now - entry.windowStart > 2 * 60_000) {
|
|
524
|
+
rateLimitState.delete(key);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}, 5 * 60_000).unref();
|
|
528
|
+
|
|
278
529
|
function esc(s) {
|
|
279
530
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
280
531
|
}
|
|
281
532
|
|
|
282
|
-
function
|
|
533
|
+
function sanitizeDisplayLabel(raw) {
|
|
283
534
|
if (!raw || typeof raw !== "string") return null;
|
|
284
|
-
const cleaned = raw.
|
|
535
|
+
const cleaned = raw.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim().slice(0, 64);
|
|
285
536
|
return cleaned.length > 0 ? cleaned : null;
|
|
286
537
|
}
|
|
287
538
|
|
|
@@ -406,14 +657,17 @@ async function handleRegisterOptions(req, res) {
|
|
|
406
657
|
let body;
|
|
407
658
|
try { body = await readBody(req); } catch { body = {}; }
|
|
408
659
|
|
|
409
|
-
// Accept
|
|
410
|
-
|
|
660
|
+
// Accept the existing `username` field for wire compatibility, but
|
|
661
|
+
// treat it only as a display label for the passkey prompt. It is not
|
|
662
|
+
// a public username, account handle, or relay tenant boundary.
|
|
663
|
+
// Duplicate display labels are allowed.
|
|
664
|
+
const displayLabel = sanitizeDisplayLabel(body?.displayName || body?.username);
|
|
411
665
|
|
|
412
666
|
const userId = randomBytes(16);
|
|
413
667
|
const userIdB64 = userId.toString("base64url");
|
|
414
668
|
|
|
415
|
-
const userName =
|
|
416
|
-
const displayName =
|
|
669
|
+
const userName = displayLabel || ("user-" + userIdB64.slice(0, 8));
|
|
670
|
+
const displayName = displayLabel || "Memory Crystal User";
|
|
417
671
|
|
|
418
672
|
let options;
|
|
419
673
|
try {
|
|
@@ -442,7 +696,7 @@ async function handleRegisterOptions(req, res) {
|
|
|
442
696
|
challenge: options.challenge,
|
|
443
697
|
type: "registration",
|
|
444
698
|
userId: userIdB64,
|
|
445
|
-
|
|
699
|
+
displayLabel,
|
|
446
700
|
expires: Date.now() + 120000,
|
|
447
701
|
};
|
|
448
702
|
|
|
@@ -495,8 +749,16 @@ async function handleRegisterVerify(req, res) {
|
|
|
495
749
|
|
|
496
750
|
const { credential: cred, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
497
751
|
|
|
498
|
-
//
|
|
499
|
-
|
|
752
|
+
// Internal tenancy is the immutable WebAuthn user id. The user-entered
|
|
753
|
+
// display label is metadata only and never owns a relay namespace.
|
|
754
|
+
const agentId = accountTenantIdForUserId(stored.userId);
|
|
755
|
+
// credentialLabel matches the userName passed to
|
|
756
|
+
// generateRegistrationOptions in handleRegisterOptions, which is what
|
|
757
|
+
// iOS Passwords / 1Password show next to the saved passkey. The
|
|
758
|
+
// welcome view should display this, not agentId. Auth semantics are
|
|
759
|
+
// unchanged; only the user-facing label is aligned with the saved
|
|
760
|
+
// credential.
|
|
761
|
+
const credentialLabel = stored.displayLabel || ("user-" + stored.userId.slice(0, 8));
|
|
500
762
|
const apiKey = generateApiKey();
|
|
501
763
|
|
|
502
764
|
const entry = {
|
|
@@ -505,18 +767,32 @@ async function handleRegisterVerify(req, res) {
|
|
|
505
767
|
counter: cred.counter,
|
|
506
768
|
userId: stored.userId,
|
|
507
769
|
agentId,
|
|
770
|
+
handle: credentialLabel,
|
|
508
771
|
apiKey,
|
|
509
772
|
deviceType: credentialDeviceType,
|
|
510
773
|
backedUp: credentialBackedUp,
|
|
511
774
|
transports: credential.response?.transports || [],
|
|
512
775
|
createdAt: new Date().toISOString(),
|
|
513
776
|
};
|
|
514
|
-
|
|
515
|
-
|
|
777
|
+
try {
|
|
778
|
+
await savePasskey(entry);
|
|
779
|
+
await saveApiKey(apiKey, agentId, { handle: credentialLabel });
|
|
780
|
+
} catch (err) {
|
|
781
|
+
console.error("Persistence failure during passkey registration:", err.message);
|
|
782
|
+
json(res, 500, { error: "persistence_failure", error_description: "Could not persist credentials. Try again." });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
516
785
|
|
|
517
|
-
console.log("WebAuthn: registered passkey for
|
|
786
|
+
console.log("WebAuthn: registered passkey for tenant '" + agentId + "' handle '" + credentialLabel + "' (credId: " + cred.id.slice(0, 16) + "...)");
|
|
518
787
|
|
|
519
|
-
json(res, 200, {
|
|
788
|
+
json(res, 200, {
|
|
789
|
+
success: true,
|
|
790
|
+
agentId: credentialLabel,
|
|
791
|
+
tenantId: agentId,
|
|
792
|
+
apiKey,
|
|
793
|
+
credentialLabel,
|
|
794
|
+
codex_pair_presence_token: generateCodexPairPresenceToken(agentId),
|
|
795
|
+
});
|
|
520
796
|
}
|
|
521
797
|
|
|
522
798
|
// POST /webauthn/auth-options
|
|
@@ -602,12 +878,60 @@ async function handleAuthVerify(req, res) {
|
|
|
602
878
|
return;
|
|
603
879
|
}
|
|
604
880
|
|
|
605
|
-
|
|
606
|
-
|
|
881
|
+
// Persist new counter before mutating in-memory entry. updatePasskeyCounter
|
|
882
|
+
// performs the in-memory update only on success, so the in-memory counter
|
|
883
|
+
// stays consistent with the DB and replay protection holds across restarts.
|
|
884
|
+
try {
|
|
885
|
+
await updatePasskeyCounter(entry.credentialId, verification.authenticationInfo.newCounter);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
console.error("Persistence failure during passkey counter update:", err.message);
|
|
888
|
+
json(res, 500, { error: "persistence_failure", error_description: "Could not persist counter. Try again." });
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
let credentialLabel = entry.handle;
|
|
893
|
+
if (!credentialLabel && entry.agentId && entry.agentId.startsWith("passkey-")) {
|
|
894
|
+
credentialLabel = (typeof entry.userId === "string" && entry.userId.length >= 8)
|
|
895
|
+
? "user-" + entry.userId.slice(0, 8)
|
|
896
|
+
: entry.agentId;
|
|
897
|
+
} else if (!credentialLabel && !isInternalTenantId(entry.agentId)) {
|
|
898
|
+
credentialLabel = entry.agentId;
|
|
899
|
+
} else if (!credentialLabel && typeof entry.userId === "string" && entry.userId.length >= 8) {
|
|
900
|
+
credentialLabel = "user-" + entry.userId.slice(0, 8);
|
|
901
|
+
} else if (!credentialLabel) {
|
|
902
|
+
credentialLabel = "you";
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Recovery path: a passkey reloaded from Postgres after a restart may
|
|
906
|
+
// have entry.apiKey = null if no ApiKey row was found for its agent
|
|
907
|
+
// at boot. Mint a fresh ck- now so the login response always carries
|
|
908
|
+
// a usable token. Without this, the browser would store
|
|
909
|
+
// sessionStorage.wip_api_key = null and Remote Control would 401 on
|
|
910
|
+
// /bootstrap and /ws-ticket.
|
|
911
|
+
if (!entry.apiKey) {
|
|
912
|
+
const newKey = generateApiKey();
|
|
913
|
+
try {
|
|
914
|
+
await saveApiKey(newKey, entry.agentId, { handle: credentialLabel });
|
|
915
|
+
} catch (err) {
|
|
916
|
+
console.error("Persistence failure minting recovery key for tenant '" + entry.agentId + "':", err.message);
|
|
917
|
+
json(res, 500, { error: "persistence_failure", error_description: "Could not mint API key. Try again." });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
entry.apiKey = newKey;
|
|
921
|
+
entry.handle = credentialLabel;
|
|
922
|
+
console.log("WebAuthn: minted recovery key for tenant '" + entry.agentId + "' (key: " + newKey.slice(0, 6) + "...)");
|
|
923
|
+
}
|
|
607
924
|
|
|
608
|
-
console.log("WebAuthn: authenticated
|
|
925
|
+
console.log("WebAuthn: authenticated tenant '" + entry.agentId + "' handle '" + credentialLabel + "'");
|
|
609
926
|
|
|
610
|
-
json(res, 200, {
|
|
927
|
+
json(res, 200, {
|
|
928
|
+
success: true,
|
|
929
|
+
agentId: credentialLabel,
|
|
930
|
+
tenantId: entry.agentId,
|
|
931
|
+
apiKey: entry.apiKey,
|
|
932
|
+
credentialLabel,
|
|
933
|
+
codex_pair_presence_token: generateCodexPairPresenceToken(entry.agentId),
|
|
934
|
+
});
|
|
611
935
|
}
|
|
612
936
|
|
|
613
937
|
// ---------- Page handlers ----------
|
|
@@ -1018,19 +1342,18 @@ async function handleOAuthToken(req, res) {
|
|
|
1018
1342
|
}
|
|
1019
1343
|
}
|
|
1020
1344
|
|
|
1021
|
-
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
await saveApiKey(apiKey, agentId);
|
|
1345
|
+
const agentHandle = stored.agent_name || "oauth-user";
|
|
1346
|
+
const apiKey = generateApiKey();
|
|
1347
|
+
const agentId = oauthTenantIdForApiKey(apiKey);
|
|
1348
|
+
try {
|
|
1349
|
+
await saveApiKey(apiKey, agentId, { handle: agentHandle });
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
console.error("Persistence failure during OAuth token issuance:", err.message);
|
|
1352
|
+
json(res, 500, { error: "server_error", error_description: "Could not issue token. Try again." });
|
|
1353
|
+
return;
|
|
1031
1354
|
}
|
|
1032
1355
|
|
|
1033
|
-
console.log("OAuth: issued token for
|
|
1356
|
+
console.log("OAuth: issued token for tenant '" + agentId + "' handle '" + agentHandle + "' (key: " + apiKey.slice(0, 10) + "...)");
|
|
1034
1357
|
|
|
1035
1358
|
json(res, 200, {
|
|
1036
1359
|
access_token: apiKey,
|
|
@@ -1383,12 +1706,58 @@ function handleAgentAuthApprove(req, res) {
|
|
|
1383
1706
|
|
|
1384
1707
|
// ---------- QR Login (Chrome fallback) ----------
|
|
1385
1708
|
|
|
1709
|
+
// `next` whitelist for the QR login flow. Three shapes are allowed; each
|
|
1710
|
+
// land the user on a known phone-side surface after successful sign-in.
|
|
1711
|
+
// Anything else is silently dropped. `next` is NOT a general redirect
|
|
1712
|
+
// primitive.
|
|
1713
|
+
//
|
|
1714
|
+
// 1. PAIR_NEXT_REGEX: /pair/<CODE> using the daemon's real alphabet
|
|
1715
|
+
// (CODEX_PAIR_ALPHABET, length 6, L IS included; I/O/0/1 excluded).
|
|
1716
|
+
// See plan ai/product/plans-prds/codex-remote-control/
|
|
1717
|
+
// 2026-04-30--cc-mini--pair-via-login-qr-flow.md constraints C1,
|
|
1718
|
+
// C8, and round-5. Per C8 the URL fallback for this shape is
|
|
1719
|
+
// mobile-only (desktop must not become the pairing authority).
|
|
1720
|
+
//
|
|
1721
|
+
// 2. REMOTE_CONTROL_NEXT_REGEX: /codex-remote-control/<UUID> for the
|
|
1722
|
+
// Kaleidoscope phone-side remote-control thread surface. Standard
|
|
1723
|
+
// ?next semantics; allowed on both desktop and mobile (this is
|
|
1724
|
+
// navigation continuation, not authority transfer).
|
|
1725
|
+
//
|
|
1726
|
+
// 3. DEMO_NEXT_REGEX: /demo for the homepage CTA. Standard post-login
|
|
1727
|
+
// continuation; allowed on both desktop and mobile.
|
|
1728
|
+
const PAIR_NEXT_REGEX = /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
|
1729
|
+
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;
|
|
1730
|
+
const DEMO_NEXT_REGEX = /^\/demo$/;
|
|
1731
|
+
|
|
1732
|
+
function sanitizeCrcPairNext(raw) {
|
|
1733
|
+
if (typeof raw !== "string") return null;
|
|
1734
|
+
// Single decode; reject if a second decode would still differ.
|
|
1735
|
+
let decoded;
|
|
1736
|
+
try { decoded = decodeURIComponent(raw); } catch { return null; }
|
|
1737
|
+
// Catch double-encoded payloads.
|
|
1738
|
+
if (decoded !== raw && /%/.test(decoded)) return null;
|
|
1739
|
+
if (!PAIR_NEXT_REGEX.test(decoded) && !REMOTE_CONTROL_NEXT_REGEX.test(decoded) && !DEMO_NEXT_REGEX.test(decoded)) return null;
|
|
1740
|
+
return decoded;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1386
1743
|
// POST /api/qr-login ... create a QR login session
|
|
1387
1744
|
async function handleQrLoginStart(req, res) {
|
|
1388
1745
|
cleanupExpiredChallenges();
|
|
1389
1746
|
const body = await readBody(req).catch(() => ({}));
|
|
1390
1747
|
const handle = ((body && body.handle) || "").trim().toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 30);
|
|
1391
1748
|
const mode = ((body && body.mode) || "register") === "signin" ? "signin" : "register";
|
|
1749
|
+
// Validate `next` strictly. Invalid next is silently dropped, not
|
|
1750
|
+
// 400'd, so legacy callers still work.
|
|
1751
|
+
//
|
|
1752
|
+
// Only /pair/<CODE> next triggers pair-mode (C6 strip on desktop
|
|
1753
|
+
// status, C8 desktop-no-redirect, the "phone is the actor" model).
|
|
1754
|
+
// /codex-remote-control/<UUID> and /demo are normal post-login
|
|
1755
|
+
// continuations: desktop status returns the full login response
|
|
1756
|
+
// (apiKey, handle, next) so the desktop poll can authenticate and
|
|
1757
|
+
// redirect on its own. The phone also gets next via approve, so both
|
|
1758
|
+
// ends can act.
|
|
1759
|
+
const next = sanitizeCrcPairNext(body && body.next);
|
|
1760
|
+
const purpose = (next && PAIR_NEXT_REGEX.test(next)) ? "pair" : null;
|
|
1392
1761
|
const sessionId = randomUUID();
|
|
1393
1762
|
const loginUrl = ISSUER_URL + "/login?s=" + sessionId + "&m=" + mode + (handle ? "&h=" + encodeURIComponent(handle) : "");
|
|
1394
1763
|
const qrBuffer = await QRCode.toBuffer(loginUrl, { type: "png", width: 400, margin: 2 });
|
|
@@ -1399,8 +1768,10 @@ async function handleQrLoginStart(req, res) {
|
|
|
1399
1768
|
apiKey: null,
|
|
1400
1769
|
handle: handle || null,
|
|
1401
1770
|
expires: Date.now() + QR_LOGIN_EXPIRY_MS,
|
|
1771
|
+
purpose, // "pair" | null
|
|
1772
|
+
next: next || null, // sanitized `/pair/<CODE>`, `/codex-remote-control/<UUID>`, `/demo`, or null
|
|
1402
1773
|
};
|
|
1403
|
-
console.log("QR login: created session " + sessionId.slice(0, 8) + "...");
|
|
1774
|
+
console.log("QR login: created session " + sessionId.slice(0, 8) + "..." + (purpose === "pair" ? " (pair-mode)" : ""));
|
|
1404
1775
|
json(res, 200, { sessionId, qrUrl: "/api/qr-login/qr?s=" + sessionId });
|
|
1405
1776
|
}
|
|
1406
1777
|
|
|
@@ -1418,6 +1789,12 @@ function handleQrLoginQR(req, res) {
|
|
|
1418
1789
|
}
|
|
1419
1790
|
|
|
1420
1791
|
// GET /api/qr-login/status?s=XXX ... poll for completion
|
|
1792
|
+
//
|
|
1793
|
+
// Response shape depends on `purpose`:
|
|
1794
|
+
// - Pair-mode (purpose === "pair"): {status, agentId} only on approved.
|
|
1795
|
+
// NEVER returns apiKey or next to the desktop. Phone receives next via
|
|
1796
|
+
// /api/qr-login/approve. Per plan C6 round 4.
|
|
1797
|
+
// - Legacy login mode: {status, agentId, apiKey} on approved (unchanged).
|
|
1421
1798
|
function handleQrLoginStatus(req, res) {
|
|
1422
1799
|
const url = parseUrl(req.url);
|
|
1423
1800
|
const s = url.searchParams.get("s");
|
|
@@ -1427,7 +1804,29 @@ function handleQrLoginStatus(req, res) {
|
|
|
1427
1804
|
return;
|
|
1428
1805
|
}
|
|
1429
1806
|
if (entry.status === "approved") {
|
|
1430
|
-
|
|
1807
|
+
if (entry.purpose === "pair") {
|
|
1808
|
+
// Pair-mode (purpose === "pair", next === /pair/<CODE>):
|
|
1809
|
+
// desktop gets ONLY a display label. No apiKey. No next. Plan
|
|
1810
|
+
// C6 round 4. Desktop never becomes the pairing authority.
|
|
1811
|
+
json(res, 200, { status: "approved", agentId: entry.agentId });
|
|
1812
|
+
} else {
|
|
1813
|
+
// Legacy login mode OR standard login continuation
|
|
1814
|
+
// (purpose === null). Desktop gets full identity to render the
|
|
1815
|
+
// welcome view OR redirect to next on its own poll.
|
|
1816
|
+
// credentialLabel matches the saved-passkey label (see
|
|
1817
|
+
// register-verify / auth-verify). next is included only if a
|
|
1818
|
+
// sanitized non-pair-mode next was set on the session
|
|
1819
|
+
// (/codex-remote-control/<UUID> or /demo); legacy login sessions
|
|
1820
|
+
// without next get next === null.
|
|
1821
|
+
json(res, 200, {
|
|
1822
|
+
status: "approved",
|
|
1823
|
+
agentId: entry.agentId,
|
|
1824
|
+
tenantId: entry.tenantId || null,
|
|
1825
|
+
apiKey: entry.apiKey,
|
|
1826
|
+
credentialLabel: entry.credentialLabel || null,
|
|
1827
|
+
next: entry.next || null,
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1431
1830
|
delete qrLoginSessions[s]; // one-time use
|
|
1432
1831
|
} else {
|
|
1433
1832
|
json(res, 200, { status: "pending" });
|
|
@@ -1435,9 +1834,13 @@ function handleQrLoginStatus(req, res) {
|
|
|
1435
1834
|
}
|
|
1436
1835
|
|
|
1437
1836
|
// POST /api/qr-login/approve ... phone calls after passkey created
|
|
1837
|
+
//
|
|
1838
|
+
// In pair-mode, the response includes the sanitized `next` so the phone
|
|
1839
|
+
// can location.replace(next) into /pair/<CODE>. Legacy login mode returns
|
|
1840
|
+
// {ok: true} unchanged.
|
|
1438
1841
|
function handleQrLoginApprove(req, res) {
|
|
1439
1842
|
readBody(req).then(function(body) {
|
|
1440
|
-
const { sessionId, agentId, apiKey } = body || {};
|
|
1843
|
+
const { sessionId, agentId, apiKey, tenantId, credentialLabel } = body || {};
|
|
1441
1844
|
const entry = qrLoginSessions[sessionId];
|
|
1442
1845
|
if (!entry || Date.now() > entry.expires) {
|
|
1443
1846
|
json(res, 404, { error: "Session not found or expired" });
|
|
@@ -1450,8 +1853,24 @@ function handleQrLoginApprove(req, res) {
|
|
|
1450
1853
|
entry.status = "approved";
|
|
1451
1854
|
entry.agentId = agentId;
|
|
1452
1855
|
entry.apiKey = apiKey;
|
|
1453
|
-
|
|
1454
|
-
|
|
1856
|
+
const verifiedIdentity = identityForApiKey(apiKey);
|
|
1857
|
+
entry.tenantId = verifiedIdentity?.tenantId || (isInternalTenantId(tenantId) ? tenantId : null);
|
|
1858
|
+
// Phone-side passes the label it received from register-verify /
|
|
1859
|
+
// auth-verify so the desktop can show the same string the user
|
|
1860
|
+
// just saved on their phone. Optional for back-compat.
|
|
1861
|
+
entry.credentialLabel = (typeof credentialLabel === "string" && credentialLabel.length <= 64) ? credentialLabel : null;
|
|
1862
|
+
console.log("QR login: approved session " + sessionId.slice(0, 8) + "... for '" + agentId + "'" + (entry.purpose === "pair" ? " (pair-mode)" : (entry.next ? " (next=" + entry.next + ")" : "")));
|
|
1863
|
+
// Phone receives next on approve regardless of purpose, so the
|
|
1864
|
+
// phone can redirect to /pair/<CODE> (pair-mode, phone is the
|
|
1865
|
+
// actor) or a standard continuation such as
|
|
1866
|
+
// /codex-remote-control/<UUID> or /demo. Desktop's separate
|
|
1867
|
+
// behavior (strip vs full response) is handled in
|
|
1868
|
+
// handleQrLoginStatus.
|
|
1869
|
+
if (entry.next) {
|
|
1870
|
+
json(res, 200, { ok: true, next: entry.next });
|
|
1871
|
+
} else {
|
|
1872
|
+
json(res, 200, { ok: true });
|
|
1873
|
+
}
|
|
1455
1874
|
}).catch(function() {
|
|
1456
1875
|
json(res, 400, { error: "Invalid request" });
|
|
1457
1876
|
});
|
|
@@ -1460,32 +1879,77 @@ function handleQrLoginApprove(req, res) {
|
|
|
1460
1879
|
// ---------- Demo API handlers ----------
|
|
1461
1880
|
|
|
1462
1881
|
// ── Wallet tracking (per agent) ──
|
|
1463
|
-
const IMAGE_COST_CENTS = 1; // $0.01
|
|
1464
|
-
const INITIAL_BALANCE_CENTS =
|
|
1882
|
+
const IMAGE_COST_CENTS = 1; // $0.01, launch onboarding image-generation step
|
|
1883
|
+
const INITIAL_BALANCE_CENTS = 1000; // $10.00
|
|
1465
1884
|
|
|
1466
1885
|
// JSON fallback for wallets
|
|
1467
1886
|
const WALLET_FILE = join(dirname(fileURLToPath(import.meta.url)), "wallets.json");
|
|
1887
|
+
const DEMO_WALLET_RESET_MARKER_FILE = join(dirname(fileURLToPath(import.meta.url)), ".demo-wallet-reset-v0-4-87.json");
|
|
1468
1888
|
function loadWalletsFromFile() { try { return JSON.parse(readFileSync(WALLET_FILE, "utf8")); } catch { return {}; } }
|
|
1469
1889
|
function saveWalletsToFile(w) { try { writeFileSync(WALLET_FILE, JSON.stringify(w, null, 2) + "\n"); } catch {} }
|
|
1470
1890
|
|
|
1891
|
+
function walletUserIdForAgent(agentId) {
|
|
1892
|
+
return typeof agentId === "string" && agentId.startsWith("acct:") ? agentId.slice("acct:".length) : agentId;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function resetAndNormalizeWalletFileEntries(wallets) {
|
|
1896
|
+
const normalizedWallets = {};
|
|
1897
|
+
let resetCount = 0;
|
|
1898
|
+
for (const agentId of Object.keys(wallets)) {
|
|
1899
|
+
normalizedWallets[walletUserIdForAgent(agentId)] = INITIAL_BALANCE_CENTS;
|
|
1900
|
+
resetCount += 1;
|
|
1901
|
+
}
|
|
1902
|
+
return { wallets: normalizedWallets, count: resetCount };
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
async function resetExistingDemoWalletsToStarterBalanceOnce() {
|
|
1906
|
+
if (existsSync(DEMO_WALLET_RESET_MARKER_FILE)) return;
|
|
1907
|
+
let prismaResetCount = 0;
|
|
1908
|
+
let jsonResetCount = 0;
|
|
1909
|
+
if (usePrisma) {
|
|
1910
|
+
try {
|
|
1911
|
+
const result = await prisma.wallet.updateMany({ data: { balance: INITIAL_BALANCE_CENTS } });
|
|
1912
|
+
prismaResetCount = result.count || 0;
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
console.error("Demo wallet reset migration error:", err.message);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
const normalizedWalletFile = resetAndNormalizeWalletFileEntries(loadWalletsFromFile());
|
|
1918
|
+
jsonResetCount = normalizedWalletFile.count;
|
|
1919
|
+
saveWalletsToFile(normalizedWalletFile.wallets);
|
|
1920
|
+
try {
|
|
1921
|
+
writeFileSync(DEMO_WALLET_RESET_MARKER_FILE, JSON.stringify({
|
|
1922
|
+
resetAt: new Date().toISOString(),
|
|
1923
|
+
balance: INITIAL_BALANCE_CENTS,
|
|
1924
|
+
prismaCount: prismaResetCount,
|
|
1925
|
+
jsonCount: jsonResetCount,
|
|
1926
|
+
}, null, 2) + "\n");
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
console.error("Demo wallet reset marker write error:", err.message);
|
|
1929
|
+
}
|
|
1930
|
+
console.log("Demo wallet reset migration: set " + (prismaResetCount + jsonResetCount) + " existing wallet balance(s) to " + formatCents(INITIAL_BALANCE_CENTS));
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1471
1933
|
async function getBalance(agentId) {
|
|
1934
|
+
const walletUserId = walletUserIdForAgent(agentId);
|
|
1472
1935
|
if (usePrisma) {
|
|
1473
1936
|
try {
|
|
1474
|
-
const wallet = await prisma.wallet.findFirst({ where: { userId:
|
|
1937
|
+
const wallet = await prisma.wallet.findFirst({ where: { userId: walletUserId } });
|
|
1475
1938
|
return wallet ? wallet.balance : INITIAL_BALANCE_CENTS;
|
|
1476
1939
|
} catch {}
|
|
1477
1940
|
}
|
|
1478
1941
|
const w = loadWalletsFromFile();
|
|
1479
|
-
return w[
|
|
1942
|
+
return w[walletUserId] !== undefined ? w[walletUserId] : INITIAL_BALANCE_CENTS;
|
|
1480
1943
|
}
|
|
1481
1944
|
|
|
1482
1945
|
async function deductBalance(agentId, cents) {
|
|
1946
|
+
const walletUserId = walletUserIdForAgent(agentId);
|
|
1483
1947
|
if (usePrisma) {
|
|
1484
1948
|
try {
|
|
1485
|
-
let wallet = await prisma.wallet.findFirst({ where: { userId:
|
|
1949
|
+
let wallet = await prisma.wallet.findFirst({ where: { userId: walletUserId } });
|
|
1486
1950
|
if (!wallet) {
|
|
1487
1951
|
wallet = await prisma.wallet.create({
|
|
1488
|
-
data: { userId:
|
|
1952
|
+
data: { userId: walletUserId, balance: INITIAL_BALANCE_CENTS },
|
|
1489
1953
|
});
|
|
1490
1954
|
}
|
|
1491
1955
|
const newBalance = Math.max(0, wallet.balance - cents);
|
|
@@ -1497,13 +1961,515 @@ async function deductBalance(agentId, cents) {
|
|
|
1497
1961
|
}
|
|
1498
1962
|
// JSON fallback
|
|
1499
1963
|
const w = loadWalletsFromFile();
|
|
1500
|
-
if (w[
|
|
1501
|
-
w[
|
|
1964
|
+
if (w[walletUserId] === undefined) w[walletUserId] = INITIAL_BALANCE_CENTS;
|
|
1965
|
+
w[walletUserId] = Math.max(0, w[walletUserId] - cents);
|
|
1502
1966
|
saveWalletsToFile(w);
|
|
1503
|
-
return w[
|
|
1967
|
+
return w[walletUserId];
|
|
1504
1968
|
}
|
|
1505
1969
|
function formatCents(c) { return "$" + (c / 100).toFixed(2); }
|
|
1506
1970
|
|
|
1971
|
+
await resetExistingDemoWalletsToStarterBalanceOnce();
|
|
1972
|
+
|
|
1973
|
+
const LIVE_WALL_FILE = process.env.KALEIDOSCOPE_LIVE_WALL_FILE || join(__dirname, "kaleidoscope-live-wall.json");
|
|
1974
|
+
const LIVE_WALL_MEDIA_ROUTE = "/media/kaleidoscope/generated/";
|
|
1975
|
+
const LIVE_WALL_DEFAULT_MEDIA_DIR = DEV_MODE
|
|
1976
|
+
? join(__dirname, "media", "kaleidoscope", "generated")
|
|
1977
|
+
: "/var/www/wip.computer/public_html/media/kaleidoscope/generated";
|
|
1978
|
+
const LIVE_WALL_MEDIA_DIR = process.env.KALEIDOSCOPE_LIVE_WALL_MEDIA_DIR || LIVE_WALL_DEFAULT_MEDIA_DIR;
|
|
1979
|
+
const LIVE_WALL_MAX_IMAGE_BYTES = Math.max(1, parseInt(process.env.KALEIDOSCOPE_LIVE_WALL_MAX_IMAGE_BYTES || String(10 * 1024 * 1024), 10));
|
|
1980
|
+
const LIVE_WALL_FETCH_TIMEOUT_MS = Math.max(1_000, parseInt(process.env.KALEIDOSCOPE_LIVE_WALL_FETCH_TIMEOUT_MS || "15000", 10));
|
|
1981
|
+
const LIVE_WALL_LIMIT = 240;
|
|
1982
|
+
const KALEIDOSCOPE_PUBLIC_STATS_BASELINE = Object.freeze({
|
|
1983
|
+
timezone: "America/Los_Angeles",
|
|
1984
|
+
date: "2026-05-21",
|
|
1985
|
+
startsAt: "2026-05-21T07:00:00.000Z",
|
|
1986
|
+
counts: Object.freeze({
|
|
1987
|
+
keysCreated: 3,
|
|
1988
|
+
genericKaleidoscopes: 11,
|
|
1989
|
+
imageKaleidoscopes: 3,
|
|
1990
|
+
}),
|
|
1991
|
+
});
|
|
1992
|
+
const LIVE_WALL_SEED_SOURCES = [
|
|
1993
|
+
{
|
|
1994
|
+
sourceUrl: "https://imgen.x.ai/xai-imgen/xai-tmp-imgen-bd814982-276f-438d-bc3c-2ca817982aae.jpeg",
|
|
1995
|
+
createdAt: "2026-05-20T00:00:00.000Z",
|
|
1996
|
+
kind: "generic",
|
|
1997
|
+
},
|
|
1998
|
+
{
|
|
1999
|
+
sourceUrl: "https://imgen.x.ai/xai-imgen/xai-tmp-imgen-226e18a9-7721-4f6c-9ef2-3c26c0ff1293.jpeg",
|
|
2000
|
+
createdAt: "2026-05-20T00:00:01.000Z",
|
|
2001
|
+
kind: "generic",
|
|
2002
|
+
},
|
|
2003
|
+
{
|
|
2004
|
+
sourceUrl: "https://imgen.x.ai/xai-imgen/xai-tmp-imgen-b1b398b5-4982-4ed8-b583-170a4ae4e811.jpeg",
|
|
2005
|
+
createdAt: "2026-05-20T00:00:02.000Z",
|
|
2006
|
+
kind: "generic",
|
|
2007
|
+
},
|
|
2008
|
+
{
|
|
2009
|
+
sourceUrl: "https://imgen.x.ai/xai-imgen/xai-tmp-imgen-5505496e-c436-4047-ab81-630a22ec75ea.jpeg",
|
|
2010
|
+
createdAt: "2026-05-20T00:00:03.000Z",
|
|
2011
|
+
kind: "generic",
|
|
2012
|
+
},
|
|
2013
|
+
];
|
|
2014
|
+
let liveWallSeedBackfillAttempted = false;
|
|
2015
|
+
|
|
2016
|
+
function ensureLiveWallRuntimeStorage() {
|
|
2017
|
+
try {
|
|
2018
|
+
mkdirSync(dirname(LIVE_WALL_FILE), { recursive: true });
|
|
2019
|
+
mkdirSync(LIVE_WALL_MEDIA_DIR, { recursive: true });
|
|
2020
|
+
accessSync(dirname(LIVE_WALL_FILE), fsConstants.W_OK);
|
|
2021
|
+
accessSync(LIVE_WALL_MEDIA_DIR, fsConstants.W_OK);
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
console.error("FATAL: Kaleidoscope live wall storage is not writable:", err.message);
|
|
2024
|
+
process.exit(1);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
ensureLiveWallRuntimeStorage();
|
|
2029
|
+
|
|
2030
|
+
const LIVE_WALL_GENERIC_PROMPT = "Create an abstract kaleidoscope image with mirrored radial symmetry, analog film grain, warm color bleed, soft exposure, organic imperfections, and no text.";
|
|
2031
|
+
const LIVE_WALL_PHOTO_PROMPT = "Create an abstract kaleidoscope image from the colors, light, and shapes in the user's photo. Use mirrored radial symmetry, analog film grain, warm color bleed, soft exposure, organic imperfections, and no text.";
|
|
2032
|
+
const LIVE_WALL_IMAGE_PROMPT_PREFIX = "Create an abstract kaleidoscope image from these visual details in the user's photo: ";
|
|
2033
|
+
const LIVE_WALL_IMAGE_PROMPT_SUFFIX = ". Use mirrored radial symmetry, analog film grain, warm color bleed, soft exposure, organic imperfections, and no text.";
|
|
2034
|
+
|
|
2035
|
+
function classifyLiveWallPrompt(prompt) {
|
|
2036
|
+
if (prompt === LIVE_WALL_GENERIC_PROMPT) return "generic";
|
|
2037
|
+
if (prompt === LIVE_WALL_PHOTO_PROMPT) return "image";
|
|
2038
|
+
if (typeof prompt === "string"
|
|
2039
|
+
&& prompt.startsWith(LIVE_WALL_IMAGE_PROMPT_PREFIX)
|
|
2040
|
+
&& prompt.endsWith(LIVE_WALL_IMAGE_PROMPT_SUFFIX)) {
|
|
2041
|
+
return "image";
|
|
2042
|
+
}
|
|
2043
|
+
return null;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
function parseTimestampMs(value) {
|
|
2047
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
2048
|
+
const ms = Date.parse(value);
|
|
2049
|
+
return Number.isFinite(ms) ? ms : null;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function createdAtSinceBaseline(item) {
|
|
2053
|
+
const baselineMs = Date.parse(KALEIDOSCOPE_PUBLIC_STATS_BASELINE.startsAt);
|
|
2054
|
+
const createdAtMs = parseTimestampMs(item?.createdAt);
|
|
2055
|
+
return createdAtMs !== null && createdAtMs >= baselineMs;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
function isWipTestHandle(handle) {
|
|
2059
|
+
return typeof handle === "string" && handle.trim().toLowerCase().startsWith("wiptest-");
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
function publicKeyCreatedSinceBaseline(entry) {
|
|
2063
|
+
return createdAtSinceBaseline(entry) && !isWipTestHandle(entry?.handle);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
function latestCreatedAt(items) {
|
|
2067
|
+
let latestMs = null;
|
|
2068
|
+
let latest = null;
|
|
2069
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
2070
|
+
const ms = parseTimestampMs(item?.createdAt);
|
|
2071
|
+
if (ms === null) continue;
|
|
2072
|
+
if (latestMs === null || ms > latestMs) {
|
|
2073
|
+
latestMs = ms;
|
|
2074
|
+
latest = new Date(ms).toISOString();
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
return latest;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function countCreatedInLast24Hours(items, now = new Date()) {
|
|
2081
|
+
const nowMs = now.getTime();
|
|
2082
|
+
const windowStartMs = nowMs - (24 * 60 * 60 * 1000);
|
|
2083
|
+
return (Array.isArray(items) ? items : []).filter(item => {
|
|
2084
|
+
const ms = parseTimestampMs(item?.createdAt);
|
|
2085
|
+
return ms !== null && ms >= windowStartMs && ms <= nowMs;
|
|
2086
|
+
}).length;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function deriveKaleidoscopePublicStats({ images, passkeyEntries, now = new Date() }) {
|
|
2090
|
+
const safeImages = Array.isArray(images) ? images : [];
|
|
2091
|
+
const safePasskeys = Array.isArray(passkeyEntries) ? passkeyEntries : [];
|
|
2092
|
+
const postBaselineGeneric = safeImages
|
|
2093
|
+
.filter(item => item && isPublicWallImageUrl(item.url) && item.kind !== "image" && createdAtSinceBaseline(item))
|
|
2094
|
+
.length;
|
|
2095
|
+
const postBaselineImage = safeImages
|
|
2096
|
+
.filter(item => item && isPublicWallImageUrl(item.url) && item.kind === "image" && createdAtSinceBaseline(item))
|
|
2097
|
+
.length;
|
|
2098
|
+
const publicPasskeys = safePasskeys.filter(item => !isWipTestHandle(item?.handle));
|
|
2099
|
+
const postBaselineKeys = publicPasskeys.filter(createdAtSinceBaseline).length;
|
|
2100
|
+
const newestCreatedAt = latestCreatedAt([...safeImages, ...publicPasskeys]);
|
|
2101
|
+
|
|
2102
|
+
return {
|
|
2103
|
+
genericKaleidoscopes: KALEIDOSCOPE_PUBLIC_STATS_BASELINE.counts.genericKaleidoscopes + postBaselineGeneric,
|
|
2104
|
+
imageKaleidoscopes: KALEIDOSCOPE_PUBLIC_STATS_BASELINE.counts.imageKaleidoscopes + postBaselineImage,
|
|
2105
|
+
keysCreated: KALEIDOSCOPE_PUBLIC_STATS_BASELINE.counts.keysCreated + postBaselineKeys,
|
|
2106
|
+
publicWallImages: safeImages.filter(item => item && isPublicWallImageUrl(item.url)).length,
|
|
2107
|
+
baseline: KALEIDOSCOPE_PUBLIC_STATS_BASELINE,
|
|
2108
|
+
newSinceBaseline: {
|
|
2109
|
+
genericKaleidoscopes: postBaselineGeneric,
|
|
2110
|
+
imageKaleidoscopes: postBaselineImage,
|
|
2111
|
+
keysCreated: postBaselineKeys,
|
|
2112
|
+
total: postBaselineGeneric + postBaselineImage + postBaselineKeys,
|
|
2113
|
+
},
|
|
2114
|
+
last24Hours: {
|
|
2115
|
+
wallImages: countCreatedInLast24Hours(safeImages, now),
|
|
2116
|
+
keysCreated: countCreatedInLast24Hours(publicPasskeys, now),
|
|
2117
|
+
},
|
|
2118
|
+
lastCreated: newestCreatedAt,
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function loadLiveWallState() {
|
|
2123
|
+
try {
|
|
2124
|
+
const parsed = JSON.parse(readFileSync(LIVE_WALL_FILE, "utf8"));
|
|
2125
|
+
const saved = Array.isArray(parsed?.images) ? parsed.images : [];
|
|
2126
|
+
return {
|
|
2127
|
+
images: saved,
|
|
2128
|
+
};
|
|
2129
|
+
} catch {
|
|
2130
|
+
return {
|
|
2131
|
+
images: [],
|
|
2132
|
+
};
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function saveLiveWallState(state) {
|
|
2137
|
+
try {
|
|
2138
|
+
mkdirSync(dirname(LIVE_WALL_FILE), { recursive: true });
|
|
2139
|
+
writeFileSync(LIVE_WALL_FILE, JSON.stringify({
|
|
2140
|
+
images: Array.isArray(state?.images) ? state.images : [],
|
|
2141
|
+
}, null, 2) + "\n");
|
|
2142
|
+
} catch (err) {
|
|
2143
|
+
console.error("Kaleidoscope live wall save error:", err.message);
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function liveWallSourceHash(value) {
|
|
2148
|
+
return createHash("sha256").update(value).digest("hex");
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function publicLiveWallMediaUrl(filename) {
|
|
2152
|
+
return ISSUER_URL + LIVE_WALL_MEDIA_ROUTE + filename;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
function liveWallMediaFilenameFromUrl(value) {
|
|
2156
|
+
try {
|
|
2157
|
+
const url = new URL(value, ISSUER_URL);
|
|
2158
|
+
if (url.origin !== ISSUER_URL || !url.pathname.startsWith(LIVE_WALL_MEDIA_ROUTE)) return null;
|
|
2159
|
+
const filename = url.pathname.slice(LIVE_WALL_MEDIA_ROUTE.length);
|
|
2160
|
+
return /^[a-f0-9]{64}\.(jpg|png|webp)$/.test(filename) ? filename : null;
|
|
2161
|
+
} catch {
|
|
2162
|
+
return null;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function liveWallMediaFileExists(value) {
|
|
2167
|
+
const filename = liveWallMediaFilenameFromUrl(value);
|
|
2168
|
+
return Boolean(filename && existsSync(join(LIVE_WALL_MEDIA_DIR, filename)));
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
function liveWallContentTypeForFilename(filename) {
|
|
2172
|
+
if (filename.endsWith(".jpg")) return "image/jpeg";
|
|
2173
|
+
if (filename.endsWith(".png")) return "image/png";
|
|
2174
|
+
if (filename.endsWith(".webp")) return "image/webp";
|
|
2175
|
+
return null;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
function serveKaleidoscopeGeneratedMedia(path, res) {
|
|
2179
|
+
const filename = path.slice(LIVE_WALL_MEDIA_ROUTE.length);
|
|
2180
|
+
if (!/^[a-f0-9]{64}\.(jpg|png|webp)$/.test(filename)) {
|
|
2181
|
+
json(res, 404, { error: "Not found" });
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
try {
|
|
2185
|
+
const content = readFileSync(join(LIVE_WALL_MEDIA_DIR, filename));
|
|
2186
|
+
const contentType = liveWallContentTypeForFilename(filename) || "application/octet-stream";
|
|
2187
|
+
res.writeHead(200, {
|
|
2188
|
+
"Content-Type": contentType,
|
|
2189
|
+
"Content-Length": content.length,
|
|
2190
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
2191
|
+
});
|
|
2192
|
+
res.end(content);
|
|
2193
|
+
} catch {
|
|
2194
|
+
json(res, 404, { error: "Not found" });
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
function isLiveWallMediaUrl(value) {
|
|
2199
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
2200
|
+
try {
|
|
2201
|
+
const url = new URL(value, ISSUER_URL);
|
|
2202
|
+
return url.origin === ISSUER_URL && url.pathname.startsWith(LIVE_WALL_MEDIA_ROUTE);
|
|
2203
|
+
} catch {
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function isAllowedLiveWallSourceUrl(value) {
|
|
2209
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
2210
|
+
if (value.startsWith("data:")) return false;
|
|
2211
|
+
try {
|
|
2212
|
+
const url = new URL(value);
|
|
2213
|
+
return url.protocol === "https:" && url.hostname === "imgen.x.ai" && url.pathname.startsWith("/xai-imgen/");
|
|
2214
|
+
} catch {
|
|
2215
|
+
return false;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function isPublicWallImageUrl(value) {
|
|
2220
|
+
return isLiveWallMediaUrl(value) || isAllowedLiveWallSourceUrl(value);
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function normalizeLiveWallContentType(value) {
|
|
2224
|
+
const type = String(value || "").split(";")[0].trim().toLowerCase();
|
|
2225
|
+
if (type === "image/jpg") return "image/jpeg";
|
|
2226
|
+
return type;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function liveWallExtensionForContentType(contentType) {
|
|
2230
|
+
if (contentType === "image/jpeg") return "jpg";
|
|
2231
|
+
if (contentType === "image/png") return "png";
|
|
2232
|
+
if (contentType === "image/webp") return "webp";
|
|
2233
|
+
return null;
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
function liveWallBufferMatchesContentType(buffer, contentType) {
|
|
2237
|
+
if (!Buffer.isBuffer(buffer) || buffer.length < 12) return false;
|
|
2238
|
+
if (contentType === "image/jpeg") {
|
|
2239
|
+
return buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
|
2240
|
+
}
|
|
2241
|
+
if (contentType === "image/png") {
|
|
2242
|
+
return buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47
|
|
2243
|
+
&& buffer[4] === 0x0d && buffer[5] === 0x0a && buffer[6] === 0x1a && buffer[7] === 0x0a;
|
|
2244
|
+
}
|
|
2245
|
+
if (contentType === "image/webp") {
|
|
2246
|
+
return buffer.subarray(0, 4).toString("ascii") === "RIFF"
|
|
2247
|
+
&& buffer.subarray(8, 12).toString("ascii") === "WEBP";
|
|
2248
|
+
}
|
|
2249
|
+
return false;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
async function readLiveWallImageResponseBody(response) {
|
|
2253
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
2254
|
+
throw new Error("source image response body is not readable");
|
|
2255
|
+
}
|
|
2256
|
+
const reader = response.body.getReader();
|
|
2257
|
+
const chunks = [];
|
|
2258
|
+
let total = 0;
|
|
2259
|
+
|
|
2260
|
+
try {
|
|
2261
|
+
while (true) {
|
|
2262
|
+
const { value, done } = await reader.read();
|
|
2263
|
+
if (done) break;
|
|
2264
|
+
if (!value) continue;
|
|
2265
|
+
total += value.byteLength;
|
|
2266
|
+
if (total > LIVE_WALL_MAX_IMAGE_BYTES) {
|
|
2267
|
+
throw new Error("source image exceeds max byte size");
|
|
2268
|
+
}
|
|
2269
|
+
chunks.push(Buffer.from(value));
|
|
2270
|
+
}
|
|
2271
|
+
} finally {
|
|
2272
|
+
try { reader.releaseLock(); } catch {}
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
if (total < 1) throw new Error("source image byte size is invalid");
|
|
2276
|
+
return Buffer.concat(chunks, total);
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
async function archiveKaleidoscopeGeneratedImage(sourceUrl) {
|
|
2280
|
+
if (!isAllowedLiveWallSourceUrl(sourceUrl)) throw new Error("source image URL is not an allowed HTTPS image source");
|
|
2281
|
+
const source = new URL(sourceUrl);
|
|
2282
|
+
|
|
2283
|
+
const controller = new AbortController();
|
|
2284
|
+
const timeout = setTimeout(() => controller.abort(), LIVE_WALL_FETCH_TIMEOUT_MS);
|
|
2285
|
+
if (typeof timeout.unref === "function") timeout.unref();
|
|
2286
|
+
let imageRes;
|
|
2287
|
+
try {
|
|
2288
|
+
imageRes = await fetch(source.href, { redirect: "follow", signal: controller.signal });
|
|
2289
|
+
if (!imageRes.ok) throw new Error("source image fetch failed with HTTP " + imageRes.status);
|
|
2290
|
+
const finalUrl = new URL(imageRes.url || source.href);
|
|
2291
|
+
if (!isAllowedLiveWallSourceUrl(finalUrl.href)) throw new Error("source image redirected away from allowed image source");
|
|
2292
|
+
|
|
2293
|
+
const contentType = normalizeLiveWallContentType(imageRes.headers.get("content-type"));
|
|
2294
|
+
const ext = liveWallExtensionForContentType(contentType);
|
|
2295
|
+
if (!ext) throw new Error("source image content type is not an allowed raster image");
|
|
2296
|
+
|
|
2297
|
+
const contentLength = Number.parseInt(imageRes.headers.get("content-length") || "0", 10);
|
|
2298
|
+
if (Number.isFinite(contentLength) && contentLength > LIVE_WALL_MAX_IMAGE_BYTES) {
|
|
2299
|
+
throw new Error("source image exceeds max byte size");
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const buffer = await readLiveWallImageResponseBody(imageRes);
|
|
2303
|
+
if (!liveWallBufferMatchesContentType(buffer, contentType)) {
|
|
2304
|
+
throw new Error("source image bytes do not match content type");
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
const contentHash = createHash("sha256").update(buffer).digest("hex");
|
|
2308
|
+
const sourceUrlHash = liveWallSourceHash(source.href);
|
|
2309
|
+
const filename = contentHash + "." + ext;
|
|
2310
|
+
mkdirSync(LIVE_WALL_MEDIA_DIR, { recursive: true });
|
|
2311
|
+
const filePath = join(LIVE_WALL_MEDIA_DIR, filename);
|
|
2312
|
+
if (!existsSync(filePath)) writeFileSync(filePath, buffer);
|
|
2313
|
+
|
|
2314
|
+
return {
|
|
2315
|
+
url: publicLiveWallMediaUrl(filename),
|
|
2316
|
+
sourceProvider: "xai",
|
|
2317
|
+
sourceUrlHash,
|
|
2318
|
+
contentHash,
|
|
2319
|
+
contentType,
|
|
2320
|
+
byteLength: buffer.length,
|
|
2321
|
+
archivedAt: new Date().toISOString(),
|
|
2322
|
+
};
|
|
2323
|
+
} catch (err) {
|
|
2324
|
+
if (err?.name === "AbortError") throw new Error("source image fetch timed out");
|
|
2325
|
+
throw err;
|
|
2326
|
+
} finally {
|
|
2327
|
+
clearTimeout(timeout);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
function liveWallEntryFromArchived({ archived, createdAt, kind, id }) {
|
|
2332
|
+
return {
|
|
2333
|
+
id: id || randomUUID(),
|
|
2334
|
+
url: archived.url,
|
|
2335
|
+
createdAt: createdAt || new Date().toISOString(),
|
|
2336
|
+
kind: kind === "image" ? "image" : "generic",
|
|
2337
|
+
sourceProvider: archived.sourceProvider,
|
|
2338
|
+
sourceUrlHash: archived.sourceUrlHash,
|
|
2339
|
+
contentHash: archived.contentHash,
|
|
2340
|
+
contentType: archived.contentType,
|
|
2341
|
+
byteLength: archived.byteLength,
|
|
2342
|
+
archivedAt: archived.archivedAt,
|
|
2343
|
+
};
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
function liveWallEntrySourceHash(item) {
|
|
2347
|
+
if (!item || typeof item !== "object") return null;
|
|
2348
|
+
if (typeof item.sourceUrlHash === "string" && item.sourceUrlHash) return item.sourceUrlHash;
|
|
2349
|
+
if (isAllowedLiveWallSourceUrl(item.url)) return liveWallSourceHash(item.url);
|
|
2350
|
+
return null;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
async function ensureLiveWallSeedImages() {
|
|
2354
|
+
if (liveWallSeedBackfillAttempted) return;
|
|
2355
|
+
liveWallSeedBackfillAttempted = true;
|
|
2356
|
+
|
|
2357
|
+
const state = loadLiveWallState();
|
|
2358
|
+
const images = [];
|
|
2359
|
+
const existingSourceHashes = new Set();
|
|
2360
|
+
const existingUrls = new Set(images.map(item => item.url).filter(Boolean));
|
|
2361
|
+
let changed = false;
|
|
2362
|
+
|
|
2363
|
+
for (const item of state.images) {
|
|
2364
|
+
if (!item || typeof item.url !== "string") {
|
|
2365
|
+
changed = true;
|
|
2366
|
+
continue;
|
|
2367
|
+
}
|
|
2368
|
+
if (isLiveWallMediaUrl(item.url) && liveWallMediaFileExists(item.url)) {
|
|
2369
|
+
images.push(item);
|
|
2370
|
+
if (item.sourceUrlHash) existingSourceHashes.add(item.sourceUrlHash);
|
|
2371
|
+
existingUrls.add(item.url);
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
if (isAllowedLiveWallSourceUrl(item.url)) {
|
|
2375
|
+
try {
|
|
2376
|
+
const archived = await archiveKaleidoscopeGeneratedImage(item.url);
|
|
2377
|
+
if (!existingUrls.has(archived.url)) {
|
|
2378
|
+
images.push(liveWallEntryFromArchived({
|
|
2379
|
+
archived,
|
|
2380
|
+
createdAt: item.createdAt,
|
|
2381
|
+
kind: item.kind,
|
|
2382
|
+
id: item.id,
|
|
2383
|
+
}));
|
|
2384
|
+
existingUrls.add(archived.url);
|
|
2385
|
+
}
|
|
2386
|
+
existingSourceHashes.add(archived.sourceUrlHash);
|
|
2387
|
+
changed = true;
|
|
2388
|
+
} catch (err) {
|
|
2389
|
+
images.push(item);
|
|
2390
|
+
console.error("Kaleidoscope live wall registry image migration failed:", JSON.stringify({
|
|
2391
|
+
sourceUrlHash: liveWallSourceHash(item.url),
|
|
2392
|
+
message: err.message,
|
|
2393
|
+
}));
|
|
2394
|
+
}
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
changed = true;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
for (const seed of LIVE_WALL_SEED_SOURCES) {
|
|
2401
|
+
const sourceUrlHash = liveWallSourceHash(seed.sourceUrl);
|
|
2402
|
+
if (existingSourceHashes.has(sourceUrlHash)) continue;
|
|
2403
|
+
try {
|
|
2404
|
+
const archived = await archiveKaleidoscopeGeneratedImage(seed.sourceUrl);
|
|
2405
|
+
if (existingUrls.has(archived.url)) continue;
|
|
2406
|
+
images.push(liveWallEntryFromArchived({
|
|
2407
|
+
archived,
|
|
2408
|
+
createdAt: seed.createdAt,
|
|
2409
|
+
kind: seed.kind,
|
|
2410
|
+
}));
|
|
2411
|
+
existingSourceHashes.add(sourceUrlHash);
|
|
2412
|
+
existingUrls.add(archived.url);
|
|
2413
|
+
changed = true;
|
|
2414
|
+
} catch (err) {
|
|
2415
|
+
console.error("Kaleidoscope live wall seed archive failed:", JSON.stringify({
|
|
2416
|
+
sourceUrlHash,
|
|
2417
|
+
message: err.message,
|
|
2418
|
+
}));
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
if (changed) saveLiveWallState({ images: images.slice(0, LIVE_WALL_LIMIT) });
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
async function registerKaleidoscopeLiveWallEvent({ kind, imageUrl }) {
|
|
2426
|
+
if (kind !== "generic" && kind !== "image") return;
|
|
2427
|
+
let archived;
|
|
2428
|
+
try {
|
|
2429
|
+
archived = await archiveKaleidoscopeGeneratedImage(imageUrl);
|
|
2430
|
+
} catch (err) {
|
|
2431
|
+
console.error("Kaleidoscope live wall archive failed:", JSON.stringify({
|
|
2432
|
+
kind,
|
|
2433
|
+
message: err.message,
|
|
2434
|
+
}));
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
const state = loadLiveWallState();
|
|
2439
|
+
const mediaImages = state.images
|
|
2440
|
+
.filter(item => item && isLiveWallMediaUrl(item.url) && liveWallMediaFileExists(item.url))
|
|
2441
|
+
.filter(item => item.url !== archived.url && item.sourceUrlHash !== archived.sourceUrlHash);
|
|
2442
|
+
const rawImages = state.images
|
|
2443
|
+
.filter(item => item && isAllowedLiveWallSourceUrl(item.url))
|
|
2444
|
+
.filter(item => liveWallEntrySourceHash(item) !== archived.sourceUrlHash);
|
|
2445
|
+
const images = [
|
|
2446
|
+
liveWallEntryFromArchived({
|
|
2447
|
+
archived,
|
|
2448
|
+
createdAt: new Date().toISOString(),
|
|
2449
|
+
kind,
|
|
2450
|
+
}),
|
|
2451
|
+
...mediaImages,
|
|
2452
|
+
...rawImages,
|
|
2453
|
+
];
|
|
2454
|
+
saveLiveWallState({ images: images.slice(0, LIVE_WALL_LIMIT) });
|
|
2455
|
+
return archived.url;
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
async function handleKaleidoscopeLiveWall(req, res) {
|
|
2459
|
+
await ensureLiveWallSeedImages();
|
|
2460
|
+
const state = loadLiveWallState();
|
|
2461
|
+
const images = state.images
|
|
2462
|
+
.filter(item => item && isLiveWallMediaUrl(item.url) && liveWallMediaFileExists(item.url))
|
|
2463
|
+
.slice(0, LIVE_WALL_LIMIT);
|
|
2464
|
+
const stats = deriveKaleidoscopePublicStats({ images, passkeyEntries: passkeys });
|
|
2465
|
+
res.setHeader("Cache-Control", "no-store");
|
|
2466
|
+
json(res, 200, {
|
|
2467
|
+
count: images.length,
|
|
2468
|
+
stats,
|
|
2469
|
+
images: images.map(item => ({ url: item.url, createdAt: item.createdAt || null })),
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
|
|
1507
2473
|
// POST /demo/api/analyze-photo
|
|
1508
2474
|
// Sends a base64 image to OpenAI GPT-4o vision to extract colors/mood.
|
|
1509
2475
|
async function handleDemoAnalyzePhoto(req, res) {
|
|
@@ -1576,6 +2542,7 @@ async function handleDemoImagine(req, res) {
|
|
|
1576
2542
|
try {
|
|
1577
2543
|
const body = await readBody(req);
|
|
1578
2544
|
const prompt = body?.prompt || "kaleidoscope";
|
|
2545
|
+
const liveWallKind = classifyLiveWallPrompt(prompt);
|
|
1579
2546
|
|
|
1580
2547
|
const XAI_KEY = process.env.XAI_API_KEY || "";
|
|
1581
2548
|
if (!XAI_KEY) {
|
|
@@ -1583,34 +2550,57 @@ async function handleDemoImagine(req, res) {
|
|
|
1583
2550
|
return;
|
|
1584
2551
|
}
|
|
1585
2552
|
|
|
2553
|
+
const imageModel = process.env.XAI_IMAGE_MODEL || "grok-imagine-image-quality";
|
|
2554
|
+
const imageRequestBody = {
|
|
2555
|
+
model: imageModel,
|
|
2556
|
+
prompt: prompt,
|
|
2557
|
+
};
|
|
1586
2558
|
const grokRes = await fetch("https://api.x.ai/v1/images/generations", {
|
|
1587
2559
|
method: "POST",
|
|
1588
2560
|
headers: {
|
|
1589
2561
|
"Content-Type": "application/json",
|
|
1590
2562
|
"Authorization": "Bearer " + XAI_KEY,
|
|
1591
2563
|
},
|
|
1592
|
-
body: JSON.stringify(
|
|
1593
|
-
model: "grok-imagine-image",
|
|
1594
|
-
prompt: prompt,
|
|
1595
|
-
n: 1,
|
|
1596
|
-
}),
|
|
2564
|
+
body: JSON.stringify(imageRequestBody),
|
|
1597
2565
|
});
|
|
1598
2566
|
|
|
1599
|
-
const
|
|
1600
|
-
|
|
1601
|
-
|
|
2567
|
+
const grokText = await grokRes.text();
|
|
2568
|
+
let grokData;
|
|
2569
|
+
try {
|
|
2570
|
+
grokData = grokText ? JSON.parse(grokText) : {};
|
|
2571
|
+
} catch {
|
|
2572
|
+
grokData = { error: { message: "Non-JSON image API response" } };
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
if (!grokRes.ok || grokData.error) {
|
|
2576
|
+
const message = grokData.error?.message || grokRes.statusText || "Image generation failed";
|
|
2577
|
+
console.error("Demo imagine upstream error:", JSON.stringify({
|
|
2578
|
+
status: grokRes.status,
|
|
2579
|
+
model: imageModel,
|
|
2580
|
+
message,
|
|
2581
|
+
type: grokData.error?.type || null,
|
|
2582
|
+
code: grokData.error?.code || null,
|
|
2583
|
+
param: grokData.error?.param || null,
|
|
2584
|
+
}));
|
|
2585
|
+
json(res, 502, { error: message });
|
|
1602
2586
|
return;
|
|
1603
2587
|
}
|
|
1604
2588
|
|
|
1605
2589
|
const imageUrl = grokData.data?.[0]?.url;
|
|
1606
2590
|
if (!imageUrl) {
|
|
2591
|
+
console.error("Demo imagine upstream returned no image URL:", JSON.stringify({
|
|
2592
|
+
status: grokRes.status,
|
|
2593
|
+
model: imageModel,
|
|
2594
|
+
responseKeys: Object.keys(grokData || {}),
|
|
2595
|
+
}));
|
|
1607
2596
|
json(res, 502, { error: "No image returned" });
|
|
1608
2597
|
return;
|
|
1609
2598
|
}
|
|
1610
2599
|
|
|
1611
2600
|
const newBalance = await deductBalance(identity.agentId, IMAGE_COST_CENTS);
|
|
1612
|
-
|
|
1613
|
-
|
|
2601
|
+
const archivedImageUrl = await registerKaleidoscopeLiveWallEvent({ kind: liveWallKind, imageUrl });
|
|
2602
|
+
console.log("Demo: generated image for agent '" + identity.agentId + "' using " + imageModel + " (balance: " + formatCents(newBalance) + ")");
|
|
2603
|
+
json(res, 200, { url: archivedImageUrl || imageUrl, prompt: prompt, cost: formatCents(IMAGE_COST_CENTS), balance: formatCents(newBalance) });
|
|
1614
2604
|
} catch (err) {
|
|
1615
2605
|
console.error("Demo imagine error:", err.message);
|
|
1616
2606
|
json(res, 500, { error: "Internal error" });
|
|
@@ -1856,6 +2846,11 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1856
2846
|
|
|
1857
2847
|
// --- Shared assets (Kaleidoscope template system) ---
|
|
1858
2848
|
|
|
2849
|
+
if (req.method === "GET" && path.startsWith(LIVE_WALL_MEDIA_ROUTE)) {
|
|
2850
|
+
serveKaleidoscopeGeneratedMedia(path, res);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
1859
2854
|
if (req.method === "GET" && path.startsWith("/shared/")) {
|
|
1860
2855
|
const filePath = join(__dirname, path);
|
|
1861
2856
|
try {
|
|
@@ -1876,13 +2871,28 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1876
2871
|
}
|
|
1877
2872
|
|
|
1878
2873
|
if (req.method === "GET" && (path === "/login" || path === "/login/")) {
|
|
1879
|
-
//
|
|
2874
|
+
// Production /login owns its own file at app/kaleidoscope-login.html.
|
|
2875
|
+
//
|
|
2876
|
+
// Earlier this route served demo/login.html, which made production
|
|
2877
|
+
// auth depend on a file under demo/. That coupling is wrong: demo/
|
|
2878
|
+
// is the demo site's domain, not production. The canonical
|
|
2879
|
+
// Kaleidoscope login HTML now lives under app/, where production
|
|
2880
|
+
// owns it.
|
|
2881
|
+
//
|
|
2882
|
+
// Fallback chain (defense in depth):
|
|
2883
|
+
// 1. app/kaleidoscope-login.html ... canonical production file.
|
|
2884
|
+
// 2. demo/login.html ... legacy fallback during the
|
|
2885
|
+
// transition; will be removed in a follow-up once the
|
|
2886
|
+
// production file is verified live.
|
|
2887
|
+
// 3. handleLoginPage ... server-rendered last resort.
|
|
2888
|
+
//
|
|
2889
|
+
// /login/app continues to serve the developed app/login.html flow
|
|
2890
|
+
// (see the next handler).
|
|
1880
2891
|
try {
|
|
1881
|
-
const
|
|
2892
|
+
const html = readFileSync(join(__dirname, "app", "kaleidoscope-login.html"), "utf8");
|
|
1882
2893
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1883
|
-
res.end(
|
|
2894
|
+
res.end(html);
|
|
1884
2895
|
} catch {
|
|
1885
|
-
// Fallback to legacy demo login, then server-rendered.
|
|
1886
2896
|
try {
|
|
1887
2897
|
const legacy = readFileSync(join(__dirname, "demo", "login.html"), "utf8");
|
|
1888
2898
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
@@ -1894,8 +2904,51 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1894
2904
|
return;
|
|
1895
2905
|
}
|
|
1896
2906
|
|
|
2907
|
+
if (req.method === "GET" && (path === "/login/app" || path === "/login/app/")) {
|
|
2908
|
+
// Explicit non-primary route for the app/login.html flow (the
|
|
2909
|
+
// newer two-path "this device or QR-from-phone" copy). This
|
|
2910
|
+
// exists so the developed flow stays reachable without hijacking
|
|
2911
|
+
// /login. If app/login.html is not present, return 404 rather
|
|
2912
|
+
// than silently falling back to the canonical /login page.
|
|
2913
|
+
try {
|
|
2914
|
+
const loginHtml = readFileSync(join(__dirname, "app", "login.html"), "utf8");
|
|
2915
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2916
|
+
res.end(loginHtml);
|
|
2917
|
+
} catch {
|
|
2918
|
+
json(res, 404, { error: "Not found" });
|
|
2919
|
+
}
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
1897
2923
|
// --- Legal pages ---
|
|
1898
2924
|
|
|
2925
|
+
if (req.method === "GET" && path === "/legal/legal.css") {
|
|
2926
|
+
try {
|
|
2927
|
+
const css = readFileSync(join(__dirname, "legal", "legal.css"), "utf8");
|
|
2928
|
+
res.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
|
|
2929
|
+
res.end(css);
|
|
2930
|
+
} catch { json(res, 404, { error: "Not found" }); }
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
if (req.method === "GET" && path === "/legal/legal-footer.js") {
|
|
2935
|
+
try {
|
|
2936
|
+
const js = readFileSync(join(__dirname, "legal", "legal-footer.js"), "utf8");
|
|
2937
|
+
res.writeHead(200, { "Content-Type": "text/javascript; charset=utf-8" });
|
|
2938
|
+
res.end(js);
|
|
2939
|
+
} catch { json(res, 404, { error: "Not found" }); }
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
if (req.method === "GET" && (path === "/legal/privacy/" || path === "/legal/privacy")) {
|
|
2944
|
+
try {
|
|
2945
|
+
const html = readFileSync(join(__dirname, "legal", "privacy", "index.html"), "utf8");
|
|
2946
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2947
|
+
res.end(html);
|
|
2948
|
+
} catch { json(res, 404, { error: "Not found" }); }
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
1899
2952
|
if (req.method === "GET" && (path === "/legal/privacy/en-ww/" || path === "/legal/privacy/en-ww")) {
|
|
1900
2953
|
try {
|
|
1901
2954
|
const html = readFileSync(join(__dirname, "legal", "privacy", "en-ww", "index.html"), "utf8");
|
|
@@ -1914,24 +2967,37 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1914
2967
|
return;
|
|
1915
2968
|
}
|
|
1916
2969
|
|
|
2970
|
+
if (req.method === "GET" && (path === "/legal/internet-services/kaleidoscope/" || path === "/legal/internet-services/kaleidoscope")) {
|
|
2971
|
+
try {
|
|
2972
|
+
const html = readFileSync(join(__dirname, "legal", "internet-services", "kaleidoscope", "index.html"), "utf8");
|
|
2973
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2974
|
+
res.end(html);
|
|
2975
|
+
} catch { json(res, 404, { error: "Not found" }); }
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
|
|
1917
2979
|
// --- WebAuthn API ---
|
|
1918
2980
|
|
|
1919
2981
|
if (req.method === "POST" && path === "/webauthn/register-options") {
|
|
2982
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1920
2983
|
await handleRegisterOptions(req, res);
|
|
1921
2984
|
return;
|
|
1922
2985
|
}
|
|
1923
2986
|
|
|
1924
2987
|
if (req.method === "POST" && path === "/webauthn/register-verify") {
|
|
2988
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1925
2989
|
await handleRegisterVerify(req, res);
|
|
1926
2990
|
return;
|
|
1927
2991
|
}
|
|
1928
2992
|
|
|
1929
2993
|
if (req.method === "POST" && path === "/webauthn/auth-options") {
|
|
2994
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1930
2995
|
await handleAuthOptions(req, res);
|
|
1931
2996
|
return;
|
|
1932
2997
|
}
|
|
1933
2998
|
|
|
1934
2999
|
if (req.method === "POST" && path === "/webauthn/auth-verify") {
|
|
3000
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1935
3001
|
await handleAuthVerify(req, res);
|
|
1936
3002
|
return;
|
|
1937
3003
|
}
|
|
@@ -1949,6 +3015,7 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1949
3015
|
}
|
|
1950
3016
|
|
|
1951
3017
|
if (req.method === "POST" && path === "/oauth/register") {
|
|
3018
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1952
3019
|
await handleOAuthRegister(req, res);
|
|
1953
3020
|
return;
|
|
1954
3021
|
}
|
|
@@ -1959,11 +3026,13 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1959
3026
|
}
|
|
1960
3027
|
|
|
1961
3028
|
if (req.method === "POST" && path === "/oauth/authorize/submit") {
|
|
3029
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1962
3030
|
await handleOAuthAuthorizeSubmit(req, res);
|
|
1963
3031
|
return;
|
|
1964
3032
|
}
|
|
1965
3033
|
|
|
1966
3034
|
if (req.method === "POST" && path === "/oauth/token") {
|
|
3035
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1967
3036
|
await handleOAuthToken(req, res);
|
|
1968
3037
|
return;
|
|
1969
3038
|
}
|
|
@@ -1971,21 +3040,25 @@ const httpServer = createServer(async (req, res) => {
|
|
|
1971
3040
|
// --- Agent QR Auth ---
|
|
1972
3041
|
|
|
1973
3042
|
if (req.method === "GET" && path === "/demo/api/agent-auth") {
|
|
3043
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
1974
3044
|
await handleAgentAuthStart(req, res);
|
|
1975
3045
|
return;
|
|
1976
3046
|
}
|
|
1977
3047
|
|
|
1978
3048
|
if (req.method === "GET" && path === "/demo/api/agent-auth/qr") {
|
|
3049
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
1979
3050
|
handleAgentAuthQR(req, res);
|
|
1980
3051
|
return;
|
|
1981
3052
|
}
|
|
1982
3053
|
|
|
1983
3054
|
if (req.method === "GET" && path === "/demo/api/agent-auth/status") {
|
|
3055
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
1984
3056
|
handleAgentAuthStatus(req, res);
|
|
1985
3057
|
return;
|
|
1986
3058
|
}
|
|
1987
3059
|
|
|
1988
3060
|
if (req.method === "POST" && path === "/demo/api/agent-auth/approve") {
|
|
3061
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
1989
3062
|
handleAgentAuthApprove(req, res);
|
|
1990
3063
|
return;
|
|
1991
3064
|
}
|
|
@@ -2017,21 +3090,25 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2017
3090
|
// --- QR Login (Chrome fallback) ---
|
|
2018
3091
|
|
|
2019
3092
|
if (req.method === "POST" && path === "/api/qr-login") {
|
|
3093
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2020
3094
|
await handleQrLoginStart(req, res);
|
|
2021
3095
|
return;
|
|
2022
3096
|
}
|
|
2023
3097
|
|
|
2024
3098
|
if (req.method === "GET" && path === "/api/qr-login/qr") {
|
|
3099
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2025
3100
|
handleQrLoginQR(req, res);
|
|
2026
3101
|
return;
|
|
2027
3102
|
}
|
|
2028
3103
|
|
|
2029
3104
|
if (req.method === "GET" && path === "/api/qr-login/status") {
|
|
3105
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2030
3106
|
handleQrLoginStatus(req, res);
|
|
2031
3107
|
return;
|
|
2032
3108
|
}
|
|
2033
3109
|
|
|
2034
3110
|
if (req.method === "POST" && path === "/api/qr-login/approve") {
|
|
3111
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
2035
3112
|
handleQrLoginApprove(req, res);
|
|
2036
3113
|
return;
|
|
2037
3114
|
}
|
|
@@ -2045,6 +3122,12 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2045
3122
|
return;
|
|
2046
3123
|
}
|
|
2047
3124
|
|
|
3125
|
+
if (req.method === "GET" && path === "/demo/api/kaleidoscope-live-wall") {
|
|
3126
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
3127
|
+
await handleKaleidoscopeLiveWall(req, res);
|
|
3128
|
+
return;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
2048
3131
|
if (req.method === "POST" && path === "/demo/api/analyze-photo") {
|
|
2049
3132
|
await handleDemoAnalyzePhoto(req, res);
|
|
2050
3133
|
return;
|
|
@@ -2094,32 +3177,38 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2094
3177
|
// --- Codex Relay (codex-daemon ↔ phone) ---
|
|
2095
3178
|
|
|
2096
3179
|
if (req.method === "POST" && path === "/api/codex-relay/pair-init") {
|
|
3180
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2097
3181
|
await handleCodexPairInit(req, res);
|
|
2098
3182
|
return;
|
|
2099
3183
|
}
|
|
2100
3184
|
|
|
2101
3185
|
if (req.method === "GET" && path.startsWith("/api/codex-relay/pair-status/")) {
|
|
3186
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2102
3187
|
handleCodexPairStatus(req, res, path.slice("/api/codex-relay/pair-status/".length));
|
|
2103
3188
|
return;
|
|
2104
3189
|
}
|
|
2105
3190
|
|
|
2106
3191
|
if (req.method === "POST" && path === "/api/codex-relay/pair-complete") {
|
|
3192
|
+
if (!applyRateLimit(req, res, "validate")) return;
|
|
2107
3193
|
await handleCodexPairComplete(req, res);
|
|
2108
3194
|
return;
|
|
2109
3195
|
}
|
|
2110
3196
|
|
|
2111
3197
|
if (req.method === "GET" && path === "/api/codex-relay/state") {
|
|
3198
|
+
if (!applyRateLimit(req, res, "status")) return;
|
|
2112
3199
|
handleCodexRelayState(req, res);
|
|
2113
3200
|
return;
|
|
2114
3201
|
}
|
|
2115
3202
|
|
|
2116
3203
|
if (req.method === "GET" && path.startsWith("/api/codex-relay/bootstrap/")) {
|
|
3204
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2117
3205
|
const tid = decodeURIComponent(path.slice("/api/codex-relay/bootstrap/".length));
|
|
2118
3206
|
handleCodexBootstrap(req, res, tid);
|
|
2119
3207
|
return;
|
|
2120
3208
|
}
|
|
2121
3209
|
|
|
2122
3210
|
if (req.method === "POST" && path === "/api/codex-relay/ws-ticket") {
|
|
3211
|
+
if (!applyRateLimit(req, res, "mint")) return;
|
|
2123
3212
|
await handleCodexWsTicket(req, res);
|
|
2124
3213
|
return;
|
|
2125
3214
|
}
|
|
@@ -2131,6 +3220,13 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2131
3220
|
return;
|
|
2132
3221
|
}
|
|
2133
3222
|
|
|
3223
|
+
// /pair/<CODE> ... URL-first pair flow. Per plan C1 + round 5: real daemon
|
|
3224
|
+
// alphabet [ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}, length 6, L IS included.
|
|
3225
|
+
if (req.method === "GET" && /^\/pair\/[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(path)) {
|
|
3226
|
+
serveAppFile(res, "pair.html");
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
2134
3230
|
// /:handle/codex-remote-control/:threadId
|
|
2135
3231
|
const remoteControlMatch = path.match(/^\/([^/]+)\/codex-remote-control\/([^/]+)\/?$/);
|
|
2136
3232
|
if (req.method === "GET" && remoteControlMatch) {
|
|
@@ -2151,21 +3247,25 @@ const httpServer = createServer(async (req, res) => {
|
|
|
2151
3247
|
// ---------- Codex Relay (codex-daemon ↔ phone) ----------
|
|
2152
3248
|
//
|
|
2153
3249
|
// In-memory state. Pairing codes: 6-char, 5-min TTL. Daemons indexed by
|
|
2154
|
-
//
|
|
2155
|
-
// indexed by `
|
|
2156
|
-
// the daemon and
|
|
2157
|
-
//
|
|
3250
|
+
// immutable tenant id (one daemon per tenant; new daemon kicks the old one). Web clients
|
|
3251
|
+
// indexed by `tenantId:threadId`. The server is a transport relay between
|
|
3252
|
+
// the daemon and matching web client(s). The relay injects the route thread
|
|
3253
|
+
// into the E2EE handshake, and the daemon enforces that bound route after
|
|
3254
|
+
// decrypting session commands.
|
|
2158
3255
|
|
|
2159
3256
|
const CODEX_PAIR_EXPIRY_MS = 5 * 60 * 1000;
|
|
3257
|
+
const CODEX_PAIR_PRESENCE_TTL_MS = 2 * 60 * 1000;
|
|
2160
3258
|
const CODEX_PAIR_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
2161
|
-
const codexPairings = {}; // pairing_id -> { code, status, expires, daemon_info, apiKey?, agentId?, daemon_public_key?, crypto_versions? }
|
|
3259
|
+
const codexPairings = {}; // pairing_id -> { code, status, expires, poll_token, poll_token_used?, daemon_info, apiKey?, agentId?, handle?, daemon_public_key?, crypto_versions? }
|
|
2162
3260
|
const codexPairingByCode = {}; // code -> pairing_id (only while pending)
|
|
3261
|
+
const codexPairPresenceTokens = new Map(); // token -> { agentId, expires, used }
|
|
2163
3262
|
const codexDaemons = new Map(); // agentId -> ws
|
|
2164
|
-
const codexWebClients = new Map(); // `${agentId}:${threadId}` -> ws
|
|
3263
|
+
const codexWebClients = new Map(); // `${agentId}:${threadId}` -> Set<ws>
|
|
3264
|
+
const codexE2eeSessionRoutes = new Map(); // `${agentId}:${e2eeSession}` -> { threadId, webKey, ws }
|
|
2165
3265
|
|
|
2166
3266
|
// E2EE substrate (Phase 2.5).
|
|
2167
3267
|
//
|
|
2168
|
-
//
|
|
3268
|
+
// codexDaemonPubkeyRegistry: per tenant id, the most recently paired daemon's
|
|
2169
3269
|
// public key (P-256 SPKI base64url) + supported crypto versions +
|
|
2170
3270
|
// registration timestamp. This is what the browser fetches via
|
|
2171
3271
|
// bootstrap before opening an encrypted session.
|
|
@@ -2174,10 +3274,103 @@ const codexWebClients = new Map(); // `${agentId}:${threadId}` -> ws
|
|
|
2174
3274
|
// ?token=ck-... in the browser WebSocket URL. Bound to a specific
|
|
2175
3275
|
// (agentId, threadId) so a leaked ticket cannot drive a different
|
|
2176
3276
|
// route, even by the same authenticated user.
|
|
2177
|
-
const codexDaemonPubkeys = new Map(); // agentId -> { pubkey, crypto_versions, registered_at }
|
|
2178
3277
|
const codexRelayTickets = new Map(); // ticket -> { agentId, threadId, expires, used }
|
|
2179
3278
|
const CODEX_RELAY_TICKET_TTL_MS = 60 * 1000; // 60s; browser must connect immediately
|
|
2180
3279
|
|
|
3280
|
+
const codexDaemonPubkeyRegistry = createCodexDaemonPubkeyRegistry({
|
|
3281
|
+
usePrisma,
|
|
3282
|
+
prisma,
|
|
3283
|
+
devMode: DEV_MODE,
|
|
3284
|
+
logger: console,
|
|
3285
|
+
});
|
|
3286
|
+
|
|
3287
|
+
await codexDaemonPubkeyRegistry.loadFromDb();
|
|
3288
|
+
|
|
3289
|
+
function codexRelayKey(agentId, id) {
|
|
3290
|
+
return agentId + ":" + id;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
function isCodexE2eeEnvelope(envelope) {
|
|
3294
|
+
return !!(envelope && typeof envelope.type === "string" && envelope.type.startsWith("e2ee."));
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
function registerCodexE2eeSessionRoute(agentId, e2eeSession, threadId, ws) {
|
|
3298
|
+
if (typeof e2eeSession !== "string" || !e2eeSession) return;
|
|
3299
|
+
if (typeof threadId !== "string" || !threadId) return;
|
|
3300
|
+
const webKey = codexRelayKey(agentId, threadId);
|
|
3301
|
+
codexE2eeSessionRoutes.set(codexRelayKey(agentId, e2eeSession), { threadId, webKey, ws });
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
function addCodexWebClient(webKey, ws) {
|
|
3305
|
+
let clients = codexWebClients.get(webKey);
|
|
3306
|
+
if (!clients) {
|
|
3307
|
+
clients = new Set();
|
|
3308
|
+
codexWebClients.set(webKey, clients);
|
|
3309
|
+
}
|
|
3310
|
+
clients.add(ws);
|
|
3311
|
+
return clients.size;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
function removeCodexWebClient(webKey, ws) {
|
|
3315
|
+
const clients = codexWebClients.get(webKey);
|
|
3316
|
+
if (!clients) return 0;
|
|
3317
|
+
clients.delete(ws);
|
|
3318
|
+
if (clients.size === 0) {
|
|
3319
|
+
codexWebClients.delete(webKey);
|
|
3320
|
+
return 0;
|
|
3321
|
+
}
|
|
3322
|
+
return clients.size;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
function openCodexWebClientsForKey(webKey) {
|
|
3326
|
+
const clients = codexWebClients.get(webKey);
|
|
3327
|
+
if (!clients) return [];
|
|
3328
|
+
return [...clients].filter((webWs) => webWs.readyState === webWs.OPEN);
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
function resolveCodexWebClientsForDaemonFrame(agentId, routeId) {
|
|
3332
|
+
const routed = codexE2eeSessionRoutes.get(codexRelayKey(agentId, routeId));
|
|
3333
|
+
if (routed && routed.ws && routed.ws.readyState === routed.ws.OPEN) return [routed.ws];
|
|
3334
|
+
return openCodexWebClientsForKey(codexRelayKey(agentId, routeId));
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
function removeCodexE2eeRoutesForWeb(agentId, threadId, ws) {
|
|
3338
|
+
const webKey = codexRelayKey(agentId, threadId);
|
|
3339
|
+
for (const [routeKey, route] of codexE2eeSessionRoutes) {
|
|
3340
|
+
if (route.webKey === webKey && (!ws || route.ws === ws)) {
|
|
3341
|
+
codexE2eeSessionRoutes.delete(routeKey);
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
function invalidateCodexBrowserSessionsForAgent(agentId, reason) {
|
|
3347
|
+
const prefix = agentId + ":";
|
|
3348
|
+
let closed = 0;
|
|
3349
|
+
for (const routeKey of [...codexE2eeSessionRoutes.keys()]) {
|
|
3350
|
+
if (routeKey.startsWith(prefix)) codexE2eeSessionRoutes.delete(routeKey);
|
|
3351
|
+
}
|
|
3352
|
+
for (const [webKey, clients] of [...codexWebClients]) {
|
|
3353
|
+
if (!webKey.startsWith(prefix)) continue;
|
|
3354
|
+
for (const webWs of clients) {
|
|
3355
|
+
if (webWs.readyState === webWs.OPEN) {
|
|
3356
|
+
closed += 1;
|
|
3357
|
+
try { webWs.close(4001, reason); } catch {}
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
codexWebClients.delete(webKey);
|
|
3361
|
+
}
|
|
3362
|
+
return closed;
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
function logCodexWsLimit({ agentId, threadId, connectionId, reason }) {
|
|
3366
|
+
console.warn(formatCodexWsLimitLog({ agentId, threadId, connectionId, reason }));
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function closeCodexWsForLimit(ws, { agentId, threadId, connectionId }, decision) {
|
|
3370
|
+
logCodexWsLimit({ agentId, threadId, connectionId, reason: decision.reason });
|
|
3371
|
+
try { ws.close(decision.code, decision.reason); } catch {}
|
|
3372
|
+
}
|
|
3373
|
+
|
|
2181
3374
|
function generateCodexPairingCode() {
|
|
2182
3375
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
2183
3376
|
let code = "";
|
|
@@ -2190,16 +3383,58 @@ function generateCodexPairingCode() {
|
|
|
2190
3383
|
throw new Error("Could not generate unique codex-relay pairing code");
|
|
2191
3384
|
}
|
|
2192
3385
|
|
|
3386
|
+
function generateCodexPairPollToken() {
|
|
3387
|
+
return "ppt_" + randomBytes(32).toString("base64url");
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
function cleanupCodexPairPresenceTokens() {
|
|
3391
|
+
const now = Date.now();
|
|
3392
|
+
for (const [token, entry] of codexPairPresenceTokens) {
|
|
3393
|
+
if (!entry || entry.used || now > entry.expires) codexPairPresenceTokens.delete(token);
|
|
3394
|
+
}
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
function generateCodexPairPresenceToken(agentId) {
|
|
3398
|
+
cleanupCodexPairPresenceTokens();
|
|
3399
|
+
if (typeof agentId !== "string" || !agentId) return null;
|
|
3400
|
+
const token = "cpt_" + randomBytes(32).toString("base64url");
|
|
3401
|
+
codexPairPresenceTokens.set(token, {
|
|
3402
|
+
agentId,
|
|
3403
|
+
expires: Date.now() + CODEX_PAIR_PRESENCE_TTL_MS,
|
|
3404
|
+
used: false,
|
|
3405
|
+
});
|
|
3406
|
+
return token;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
function consumeCodexPairPresenceToken(token, agentId) {
|
|
3410
|
+
cleanupCodexPairPresenceTokens();
|
|
3411
|
+
const entry = codexPairPresenceTokens.get(token);
|
|
3412
|
+
if (!entry || entry.used || entry.agentId !== agentId || Date.now() > entry.expires) return false;
|
|
3413
|
+
entry.used = true;
|
|
3414
|
+
codexPairPresenceTokens.delete(token);
|
|
3415
|
+
return true;
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3418
|
+
function getBearerToken(req) {
|
|
3419
|
+
const auth = req.headers["authorization"];
|
|
3420
|
+
if (typeof auth !== "string" || !auth.startsWith("Bearer ")) return null;
|
|
3421
|
+
const token = auth.slice(7).trim();
|
|
3422
|
+
return token || null;
|
|
3423
|
+
}
|
|
3424
|
+
|
|
2193
3425
|
async function handleCodexPairInit(req, res) {
|
|
2194
3426
|
let body = {};
|
|
2195
3427
|
try { body = (await readBody(req)) || {}; } catch {}
|
|
2196
3428
|
const code = generateCodexPairingCode();
|
|
2197
3429
|
const pairingId = randomUUID();
|
|
3430
|
+
const pollToken = generateCodexPairPollToken();
|
|
2198
3431
|
const expires = Date.now() + CODEX_PAIR_EXPIRY_MS;
|
|
2199
3432
|
codexPairings[pairingId] = {
|
|
2200
3433
|
code,
|
|
2201
3434
|
status: "pending",
|
|
2202
3435
|
expires,
|
|
3436
|
+
poll_token: pollToken,
|
|
3437
|
+
poll_token_used: false,
|
|
2203
3438
|
daemon_info: {
|
|
2204
3439
|
hostname: typeof body.hostname === "string" ? body.hostname.slice(0, 64) : null,
|
|
2205
3440
|
platform: typeof body.platform === "string" ? body.platform.slice(0, 32) : null,
|
|
@@ -2216,10 +3451,14 @@ async function handleCodexPairInit(req, res) {
|
|
|
2216
3451
|
: null,
|
|
2217
3452
|
};
|
|
2218
3453
|
codexPairingByCode[code] = pairingId;
|
|
3454
|
+
// Per plan: web_url goes through /login first so the existing Kaleidoscope
|
|
3455
|
+
// QR + phone-passkey ceremony handles auth. After phone passkey, phone
|
|
3456
|
+
// (not desktop) redirects to /pair/<CODE> and completes pair-complete.
|
|
2219
3457
|
json(res, 200, {
|
|
2220
3458
|
code,
|
|
2221
3459
|
pairing_id: pairingId,
|
|
2222
|
-
|
|
3460
|
+
pair_poll_token: pollToken,
|
|
3461
|
+
web_url: ISSUER_URL + "/login?next=" + encodeURIComponent("/pair/" + code),
|
|
2223
3462
|
expires_at: new Date(expires).toISOString(),
|
|
2224
3463
|
});
|
|
2225
3464
|
}
|
|
@@ -2227,12 +3466,25 @@ async function handleCodexPairInit(req, res) {
|
|
|
2227
3466
|
function handleCodexPairStatus(req, res, pairingId) {
|
|
2228
3467
|
const p = codexPairings[pairingId];
|
|
2229
3468
|
if (!p) { json(res, 404, { error: "pairing not found" }); return; }
|
|
2230
|
-
if (
|
|
3469
|
+
if (Date.now() > p.expires) {
|
|
2231
3470
|
p.status = "expired";
|
|
2232
3471
|
if (codexPairingByCode[p.code] === pairingId) delete codexPairingByCode[p.code];
|
|
3472
|
+
json(res, 401, { error: "pair_poll_token_expired" });
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
const pollToken = getBearerToken(req);
|
|
3476
|
+
if (!pollToken || pollToken !== p.poll_token || p.poll_token_used) {
|
|
3477
|
+
json(res, 401, { error: "invalid_pair_poll_token" });
|
|
3478
|
+
return;
|
|
2233
3479
|
}
|
|
2234
3480
|
if (p.status === "completed") {
|
|
2235
|
-
|
|
3481
|
+
p.poll_token_used = true;
|
|
3482
|
+
json(res, 200, {
|
|
3483
|
+
status: "completed",
|
|
3484
|
+
api_key: p.apiKey,
|
|
3485
|
+
handle: p.handle || p.agentId,
|
|
3486
|
+
replaced_daemon_key: !!p.replaced_daemon_key,
|
|
3487
|
+
});
|
|
2236
3488
|
} else {
|
|
2237
3489
|
json(res, 200, { status: p.status });
|
|
2238
3490
|
}
|
|
@@ -2245,6 +3497,14 @@ async function handleCodexPairComplete(req, res) {
|
|
|
2245
3497
|
try { body = await readBody(req); } catch { json(res, 400, { error: "bad request" }); return; }
|
|
2246
3498
|
const code = (body && typeof body.code === "string") ? body.code.trim().toUpperCase() : "";
|
|
2247
3499
|
if (!code) { json(res, 400, { error: "missing code" }); return; }
|
|
3500
|
+
// Defensive: reject codes that don't match the daemon's alphabet up front,
|
|
3501
|
+
// before the map lookup, so probe attempts get one uniform reject path
|
|
3502
|
+
// instead of leaking timing/shape info between "wrong-alphabet" and "valid
|
|
3503
|
+
// shape but unknown code." Per plan C3 + round 5.
|
|
3504
|
+
if (!/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/.test(code)) {
|
|
3505
|
+
json(res, 404, { error: "invalid or already-used code" });
|
|
3506
|
+
return;
|
|
3507
|
+
}
|
|
2248
3508
|
const pairingId = codexPairingByCode[code];
|
|
2249
3509
|
if (!pairingId) { json(res, 404, { error: "invalid or already-used code" }); return; }
|
|
2250
3510
|
const p = codexPairings[pairingId];
|
|
@@ -2252,30 +3512,51 @@ async function handleCodexPairComplete(req, res) {
|
|
|
2252
3512
|
json(res, 410, { error: "code expired or already used" });
|
|
2253
3513
|
return;
|
|
2254
3514
|
}
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
3515
|
+
const pairPresenceToken = body && typeof body.codex_pair_presence_token === "string"
|
|
3516
|
+
? body.codex_pair_presence_token
|
|
3517
|
+
: "";
|
|
3518
|
+
if (p.daemon_public_key && !consumeCodexPairPresenceToken(pairPresenceToken, identity.agentId)) {
|
|
3519
|
+
json(res, 403, {
|
|
3520
|
+
error: "fresh_presence_required",
|
|
3521
|
+
error_description: "Pairing this daemon requires a fresh passkey confirmation. Sign in again from the pair page.",
|
|
3522
|
+
});
|
|
3523
|
+
return;
|
|
3524
|
+
}
|
|
2258
3525
|
// Phase 2.5: register the daemon's E2EE public key against the
|
|
2259
|
-
// authenticated
|
|
2260
|
-
//
|
|
3526
|
+
// authenticated immutable tenant id. The display handle is returned
|
|
3527
|
+
// as metadata only.
|
|
3528
|
+
let daemonKeyResult = null;
|
|
2261
3529
|
if (p.daemon_public_key) {
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
3530
|
+
daemonKeyResult = await codexDaemonPubkeyRegistry.register(identity.agentId, p.daemon_public_key, p.crypto_versions, "pair-complete");
|
|
3531
|
+
if (daemonKeyResult?.replaced) {
|
|
3532
|
+
const closed = invalidateCodexBrowserSessionsForAgent(identity.agentId, "daemon key replaced");
|
|
3533
|
+
console.log(
|
|
3534
|
+
"codex-relay: replaced daemon E2EE key for tenant " + identity.agentId
|
|
3535
|
+
+ " old=" + daemonKeyResult.old_fingerprint
|
|
3536
|
+
+ " new=" + daemonKeyResult.new_fingerprint
|
|
3537
|
+
+ " closed_browser_sessions=" + closed
|
|
3538
|
+
);
|
|
3539
|
+
}
|
|
2268
3540
|
}
|
|
3541
|
+
p.status = "completed";
|
|
3542
|
+
p.apiKey = identity.apiKey;
|
|
3543
|
+
p.agentId = identity.agentId;
|
|
3544
|
+
p.handle = identity.handle;
|
|
3545
|
+
p.replaced_daemon_key = !!daemonKeyResult?.replaced;
|
|
2269
3546
|
delete codexPairingByCode[code];
|
|
2270
|
-
console.log("codex-relay: paired daemon for " + identity.agentId);
|
|
2271
|
-
json(res, 200, {
|
|
3547
|
+
console.log("codex-relay: paired daemon for tenant " + identity.agentId + " handle " + identity.handle);
|
|
3548
|
+
json(res, 200, {
|
|
3549
|
+
ok: true,
|
|
3550
|
+
handle: identity.handle,
|
|
3551
|
+
replaced_daemon_key: !!daemonKeyResult?.replaced,
|
|
3552
|
+
});
|
|
2272
3553
|
}
|
|
2273
3554
|
|
|
2274
3555
|
function handleCodexRelayState(req, res) {
|
|
2275
3556
|
const identity = authenticate(req);
|
|
2276
3557
|
if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
|
|
2277
3558
|
json(res, 200, {
|
|
2278
|
-
handle: identity.
|
|
3559
|
+
handle: identity.handle,
|
|
2279
3560
|
daemon_online: codexDaemons.has(identity.agentId),
|
|
2280
3561
|
});
|
|
2281
3562
|
}
|
|
@@ -2289,16 +3570,8 @@ function handleCodexBootstrap(req, res, threadId) {
|
|
|
2289
3570
|
if (!identity) { json(res, 401, { error: "Unauthorized" }); return; }
|
|
2290
3571
|
if (!threadId) { json(res, 400, { error: "missing threadId" }); return; }
|
|
2291
3572
|
const daemonOnline = codexDaemons.has(identity.agentId);
|
|
2292
|
-
const daemonKey =
|
|
2293
|
-
json(res, 200, {
|
|
2294
|
-
handle: identity.agentId,
|
|
2295
|
-
thread_id: threadId,
|
|
2296
|
-
daemon_online: daemonOnline,
|
|
2297
|
-
daemon_public_key: daemonKey ? daemonKey.pubkey : null,
|
|
2298
|
-
daemon_crypto_versions: daemonKey ? daemonKey.crypto_versions : null,
|
|
2299
|
-
supported_crypto_versions: ["e2ee-v1"],
|
|
2300
|
-
e2ee_available: !!daemonKey,
|
|
2301
|
-
});
|
|
3573
|
+
const daemonKey = codexDaemonPubkeyRegistry.get(identity.agentId);
|
|
3574
|
+
json(res, 200, buildCodexBootstrapPayload({ identity, threadId, daemonOnline, daemonKey }));
|
|
2302
3575
|
}
|
|
2303
3576
|
|
|
2304
3577
|
// POST /api/codex-relay/ws-ticket
|
|
@@ -2318,6 +3591,7 @@ async function handleCodexWsTicket(req, res) {
|
|
|
2318
3591
|
const expires = Date.now() + CODEX_RELAY_TICKET_TTL_MS;
|
|
2319
3592
|
codexRelayTickets.set(ticket, {
|
|
2320
3593
|
agentId: identity.agentId,
|
|
3594
|
+
handle: identity.handle,
|
|
2321
3595
|
threadId,
|
|
2322
3596
|
expires,
|
|
2323
3597
|
used: false,
|
|
@@ -2342,7 +3616,7 @@ function consumeCodexRelayTicket(ticket, threadId) {
|
|
|
2342
3616
|
if (Date.now() > entry.expires) { codexRelayTickets.delete(ticket); return null; }
|
|
2343
3617
|
if (entry.threadId !== threadId) return null; // bound to specific route
|
|
2344
3618
|
entry.used = true;
|
|
2345
|
-
return { agentId: entry.agentId };
|
|
3619
|
+
return { agentId: entry.agentId, handle: entry.handle || entry.agentId };
|
|
2346
3620
|
}
|
|
2347
3621
|
|
|
2348
3622
|
function serveAppFile(res, relPath) {
|
|
@@ -2368,23 +3642,64 @@ function serveAppFile(res, relPath) {
|
|
|
2368
3642
|
}
|
|
2369
3643
|
}
|
|
2370
3644
|
|
|
2371
|
-
|
|
3645
|
+
// authenticateWs verifies a WS upgrade request against the API_KEYS
|
|
3646
|
+
// map. Default behavior is HEADER ONLY: Authorization: Bearer ck-...
|
|
3647
|
+
// is accepted; ?token=ck-... in the URL is ignored.
|
|
3648
|
+
//
|
|
3649
|
+
// Set { allowQueryToken: true } only on the explicit web-side
|
|
3650
|
+
// back-compat branch that runs inside ALLOW_WS_URL_TOKEN. Daemon
|
|
3651
|
+
// connections never enable this; CLI clients can always set
|
|
3652
|
+
// Authorization, and a daemon accepting URL-token would be a needless
|
|
3653
|
+
// attack surface (URL leaks via referrer / log scrape are not relevant
|
|
3654
|
+
// for daemons, but the asymmetry of "header-only on the daemon path"
|
|
3655
|
+
// keeps the policy clean and auditable).
|
|
3656
|
+
function authenticateWs(req, { allowQueryToken = false } = {}) {
|
|
2372
3657
|
const auth = req.headers["authorization"];
|
|
2373
3658
|
if (auth && auth.startsWith("Bearer ")) {
|
|
2374
3659
|
const key = auth.slice(7).trim();
|
|
2375
|
-
|
|
3660
|
+
const identity = identityForApiKey(key);
|
|
3661
|
+
if (identity) return identity;
|
|
2376
3662
|
}
|
|
2377
|
-
|
|
3663
|
+
if (!allowQueryToken) return null;
|
|
3664
|
+
|
|
3665
|
+
// Web back-compat path only. Browsers cannot set Authorization on a
|
|
3666
|
+
// WebSocket() handshake, so legacy clients put ck- in the URL.
|
|
3667
|
+
// parseUrl returns a WHATWG URL, which has no .query getter (only
|
|
3668
|
+
// .search/.searchParams). Strip the leading "?" off .search and
|
|
3669
|
+
// parse with querystring so the array/string handling below works.
|
|
2378
3670
|
const u = parseUrl(req.url);
|
|
2379
|
-
const qs = u.
|
|
3671
|
+
const qs = u.search ? parseUrlQs(u.search.slice(1)) : {};
|
|
2380
3672
|
const tokenParam = Array.isArray(qs.token) ? qs.token[0] : qs.token;
|
|
2381
|
-
if (typeof tokenParam === "string"
|
|
2382
|
-
return
|
|
3673
|
+
if (typeof tokenParam === "string") {
|
|
3674
|
+
return identityForApiKey(tokenParam);
|
|
2383
3675
|
}
|
|
2384
3676
|
return null;
|
|
2385
3677
|
}
|
|
2386
3678
|
|
|
2387
|
-
|
|
3679
|
+
// F-001: subprotocol-based WS auth for the codex-relay surface. The web
|
|
3680
|
+
// client sends Sec-WebSocket-Protocol: ldm-codex-relay.v1, ticket.<v>.
|
|
3681
|
+
// Server echoes only the protocol name (not the ticket-bearing entry).
|
|
3682
|
+
// Daemon connections do not use a subprotocol, so empty Sets pass.
|
|
3683
|
+
const codexRelayWss = new WebSocketServer({
|
|
3684
|
+
noServer: true,
|
|
3685
|
+
handleProtocols: (protocols /*, request */) => {
|
|
3686
|
+
if (!protocols || protocols.size === 0) return undefined;
|
|
3687
|
+
if (protocols.has("ldm-codex-relay.v1")) return "ldm-codex-relay.v1";
|
|
3688
|
+
return false;
|
|
3689
|
+
},
|
|
3690
|
+
});
|
|
3691
|
+
|
|
3692
|
+
function getTicketFromSubprotocol(req) {
|
|
3693
|
+
const header = req.headers["sec-websocket-protocol"];
|
|
3694
|
+
if (!header) return null;
|
|
3695
|
+
const tokens = header.split(",").map(s => s.trim()).filter(Boolean);
|
|
3696
|
+
for (const t of tokens) {
|
|
3697
|
+
if (t.startsWith("ticket.")) {
|
|
3698
|
+
return t.slice("ticket.".length);
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
return null;
|
|
3702
|
+
}
|
|
2388
3703
|
|
|
2389
3704
|
httpServer.on("upgrade", (req, socket, head) => {
|
|
2390
3705
|
const u = parseUrl(req.url);
|
|
@@ -2393,21 +3708,64 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2393
3708
|
const isWeb = path.startsWith("/api/codex-relay/web/");
|
|
2394
3709
|
if (!isDaemon && !isWeb) return; // let other listeners (or default) handle it
|
|
2395
3710
|
|
|
3711
|
+
// F-003: enforce Origin allowlist for browser-borne web upgrades.
|
|
3712
|
+
// Runs BEFORE ticket consumption / authenticateWs so a request from
|
|
3713
|
+
// a disallowed origin cannot burn a valid one-time ticket or trigger
|
|
3714
|
+
// an auth check side effect. Daemon path is exempt because CLI
|
|
3715
|
+
// clients do not send a browser Origin header. Requires nginx to
|
|
3716
|
+
// pass the Origin header through unchanged on the upgrade hop;
|
|
3717
|
+
// verified in Lane B B2 of the audit doc.
|
|
3718
|
+
if (isWeb) {
|
|
3719
|
+
const origin = req.headers["origin"];
|
|
3720
|
+
if (!isWsOriginAllowed(origin)) {
|
|
3721
|
+
console.warn("WS upgrade rejected: bad origin (" + (origin || "<none>") + ") for " + path);
|
|
3722
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
3723
|
+
socket.destroy();
|
|
3724
|
+
return;
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
|
|
2396
3728
|
// Daemon side keeps the existing Bearer ck- token auth.
|
|
2397
|
-
// Web side: prefer
|
|
2398
|
-
// to ?
|
|
3729
|
+
// Web side: prefer ticket via Sec-WebSocket-Protocol (F-001), fall
|
|
3730
|
+
// back to ?ticket= query string. The legacy ?token=ck- URL fallback
|
|
3731
|
+
// is gated behind LDM_HOSTED_MCP_ALLOW_WS_URL_TOKEN=1; production
|
|
3732
|
+
// does not accept it.
|
|
2399
3733
|
let identity = null;
|
|
2400
3734
|
if (isDaemon) {
|
|
2401
|
-
|
|
3735
|
+
// Daemon connections must use Authorization: Bearer ck-. URL-token
|
|
3736
|
+
// is never accepted on the daemon path (allowQueryToken: false).
|
|
3737
|
+
identity = authenticateWs(req, { allowQueryToken: false });
|
|
2402
3738
|
} else {
|
|
2403
3739
|
const threadId = decodeURIComponent(path.slice("/api/codex-relay/web/".length));
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
3740
|
+
|
|
3741
|
+
// Preferred path: ticket carried in Sec-WebSocket-Protocol. Avoids
|
|
3742
|
+
// URL/log/referrer exposure of the ticket value.
|
|
3743
|
+
const subTicket = getTicketFromSubprotocol(req);
|
|
3744
|
+
if (subTicket) {
|
|
3745
|
+
const consumed = consumeCodexRelayTicket(subTicket, threadId);
|
|
3746
|
+
if (consumed) identity = { agentId: consumed.agentId, handle: consumed.handle, viaTicket: true };
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
// Back-compat: ?ticket= query string. Same single-use binding.
|
|
3750
|
+
if (!identity) {
|
|
3751
|
+
// parseUrl returns a WHATWG URL with .search/.searchParams, no
|
|
3752
|
+
// .query. Strip the leading "?" off .search and parse with
|
|
3753
|
+
// querystring so the array/string handling below still works.
|
|
3754
|
+
const qs = u.search ? parseUrlQs(u.search.slice(1)) : {};
|
|
3755
|
+
const ticketParam = Array.isArray(qs.ticket) ? qs.ticket[0] : qs.ticket;
|
|
3756
|
+
if (typeof ticketParam === "string" && ticketParam) {
|
|
3757
|
+
const consumed = consumeCodexRelayTicket(ticketParam, threadId);
|
|
3758
|
+
if (consumed) identity = { agentId: consumed.agentId, handle: consumed.handle, viaTicket: true };
|
|
3759
|
+
}
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
// Legacy ?token=ck- URL fallback: dev/back-compat only. Production
|
|
3763
|
+
// refuses long-lived bearer in WS URLs (gate condition 2). The
|
|
3764
|
+
// URL-token branch in authenticateWs is gated by allowQueryToken,
|
|
3765
|
+
// and we only set it true here, only when ALLOW_WS_URL_TOKEN is on.
|
|
3766
|
+
if (!identity && ALLOW_WS_URL_TOKEN) {
|
|
3767
|
+
identity = authenticateWs(req, { allowQueryToken: true });
|
|
2409
3768
|
}
|
|
2410
|
-
if (!identity) identity = authenticateWs(req);
|
|
2411
3769
|
}
|
|
2412
3770
|
|
|
2413
3771
|
if (!identity) {
|
|
@@ -2418,18 +3776,109 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2418
3776
|
|
|
2419
3777
|
if (isDaemon) {
|
|
2420
3778
|
codexRelayWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
3779
|
+
let daemonIdentityAccepted = false;
|
|
3780
|
+
function activateCodexDaemonWs() {
|
|
3781
|
+
const previous = codexDaemons.get(identity.agentId);
|
|
3782
|
+
if (previous && previous !== ws && previous.readyState === previous.OPEN) {
|
|
3783
|
+
console.warn("codex-relay: rejected duplicate daemon reconnect for online tenant " + identity.agentId);
|
|
3784
|
+
try { ws.close(4004, "daemon already online"); } catch {}
|
|
3785
|
+
return false;
|
|
3786
|
+
}
|
|
3787
|
+
if (previous && previous !== ws) try { previous.close(4000, "replaced"); } catch {}
|
|
3788
|
+
codexDaemons.set(identity.agentId, ws);
|
|
3789
|
+
console.log("codex-relay: daemon online for " + identity.agentId);
|
|
3790
|
+
return true;
|
|
3791
|
+
}
|
|
3792
|
+
// F-001 per-thread isolation. Daemon -> web routing must NOT
|
|
3793
|
+
// fan out every frame to every same-agent web socket; that
|
|
3794
|
+
// breaks isolation when one user has multiple threads open.
|
|
3795
|
+
// Parse the OUTER envelope only to read the routing field
|
|
3796
|
+
// (session/sessionId). The encrypted ciphertext (or any inner
|
|
3797
|
+
// session.* payload) is never inspected, so gate 3a still
|
|
3798
|
+
// holds: the relay sees only routing metadata on the envelope.
|
|
3799
|
+
// No-session frames are an explicit allowlist (control/presence
|
|
3800
|
+
// types) or are dropped with a redacted warning. We never
|
|
3801
|
+
// broadcast unknown frames.
|
|
3802
|
+
const BROADCAST_TYPES = new Set([
|
|
3803
|
+
"presence",
|
|
3804
|
+
"presence.web",
|
|
3805
|
+
"presence.daemon",
|
|
3806
|
+
"daemon.online",
|
|
3807
|
+
"daemon.offline",
|
|
3808
|
+
]);
|
|
2425
3809
|
ws.on("message", (data) => {
|
|
2426
3810
|
const text = data.toString();
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
3811
|
+
let envelope = null;
|
|
3812
|
+
try { envelope = JSON.parse(text); } catch {}
|
|
3813
|
+
if (envelope?.type === "daemon.identity") {
|
|
3814
|
+
const reconnectPolicy = evaluateCodexDaemonReconnectPubkey(
|
|
3815
|
+
codexDaemonPubkeyRegistry.get(identity.agentId),
|
|
3816
|
+
envelope.daemon_public_key,
|
|
3817
|
+
);
|
|
3818
|
+
if (!reconnectPolicy.allowed) {
|
|
3819
|
+
console.warn(
|
|
3820
|
+
"codex-relay: rejected daemon reconnect E2EE key for tenant " + identity.agentId
|
|
3821
|
+
+ " reason=" + reconnectPolicy.reason
|
|
3822
|
+
+ " old=" + (reconnectPolicy.old_fingerprint || "<none>")
|
|
3823
|
+
+ " new=" + (reconnectPolicy.new_fingerprint || codexDaemonPubkeyFingerprint(envelope.daemon_public_key) || "<none>"),
|
|
3824
|
+
);
|
|
3825
|
+
const closeReason = reconnectPolicy.replaced
|
|
3826
|
+
? "daemon key change requires fresh pair"
|
|
3827
|
+
: "invalid daemon identity";
|
|
3828
|
+
try { ws.close(reconnectPolicy.replaced ? 4003 : 1008, closeReason); } catch {}
|
|
3829
|
+
return;
|
|
2431
3830
|
}
|
|
3831
|
+
void codexDaemonPubkeyRegistry.register(
|
|
3832
|
+
identity.agentId,
|
|
3833
|
+
envelope.daemon_public_key,
|
|
3834
|
+
envelope.crypto_versions,
|
|
3835
|
+
"daemon-reconnect",
|
|
3836
|
+
).then((result) => {
|
|
3837
|
+
if (!result?.registered) {
|
|
3838
|
+
try { ws.close(1011, "daemon identity persistence failed"); } catch {}
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3841
|
+
daemonIdentityAccepted = activateCodexDaemonWs();
|
|
3842
|
+
}).catch(() => {
|
|
3843
|
+
try { ws.close(1011, "daemon identity persistence failed"); } catch {}
|
|
3844
|
+
});
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3847
|
+
if (!daemonIdentityAccepted) {
|
|
3848
|
+
try { ws.close(1008, "daemon identity required"); } catch {}
|
|
3849
|
+
return;
|
|
2432
3850
|
}
|
|
3851
|
+
const sessionId = envelope?.session || envelope?.sessionId || envelope?.threadId;
|
|
3852
|
+
if (sessionId) {
|
|
3853
|
+
const targets = resolveCodexWebClientsForDaemonFrame(identity.agentId, sessionId);
|
|
3854
|
+
for (const target of targets) {
|
|
3855
|
+
target.send(text);
|
|
3856
|
+
}
|
|
3857
|
+
// No matching web client: drop silently. Daemon-emitted
|
|
3858
|
+
// frames for a thread the user has not opened in any browser
|
|
3859
|
+
// are not interesting to fan out elsewhere.
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
const type = envelope?.type;
|
|
3863
|
+
if (type && BROADCAST_TYPES.has(type)) {
|
|
3864
|
+
// Allowlisted agent-level frame (presence, online status).
|
|
3865
|
+
// Fan out within agent, never across agents.
|
|
3866
|
+
const prefix = identity.agentId + ":";
|
|
3867
|
+
for (const [key, webClients] of codexWebClients) {
|
|
3868
|
+
if (key.startsWith(prefix)) {
|
|
3869
|
+
for (const webWs of webClients) {
|
|
3870
|
+
if (webWs.readyState === webWs.OPEN) webWs.send(text);
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
// Parse failed, missing session, or unknown type: drop. Log a
|
|
3877
|
+
// redacted notice so the operator can see if a daemon is
|
|
3878
|
+
// emitting unrouteable frames. We never log envelope/payload
|
|
3879
|
+
// bytes; only the agent and the type (or "no-type").
|
|
3880
|
+
const typeMarker = type ? String(type).slice(0, 32) : "no-type";
|
|
3881
|
+
console.warn("codex-relay: dropped unroutable daemon frame for " + identity.agentId + " (type=" + typeMarker + ")");
|
|
2433
3882
|
});
|
|
2434
3883
|
ws.on("close", () => {
|
|
2435
3884
|
if (codexDaemons.get(identity.agentId) === ws) {
|
|
@@ -2451,22 +3900,87 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2451
3900
|
socket.destroy();
|
|
2452
3901
|
return;
|
|
2453
3902
|
}
|
|
3903
|
+
const webKey = codexRelayKey(identity.agentId, threadId);
|
|
3904
|
+
if (isCodexWsAgentDisabled(CODEX_WS_ABUSE_LIMITS, identity.agentId)) {
|
|
3905
|
+
console.warn(formatCodexWsLimitLog({
|
|
3906
|
+
agentId: identity.agentId,
|
|
3907
|
+
threadId,
|
|
3908
|
+
connectionId: "upgrade",
|
|
3909
|
+
reason: "operator disabled",
|
|
3910
|
+
}));
|
|
3911
|
+
socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
|
|
3912
|
+
socket.destroy();
|
|
3913
|
+
return;
|
|
3914
|
+
}
|
|
3915
|
+
const openBrowserSockets = openCodexWebClientsForKey(webKey).length;
|
|
3916
|
+
if (openBrowserSockets >= CODEX_WS_ABUSE_LIMITS.maxBrowserSocketsPerThread) {
|
|
3917
|
+
console.warn(formatCodexWsLimitLog({
|
|
3918
|
+
agentId: identity.agentId,
|
|
3919
|
+
threadId,
|
|
3920
|
+
connectionId: "upgrade",
|
|
3921
|
+
reason: "too many browser sockets",
|
|
3922
|
+
}));
|
|
3923
|
+
socket.write("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n");
|
|
3924
|
+
socket.destroy();
|
|
3925
|
+
return;
|
|
3926
|
+
}
|
|
2454
3927
|
codexRelayWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2455
|
-
const
|
|
2456
|
-
const
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
3928
|
+
const connectionId = randomUUID();
|
|
3929
|
+
const guard = createCodexWsConnectionGuard({
|
|
3930
|
+
config: CODEX_WS_ABUSE_LIMITS,
|
|
3931
|
+
agentId: identity.agentId,
|
|
3932
|
+
});
|
|
3933
|
+
const guardContext = { agentId: identity.agentId, threadId, connectionId };
|
|
3934
|
+
const idleIntervalMs = Math.max(1000, Math.min(60_000, Math.floor(CODEX_WS_ABUSE_LIMITS.idleTtlMs / 2)));
|
|
3935
|
+
const idleTimer = setInterval(() => {
|
|
3936
|
+
const decision = guard.observeIdle();
|
|
3937
|
+
if (!decision.ok && ws.readyState === ws.OPEN) {
|
|
3938
|
+
closeCodexWsForLimit(ws, guardContext, decision);
|
|
3939
|
+
}
|
|
3940
|
+
}, idleIntervalMs);
|
|
3941
|
+
const clientCount = addCodexWebClient(webKey, ws);
|
|
3942
|
+
console.log("codex-relay: web online " + webKey + " clients=" + clientCount + " conn=" + connectionId);
|
|
2460
3943
|
ws.on("message", (data) => {
|
|
3944
|
+
const frameDecision = guard.observeFrame(codexWsFrameByteLength(data));
|
|
3945
|
+
if (!frameDecision.ok) {
|
|
3946
|
+
closeCodexWsForLimit(ws, guardContext, frameDecision);
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
let text = data.toString();
|
|
3950
|
+
let envelope = null;
|
|
3951
|
+
try { envelope = JSON.parse(text); } catch {}
|
|
3952
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
3953
|
+
const malformedDecision = guard.observeMalformed();
|
|
3954
|
+
if (!malformedDecision.ok) {
|
|
3955
|
+
closeCodexWsForLimit(ws, guardContext, malformedDecision);
|
|
3956
|
+
}
|
|
3957
|
+
return;
|
|
3958
|
+
}
|
|
3959
|
+
if (isCodexE2eeEnvelope(envelope) && envelope.session) {
|
|
3960
|
+
// The browser cannot be allowed to choose this value. The relay
|
|
3961
|
+
// owns the route because it consumed the ticket for this URL
|
|
3962
|
+
// thread. The daemon uses this metadata to bind the encrypted
|
|
3963
|
+
// session before it decrypts any session.* command.
|
|
3964
|
+
envelope.route_thread_id = threadId;
|
|
3965
|
+
text = JSON.stringify(envelope);
|
|
3966
|
+
registerCodexE2eeSessionRoute(identity.agentId, envelope.session, threadId, ws);
|
|
3967
|
+
}
|
|
2461
3968
|
const daemonWs = codexDaemons.get(identity.agentId);
|
|
2462
3969
|
if (daemonWs && daemonWs.readyState === daemonWs.OPEN) {
|
|
2463
|
-
|
|
3970
|
+
const pendingDecision = guard.observePendingBytes(daemonWs.bufferedAmount || 0);
|
|
3971
|
+
if (!pendingDecision.ok) {
|
|
3972
|
+
closeCodexWsForLimit(ws, guardContext, pendingDecision);
|
|
3973
|
+
return;
|
|
3974
|
+
}
|
|
3975
|
+
daemonWs.send(text);
|
|
2464
3976
|
} else {
|
|
2465
3977
|
try { ws.send(JSON.stringify({ type: "error", message: "daemon offline" })); } catch {}
|
|
2466
3978
|
}
|
|
2467
3979
|
});
|
|
2468
3980
|
ws.on("close", () => {
|
|
2469
|
-
|
|
3981
|
+
clearInterval(idleTimer);
|
|
3982
|
+
removeCodexE2eeRoutesForWeb(identity.agentId, threadId, ws);
|
|
3983
|
+
removeCodexWebClient(webKey, ws);
|
|
2470
3984
|
});
|
|
2471
3985
|
ws.on("error", (err) => {
|
|
2472
3986
|
console.error("codex-relay web ws error:", err.message);
|
|
@@ -2476,6 +3990,7 @@ httpServer.on("upgrade", (req, socket, head) => {
|
|
|
2476
3990
|
|
|
2477
3991
|
httpServer.listen(PORT, SERVER_BIND, () => {
|
|
2478
3992
|
console.log(SERVER_NAME + " v" + SERVER_VERSION + " listening on " + SERVER_BIND + ":" + PORT);
|
|
3993
|
+
console.log("WS origin allowlist: " + WS_ORIGIN_ALLOWLIST.join(", "));
|
|
2479
3994
|
console.log("Health: http://localhost:" + PORT + "/health");
|
|
2480
3995
|
console.log("MCP: http://localhost:" + PORT + "/mcp");
|
|
2481
3996
|
console.log("OAuth: http://localhost:" + PORT + "/.well-known/oauth-authorization-server");
|