prism-mcp-server 20.21.6 → 20.21.8

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 CHANGED
@@ -149,6 +149,23 @@ or by re-enabling after each run.
149
149
  <details>
150
150
  <summary>Release history (optional)</summary>
151
151
 
152
+ ## What's New in v20.21.8
153
+
154
+ ### Dashboard graphs use the authenticated Portal
155
+
156
+ - The selected cloud project now shows its real session nodes and stored links
157
+ instead of collapsing to a keyword-only project bubble.
158
+ - **Synthesize Edges** uses the authenticated Synalux Portal embedding contract;
159
+ the Prism dashboard process does not need a Google or Gemini API key.
160
+ - Existing vectors and links are reused on repeat runs, synthesis fan-out is
161
+ bounded, and Portal failures are shown rather than rendered as an empty graph.
162
+ - **Account & Settings** now shows the current user, role, and Free, Standard,
163
+ Advanced, or Enterprise plan, with visible sign-in, upgrade, billing, and
164
+ sign-out actions.
165
+ - A selected paid plan survives the Portal sign-in round trip, live and test
166
+ Stripe catalogs resolve every paid tier, and localized checkout never reuses
167
+ a USD Price for another currency.
168
+
152
169
  ## What's New in v20.18.1
153
170
 
154
171
  ### Task skills survive compaction and update in place
package/dist/connect.js CHANGED
@@ -1250,6 +1250,7 @@ function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
1250
1250
  label: "Gemini CLI",
1251
1251
  format: "json",
1252
1252
  configPath: join(homeDir, ".gemini", "settings.json"),
1253
+ additionalConfigPaths: antigravityMcpConfigPaths(homeDir),
1253
1254
  detectionPaths: [join(homeDir, ".gemini")],
1254
1255
  executables: ["gemini"],
1255
1256
  }, {
@@ -1304,6 +1305,22 @@ function executableExists(name, platform, pathEnv) {
1304
1305
  }
1305
1306
  return false;
1306
1307
  }
1308
+ /**
1309
+ * Antigravity has used three user-level MCP locations across releases. Keep
1310
+ * the Gemini CLI settings registration as the primary result, and converge
1311
+ * only Antigravity paths whose parent directory already exists. That avoids
1312
+ * creating an unrelated host config on a Gemini-CLI-only installation while
1313
+ * repairing an existing Antigravity installation, including the legacy IDE
1314
+ * path that can otherwise continue launching an old server indefinitely.
1315
+ */
1316
+ function antigravityMcpConfigPaths(homeDir) {
1317
+ const geminiDir = join(homeDir, ".gemini");
1318
+ return [
1319
+ join(geminiDir, "config", "mcp_config.json"),
1320
+ join(geminiDir, "antigravity", "mcp_config.json"),
1321
+ join(geminiDir, "antigravity-ide", "mcp_config.json"),
1322
+ ].filter((configPath) => existsSync(configPath) || existsSync(dirname(configPath)));
1323
+ }
1307
1324
  function buildMcpEntry(nodePath, serverPath, env) {
1308
1325
  const storage = env.PRISM_STORAGE ?? "auto";
1309
1326
  if (!CONNECT_STORAGE_BACKENDS.includes(storage)) {
@@ -1331,10 +1348,42 @@ function registerHost(definition, entry, dryRun, refresh, beforeCommit) {
1331
1348
  if (definition.configurationError) {
1332
1349
  return result(definition, "error", definition.configurationError);
1333
1350
  }
1334
- if (definition.format === "codex-toml") {
1335
- return registerCodexTomlHost(definition, entry, dryRun, refresh, beforeCommit);
1351
+ const configDefinitions = [
1352
+ definition,
1353
+ ...(definition.additionalConfigPaths ?? []).map((configPath) => ({ ...definition, configPath })),
1354
+ ];
1355
+ const registerOne = (configDefinition) => {
1356
+ if (configDefinition.format === "codex-toml") {
1357
+ return registerCodexTomlHost(configDefinition, entry, dryRun, refresh, beforeCommit);
1358
+ }
1359
+ return registerJsonHost(configDefinition, entry, dryRun, refresh, beforeCommit);
1360
+ };
1361
+ const primary = registerOne(configDefinitions[0]);
1362
+ if (primary.status === "error")
1363
+ return primary;
1364
+ const registrations = [primary, ...configDefinitions.slice(1).map(registerOne)];
1365
+ const failures = registrations.slice(1).filter((registration) => registration.status === "error");
1366
+ if (failures.length > 0) {
1367
+ return {
1368
+ ...primary,
1369
+ status: "error",
1370
+ startupCompatible: false,
1371
+ message: `additional config registration failed: ${failures.map((failure) => `${failure.path}: ${failure.message || "unknown error"}`).join("; ")}`,
1372
+ };
1336
1373
  }
1337
- return registerJsonHost(definition, entry, dryRun, refresh, beforeCommit);
1374
+ const additionalChanges = registrations.slice(1).filter((registration) => registration.status !== "existing");
1375
+ const incompatible = registrations.filter((registration) => !registration.startupCompatible);
1376
+ const additionalMessage = additionalChanges.length > 0
1377
+ ? `also ${additionalChanges.map((registration) => `${registration.status} ${registration.path}`).join(", ")}`
1378
+ : undefined;
1379
+ const incompatibleMessage = incompatible.length > 0
1380
+ ? `retained incompatible or unmanaged registration(s): ${incompatible.map((registration) => registration.path).join(", ")}`
1381
+ : undefined;
1382
+ return {
1383
+ ...primary,
1384
+ startupCompatible: registrations.every((registration) => registration.startupCompatible),
1385
+ message: [primary.message, additionalMessage, incompatibleMessage].filter(Boolean).join("; ") || undefined,
1386
+ };
1338
1387
  }
1339
1388
  function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
1340
1389
  const { configPath } = definition;
@@ -0,0 +1,328 @@
1
+ import { invalidateEntitlements } from "../utils/entitlements.js";
2
+ import { getSynaluxJwt, invalidateSynaluxJwt } from "../utils/synaluxJwt.js";
3
+ import { resolvePortalBaseUrl, usablePortalKey } from "../utils/synaluxSearch.js";
4
+ import { isSynaluxSignedOut, setSynaluxSignedOut } from "../utils/synaluxCredentialState.js";
5
+ import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
6
+ const DEFAULT_PORTAL_URL = "https://synalux.ai";
7
+ const MAX_BODY_BYTES = 16 * 1024;
8
+ const VALID_PLANS = new Set(["free", "standard", "advanced", "enterprise"]);
9
+ let accountMutationQueue = Promise.resolve();
10
+ async function serializeAccountMutation(operation) {
11
+ const previous = accountMutationQueue;
12
+ let release;
13
+ accountMutationQueue = new Promise((resolve) => { release = resolve; });
14
+ await previous.catch(() => { });
15
+ try {
16
+ return await operation();
17
+ }
18
+ finally {
19
+ release();
20
+ }
21
+ }
22
+ const defaultDeps = {
23
+ fetcher: fetch,
24
+ getSetting: async (key, defaultValue) => (await import("../storage/configStorage.js")).getSetting(key, defaultValue),
25
+ setSetting: async (key, value) => (await import("../storage/configStorage.js")).setSetting(key, value),
26
+ getJwt: getSynaluxJwt,
27
+ invalidateJwt: invalidateSynaluxJwt,
28
+ invalidateEntitlements,
29
+ closeStorage: async () => (await import("../storage/index.js")).closeStorage(),
30
+ resolvePortalBaseUrl,
31
+ usablePortalKey,
32
+ };
33
+ function json(res, status, body) {
34
+ res.writeHead(status, {
35
+ "Content-Type": "application/json",
36
+ "Cache-Control": "private, no-store",
37
+ });
38
+ res.end(JSON.stringify(body));
39
+ }
40
+ async function readJson(req) {
41
+ const chunks = [];
42
+ let total = 0;
43
+ for await (const chunk of req) {
44
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
45
+ total += buffer.length;
46
+ if (total > MAX_BODY_BYTES)
47
+ throw new Error("Request body too large");
48
+ chunks.push(buffer);
49
+ }
50
+ const raw = Buffer.concat(chunks).toString("utf8");
51
+ if (!raw)
52
+ return {};
53
+ const value = JSON.parse(raw);
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
55
+ throw new Error("Body must be a JSON object");
56
+ }
57
+ return value;
58
+ }
59
+ function safePortalOrigin(raw) {
60
+ if (!raw)
61
+ return null;
62
+ try {
63
+ const upgraded = upgradeInsecureCloudUrl(raw.trim());
64
+ const parsed = new URL(upgraded);
65
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(parsed.hostname))) {
66
+ return null;
67
+ }
68
+ return parsed.origin;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ async function portalOrigin(deps) {
75
+ return safePortalOrigin(deps.resolvePortalBaseUrl())
76
+ ?? safePortalOrigin(await deps.getSetting("PRISM_SYNALUX_BASE_URL", ""))
77
+ ?? safePortalOrigin(await deps.getSetting("SYNALUX_BASE_URL", ""))
78
+ ?? DEFAULT_PORTAL_URL;
79
+ }
80
+ async function applyStoredSignOut(deps) {
81
+ const signedOut = (await deps.getSetting("PRISM_SYNALUX_SIGNED_OUT", "")) === "true";
82
+ setSynaluxSignedOut(signedOut);
83
+ if (signedOut)
84
+ delete process.env.PRISM_SYNALUX_API_KEY;
85
+ return signedOut;
86
+ }
87
+ function authUrl(origin) {
88
+ return `${origin}/auth?source=prism`;
89
+ }
90
+ function signedOutAccount(origin) {
91
+ return {
92
+ signed_in: false,
93
+ configured: false,
94
+ name: null,
95
+ role_key: null,
96
+ plan: "free",
97
+ billing: { action: "upgrade", url: `${origin}/pricing` },
98
+ auth_url: authUrl(origin),
99
+ };
100
+ }
101
+ function normalizeAccount(value, origin) {
102
+ if (!value || typeof value !== "object")
103
+ throw new Error("Portal returned an invalid account response");
104
+ const raw = value;
105
+ const plan = typeof raw.plan === "string" && VALID_PLANS.has(raw.plan) ? raw.plan : "free";
106
+ const billingRaw = raw.billing && typeof raw.billing === "object"
107
+ ? raw.billing
108
+ : {};
109
+ const action = billingRaw.action === "manage" || billingRaw.action === "included"
110
+ ? billingRaw.action
111
+ : "upgrade";
112
+ const url = typeof billingRaw.url === "string" && isAllowedBillingUrl(billingRaw.url, origin)
113
+ ? billingRaw.url
114
+ : null;
115
+ return {
116
+ signed_in: raw.signed_in === true,
117
+ configured: true,
118
+ name: typeof raw.name === "string" ? raw.name.slice(0, 160) : null,
119
+ role_key: typeof raw.role_key === "string" ? raw.role_key.slice(0, 80) : null,
120
+ plan: plan,
121
+ plan_source: raw.plan_source === "managed" ? "managed" : "stripe",
122
+ billing: { action, url },
123
+ auth_url: authUrl(origin),
124
+ };
125
+ }
126
+ function isAllowedBillingUrl(raw, origin) {
127
+ try {
128
+ const url = new URL(raw);
129
+ return url.origin === origin
130
+ || (url.protocol === "https:" && url.hostname === "billing.stripe.com");
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ }
136
+ async function accountWithJwt(deps, origin) {
137
+ let jwt = await deps.getJwt();
138
+ if (!jwt || isSynaluxSignedOut())
139
+ throw new Error("Synalux sign-in expired. Sign in again.");
140
+ const send = (token) => deps.fetcher(`${origin}/api/v1/prism/account`, {
141
+ method: "GET",
142
+ headers: { Authorization: `Bearer ${token}`, "X-Prism-Client": "prism-dashboard" },
143
+ signal: AbortSignal.timeout(10_000),
144
+ redirect: "error",
145
+ });
146
+ let response = await send(jwt);
147
+ if (isSynaluxSignedOut())
148
+ throw new Error("Synalux account is signed out");
149
+ if (response.status === 401) {
150
+ deps.invalidateJwt();
151
+ jwt = await deps.getJwt();
152
+ if (!jwt)
153
+ throw new Error("Synalux sign-in expired. Sign in again.");
154
+ response = await send(jwt);
155
+ if (isSynaluxSignedOut())
156
+ throw new Error("Synalux account is signed out");
157
+ }
158
+ if (!response.ok)
159
+ throw new Error(`Unable to load Synalux account (HTTP ${response.status})`);
160
+ const data = await response.json();
161
+ if (isSynaluxSignedOut())
162
+ throw new Error("Synalux account is signed out");
163
+ return normalizeAccount(data, origin);
164
+ }
165
+ export async function loadDashboardAccount(overrides = {}) {
166
+ const deps = { ...defaultDeps, ...overrides };
167
+ const origin = await portalOrigin(deps);
168
+ if (await applyStoredSignOut(deps))
169
+ return signedOutAccount(origin);
170
+ if (!deps.usablePortalKey())
171
+ return signedOutAccount(origin);
172
+ return accountWithJwt(deps, origin);
173
+ }
174
+ async function revokeRawToken(deps, origin, token) {
175
+ try {
176
+ const response = await deps.fetcher(`${origin}/api/v1/prism/account`, {
177
+ method: "DELETE",
178
+ headers: { Authorization: `Bearer ${token}`, "X-Prism-Client": "prism-dashboard" },
179
+ signal: AbortSignal.timeout(10_000),
180
+ redirect: "error",
181
+ });
182
+ return response.ok || response.status === 401;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ export async function connectDashboardAccount(code, overrides = {}) {
189
+ return serializeAccountMutation(async () => {
190
+ const deps = { ...defaultDeps, ...overrides };
191
+ if (typeof code !== "string" || !code.startsWith("synalux_code_") || code.length > 256) {
192
+ throw new Error("Enter a valid one-time Prism sign-in code");
193
+ }
194
+ const origin = await portalOrigin(deps);
195
+ const response = await deps.fetcher(`${origin}/api/v1/auth/code-exchange`, {
196
+ method: "POST",
197
+ headers: { "Content-Type": "application/json", "X-Prism-Client": "prism-dashboard" },
198
+ body: JSON.stringify({ code, client: "prism" }),
199
+ signal: AbortSignal.timeout(10_000),
200
+ redirect: "error",
201
+ });
202
+ const data = await response.json().catch(() => ({}));
203
+ if (!response.ok)
204
+ throw new Error(typeof data.error === "string" ? data.error : "Prism sign-in failed");
205
+ const token = typeof data.api_token === "string" ? data.api_token : "";
206
+ if (!token.startsWith("synalux_sk_") || token.length > 512) {
207
+ throw new Error("Portal returned an invalid Prism credential");
208
+ }
209
+ try {
210
+ await deps.setSetting("PRISM_SYNALUX_BASE_URL", origin);
211
+ await deps.setSetting("PRISM_SYNALUX_API_KEY", token);
212
+ await deps.setSetting("PRISM_SYNALUX_SIGNED_OUT", "false");
213
+ }
214
+ catch (error) {
215
+ await revokeRawToken(deps, origin, token);
216
+ await deps.setSetting("PRISM_SYNALUX_SIGNED_OUT", "true").catch(() => { });
217
+ await deps.setSetting("PRISM_SYNALUX_API_KEY", "").catch(() => { });
218
+ throw new Error(`Could not save Prism sign-in: ${error instanceof Error ? error.message : String(error)}`);
219
+ }
220
+ setSynaluxSignedOut(false);
221
+ process.env.PRISM_SYNALUX_BASE_URL = origin;
222
+ process.env.PRISM_SYNALUX_API_KEY = token;
223
+ deps.invalidateJwt();
224
+ deps.invalidateEntitlements();
225
+ await deps.closeStorage();
226
+ return accountWithJwt(deps, origin);
227
+ });
228
+ }
229
+ export async function openDashboardBilling(overrides = {}) {
230
+ const deps = { ...defaultDeps, ...overrides };
231
+ const origin = await portalOrigin(deps);
232
+ if (await applyStoredSignOut(deps) || !deps.usablePortalKey()) {
233
+ return { url: `${origin}/pricing`, action: "upgrade" };
234
+ }
235
+ let jwt = await deps.getJwt();
236
+ if (!jwt || isSynaluxSignedOut())
237
+ throw new Error("Synalux sign-in expired. Sign in again.");
238
+ const send = (token) => deps.fetcher(`${origin}/api/v1/prism/account`, {
239
+ method: "POST",
240
+ headers: { Authorization: `Bearer ${token}`, "X-Prism-Client": "prism-dashboard" },
241
+ signal: AbortSignal.timeout(10_000),
242
+ redirect: "error",
243
+ });
244
+ let response = await send(jwt);
245
+ if (isSynaluxSignedOut())
246
+ throw new Error("Synalux account is signed out");
247
+ if (response.status === 401) {
248
+ deps.invalidateJwt();
249
+ jwt = await deps.getJwt();
250
+ if (!jwt)
251
+ throw new Error("Synalux sign-in expired. Sign in again.");
252
+ response = await send(jwt);
253
+ if (isSynaluxSignedOut())
254
+ throw new Error("Synalux account is signed out");
255
+ }
256
+ const data = await response.json().catch(() => ({}));
257
+ if (isSynaluxSignedOut())
258
+ throw new Error("Synalux account is signed out");
259
+ if (!response.ok)
260
+ throw new Error(typeof data.error === "string" ? data.error : `Billing unavailable (HTTP ${response.status})`);
261
+ if (typeof data.url !== "string" || !isAllowedBillingUrl(data.url, origin)) {
262
+ throw new Error("Portal returned an unsafe billing URL");
263
+ }
264
+ return { url: data.url, action: typeof data.action === "string" ? data.action : "manage" };
265
+ }
266
+ export async function signOutDashboardAccount(overrides = {}) {
267
+ return serializeAccountMutation(async () => {
268
+ const deps = { ...defaultDeps, ...overrides };
269
+ const origin = await portalOrigin(deps);
270
+ const token = deps.usablePortalKey() ?? (await deps.getSetting("PRISM_SYNALUX_API_KEY", "")).trim();
271
+ // Cut off every local credential path before waiting on the network. The
272
+ // storage singleton captures its refresh token at construction, so clearing
273
+ // only process.env would leave an already-created backend signed in.
274
+ setSynaluxSignedOut(true);
275
+ delete process.env.PRISM_SYNALUX_API_KEY;
276
+ deps.invalidateJwt();
277
+ deps.invalidateEntitlements();
278
+ await deps.setSetting("PRISM_SYNALUX_SIGNED_OUT", "true");
279
+ await deps.setSetting("PRISM_SYNALUX_API_KEY", "");
280
+ await deps.closeStorage();
281
+ const revoked = token ? await revokeRawToken(deps, origin, token) : true;
282
+ return { signed_out: true, revoked };
283
+ });
284
+ }
285
+ export async function handleAccountRoutes(url, req, res) {
286
+ if (url.pathname === "/api/account" && req.method === "GET") {
287
+ try {
288
+ json(res, 200, await loadDashboardAccount());
289
+ }
290
+ catch (error) {
291
+ json(res, 502, { error: error instanceof Error ? error.message : "Unable to load account" });
292
+ }
293
+ return true;
294
+ }
295
+ if (url.pathname === "/api/account/connect" && req.method === "POST") {
296
+ try {
297
+ const body = await readJson(req);
298
+ json(res, 200, await connectDashboardAccount(body.code));
299
+ }
300
+ catch (error) {
301
+ json(res, 400, { error: error instanceof Error ? error.message : "Prism sign-in failed" });
302
+ }
303
+ return true;
304
+ }
305
+ if (url.pathname === "/api/account/billing" && req.method === "POST") {
306
+ try {
307
+ json(res, 200, await openDashboardBilling());
308
+ }
309
+ catch (error) {
310
+ json(res, 502, { error: error instanceof Error ? error.message : "Billing unavailable" });
311
+ }
312
+ return true;
313
+ }
314
+ if (url.pathname === "/api/account/signout" && req.method === "POST") {
315
+ try {
316
+ const result = await signOutDashboardAccount();
317
+ json(res, result.revoked ? 200 : 502, {
318
+ ...result,
319
+ ...(result.revoked ? {} : { warning: "Signed out locally, but remote token revocation could not be confirmed" }),
320
+ });
321
+ }
322
+ catch (error) {
323
+ json(res, 500, { error: error instanceof Error ? error.message : "Sign-out failed" });
324
+ }
325
+ return true;
326
+ }
327
+ return false;
328
+ }
@@ -20,24 +20,30 @@ export function resolveDashboardToken(cfg) {
20
20
  return randomBytes(32).toString("hex");
21
21
  }
22
22
  /** Extract the prism_dashboard_token cookie value, if present. */
23
- export function tokenFromCookie(cookieHeader) {
23
+ export function dashboardTokenCookieName(port) {
24
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
25
+ throw new Error('Invalid dashboard port');
26
+ return `prism_dashboard_token_${port}`;
27
+ }
28
+ export function tokenFromCookie(cookieHeader, cookieName = 'prism_dashboard_token') {
24
29
  // Capture the whole value (any run of non-";", non-space) so pinned tokens
25
30
  // with hyphens/underscores match; the name is anchored to start-or-"; " so a
26
31
  // look-alike cookie (evil_prism_dashboard_token=…) cannot match.
27
- const m = (cookieHeader || "").match(/(?:^|;\s*)prism_dashboard_token=([^;\s]+)/);
28
- return m ? m[1] : null;
32
+ const entry = (cookieHeader || '').split(';').map(c => c.trim()).find(c => c.startsWith(`${cookieName}=`));
33
+ const value = entry?.slice(cookieName.length + 1);
34
+ return value && !/\s/.test(value) ? value : null;
29
35
  }
30
36
  /**
31
37
  * True when the request presents the active token via the cookie, the
32
38
  * X-Prism-Dashboard-Token header, or a ?token= query param. Every comparison is
33
39
  * timing-safe and only runs against the single active token.
34
40
  */
35
- export function requestHasToken(headers, queryToken, activeToken) {
36
- const candidates = [tokenFromCookie(headers.cookie), headers.headerToken ?? null, queryToken];
41
+ export function requestHasToken(headers, queryToken, activeToken, cookieName = 'prism_dashboard_token') {
42
+ const candidates = [tokenFromCookie(headers.cookie, cookieName), tokenFromCookie(headers.cookie), headers.headerToken ?? null, queryToken];
37
43
  return candidates.some((c) => c !== null && safeCompare(c, activeToken));
38
44
  }
39
45
  /** Build the Set-Cookie value that stores the token for a browser session. */
40
- export function buildTokenCookie(token, maxAgeMs, secure) {
41
- return (`prism_dashboard_token=${token}; Path=/; HttpOnly; SameSite=Strict; ` +
46
+ export function buildTokenCookie(token, maxAgeMs, secure, cookieName = 'prism_dashboard_token') {
47
+ return (`${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; ` +
42
48
  `Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? "; Secure" : ""}`);
43
49
  }
@@ -65,16 +65,70 @@ export async function handleGraphRoutes(url, req, res, getStorageSafe) {
65
65
  else {
66
66
  params.limit = "200"; // Bump limit when exploring specific filters
67
67
  }
68
+ let createdAfter;
68
69
  if (project)
69
70
  params.project = `eq.${project}`;
70
71
  if (days) {
71
72
  const past = new Date();
72
73
  past.setDate(past.getDate() - parseInt(days, 10));
73
- params.created_at = `gte.${past.toISOString()}`;
74
+ createdAfter = past.toISOString();
75
+ params.created_at = `gte.${createdAfter}`;
74
76
  }
75
77
  if (min_importance)
76
78
  params.importance = `gte.${parseInt(min_importance, 10)}`;
77
- const entries = await s.getLedgerEntries(params);
79
+ const graphLimit = !project && !days && !min_importance ? 30 : 200;
80
+ if (project && s.getDashboardMemoryGraph) {
81
+ const graph = await s.getDashboardMemoryGraph({
82
+ project,
83
+ ...(createdAfter ? { createdAfter } : {}),
84
+ ...(min_importance ? { minImportance: parseInt(min_importance, 10) } : {}),
85
+ limit: graphLimit,
86
+ });
87
+ const now = Date.now();
88
+ const nodes = graph.nodes.map((row) => {
89
+ const accessedAt = row.last_accessed_at || row.created_at;
90
+ const daysSince = accessedAt
91
+ ? Math.max(0, Math.floor((now - new Date(accessedAt).getTime()) / (1000 * 60 * 60 * 24)))
92
+ : 999;
93
+ const baseImportance = typeof row.importance === "number" ? row.importance : 0;
94
+ return {
95
+ id: row.id,
96
+ label: typeof row.summary === "string" && row.summary.trim() ? row.summary.trim() : String(row.id),
97
+ group: "memory",
98
+ days_since_access: daysSince,
99
+ decayed_importance: baseImportance > 0
100
+ ? Math.round(baseImportance * Math.pow(0.95, daysSince) * 100) / 100
101
+ : 0,
102
+ base_importance: baseImportance,
103
+ };
104
+ });
105
+ const nodeIds = new Set(nodes.map(node => node.id));
106
+ const edges = graph.edges
107
+ .filter((edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id))
108
+ .map((edge) => ({
109
+ from: edge.source_id,
110
+ to: edge.target_id,
111
+ label: edge.link_type,
112
+ value: typeof edge.strength === "number" ? edge.strength : 1,
113
+ }));
114
+ res.writeHead(200, { "Content-Type": "application/json" });
115
+ res.end(JSON.stringify({
116
+ graphType: "memory",
117
+ entryCount: nodes.length,
118
+ truncated: graph.truncated,
119
+ nodes,
120
+ edges,
121
+ }));
122
+ return true;
123
+ }
124
+ const entries = s.getDashboardGraphEntries
125
+ ? await s.getDashboardGraphEntries({
126
+ ...(project ? { project } : {}),
127
+ ...(createdAfter ? { createdAfter } : {}),
128
+ ...(min_importance ? { minImportance: parseInt(min_importance, 10) } : {}),
129
+ limit: graphLimit,
130
+ })
131
+ : await s.getLedgerEntries(params);
78
132
  const now = Date.now();
79
133
  // ── Decay tracking: aggregate per-keyword and per-project ──
80
134
  // For each keyword/project, track the *freshest* entry's decay info
@@ -0,0 +1,9 @@
1
+ /** Paid clients have Portal credentials, not direct Supabase credentials. */
2
+ export async function readDashboardLedger(storage, backend, project, order, limit) {
3
+ if (backend !== "synalux") {
4
+ return storage.getLedgerEntries({ project: `eq.${project}`, order, limit: String(limit) });
5
+ }
6
+ if (!storage.getDashboardLedger)
7
+ throw new Error("Cloud dashboard ledger reader unavailable");
8
+ return storage.getDashboardLedger(project, order, limit);
9
+ }
@@ -22,19 +22,21 @@ import * as os from "os";
22
22
  import * as fs from "fs";
23
23
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
24
24
  import { createServer, getAllPossibleTools } from "../server.js";
25
- import { getStorage } from "../storage/index.js";
25
+ import { getStorage, activeStorageBackend } from "../storage/index.js";
26
+ import { readDashboardLedger } from "./ledgerReader.js";
26
27
  import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
27
28
  import { renderDashboardHTML } from "./ui.js";
28
29
  import { computeIntentHealth } from "./intentHealth.js";
29
30
  import { getAllSettings, setSetting, getSetting, getSettingSync } from "../storage/configStorage.js";
30
31
  import { compactLedgerHandler } from "../tools/compactionHandler.js";
31
- import { getLLMProvider } from "../utils/llm/factory.js";
32
+ import { getEmbeddingProvider } from "../utils/llm/factory.js";
32
33
  import { buildVaultDirectory } from "../utils/vaultExporter.js";
33
34
  import { redactSettings } from "../tools/commonHelpers.js";
34
35
  import { handleGraphRoutes } from "./graphRouter.js";
36
+ import { handleAccountRoutes } from "./accountRouter.js";
35
37
  import { isDashboardSettingKeyAllowed, isDashboardSettingValueAllowed } from "./settingsPolicy.js";
36
38
  import { isTrustedRequest, isRebindGuardedPath } from "./hostGuard.js";
37
- import { resolveDashboardToken, requestHasToken, buildTokenCookie, } from "./dashboardToken.js";
39
+ import { resolveDashboardToken, requestHasToken, buildTokenCookie, dashboardTokenCookieName, } from "./dashboardToken.js";
38
40
  import { safeCompare, generateToken, isAuthenticated, createRateLimiter, initJWKS, } from "./authUtils.js";
39
41
  const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
40
42
  /** Read HTTP request body as string (Buffer-based to avoid GC thrash on large imports) */
@@ -238,7 +240,7 @@ return false;}
238
240
  reqUrl.searchParams.delete("token");
239
241
  const cleanTarget = reqUrl.pathname + (reqUrl.search ? reqUrl.search : "");
240
242
  res.writeHead(302, {
241
- "Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE),
243
+ "Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE, dashboardTokenCookieName(req.socket.localPort)),
242
244
  Location: cleanTarget,
243
245
  });
244
246
  return res.end();
@@ -247,7 +249,7 @@ return false;}
247
249
  !requestHasToken({
248
250
  cookie: req.headers.cookie,
249
251
  headerToken: req.headers["x-prism-dashboard-token"] || null,
250
- }, qToken, DASHBOARD_TOKEN)) {
252
+ }, qToken, DASHBOARD_TOKEN, dashboardTokenCookieName(req.socket.localPort))) {
251
253
  res.writeHead(401, { "Content-Type": "application/json" });
252
254
  return res.end(JSON.stringify({
253
255
  error: "Dashboard token required — open the tokenized URL printed in the Prism startup log.",
@@ -482,11 +484,7 @@ return false;}
482
484
  return res.end(JSON.stringify({ error: "Storage initializing..." }));
483
485
  }
484
486
  const context = await s.loadContext(projectName, "deep", PRISM_USER_ID);
485
- const ledger = await s.getLedgerEntries({
486
- project: `eq.${projectName}`,
487
- order: "created_at.desc",
488
- limit: "20",
489
- });
487
+ const ledger = await readDashboardLedger(s, activeStorageBackend, projectName, "created_at.desc", 20);
490
488
  let history = [];
491
489
  try {
492
490
  history = await s.getHistory(projectName, PRISM_USER_ID, 10);
@@ -643,6 +641,11 @@ return false;}
643
641
  res.writeHead(200, { "Content-Type": "application/json" });
644
642
  return res.end(JSON.stringify({ ok: true, role, namespace: "user_skill" }));
645
643
  }
644
+ // ─── API: Synalux Account & Subscription ───
645
+ if (url.pathname.startsWith("/api/account")) {
646
+ if (await handleAccountRoutes(url, req, res))
647
+ return;
648
+ }
646
649
  // ─── API: Knowledge Graph (v6.2 — extracted to graphRouter.ts) ───
647
650
  if (url.pathname.startsWith("/api/graph")) {
648
651
  if (await handleGraphRoutes(url, req, res, getStorageSafe))
@@ -824,11 +827,7 @@ return false;}
824
827
  }
825
828
  // Gather data (mirrors sessionExportMemoryHandler)
826
829
  const ctx = await s.loadContext(projectName, "deep", PRISM_USER_ID);
827
- const rawLedger = await s.getLedgerEntries({
828
- project: `eq.${projectName}`,
829
- order: "created_at.asc",
830
- limit: "1000",
831
- });
830
+ const rawLedger = await readDashboardLedger(s, activeStorageBackend, projectName, "created_at.asc", 1000);
832
831
  // Strip binary embedding fields
833
832
  const cleanLedger = rawLedger.map(({ embedding: _e, embedding_compressed: _ec, ...rest }) => rest);
834
833
  const rawSettings = await getAllSettings();
@@ -1014,7 +1013,7 @@ return false;}
1014
1013
  // Check LLM provider availability before attempting embedding
1015
1014
  let llm;
1016
1015
  try {
1017
- llm = getLLMProvider();
1016
+ llm = getEmbeddingProvider();
1018
1017
  }
1019
1018
  catch {
1020
1019
  res.writeHead(503, { "Content-Type": "application/json" });