prism-mcp-server 20.21.10 → 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
@@ -151,6 +151,19 @@ or by re-enabling after each run.
151
151
  <details>
152
152
  <summary>Release history (optional)</summary>
153
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
+
154
167
  ## What's New in v20.21.10
155
168
 
156
169
  ### Signed-out Free dashboards keep working locally
@@ -34,12 +34,12 @@ export function renderDashboardLocalOpenHTML() {
34
34
  </head>
35
35
  <body>
36
36
  <main>
37
- <div class="eyebrow">Local Prism Free</div>
38
- <h1>No account is required.</h1>
39
- <p>This browser needs the current local dashboard link after Prism starts. Run this command in Terminal; it opens the dashboard directly without Synalux sign-in or plan redemption.</p>
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
40
  <code>prism dashboard</code>
41
41
  <button type="button" onclick="navigator.clipboard.writeText('prism dashboard').then(() => { this.textContent = 'Copied'; })">Copy command</button>
42
- <p class="note">Synalux sign-in remains optional for cloud sync, billing, and paid features.</p>
42
+ <p class="note">This local browser access check is separate from Synalux sign-in.</p>
43
43
  </main>
44
44
  </body>
45
45
  </html>`;
@@ -4,6 +4,7 @@ import { SupabaseStorage } from "./supabase.js";
4
4
  import { getSetting } from "./configStorage.js";
5
5
  import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
6
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();
@@ -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.10",
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",