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 +44 -2
- package/dist/cli.js +23 -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/server.js +85 -51
- package/dist/dashboard/storageAccessor.js +17 -0
- package/dist/dashboard/ui.js +100 -19
- package/dist/onboarding/wizard.js +1 -1
- package/dist/storage/index.js +38 -8
- package/dist/tools/ledgerHandlers.js +22 -12
- package/dist/utils/synaluxJwt.js +105 -21
- package/package.json +1 -1
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
|
|
933
|
-
|
|
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
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
const getStorageSafe =
|
|
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
|
|
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,
|
|
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 (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
1464
|
-
|
|
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 →
|
|
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
|
+
}
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -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:
|
|
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
|
-
<!--
|
|
834
|
+
<!-- Latest Activity -->
|
|
796
835
|
<div class="card">
|
|
797
|
-
<div class="card-title"><span class="dot" style="background:var(--accent-blue)"></span>
|
|
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
|
-
<!--
|
|
1080
|
+
<!-- Recent Sessions -->
|
|
1041
1081
|
<div class="card">
|
|
1042
|
-
<div class="card-title"><span class="dot" style="background:var(--accent-
|
|
1043
|
-
<div class="timeline"
|
|
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
|
-
<!--
|
|
1087
|
+
<!-- Saved Handoff Versions -->
|
|
1047
1088
|
<div class="card">
|
|
1048
|
-
<div class="card-title"><span class="dot" style="background:var(--accent-
|
|
1049
|
-
<div class="timeline"
|
|
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
|
-
'
|
|
2130
|
-
'<div class="account-actions"><button class="account-action" onclick="startAccountSignIn()">
|
|
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
|
-
'<
|
|
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">
|
|
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
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
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 (
|
|
2409
|
-
ledgerEl.innerHTML =
|
|
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:
|
|
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
|
],
|
package/dist/storage/index.js
CHANGED
|
@@ -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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
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.
|
|
637
|
-
//
|
|
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: "/
|
|
642
|
-
|
|
643
|
-
|
|
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:**
|
|
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
|
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",
|