prism-mcp-server 20.21.8 → 20.21.11

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
@@ -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,45 @@ 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.11
155
+
156
+ ### Linked accounts persist across dashboard refreshes
157
+
158
+ - A host process with an obsolete launch credential now recovers from the
159
+ newer account saved by the dashboard after the obsolete credential is
160
+ rejected.
161
+ - Valid host credentials remain authoritative; sign-out, concurrent account
162
+ changes, network failures, and server failures do not trigger an unsafe
163
+ credential switch.
164
+ - `prism dashboard` opens the active local dashboard without relabeling a
165
+ linked Standard, Advanced, or Enterprise account as Free.
166
+
167
+ ## What's New in v20.21.10
168
+
169
+ ### Signed-out Free dashboards keep working locally
170
+
171
+ - Signing out now switches Project View, project details, and graph reads to
172
+ local SQLite without requiring an account, configuration edit, or restart.
173
+ - The saved Synalux backend preference remains ready for the next sign-in;
174
+ accidental missing cloud credentials still fail closed.
175
+ - Latest Activity uses the newest durable session when it is newer than the
176
+ saved handoff. Recent Sessions are newest first, and saved handoff versions
177
+ remain available as restore points.
178
+
179
+ ## What's New in v20.21.9
180
+
181
+ ### Prism Free opens without a Synalux account
182
+
183
+ - Run `prism dashboard` to open the current local Mind Palace. No sign-in,
184
+ account-link code, API key, paid plan, or Free-plan redemption is required.
185
+ - A bare local dashboard address now gives the exact local-open command instead
186
+ of rendering a broken account state.
187
+ - Account & Settings shows Prism Free as active. Synalux linking remains an
188
+ optional action for cloud sync, billing, and paid features.
189
+ - Local access stays protected without printing its browser capability into
190
+ startup output, and unsafe state paths or redirected identity probes fail
191
+ closed.
192
+
152
193
  ## What's New in v20.21.8
153
194
 
154
195
  ### Dashboard graphs use the authenticated Portal
@@ -929,8 +970,9 @@ Every conversation feeds a persistent store. The next session loads the right co
929
970
 
930
971
  The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
931
972
 
932
- It runs on loopback and is gated by a per-startup token by default — open the
933
- tokenized URL printed in the startup log (`http://localhost:3000/?token=…`).
973
+ It runs on loopback and is gated by a local token by default — run
974
+ `prism dashboard` to open it without signing in. This local browser step is not
975
+ a Free-plan redemption.
934
976
  Requests with an untrusted `Host`/`Origin` are refused, closing the DNS-rebinding
935
977
  exposure fixed in GHSA-9cvx-7x8q-3g6m. See [docs/IDE_SETUP.md](docs/IDE_SETUP.md#securing-the-dashboard)
936
978
  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,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
+ }
@@ -0,0 +1,52 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ export const DASHBOARD_PROBE_PATH = "/api/dashboard/probe";
3
+ const HEX_256 = /^[a-f0-9]{64}$/;
4
+ const PROBE_PROTOCOL = "prism-dashboard-probe:v1";
5
+ export function generateDashboardProbeKey() {
6
+ return randomBytes(32).toString("hex");
7
+ }
8
+ export function validateDashboardProbeKey(value) {
9
+ if (!HEX_256.test(value))
10
+ throw new Error("Local dashboard probe key is invalid");
11
+ return value;
12
+ }
13
+ function validateNonce(value) {
14
+ if (!HEX_256.test(value))
15
+ throw new Error("Local dashboard probe nonce is invalid");
16
+ return value;
17
+ }
18
+ function proof(key, purpose, nonce) {
19
+ const validatedKey = validateDashboardProbeKey(key);
20
+ const validatedNonce = validateNonce(nonce);
21
+ return createHmac("sha256", Buffer.from(validatedKey, "hex"))
22
+ .update(`${PROBE_PROTOCOL}:${purpose}:${validatedNonce}`)
23
+ .digest("hex");
24
+ }
25
+ function proofMatches(expected, candidate) {
26
+ if (!HEX_256.test(candidate))
27
+ return false;
28
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(candidate, "hex"));
29
+ }
30
+ export function createDashboardProbeRequest(key, nonce = randomBytes(32).toString("hex")) {
31
+ const validatedNonce = validateNonce(nonce);
32
+ return { nonce: validatedNonce, proof: proof(key, "request", validatedNonce) };
33
+ }
34
+ export function verifyDashboardProbeRequest(key, nonce, candidate) {
35
+ try {
36
+ return proofMatches(proof(key, "request", nonce), candidate);
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ export function createDashboardProbeResponse(key, nonce) {
43
+ return proof(key, "response", nonce);
44
+ }
45
+ export function verifyDashboardProbeResponse(key, nonce, candidate) {
46
+ try {
47
+ return proofMatches(proof(key, "response", nonce), candidate);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
@@ -47,3 +47,7 @@ export function buildTokenCookie(token, maxAgeMs, secure, cookieName = 'prism_da
47
47
  return (`${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; ` +
48
48
  `Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? "; Secure" : ""}`);
49
49
  }
50
+ /** Every dashboard data API, including optional account linking, uses the local gate. */
51
+ export function isDashboardTokenProtectedPath(pathname) {
52
+ return pathname.startsWith('/api/');
53
+ }
@@ -22,10 +22,12 @@ 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, activeStorageBackend } from "../storage/index.js";
25
+ import { activeStorageBackend } from "../storage/index.js";
26
26
  import { readDashboardLedger } from "./ledgerReader.js";
27
27
  import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
28
- import { renderDashboardHTML } from "./ui.js";
28
+ import { renderDashboardHTML, renderDashboardLocalOpenHTML } from "./ui.js";
29
+ import { writeDashboardAccessUrl } from "./dashboardAccess.js";
30
+ import { createDashboardProbeResponse, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, verifyDashboardProbeRequest, } from "./dashboardProbe.js";
29
31
  import { computeIntentHealth } from "./intentHealth.js";
30
32
  import { getAllSettings, setSetting, getSetting, getSettingSync } from "../storage/configStorage.js";
31
33
  import { compactLedgerHandler } from "../tools/compactionHandler.js";
@@ -34,9 +36,10 @@ import { buildVaultDirectory } from "../utils/vaultExporter.js";
34
36
  import { redactSettings } from "../tools/commonHelpers.js";
35
37
  import { handleGraphRoutes } from "./graphRouter.js";
36
38
  import { handleAccountRoutes } from "./accountRouter.js";
39
+ import { createDashboardStorageAccessor } from "./storageAccessor.js";
37
40
  import { isDashboardSettingKeyAllowed, isDashboardSettingValueAllowed } from "./settingsPolicy.js";
38
41
  import { isTrustedRequest, isRebindGuardedPath } from "./hostGuard.js";
39
- import { resolveDashboardToken, requestHasToken, buildTokenCookie, dashboardTokenCookieName, } from "./dashboardToken.js";
42
+ import { resolveDashboardToken, requestHasToken, buildTokenCookie, dashboardTokenCookieName, isDashboardTokenProtectedPath, } from "./dashboardToken.js";
40
43
  import { safeCompare, generateToken, isAuthenticated, createRateLimiter, initJWKS, } from "./authUtils.js";
41
44
  const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
42
45
  /** Read HTTP request body as string (Buffer-based to avoid GC thrash on large imports) */
@@ -61,20 +64,10 @@ function readBody(req) {
61
64
  export async function startDashboardServer() {
62
65
  // Port 3000 conflicts are gracefully handled by server.ts catching EADDRINUSE
63
66
  // which will just disable the dashboard on secondary instances, keeping MCP alive.
64
- // Lazy storage accessor — returns null if storage isn't ready yet.
65
- // API routes gracefully degrade with 503 instead of blocking startup.
66
- let _storage = null;
67
- const getStorageSafe = async () => {
68
- if (_storage)
69
- return _storage;
70
- try {
71
- _storage = await getStorage();
72
- return _storage;
73
- }
74
- catch {
75
- return null;
76
- }
77
- };
67
+ // Resolve through the canonical singleton on each request. Account
68
+ // connect/sign-out closes that singleton so the next request switches to the
69
+ // newly selected cloud or local backend without restarting the MCP host.
70
+ const getStorageSafe = createDashboardStorageAccessor();
78
71
  /**
79
72
  * v5.1: Optional HTTP Basic Auth for remote dashboard access.
80
73
  *
@@ -108,6 +101,7 @@ export async function startDashboardServer() {
108
101
  pinnedToken: process.env.PRISM_DASHBOARD_TOKEN,
109
102
  optOut: process.env.PRISM_DASHBOARD_NO_TOKEN,
110
103
  });
104
+ const DASHBOARD_PROBE_KEY = generateDashboardProbeKey();
111
105
  const COOKIE_SECURE = !!process.env.PRISM_DASHBOARD_ORIGIN?.startsWith("https://") || !!process.env.PRISM_DASHBOARD_SECURE;
112
106
  // Auth config object — injectable for testing via authUtils.ts
113
107
  const authConfig = {
@@ -227,6 +221,24 @@ return false;}
227
221
  }
228
222
  // ─── v5.1: Auth login endpoint (always accessible) ───
229
223
  const reqUrl = new URL(req.url || "/", `http://${req.headers.host}`);
224
+ // Authenticate the recorded listener before the CLI opens a browser. The
225
+ // request and response use domain-separated HMACs over a fresh nonce, so a
226
+ // foreign process that copied the public manifest cannot impersonate this
227
+ // dashboard or learn the local dashboard capability from the probe.
228
+ if (req.method === "GET" && reqUrl.pathname === DASHBOARD_PROBE_PATH) {
229
+ const nonce = reqUrl.searchParams.get("nonce") || "";
230
+ const requestProof = reqUrl.searchParams.get("proof") || "";
231
+ if (!verifyDashboardProbeRequest(DASHBOARD_PROBE_KEY, nonce, requestProof)) {
232
+ res.writeHead(401, { "Content-Type": "application/json", "Cache-Control": "no-store" });
233
+ return res.end(JSON.stringify({ error: "Invalid dashboard probe" }));
234
+ }
235
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" });
236
+ return res.end(JSON.stringify({
237
+ name: "Prism Mind Palace",
238
+ nonce,
239
+ proof: createDashboardProbeResponse(DASHBOARD_PROBE_KEY, nonce),
240
+ }));
241
+ }
230
242
  // ─── SECURITY: dashboard token gate (GHSA-9cvx-7x8q-3g6m, remediation #2) ───
231
243
  // Second layer beneath the Host guard. Inert when DASHBOARD_TOKEN is null
232
244
  // (real auth configured, or opted out). A page load carrying a valid ?token=
@@ -235,24 +247,34 @@ return false;}
235
247
  // token via cookie, X-Prism-Dashboard-Token header, or ?token= query.
236
248
  if (DASHBOARD_TOKEN) {
237
249
  const qToken = reqUrl.searchParams.get("token");
238
- const isApiPath = reqUrl.pathname.startsWith("/api/");
250
+ const isApiPath = isDashboardTokenProtectedPath(reqUrl.pathname);
251
+ const cookieName = dashboardTokenCookieName(req.socket.localPort);
252
+ const hasToken = requestHasToken({
253
+ cookie: req.headers.cookie,
254
+ headerToken: req.headers["x-prism-dashboard-token"] || null,
255
+ }, qToken, DASHBOARD_TOKEN, cookieName);
239
256
  if (!isApiPath && qToken && safeCompare(qToken, DASHBOARD_TOKEN)) {
240
257
  reqUrl.searchParams.delete("token");
241
258
  const cleanTarget = reqUrl.pathname + (reqUrl.search ? reqUrl.search : "");
242
259
  res.writeHead(302, {
243
- "Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE, dashboardTokenCookieName(req.socket.localPort)),
260
+ "Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE, cookieName),
244
261
  Location: cleanTarget,
245
262
  });
246
263
  return res.end();
247
264
  }
248
- if (isApiPath &&
249
- !requestHasToken({
250
- cookie: req.headers.cookie,
251
- headerToken: req.headers["x-prism-dashboard-token"] || null,
252
- }, qToken, DASHBOARD_TOKEN, dashboardTokenCookieName(req.socket.localPort))) {
265
+ if (req.method === "GET" &&
266
+ (reqUrl.pathname === "/" || reqUrl.pathname === "/index.html") &&
267
+ !hasToken) {
268
+ res.writeHead(200, {
269
+ "Content-Type": "text/html; charset=utf-8",
270
+ "Cache-Control": "no-store, no-cache, must-revalidate",
271
+ });
272
+ return res.end(renderDashboardLocalOpenHTML());
273
+ }
274
+ if (isApiPath && !hasToken) {
253
275
  res.writeHead(401, { "Content-Type": "application/json" });
254
276
  return res.end(JSON.stringify({
255
- error: "Dashboard token required — open the tokenized URL printed in the Prism startup log.",
277
+ error: "Local dashboard access required — run prism dashboard.",
256
278
  }));
257
279
  }
258
280
  }
@@ -394,6 +416,31 @@ return false;}
394
416
  return res.end(JSON.stringify({ error: err.message || "Failed to generate server card" }));
395
417
  }
396
418
  }
419
+ // Public PWA metadata also serves as the CLI's bounded local identity
420
+ // probe. It contains no dashboard data and must remain reachable when an
421
+ // operator configures Basic Auth or JWKS for the actual dashboard.
422
+ if (reqUrl.pathname === "/manifest.json" && req.method === "GET") {
423
+ const manifest = {
424
+ name: "Prism Mind Palace",
425
+ short_name: "Prism",
426
+ description: "Prism MCP Mobile Dashboard",
427
+ start_url: "/",
428
+ display: "standalone",
429
+ background_color: "#0a0e1a",
430
+ theme_color: "#0a0e1a",
431
+ icons: [
432
+ { src: "/icon-192.svg", sizes: "192x192", type: "image/svg+xml", purpose: "any" },
433
+ { src: "/icon-192-maskable.svg", sizes: "192x192", type: "image/svg+xml", purpose: "maskable" },
434
+ { src: "/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "any" },
435
+ { src: "/icon-512-maskable.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" },
436
+ ],
437
+ };
438
+ res.writeHead(200, {
439
+ "Content-Type": "application/json",
440
+ "Cache-Control": "public, max-age=86400",
441
+ });
442
+ return res.end(JSON.stringify(manifest));
443
+ }
397
444
  // ─── AUTHENTICATION GATE ───
398
445
  // Basic Auth & Session & JWKS
399
446
  if (AUTH_ENABLED && !(await isAuthenticated(req, authConfig))) {
@@ -1143,28 +1190,6 @@ return false;}
1143
1190
  return res.end(JSON.stringify({ error: "Failed to abort pipeline" }));
1144
1191
  }
1145
1192
  }
1146
- if (url.pathname === "/manifest.json" && req.method === "GET") {
1147
- const manifest = {
1148
- name: "Prism Mind Palace",
1149
- short_name: "Prism",
1150
- description: "Prism MCP Mobile Dashboard",
1151
- start_url: "/",
1152
- display: "standalone",
1153
- background_color: "#0a0e1a",
1154
- theme_color: "#0a0e1a",
1155
- icons: [
1156
- { src: "/icon-192.svg", sizes: "192x192", type: "image/svg+xml", purpose: "any" },
1157
- { src: "/icon-192-maskable.svg", sizes: "192x192", type: "image/svg+xml", purpose: "maskable" },
1158
- { src: "/icon-512.svg", sizes: "512x512", type: "image/svg+xml", purpose: "any" },
1159
- { src: "/icon-512-maskable.svg", sizes: "512x512", type: "image/svg+xml", purpose: "maskable" }
1160
- ]
1161
- };
1162
- res.writeHead(200, {
1163
- "Content-Type": "application/json",
1164
- "Cache-Control": "public, max-age=86400"
1165
- });
1166
- return res.end(JSON.stringify(manifest));
1167
- }
1168
1193
  // ─── PWA: Service Worker (v5.4) ───
1169
1194
  if (url.pathname === "/sw.js" && req.method === "GET") {
1170
1195
  const swContent = `
@@ -1451,7 +1476,10 @@ self.addEventListener('message', (e) => {
1451
1476
  }
1452
1477
  }
1453
1478
  }
1454
- // Write the active port to a file for discoverability
1479
+ const dashboardUrl = DASHBOARD_TOKEN
1480
+ ? `http://localhost:${boundPort}/?token=${DASHBOARD_TOKEN}`
1481
+ : `http://localhost:${boundPort}/`;
1482
+ // Write the active port and owner-only local opener link for discoverability.
1455
1483
  try {
1456
1484
  const portFile = path.join(os.homedir(), ".prism-mcp", "dashboard.port");
1457
1485
  fs.writeFileSync(portFile, String(boundPort), "utf8");
@@ -1459,13 +1487,19 @@ self.addEventListener('message', (e) => {
1459
1487
  catch {
1460
1488
  // Non-fatal — just means the user has to know the port
1461
1489
  }
1490
+ try {
1491
+ writeDashboardAccessUrl(dashboardUrl, os.homedir(), DASHBOARD_PROBE_KEY);
1492
+ }
1493
+ catch (error) {
1494
+ console.error(`[Dashboard] Could not save local opener link: ${error instanceof Error ? error.message : String(error)}`);
1495
+ }
1462
1496
  if (DASHBOARD_TOKEN) {
1463
- console.error(`[Prism] 🔐 Mind Palace Dashboard → http://localhost:${boundPort}/?token=${DASHBOARD_TOKEN}`);
1464
- console.error(`[Prism] Data API is token-gated by default (GHSA-9cvx-7x8q-3g6m). Open the URL above once; ` +
1497
+ console.error(`[Prism] 🔐 Mind Palace Dashboard ready on http://localhost:${boundPort}. ` +
1498
+ `Run prism dashboard to open locally without a Synalux account; ` +
1465
1499
  `pin it with PRISM_DASHBOARD_TOKEN, or disable with PRISM_DASHBOARD_NO_TOKEN=1.`);
1466
1500
  }
1467
1501
  else {
1468
- console.error(`[Prism] 🧠 Mind Palace Dashboard → http://localhost:${boundPort}`);
1502
+ console.error(`[Prism] 🧠 Mind Palace Dashboard → ${dashboardUrl}`);
1469
1503
  }
1470
1504
  // ─── v3.1: TTL Sweep — runs at startup + every 12 hours ───────────
1471
1505
  // NOTE (v5.4): The Background Scheduler in server.ts now also handles
@@ -0,0 +1,17 @@
1
+ import { getStorage } from "../storage/index.js";
2
+ /**
3
+ * Resolve dashboard storage through the canonical storage singleton on every
4
+ * request. Account connect/sign-out closes that singleton so the next request
5
+ * can select the newly active cloud or local backend. Keeping a second cache
6
+ * here would retain a closed backend across that transition.
7
+ */
8
+ export function createDashboardStorageAccessor(resolveStorage = getStorage) {
9
+ return async () => {
10
+ try {
11
+ return await resolveStorage();
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ };
17
+ }
@@ -13,6 +13,37 @@
13
13
  * - Responsive grid layout
14
14
  * ═══════════════════════════════════════════════════════════════════
15
15
  */
16
+ export function renderDashboardLocalOpenHTML() {
17
+ return `<!DOCTYPE html>
18
+ <html lang="en">
19
+ <head>
20
+ <meta charset="UTF-8">
21
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22
+ <title>Prism — Open Local Dashboard</title>
23
+ <style>
24
+ * { box-sizing: border-box; }
25
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 24px; background: #0a0e1a; color: #f1f5f9; font-family: Inter, system-ui, sans-serif; }
26
+ main { width: min(620px, 100%); padding: 36px; border: 1px solid rgba(139,92,246,.35); border-radius: 20px; background: #111827; box-shadow: 0 24px 80px rgba(0,0,0,.35); }
27
+ .eyebrow { color: #22d3ee; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; font-size: 13px; }
28
+ h1 { margin: 12px 0; font-size: clamp(30px, 6vw, 46px); line-height: 1.05; }
29
+ p { color: #a5b4cf; font-size: 17px; line-height: 1.6; }
30
+ code { display: block; margin: 24px 0 12px; padding: 16px 18px; border-radius: 12px; background: #080d19; border: 1px solid #293554; color: #c4b5fd; font: 600 18px ui-monospace, SFMono-Regular, Menlo, monospace; }
31
+ button { width: 100%; padding: 14px 18px; border: 0; border-radius: 12px; color: white; background: linear-gradient(135deg,#8b5cf6,#2563eb); font: 700 16px Inter,system-ui,sans-serif; cursor: pointer; }
32
+ .note { margin-top: 18px; color: #71809c; font-size: 14px; }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <main>
37
+ <div class="eyebrow">Local dashboard access</div>
38
+ <h1>Open the current Prism dashboard.</h1>
39
+ <p>This browser does not have the current local dashboard link. Run this command in Terminal to open the active dashboard securely. Your Synalux account and plan remain unchanged.</p>
40
+ <code>prism dashboard</code>
41
+ <button type="button" onclick="navigator.clipboard.writeText('prism dashboard').then(() => { this.textContent = 'Copied'; })">Copy command</button>
42
+ <p class="note">This local browser access check is separate from Synalux sign-in.</p>
43
+ </main>
44
+ </body>
45
+ </html>`;
46
+ }
16
47
  export function renderDashboardHTML(version) {
17
48
  return `<!DOCTYPE html>
18
49
  <html lang="en">
@@ -206,7 +237,12 @@ export function renderDashboardHTML(version) {
206
237
  }
207
238
 
208
239
  /* ─── State Panel ─── */
209
- .summary-text { color: var(--text-secondary); font-size: 0.9rem; line-height: 1.7; margin-bottom: 1rem; }
240
+ .summary-text { color: var(--text-secondary); font-size: 0.9rem; line-height: 1.7; margin-bottom: 0.4rem; }
241
+ .state-source, .timeline-note {
242
+ color: var(--text-muted); font-size: 0.72rem; line-height: 1.5;
243
+ }
244
+ .state-source { margin-bottom: 1rem; }
245
+ .timeline-note { margin: -0.25rem 0 0.8rem; }
210
246
  .todo-list { list-style: none; padding: 0; }
211
247
  .todo-list li {
212
248
  padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.05);
@@ -527,6 +563,9 @@ export function renderDashboardHTML(version) {
527
563
  .account-connect {
528
564
  padding: 1rem; border: 1px solid var(--border-glass); border-radius: var(--radius-sm); background: rgba(15,23,42,0.42);
529
565
  }
566
+ .account-connect summary { cursor: pointer; color: var(--text-secondary); font-size: 0.8rem; font-weight: 600; }
567
+ .account-connect summary::marker { color: var(--accent-purple); }
568
+ .account-connect[open] summary { margin-bottom: 0.8rem; color: var(--text-primary); }
530
569
  .account-connect label { display: block; margin-bottom: 0.45rem; color: var(--text-primary); font-size: 0.8rem; font-weight: 600; }
531
570
  .account-code-row { display: flex; gap: 0.6rem; }
532
571
  .account-code-input {
@@ -792,10 +831,11 @@ export function renderDashboardHTML(version) {
792
831
  <div id="content" class="grid grid-main fade-in">
793
832
  <!-- Left Column -->
794
833
  <div class="grid" style="align-content: start;">
795
- <!-- Current State -->
834
+ <!-- Latest Activity -->
796
835
  <div class="card">
797
- <div class="card-title"><span class="dot" style="background:var(--accent-blue)"></span> Current State <span id="versionBadge" class="badge badge-purple" style="margin-left:auto"></span></div>
836
+ <div class="card-title"><span class="dot" style="background:var(--accent-blue)"></span> Latest Activity <span id="versionBadge" class="badge badge-purple" style="margin-left:auto"></span></div>
798
837
  <div class="summary-text" id="summary"></div>
838
+ <div class="state-source" id="currentStateSource"></div>
799
839
  <div class="card-title" style="margin-top:0.5rem"><span class="dot" style="background:var(--accent-cyan)"></span> Pending TODOs</div>
800
840
  <ul class="todo-list" id="todos"></ul>
801
841
  </div>
@@ -1037,16 +1077,18 @@ export function renderDashboardHTML(version) {
1037
1077
  </div>
1038
1078
  </div>
1039
1079
 
1040
- <!-- Time Travel -->
1080
+ <!-- Recent Sessions -->
1041
1081
  <div class="card">
1042
- <div class="card-title"><span class="dot" style="background:var(--accent-purple)"></span> Time Travel History 🕰️</div>
1043
- <div class="timeline" id="historyTimeline"></div>
1082
+ <div class="card-title"><span class="dot" style="background:var(--accent-amber)"></span> Recent Sessions</div>
1083
+ <div class="timeline-note">Durable session activity, newest first.</div>
1084
+ <div class="timeline" id="ledgerTimeline"></div>
1044
1085
  </div>
1045
1086
 
1046
- <!-- Ledger -->
1087
+ <!-- Saved Handoff Versions -->
1047
1088
  <div class="card">
1048
- <div class="card-title"><span class="dot" style="background:var(--accent-amber)"></span> Session Ledger</div>
1049
- <div class="timeline" id="ledgerTimeline"></div>
1089
+ <div class="card-title"><span class="dot" style="background:var(--accent-purple)"></span> Saved Handoff Versions 🕰️</div>
1090
+ <div class="timeline-note">Restore points created when an agent saves a handoff.</div>
1091
+ <div class="timeline" id="historyTimeline"></div>
1050
1092
  </div>
1051
1093
  </div>
1052
1094
 
@@ -2126,13 +2168,14 @@ function renderAccountPanel(account, error) {
2126
2168
  '<div class="account-card-head"><div><div class="account-badges">' +
2127
2169
  '<span class="account-badge">Free</span><span class="account-badge role">Local</span></div>' +
2128
2170
  '<div class="account-name">Prism Free</div><div class="account-summary">' + accountPlanSummary('free') +
2129
- ' Sign in to add cloud sync, larger models, and team features.</div></div></div>' +
2130
- '<div class="account-actions"><button class="account-action" onclick="startAccountSignIn()">Sign in</button>' +
2171
+ ' No sign-in or redemption is required. Link a Synalux account only for cloud sync, larger models, or team features.</div></div></div>' +
2172
+ '<div class="account-actions"><button class="account-action" onclick="startAccountSignIn()">Link Synalux account</button>' +
2131
2173
  '<button class="account-action secondary" onclick="openAccountBilling()">View plans</button></div></div>' +
2132
- '<div class="account-connect"><label for="accountCodeInput">Complete sign-in with a one-time code</label>' +
2174
+ '<details class="account-connect" id="accountConnectDetails"><summary>Already have a Synalux account-link code? (optional)</summary>' +
2175
+ '<label for="accountCodeInput">Account-link code</label>' +
2133
2176
  '<div class="account-code-row"><input class="account-code-input" id="accountCodeInput" autocomplete="off" spellcheck="false" placeholder="synalux_code_…" />' +
2134
2177
  '<button class="account-action" id="accountConnectButton" onclick="connectAccount()">Connect account</button></div>' +
2135
- '<div class="account-status" id="accountStatus">Sign in opens Synalux in a new tab. Paste the code shown there.</div></div>' +
2178
+ '<div class="account-status" id="accountStatus">Use this only after choosing Link Synalux account. Prism Free is already active locally.</div></details>' +
2136
2179
  renderPlanLadder('free');
2137
2180
  return;
2138
2181
  }
@@ -2185,8 +2228,14 @@ function loadIdentityChip() {
2185
2228
  }
2186
2229
  function startAccountSignIn() {
2187
2230
  var url = currentAccount && currentAccount.auth_url ? currentAccount.auth_url : 'https://synalux.ai/auth?source=prism';
2231
+ var details = document.getElementById('accountConnectDetails');
2232
+ if (details)
2233
+ details.open = true;
2188
2234
  window.open(url, '_blank', 'noopener,noreferrer');
2189
2235
  setAccountStatus('Finish sign-in in Synalux, then paste the one-time code here.');
2236
+ var input = document.getElementById('accountCodeInput');
2237
+ if (input)
2238
+ input.focus();
2190
2239
  }
2191
2240
  function openAccountBilling() {
2192
2241
  var target = window.open('about:blank', '_blank');
@@ -2325,7 +2374,7 @@ function signOutAccount() {
2325
2374
  })();
2326
2375
  function loadProject() {
2327
2376
  return __awaiter(this, void 0, void 0, function () {
2328
- var project, res, data, ctx, todos, todoList, meta, briefingCard, visualCard, visuals, historyEl, ledgerEl, healthRes, healthData, healthCard, healthDot, healthLabel, healthSummary, healthIssues, statusMap, t, issues, cleanupBtn, sevIcons, he_1, e_3;
2377
+ var project, res, data, ctx, ledgerEntries, latestLedger, contextTime, latestLedgerTime, useLatestLedger, versionBadge, currentStateSource, currentSummary, todos, todoList, meta, briefingCard, visualCard, visuals, historyEl, ledgerEl, healthRes, healthData, healthCard, healthDot, healthLabel, healthSummary, healthIssues, statusMap, t, issues, cleanupBtn, sevIcons, he_1, e_3;
2329
2378
  return __generator(this, function (_a) {
2330
2379
  switch (_a.label) {
2331
2380
  case 0:
@@ -2354,9 +2403,41 @@ function loadProject() {
2354
2403
  case 3:
2355
2404
  data = _a.sent();
2356
2405
  ctx = data.context || {};
2357
- document.getElementById('versionBadge').textContent = 'v' + (ctx.version || '?');
2358
- document.getElementById('summary').textContent = ctx.last_summary || ctx.summary || 'No summary available.';
2359
- todos = ctx.pending_todo || ctx.active_context || [];
2406
+ ledgerEntries = Array.isArray(data.ledger) ? data.ledger.slice() : [];
2407
+ ledgerEntries.sort(function (a, b) {
2408
+ var aTime = Date.parse(a && a.created_at ? a.created_at : '');
2409
+ var bTime = Date.parse(b && b.created_at ? b.created_at : '');
2410
+ if (!Number.isFinite(aTime)) aTime = 0;
2411
+ if (!Number.isFinite(bTime)) bTime = 0;
2412
+ return bTime - aTime;
2413
+ });
2414
+ latestLedger = ledgerEntries.length > 0 ? ledgerEntries[0] : null;
2415
+ contextTime = Date.parse(ctx.updated_at || '');
2416
+ latestLedgerTime = Date.parse(latestLedger && latestLedger.created_at ? latestLedger.created_at : '');
2417
+ useLatestLedger = !!latestLedger && !!(latestLedger.summary || latestLedger.content) &&
2418
+ ((!Number.isFinite(contextTime) && Number.isFinite(latestLedgerTime)) ||
2419
+ (Number.isFinite(latestLedgerTime) && latestLedgerTime > contextTime) ||
2420
+ !(ctx.last_summary || ctx.summary));
2421
+ versionBadge = document.getElementById('versionBadge');
2422
+ versionBadge.textContent = useLatestLedger ? 'latest' : 'v' + (ctx.version || '?');
2423
+ versionBadge.className = 'badge ' + (useLatestLedger ? 'badge-amber' : 'badge-purple');
2424
+ currentSummary = useLatestLedger
2425
+ ? (latestLedger.summary || latestLedger.content)
2426
+ : (ctx.last_summary || ctx.summary);
2427
+ document.getElementById('summary').textContent = currentSummary || 'No summary available.';
2428
+ currentStateSource = document.getElementById('currentStateSource');
2429
+ if (useLatestLedger) {
2430
+ currentStateSource.textContent = 'Latest session' +
2431
+ (latestLedger.created_at ? ' · ' + formatDate(latestLedger.created_at) : '') +
2432
+ (ctx.version ? ' · saved handoff v' + ctx.version + ' remains available below' : '');
2433
+ }
2434
+ else {
2435
+ currentStateSource.textContent = 'Saved handoff' +
2436
+ (ctx.updated_at ? ' · ' + formatDate(ctx.updated_at) : '');
2437
+ }
2438
+ todos = useLatestLedger && Array.isArray(latestLedger.todos)
2439
+ ? latestLedger.todos
2440
+ : (ctx.pending_todo || ctx.active_context || []);
2360
2441
  todoList = document.getElementById('todos');
2361
2442
  if (Array.isArray(todos) && todos.length > 0) {
2362
2443
  todoList.innerHTML = todos.map(function (t) { return '<li>' + escapeHtml(t) + '</li>'; }).join('');
@@ -2405,8 +2486,8 @@ function loadProject() {
2405
2486
  historyEl.innerHTML = '<div style="color:var(--text-muted);font-size:0.85rem;padding:1rem;text-align:center">No time travel history yet.</div>';
2406
2487
  }
2407
2488
  ledgerEl = document.getElementById('ledgerTimeline');
2408
- if (data.ledger && data.ledger.length > 0) {
2409
- ledgerEl.innerHTML = data.ledger.map(function (l) {
2489
+ if (ledgerEntries.length > 0) {
2490
+ ledgerEl.innerHTML = ledgerEntries.map(function (l) {
2410
2491
  var summary = l.summary || l.content || 'Entry';
2411
2492
  var decisions = l.decisions;
2412
2493
  var extra = '';
@@ -177,7 +177,7 @@ function getCompleteContent() {
177
177
  "Your AI agents now have persistent, searchable memory across all sessions.",
178
178
  instructions: [
179
179
  "📖 Full documentation: https://github.com/synalux/prism",
180
- "🎛️ Dashboard: http://localhost:3080 (when server is running)",
180
+ "🎛️ Dashboard: run `prism dashboard` after the MCP host starts (no account required)",
181
181
  "💬 Community: GitHub Discussions for questions and feedback",
182
182
  "🐛 Issues: GitHub Issues for bug reports",
183
183
  ],
@@ -3,7 +3,8 @@ import { debugLog } from "../utils/logger.js";
3
3
  import { SupabaseStorage } from "./supabase.js";
4
4
  import { getSetting } from "./configStorage.js";
5
5
  import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
6
- import { setSynaluxSignedOut } from "../utils/synaluxCredentialState.js";
6
+ import { isSynaluxSignedOut, setSynaluxSignedOut } from "../utils/synaluxCredentialState.js";
7
+ import { getSynaluxJwt, isUsableSynaluxApiKey } from "../utils/synaluxJwt.js";
7
8
  export function isValidHttpUrl(url) {
8
9
  try {
9
10
  const parsed = new URL(url);
@@ -56,15 +57,35 @@ export async function ensureSynaluxCredentials() {
56
57
  delete process.env.PRISM_SYNALUX_API_KEY;
57
58
  return false;
58
59
  }
59
- if (SYNALUX_CONFIGURED)
60
- return true;
61
60
  // Re-check process.env directly: SYNALUX_CONFIGURED is captured at module
62
61
  // load, so credentials injected later by another caller would be invisible
63
62
  // to it. Mirrors ensureSupabaseCredentials below.
64
63
  const rawEnvUrl = process.env.PRISM_SYNALUX_BASE_URL?.trim() || process.env.SYNALUX_BASE_URL?.trim();
65
64
  const envUrl = rawEnvUrl ? upgradeInsecureCloudUrl(rawEnvUrl) : rawEnvUrl;
66
65
  const envKey = process.env.PRISM_SYNALUX_API_KEY?.trim();
67
- if (envUrl && envKey && isValidHttpUrl(envUrl))
66
+ if (envUrl && envKey && isValidHttpUrl(envUrl)) {
67
+ const storedKey = (await getSetting("PRISM_SYNALUX_API_KEY", ""))?.trim();
68
+ if (isUsableSynaluxApiKey(storedKey) &&
69
+ storedKey !== envKey) {
70
+ // Validate the launcher credential before SynaluxStorage captures it.
71
+ // getSynaluxJwt keeps a valid explicit env key authoritative and promotes
72
+ // the persisted key only after the env key receives 401/403 and the
73
+ // persisted key succeeds.
74
+ const validatedJwt = await getSynaluxJwt();
75
+ if (!validatedJwt) {
76
+ // A launcher can retain a revoked credential while the dashboard has
77
+ // already saved the replacement. Do not let SynaluxStorage capture
78
+ // the launcher key when the validation/recovery request is currently
79
+ // unavailable: its refresh token is immutable, so publishing that
80
+ // singleton would keep project access broken even after recovery.
81
+ throw new Error("[Prism Storage] Could not validate the active Synalux account credential. " +
82
+ "Refusing to initialize cloud storage with an unverified credential because that could " +
83
+ "strand project access or split session history. Retry when Synalux is reachable.");
84
+ }
85
+ }
86
+ return true;
87
+ }
88
+ if (SYNALUX_CONFIGURED)
68
89
  return true;
69
90
  const rawUrl = (await getSetting("PRISM_SYNALUX_BASE_URL"))?.trim() ||
70
91
  (await getSetting("SYNALUX_BASE_URL"))?.trim();
@@ -154,10 +175,19 @@ export async function getStorage() {
154
175
  // (startupRecovery.ts): a missing credential is a configuration fault, not a
155
176
  // transient one, so startup must not paper over it with last-good context.
156
177
  if (requested === "synalux" && !(await ensureSynaluxCredentials())) {
157
- throw new Error("[Prism Storage] PRISM_STORAGE=synalux but Synalux credentials are missing or invalid " +
158
- "(need PRISM_SYNALUX_BASE_URL and PRISM_SYNALUX_API_KEY). " +
159
- "Refusing to fall back to local storage because that silently splits session history. " +
160
- "Set PRISM_STORAGE=local explicitly if local-only storage is intended.");
178
+ if (isSynaluxSignedOut()) {
179
+ // Sign-out is an explicit request to stop using the account. Keep the
180
+ // configured cloud preference intact for a later sign-in, but serve the
181
+ // unauthenticated Free dashboard from local SQLite in the meantime.
182
+ requested = "local";
183
+ debugLog("[Prism Storage] Synalux account signed out — using local SQLite");
184
+ }
185
+ else {
186
+ throw new Error("[Prism Storage] PRISM_STORAGE=synalux but Synalux credentials are missing or invalid " +
187
+ "(need PRISM_SYNALUX_BASE_URL and PRISM_SYNALUX_API_KEY). " +
188
+ "Refusing to fall back to local storage because that silently splits session history. " +
189
+ "Set PRISM_STORAGE=local explicitly if local-only storage is intended.");
190
+ }
161
191
  }
162
192
  if (requested === "supabase" && !(await ensureSupabaseCredentials())) {
163
193
  throw new Error("[Prism Storage] PRISM_STORAGE=supabase but Supabase credentials are missing or invalid " +
@@ -606,10 +606,9 @@ function freeTierUpgradeLine(tier) {
606
606
  `super-skills, and agent routing → ${FREE_ENTITLEMENTS.upgrade_url}`;
607
607
  }
608
608
  /**
609
- * Dashboard URL for startup output. The bound port is announced on stderr
610
- * only (MCP stdio owns stdout), so users never saw it; the dashboard also
611
- * writes the port to ~/.prism-mcp/dashboard.port — read that, then the env
612
- * override, then the default.
609
+ * Detect the local dashboard for startup output. The browser link may contain
610
+ * a private localhost capability, so startup advertises the local CLI opener
611
+ * rather than copying that link into agent context.
613
612
  */
614
613
  async function readDashboardUrl() {
615
614
  // Precedence: explicit env override > recorded port file > default. The
@@ -633,14 +632,25 @@ async function readDashboardUrl() {
633
632
  // URL as the first-run headline action is worse than omitting it.
634
633
  //
635
634
  // A TCP connect is NOT sufficient: it proves something is listening, not
636
- // that it is Prism. The default is 3000 — the single most commonly occupied
637
- // port on a developer machine — so a bare liveness check would confidently
638
- // point a first-run user at their own dev server. Hit the dashboard's
639
- // /api/health instead, so identity is verified rather than assumed.
635
+ // that it is Prism. Probe the public PWA manifest instead of a token-gated API
636
+ // so an accountless install is detected without weakening dashboard access.
640
637
  const healthy = await new Promise((resolveProbe) => {
641
- const request = http.get({ host: "127.0.0.1", port: Number(port), path: "/api/health", timeout: 300 }, (response) => {
642
- response.resume(); // drain so the socket can close
643
- resolveProbe(response.statusCode === 200);
638
+ const request = http.get({ host: "127.0.0.1", port: Number(port), path: "/manifest.json", timeout: 300 }, (response) => {
639
+ let body = "";
640
+ response.setEncoding("utf8");
641
+ response.on("data", (chunk) => {
642
+ if (body.length <= 4096)
643
+ body += String(chunk);
644
+ });
645
+ response.on("end", () => {
646
+ try {
647
+ const parsed = JSON.parse(body);
648
+ resolveProbe(response.statusCode === 200 && parsed.name === "Prism Mind Palace");
649
+ }
650
+ catch {
651
+ resolveProbe(false);
652
+ }
653
+ });
644
654
  });
645
655
  request.once("timeout", () => { request.destroy(); resolveProbe(false); });
646
656
  request.once("error", () => resolveProbe(false));
@@ -2330,7 +2340,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
2330
2340
  if (projects.length === 0) {
2331
2341
  const dashboardUrl = await readDashboardUrl();
2332
2342
  const dashboardLine = dashboardUrl
2333
- ? `- 🎛️ **Dashboard:** ${dashboardUrl} — configure projects, identity, and context depth`
2343
+ ? `- 🎛️ **Dashboard:** run \`prism dashboard\` — opens locally with no Synalux account required`
2334
2344
  : `- 🎛️ **Dashboard:** not running — start Prism's dashboard to configure projects, identity, and context depth`;
2335
2345
  if (isFirstRun) {
2336
2346
  // Action-first instead of absence-first: every line is a capability or
@@ -27,6 +27,67 @@ const MIN_CACHE_MS = 60_000;
27
27
  let cache = null;
28
28
  let inFlight = null;
29
29
  let generation = 0;
30
+ async function exchangeJwt(baseUrl, apiKey) {
31
+ try {
32
+ const res = await fetch(`${baseUrl}/api/v1/auth/jwt`, {
33
+ method: "POST",
34
+ headers: {
35
+ "Authorization": `Bearer ${apiKey}`,
36
+ "Content-Type": "application/json",
37
+ },
38
+ signal: AbortSignal.timeout(10_000),
39
+ redirect: "error",
40
+ });
41
+ if (!res.ok) {
42
+ debugLog(`[synaluxJwt] exchange HTTP ${res.status}`);
43
+ return { kind: "http_error", status: res.status };
44
+ }
45
+ const data = (await res.json());
46
+ if (!data?.jwt) {
47
+ debugLog(`[synaluxJwt] exchange returned no jwt (status=${data?.status})`);
48
+ return { kind: "invalid_response" };
49
+ }
50
+ return {
51
+ kind: "success",
52
+ jwt: data.jwt,
53
+ ttlMs: Math.max(MIN_CACHE_MS, (data.expires_in ?? 900) * 1000),
54
+ };
55
+ }
56
+ catch (err) {
57
+ debugLog(`[synaluxJwt] exchange error: ${err instanceof Error ? err.message : String(err)}`);
58
+ return { kind: "network_error" };
59
+ }
60
+ }
61
+ async function readPersistedCredentialState() {
62
+ try {
63
+ const { getSetting } = await import("../storage/configStorage.js");
64
+ const signedOut = (await getSetting("PRISM_SYNALUX_SIGNED_OUT", "")) === "true";
65
+ const baseUrl = normalizePortalBaseUrl(await getSetting("PRISM_SYNALUX_BASE_URL", ""));
66
+ const apiKey = (await getSetting("PRISM_SYNALUX_API_KEY", "")).trim();
67
+ return { signedOut, baseUrl, apiKey };
68
+ }
69
+ catch (err) {
70
+ debugLog(`[synaluxJwt] persisted credential lookup failed: ${err instanceof Error ? err.message : String(err)}`);
71
+ return null;
72
+ }
73
+ }
74
+ function normalizePortalBaseUrl(raw) {
75
+ try {
76
+ const parsed = new URL(raw.trim());
77
+ const loopback = parsed.hostname === "localhost" ||
78
+ parsed.hostname === "127.0.0.1" || parsed.hostname === "::1" || parsed.hostname === "[::1]";
79
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback))
80
+ return "";
81
+ return parsed.origin;
82
+ }
83
+ catch {
84
+ return "";
85
+ }
86
+ }
87
+ const SYNALUX_API_KEY_PREFIX = ["synalux", "sk", ""].join("_");
88
+ export function isUsableSynaluxApiKey(apiKey) {
89
+ return apiKey.startsWith(SYNALUX_API_KEY_PREFIX) && apiKey.length <= 512;
90
+ }
30
91
  /**
31
92
  * Returns a usable JWT, exchanging from the sk_ token if needed.
32
93
  * Returns null when synalux is not configured or exchange fails.
@@ -54,24 +115,48 @@ export async function getSynaluxJwt() {
54
115
  let exchange;
55
116
  exchange = (async () => {
56
117
  try {
57
- const url = `${baseUrl}/api/v1/auth/jwt`;
58
- const res = await fetch(url, {
59
- method: "POST",
60
- headers: {
61
- "Authorization": `Bearer ${apiKey}`,
62
- "Content-Type": "application/json",
63
- },
64
- signal: AbortSignal.timeout(10_000),
65
- redirect: "error",
66
- });
67
- if (!res.ok) {
68
- debugLog(`[synaluxJwt] exchange HTTP ${res.status}`);
69
- cache = null;
70
- return null;
118
+ let attempt = await exchangeJwt(baseUrl, apiKey);
119
+ const requestOrigin = normalizePortalBaseUrl(baseUrl);
120
+ // MCP launchers can keep an older environment snapshot after the
121
+ // dashboard saves a freshly-linked account token. A valid explicit
122
+ // environment token remains authoritative; only an authentication
123
+ // rejection may try the newer persisted token, and only once.
124
+ if (attempt.kind === "http_error" && (attempt.status === 401 || attempt.status === 403)) {
125
+ const persisted = await readPersistedCredentialState();
126
+ if (persisted &&
127
+ !persisted.signedOut &&
128
+ !isSynaluxSignedOut() &&
129
+ !!persisted.baseUrl &&
130
+ persisted.baseUrl === requestOrigin &&
131
+ isUsableSynaluxApiKey(persisted.apiKey) &&
132
+ persisted.apiKey !== apiKey) {
133
+ // The saved key may only recover the same portal origin.
134
+ // Callers can resolve their request URL before this helper
135
+ // returns, so cross-origin recovery would expose either the
136
+ // refresh key or its short-lived JWT to the launcher URL.
137
+ const recovered = await exchangeJwt(persisted.baseUrl, persisted.apiKey);
138
+ if (recovered.kind === "success") {
139
+ // Re-read after the network boundary. A sign-out or a
140
+ // newer account link must win over this older retry.
141
+ const current = await readPersistedCredentialState();
142
+ if (current &&
143
+ !current.signedOut &&
144
+ !isSynaluxSignedOut() &&
145
+ generation === requestGeneration &&
146
+ current.baseUrl === persisted.baseUrl &&
147
+ current.apiKey === persisted.apiKey) {
148
+ process.env.PRISM_SYNALUX_API_KEY = persisted.apiKey;
149
+ attempt = recovered;
150
+ debugLog("[synaluxJwt] recovered from a stale launcher credential");
151
+ }
152
+ else {
153
+ cache = null;
154
+ return null;
155
+ }
156
+ }
157
+ }
71
158
  }
72
- const data = (await res.json());
73
- if (!data?.jwt) {
74
- debugLog(`[synaluxJwt] exchange returned no jwt (status=${data?.status})`);
159
+ if (attempt.kind !== "success") {
75
160
  cache = null;
76
161
  return null;
77
162
  }
@@ -81,10 +166,9 @@ export async function getSynaluxJwt() {
81
166
  if (isSynaluxSignedOut() || generation !== requestGeneration) {
82
167
  return null;
83
168
  }
84
- const ttlMs = Math.max(MIN_CACHE_MS, (data.expires_in ?? 900) * 1000);
85
- cache = { jwt: data.jwt, expiresAt: Date.now() + ttlMs };
86
- debugLog(`[synaluxJwt] exchanged ok, ttl=${ttlMs}ms`);
87
- return data.jwt;
169
+ cache = { jwt: attempt.jwt, expiresAt: Date.now() + attempt.ttlMs };
170
+ debugLog(`[synaluxJwt] exchanged ok, ttl=${attempt.ttlMs}ms`);
171
+ return attempt.jwt;
88
172
  }
89
173
  catch (err) {
90
174
  debugLog(`[synaluxJwt] exchange error: ${err instanceof Error ? err.message : String(err)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.21.8",
3
+ "version": "20.21.11",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",