prism-mcp-server 20.21.7 → 20.21.10
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 +48 -2
- package/dist/cli.js +23 -0
- package/dist/dashboard/accountRouter.js +328 -0
- package/dist/dashboard/dashboardAccess.js +213 -0
- package/dist/dashboard/dashboardProbe.js +52 -0
- package/dist/dashboard/dashboardToken.js +4 -0
- package/dist/dashboard/graphRouter.js +44 -0
- package/dist/dashboard/server.js +93 -53
- package/dist/dashboard/storageAccessor.js +17 -0
- package/dist/dashboard/ui.js +379 -62
- package/dist/onboarding/wizard.js +1 -1
- package/dist/storage/index.js +20 -4
- package/dist/storage/synalux.js +170 -3
- package/dist/tools/graphHandlers.js +81 -18
- package/dist/tools/hygieneHandlers.js +10 -8
- package/dist/tools/ledgerHandlers.js +27 -17
- 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
|
@@ -58,12 +58,14 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
|
|
|
58
58
|
```bash
|
|
59
59
|
npm install -g prism-mcp-server
|
|
60
60
|
prism connect
|
|
61
|
+
prism dashboard
|
|
61
62
|
```
|
|
62
63
|
|
|
63
64
|
Use `prism connect --dry-run` to preview changes, `prism connect --all` to
|
|
64
65
|
configure every detected host, or `prism connect --refresh` to reconcile
|
|
65
66
|
Prism-managed entries after an upgrade. Restart the host after connecting.
|
|
66
67
|
|
|
68
|
+
`prism dashboard` opens the current local Mind Palace without a Synalux login.
|
|
67
69
|
Prism works locally without an account, API key, or cloud subscription. Add a
|
|
68
70
|
Synalux subscription when you want cloud memory, paid-tier skills, or team
|
|
69
71
|
features.
|
|
@@ -149,6 +151,49 @@ or by re-enabling after each run.
|
|
|
149
151
|
<details>
|
|
150
152
|
<summary>Release history (optional)</summary>
|
|
151
153
|
|
|
154
|
+
## What's New in v20.21.10
|
|
155
|
+
|
|
156
|
+
### Signed-out Free dashboards keep working locally
|
|
157
|
+
|
|
158
|
+
- Signing out now switches Project View, project details, and graph reads to
|
|
159
|
+
local SQLite without requiring an account, configuration edit, or restart.
|
|
160
|
+
- The saved Synalux backend preference remains ready for the next sign-in;
|
|
161
|
+
accidental missing cloud credentials still fail closed.
|
|
162
|
+
- Latest Activity uses the newest durable session when it is newer than the
|
|
163
|
+
saved handoff. Recent Sessions are newest first, and saved handoff versions
|
|
164
|
+
remain available as restore points.
|
|
165
|
+
|
|
166
|
+
## What's New in v20.21.9
|
|
167
|
+
|
|
168
|
+
### Prism Free opens without a Synalux account
|
|
169
|
+
|
|
170
|
+
- Run `prism dashboard` to open the current local Mind Palace. No sign-in,
|
|
171
|
+
account-link code, API key, paid plan, or Free-plan redemption is required.
|
|
172
|
+
- A bare local dashboard address now gives the exact local-open command instead
|
|
173
|
+
of rendering a broken account state.
|
|
174
|
+
- Account & Settings shows Prism Free as active. Synalux linking remains an
|
|
175
|
+
optional action for cloud sync, billing, and paid features.
|
|
176
|
+
- Local access stays protected without printing its browser capability into
|
|
177
|
+
startup output, and unsafe state paths or redirected identity probes fail
|
|
178
|
+
closed.
|
|
179
|
+
|
|
180
|
+
## What's New in v20.21.8
|
|
181
|
+
|
|
182
|
+
### Dashboard graphs use the authenticated Portal
|
|
183
|
+
|
|
184
|
+
- The selected cloud project now shows its real session nodes and stored links
|
|
185
|
+
instead of collapsing to a keyword-only project bubble.
|
|
186
|
+
- **Synthesize Edges** uses the authenticated Synalux Portal embedding contract;
|
|
187
|
+
the Prism dashboard process does not need a Google or Gemini API key.
|
|
188
|
+
- Existing vectors and links are reused on repeat runs, synthesis fan-out is
|
|
189
|
+
bounded, and Portal failures are shown rather than rendered as an empty graph.
|
|
190
|
+
- **Account & Settings** now shows the current user, role, and Free, Standard,
|
|
191
|
+
Advanced, or Enterprise plan, with visible sign-in, upgrade, billing, and
|
|
192
|
+
sign-out actions.
|
|
193
|
+
- A selected paid plan survives the Portal sign-in round trip, live and test
|
|
194
|
+
Stripe catalogs resolve every paid tier, and localized checkout never reuses
|
|
195
|
+
a USD Price for another currency.
|
|
196
|
+
|
|
152
197
|
## What's New in v20.18.1
|
|
153
198
|
|
|
154
199
|
### Task skills survive compaction and update in place
|
|
@@ -912,8 +957,9 @@ Every conversation feeds a persistent store. The next session loads the right co
|
|
|
912
957
|
|
|
913
958
|
The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
|
|
914
959
|
|
|
915
|
-
It runs on loopback and is gated by a
|
|
916
|
-
|
|
960
|
+
It runs on loopback and is gated by a local token by default — run
|
|
961
|
+
`prism dashboard` to open it without signing in. This local browser step is not
|
|
962
|
+
a Free-plan redemption.
|
|
917
963
|
Requests with an untrusted `Host`/`Origin` are refused, closing the DNS-rebinding
|
|
918
964
|
exposure fixed in GHSA-9cvx-7x8q-3g6m. See [docs/IDE_SETUP.md](docs/IDE_SETUP.md#securing-the-dashboard)
|
|
919
965
|
to pin the token, disable it, or configure Basic Auth / JWKS.
|
package/dist/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import { runBrowserCli } from './browserCli.js';
|
|
|
15
15
|
import { filterPrismMemoryContext } from './utils/memoryQuality.js';
|
|
16
16
|
import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
|
|
17
17
|
import { verifyBehaviorHandler } from './tools/behavioralVerifierHandler.js';
|
|
18
|
+
import { isLocalDashboardRunning, openDashboardUrl, readDashboardAccessState, } from './dashboard/dashboardAccess.js';
|
|
18
19
|
const program = new Command();
|
|
19
20
|
/** Build the stable `prism load --json` envelope from depth-specific context. */
|
|
20
21
|
export function buildLoadJsonOutput(project, data, level, metadata) {
|
|
@@ -114,6 +115,28 @@ program
|
|
|
114
115
|
.command('bootstrap')
|
|
115
116
|
.description('Print the canonical dashboard-configured first-turn Prism greeting')
|
|
116
117
|
.action(runBootstrapCommand);
|
|
118
|
+
program
|
|
119
|
+
.command('dashboard')
|
|
120
|
+
.description('Open the current local Mind Palace dashboard (no Synalux account required)')
|
|
121
|
+
.option('--print', 'Print the current local dashboard link instead of opening a browser')
|
|
122
|
+
.action(async (options) => {
|
|
123
|
+
try {
|
|
124
|
+
const { url, probeKey } = readDashboardAccessState();
|
|
125
|
+
if (!(await isLocalDashboardRunning(url, probeKey))) {
|
|
126
|
+
throw new Error('The recorded Prism dashboard is not running. Restart your connected MCP host first.');
|
|
127
|
+
}
|
|
128
|
+
if (options.print) {
|
|
129
|
+
console.log(url);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
openDashboardUrl(url);
|
|
133
|
+
console.log('Opened the local Prism Free dashboard. No Synalux account is required.');
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
console.error(`Dashboard unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
117
140
|
/**
|
|
118
141
|
* One-shot fallback for hosts whose long-lived MCP transport has closed.
|
|
119
142
|
* This calls the canonical handler, including its authenticated portal path
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createDashboardProbeRequest, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, validateDashboardProbeKey, verifyDashboardProbeResponse, } from "./dashboardProbe.js";
|
|
7
|
+
const DASHBOARD_URL_FILE = "dashboard.url";
|
|
8
|
+
const MAX_DASHBOARD_URL_BYTES = 4096;
|
|
9
|
+
export function dashboardAccessUrlPath(home = homedir()) {
|
|
10
|
+
return join(home, ".prism-mcp", DASHBOARD_URL_FILE);
|
|
11
|
+
}
|
|
12
|
+
function dashboardAccessDirectory(home) {
|
|
13
|
+
const directory = join(home, ".prism-mcp");
|
|
14
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
15
|
+
const stat = lstatSync(directory);
|
|
16
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
17
|
+
throw new Error("Local dashboard directory is not a regular directory");
|
|
18
|
+
}
|
|
19
|
+
if (process.platform !== "win32")
|
|
20
|
+
chmodSync(directory, 0o700);
|
|
21
|
+
return directory;
|
|
22
|
+
}
|
|
23
|
+
function validateDashboardUrl(raw) {
|
|
24
|
+
const value = raw.trim();
|
|
25
|
+
if (!value || Buffer.byteLength(value, "utf8") > MAX_DASHBOARD_URL_BYTES) {
|
|
26
|
+
throw new Error("Local dashboard link is missing or invalid");
|
|
27
|
+
}
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error("Local dashboard link is invalid");
|
|
34
|
+
}
|
|
35
|
+
const loopbackHosts = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
36
|
+
const queryKeys = [...parsed.searchParams.keys()];
|
|
37
|
+
if (parsed.protocol !== "http:" ||
|
|
38
|
+
!loopbackHosts.has(parsed.hostname) ||
|
|
39
|
+
!/^\d{1,5}$/.test(parsed.port) ||
|
|
40
|
+
Number(parsed.port) < 1 ||
|
|
41
|
+
Number(parsed.port) > 65535 ||
|
|
42
|
+
parsed.username ||
|
|
43
|
+
parsed.password ||
|
|
44
|
+
parsed.pathname !== "/" ||
|
|
45
|
+
parsed.hash ||
|
|
46
|
+
queryKeys.some((key) => key !== "token") ||
|
|
47
|
+
(parsed.searchParams.has("token") && !parsed.searchParams.get("token"))) {
|
|
48
|
+
throw new Error("Local dashboard link is invalid");
|
|
49
|
+
}
|
|
50
|
+
return parsed.toString();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Persist the current local dashboard link for the `prism dashboard` command.
|
|
54
|
+
* The token is a localhost capability, so the file is owner-only and symlinks
|
|
55
|
+
* present at validation time are rejected rather than followed. This is not a
|
|
56
|
+
* boundary against another process running as the same OS user: that process
|
|
57
|
+
* can already read or replace owner-only Prism state.
|
|
58
|
+
*/
|
|
59
|
+
export function writeDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey()) {
|
|
60
|
+
const validated = validateDashboardUrl(url);
|
|
61
|
+
const validatedProbeKey = validateDashboardProbeKey(probeKey);
|
|
62
|
+
const directory = dashboardAccessDirectory(home);
|
|
63
|
+
const filePath = dashboardAccessUrlPath(home);
|
|
64
|
+
try {
|
|
65
|
+
const existing = lstatSync(filePath);
|
|
66
|
+
if (!existing.isFile() || existing.isSymbolicLink()) {
|
|
67
|
+
throw new Error("Local dashboard link path is not a regular file");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const code = error.code;
|
|
72
|
+
if (code !== "ENOENT")
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
// Write a new regular file and atomically replace the directory entry. This
|
|
76
|
+
// never opens an existing target for writing, so a pre-existing target
|
|
77
|
+
// symlink cannot redirect the token write even on platforms without
|
|
78
|
+
// O_NOFOLLOW. The containing directory follows the same-OS-user trust model
|
|
79
|
+
// documented above.
|
|
80
|
+
const tempPath = join(directory, `.${DASHBOARD_URL_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
81
|
+
const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
|
|
82
|
+
const fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
|
|
83
|
+
let tempExists = true;
|
|
84
|
+
try {
|
|
85
|
+
if (process.platform !== "win32")
|
|
86
|
+
fchmodSync(fd, 0o600);
|
|
87
|
+
writeFileSync(fd, `${JSON.stringify({ version: 1, url: validated, probe_key: validatedProbeKey })}\n`, "utf8");
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
renameSync(tempPath, filePath);
|
|
94
|
+
tempExists = false;
|
|
95
|
+
if (process.platform !== "win32")
|
|
96
|
+
chmodSync(filePath, 0o600);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
if (tempExists) {
|
|
100
|
+
try {
|
|
101
|
+
unlinkSync(tempPath);
|
|
102
|
+
}
|
|
103
|
+
catch { /* best-effort cleanup */ }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return filePath;
|
|
107
|
+
}
|
|
108
|
+
export function readDashboardAccessState(home = homedir()) {
|
|
109
|
+
dashboardAccessDirectory(home);
|
|
110
|
+
const filePath = dashboardAccessUrlPath(home);
|
|
111
|
+
const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
|
|
112
|
+
let fd;
|
|
113
|
+
try {
|
|
114
|
+
fd = openSync(filePath, fsConstants.O_RDONLY | noFollow);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const code = error.code;
|
|
118
|
+
if (code === "ENOENT") {
|
|
119
|
+
throw new Error("No current local dashboard link. Restart your connected MCP host first.");
|
|
120
|
+
}
|
|
121
|
+
if (code === "ELOOP")
|
|
122
|
+
throw new Error("Local dashboard link path is not a regular file");
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
// Validate and read the same opened object. A path-level lstat followed by
|
|
127
|
+
// readFileSync(path) permits the directory entry to be swapped between the
|
|
128
|
+
// check and use; O_NOFOLLOW plus fstat/read on this descriptor closes that
|
|
129
|
+
// race on POSIX without broadening the same-OS-user trust boundary.
|
|
130
|
+
const stat = fstatSync(fd);
|
|
131
|
+
if (!stat.isFile())
|
|
132
|
+
throw new Error("Local dashboard link path is not a regular file");
|
|
133
|
+
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
|
134
|
+
throw new Error("Local dashboard link file permissions are unsafe");
|
|
135
|
+
}
|
|
136
|
+
const raw = readFileSync(fd, "utf8");
|
|
137
|
+
if (!raw || Buffer.byteLength(raw, "utf8") > MAX_DASHBOARD_URL_BYTES) {
|
|
138
|
+
throw new Error("Local dashboard link is missing or invalid");
|
|
139
|
+
}
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(raw);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new Error("Local dashboard link is invalid");
|
|
146
|
+
}
|
|
147
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
148
|
+
throw new Error("Local dashboard link is invalid");
|
|
149
|
+
}
|
|
150
|
+
const state = parsed;
|
|
151
|
+
if (state.version !== 1 || typeof state.url !== "string" || typeof state.probe_key !== "string") {
|
|
152
|
+
throw new Error("Local dashboard link is invalid");
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
url: validateDashboardUrl(state.url),
|
|
156
|
+
probeKey: validateDashboardProbeKey(state.probe_key),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
closeSync(fd);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function readDashboardAccessUrl(home = homedir()) {
|
|
164
|
+
return readDashboardAccessState(home).url;
|
|
165
|
+
}
|
|
166
|
+
export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher = fetch) {
|
|
167
|
+
const validated = validateDashboardUrl(dashboardUrl);
|
|
168
|
+
const challenge = createDashboardProbeRequest(probeKey);
|
|
169
|
+
const probe = new URL(DASHBOARD_PROBE_PATH, validated);
|
|
170
|
+
probe.searchParams.set("nonce", challenge.nonce);
|
|
171
|
+
probe.searchParams.set("proof", challenge.proof);
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetcher(probe, {
|
|
174
|
+
redirect: "error",
|
|
175
|
+
signal: AbortSignal.timeout(1_500),
|
|
176
|
+
});
|
|
177
|
+
if (!response.ok)
|
|
178
|
+
return false;
|
|
179
|
+
const finalUrl = response.url ? new URL(response.url) : probe;
|
|
180
|
+
if (finalUrl.href !== probe.href) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const body = await response.json();
|
|
184
|
+
return body.name === "Prism Mind Palace" &&
|
|
185
|
+
body.nonce === challenge.nonce &&
|
|
186
|
+
typeof body.proof === "string" &&
|
|
187
|
+
verifyDashboardProbeResponse(probeKey, challenge.nonce, body.proof);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export function dashboardOpenCommand(url, platform = process.platform) {
|
|
194
|
+
const validated = validateDashboardUrl(url);
|
|
195
|
+
if (platform === "darwin")
|
|
196
|
+
return { command: "open", args: [validated] };
|
|
197
|
+
if (platform === "win32") {
|
|
198
|
+
return { command: "rundll32", args: ["url.dll,FileProtocolHandler", validated] };
|
|
199
|
+
}
|
|
200
|
+
return { command: "xdg-open", args: [validated] };
|
|
201
|
+
}
|
|
202
|
+
export function openDashboardUrl(url, platform = process.platform, runner = (command, args) => spawnSync(command, args, {
|
|
203
|
+
shell: false,
|
|
204
|
+
stdio: "ignore",
|
|
205
|
+
timeout: 5_000,
|
|
206
|
+
windowsHide: true,
|
|
207
|
+
})) {
|
|
208
|
+
const launch = dashboardOpenCommand(url, platform);
|
|
209
|
+
const result = runner(launch.command, launch.args);
|
|
210
|
+
if (result.error || result.status !== 0) {
|
|
211
|
+
throw new Error("Could not open the local dashboard browser");
|
|
212
|
+
}
|
|
213
|
+
}
|