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 +13 -0
- package/dist/dashboard/ui.js +4 -4
- package/dist/storage/index.js +24 -3
- package/dist/utils/synaluxJwt.js +105 -21
- package/package.json +1 -1
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
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -34,12 +34,12 @@ export function renderDashboardLocalOpenHTML() {
|
|
|
34
34
|
</head>
|
|
35
35
|
<body>
|
|
36
36
|
<main>
|
|
37
|
-
<div class="eyebrow">Local
|
|
38
|
-
<h1>
|
|
39
|
-
<p>This browser
|
|
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">
|
|
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>`;
|
package/dist/storage/index.js
CHANGED
|
@@ -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();
|
package/dist/utils/synaluxJwt.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
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",
|