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 +17 -0
- package/dist/connect.js +52 -3
- package/dist/dashboard/accountRouter.js +328 -0
- package/dist/dashboard/dashboardToken.js +13 -7
- package/dist/dashboard/graphRouter.js +56 -2
- package/dist/dashboard/ledgerReader.js +9 -0
- package/dist/dashboard/server.js +15 -16
- package/dist/dashboard/ui.js +290 -48
- package/dist/storage/index.js +7 -0
- package/dist/storage/synalux.js +236 -3
- package/dist/tools/graphHandlers.js +81 -18
- package/dist/tools/hygieneHandlers.js +10 -8
- package/dist/tools/ledgerHandlers.js +5 -5
- package/dist/tools/sessionMemoryDefinitions.js +18 -3
- package/dist/utils/imageCaptioner.js +3 -3
- package/dist/utils/llm/adapters/synalux.js +98 -0
- package/dist/utils/llm/factory.js +31 -0
- package/dist/utils/synaluxCredentialState.js +19 -0
- package/dist/utils/synaluxJwt.js +22 -3
- package/dist/utils/synaluxSearch.js +23 -1
- package/package.json +2 -2
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
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
28
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
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.
|
|
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 =
|
|
1016
|
+
llm = getEmbeddingProvider();
|
|
1018
1017
|
}
|
|
1019
1018
|
catch {
|
|
1020
1019
|
res.writeHead(503, { "Content-Type": "application/json" });
|