slidemind 0.2.3 → 0.3.0

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.
@@ -14,13 +14,24 @@
14
14
  // The legacy v1 shape ({ backendURL, sessionToken }) is migrated transparently
15
15
  // on read, so older installs and the raw-file test writers keep working.
16
16
  //
17
+ // A record may additionally carry `msGraphRefreshToken` — the user's own Entra
18
+ // refresh token from `slidemind login` (see graph-auth.mjs). It is a SECOND
19
+ // credential with a different subject: the CLI session is minted by the add-in
20
+ // for whoever clicked Connect CLI, the Graph token belongs to whoever completed
21
+ // the browser sign-in. Written only when present, so a machine that never ran
22
+ // `login` keeps a byte-identical file.
23
+ //
17
24
  // Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
18
25
  // clearly user-scoped. 0600 — it holds bearer credentials.
19
26
 
27
+ import { execFileSync } from "node:child_process";
28
+ import crypto from "node:crypto";
20
29
  import fs from "node:fs";
21
30
  import os from "node:os";
22
31
  import path from "node:path";
23
32
 
33
+ import { register as registerSecret } from "./secrets.mjs";
34
+
24
35
  export function sessionConfigDir() {
25
36
  // An explicitly set XDG_CONFIG_HOME wins on EVERY platform — it's a
26
37
  // deliberate override (and how the test suite isolates the store, also on
@@ -60,6 +71,46 @@ export function deriveEnvLabel(backendURL) {
60
71
  return host;
61
72
  }
62
73
 
74
+ // ---------------------------------------------------------------------------
75
+ // How loudly to talk about environments
76
+ // ---------------------------------------------------------------------------
77
+ //
78
+ // The env label exists because a slidemind DEVELOPER pairs local / dev / prod at
79
+ // once. A normal user connects exactly one backend, and for them the label on
80
+ // every line is noise that makes ordinary output read like a debugging session.
81
+ // So it is routing metadata, shown only when it actually disambiguates: more
82
+ // than one backend connected.
83
+ //
84
+ // Deliberately NOT gated: `--json` (agents route on it), `doctor` and `whoami`
85
+ // (reporting environments is their whole job), and any label the user just
86
+ // typed — `--backend prod`, `logout dev` — because suppressing an echo of their
87
+ // own argument is confusing, not quiet.
88
+ //
89
+ // Memoised: `presentations` would otherwise re-read the session file once per
90
+ // printed row. `writeAll` clears it, which covers every mutation (setup, login,
91
+ // logout, use) inside a single process.
92
+ let _sessionCount = null;
93
+
94
+ export function isMultiEnv() {
95
+ if (_sessionCount === null) _sessionCount = readSessions().length;
96
+ return _sessionCount > 1;
97
+ }
98
+
99
+ /** " [dev]" when more than one backend is connected, "" otherwise. */
100
+ export function envTag(env) {
101
+ return env && isMultiEnv() ? ` [${env}]` : "";
102
+ }
103
+
104
+ /**
105
+ * An in-sentence env mention — `envPhrase("dev", "for")` → " for dev" — or ""
106
+ * when there is only one environment to mean. Callers word the sentence so it
107
+ * reads correctly either way.
108
+ */
109
+ export function envPhrase(env, preposition = "on") {
110
+ if (!env || !isMultiEnv()) return "";
111
+ return preposition === "on" ? ` on '${env}'` : ` ${preposition} ${env}`;
112
+ }
113
+
63
114
  function isValidRecord(o) {
64
115
  return (
65
116
  o &&
@@ -84,6 +135,20 @@ function readRaw() {
84
135
  }
85
136
  }
86
137
 
138
+ // Every credential this file hands out is registered for output scrubbing the
139
+ // moment it is read, so a token spliced into an error message downstream — a
140
+ // server echoing the request back, a response body used as an error detail —
141
+ // renders as an opaque tag instead of the value. Reading the session file is
142
+ // the single door every session token comes through, so this is the one place
143
+ // that has to remember. The `@keychain` marker is a literal, not a secret.
144
+ function registerRecordSecrets(record) {
145
+ registerSecret(record.sessionToken);
146
+ if (record.msGraphRefreshToken && record.msGraphRefreshToken !== KEYCHAIN_MARKER) {
147
+ registerSecret(record.msGraphRefreshToken);
148
+ }
149
+ return record;
150
+ }
151
+
87
152
  // All paired sessions as [{ backendURL, sessionToken, label }]. Migrates the v1
88
153
  // single-record shape transparently. Returns [] when not paired / unreadable.
89
154
  export function readSessions() {
@@ -91,14 +156,18 @@ export function readSessions() {
91
156
  if (!obj) return [];
92
157
  // v2: { version, sessions: [...], default }
93
158
  if (Array.isArray(obj.sessions)) {
94
- return obj.sessions.filter(isValidRecord).map((s) => ({
159
+ return obj.sessions.filter(isValidRecord).map(registerRecordSecrets).map((s) => ({
95
160
  backendURL: s.backendURL,
96
161
  sessionToken: s.sessionToken,
97
162
  label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
163
+ ...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
164
+ ? { msGraphRefreshToken: s.msGraphRefreshToken }
165
+ : {}),
98
166
  }));
99
167
  }
100
168
  // v1: { backendURL, sessionToken }
101
169
  if (isValidRecord(obj)) {
170
+ registerRecordSecrets(obj);
102
171
  return [
103
172
  {
104
173
  backendURL: obj.backendURL,
@@ -122,6 +191,7 @@ export function getDefaultBackend() {
122
191
  }
123
192
 
124
193
  function writeAll(sessions, defaultBackend) {
194
+ _sessionCount = null; // the count just changed — see isMultiEnv()
125
195
  fs.mkdirSync(sessionConfigDir(), { recursive: true });
126
196
  const payload = {
127
197
  version: 2,
@@ -129,6 +199,7 @@ function writeAll(sessions, defaultBackend) {
129
199
  backendURL: s.backendURL,
130
200
  sessionToken: s.sessionToken,
131
201
  label: s.label || deriveEnvLabel(s.backendURL),
202
+ ...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
132
203
  })),
133
204
  };
134
205
  const def =
@@ -143,6 +214,12 @@ function writeAll(sessions, defaultBackend) {
143
214
 
144
215
  // Add or replace a session (keyed by backendURL). Becomes the default if none
145
216
  // is set yet. Preserves the existing default otherwise.
217
+ //
218
+ // Any stored `msGraphRefreshToken` is deliberately NOT carried over: redeeming
219
+ // a fresh claim can hand this machine to a different identity, and silently
220
+ // pairing that session with the previous user's delegated Graph token would
221
+ // fetch one person's content under another's session. `slidemind login` again
222
+ // is one command; a mismatched identity is not recoverable by the user.
146
223
  export function upsertSession({ backendURL, sessionToken, label = null }) {
147
224
  if (!backendURL || !sessionToken) {
148
225
  throw new Error("upsertSession: backendURL and sessionToken are required");
@@ -200,6 +277,121 @@ export function getSessionFor(backendURL) {
200
277
  return readSessions().find((s) => s.backendURL === backendURL) || null;
201
278
  }
202
279
 
280
+ // --- Graph refresh-token at-rest storage ------------------------------------
281
+ //
282
+ // On macOS the token goes into the login Keychain (encrypted at rest, absent
283
+ // from file backups and accidental config-dir syncs); the session file then
284
+ // carries only the marker below so hasGraphSession stays a file read. Any
285
+ // keychain failure falls back to the plaintext field silently — the 0600 file
286
+ // is the universal path and stays what Linux and Windows use.
287
+ // ponytail: macOS-only at-rest hardening — upgrade path is libsecret on Linux
288
+ // and DPAPI/Credential Manager on Windows, both of which need a dependency.
289
+ //
290
+ // The `security` CLI takes the secret as an argv, which is visible in a process
291
+ // listing for the milliseconds the write takes — to the same user and root,
292
+ // i.e. the same principals who can already read the 0600 fallback file. Judged
293
+ // acceptable against what the keychain buys at rest.
294
+ //
295
+ // Never during tests: `node --test` sets NODE_TEST_CONTEXT, and a test writing
296
+ // into the developer's real login keychain would outlive the test's isolated
297
+ // XDG_CONFIG_HOME. SLIDEMIND_NO_KEYCHAIN is the explicit opt-out.
298
+ const KEYCHAIN_MARKER = "@keychain";
299
+ const KEYCHAIN_SERVICE = "slidemind-msgraph";
300
+
301
+ function keychainUsable() {
302
+ return (
303
+ process.platform === "darwin" &&
304
+ !process.env.NODE_TEST_CONTEXT &&
305
+ !process.env.SLIDEMIND_NO_KEYCHAIN
306
+ );
307
+ }
308
+
309
+ // Stable per-backend account key. Hashed: keychain account fields end up in
310
+ // `security dump-keychain` output and logs, and the backend URL is exactly the
311
+ // thing user-facing output must never carry.
312
+ function keychainAccount(backendURL) {
313
+ return crypto.createHash("sha256").update(String(backendURL)).digest("hex").slice(0, 32);
314
+ }
315
+
316
+ function keychainWrite(backendURL, token) {
317
+ execFileSync(
318
+ "security",
319
+ ["add-generic-password", "-U", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL), "-w", token],
320
+ { stdio: "ignore" },
321
+ );
322
+ }
323
+
324
+ function keychainRead(backendURL) {
325
+ const out = execFileSync(
326
+ "security",
327
+ ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL), "-w"],
328
+ { stdio: ["ignore", "pipe", "ignore"] },
329
+ );
330
+ const token = out.toString("utf8").trim();
331
+ return token || null;
332
+ }
333
+
334
+ function keychainDelete(backendURL) {
335
+ try {
336
+ execFileSync(
337
+ "security",
338
+ ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", keychainAccount(backendURL)],
339
+ { stdio: "ignore" },
340
+ );
341
+ } catch {
342
+ /* nothing stored — logout of a file-stored or never-stored token */
343
+ }
344
+ }
345
+
346
+ // The user's stored Entra refresh token for one backend, or null. See
347
+ // graph-auth.mjs — nothing else may read it, and it never leaves this machine
348
+ // except as an Authorization on a token refresh.
349
+ export function getGraphRefreshToken(backendURL) {
350
+ const stored = getSessionFor(backendURL)?.msGraphRefreshToken ?? null;
351
+ if (stored !== KEYCHAIN_MARKER) return stored;
352
+ try {
353
+ // The file held only the marker, so `readSessions` had nothing to register —
354
+ // the real value first exists here.
355
+ const fromKeychain = keychainRead(backendURL);
356
+ registerSecret(fromKeychain);
357
+ return fromKeychain;
358
+ } catch {
359
+ // Marker without a readable keychain item (locked keychain, item removed
360
+ // by hand): behave as signed out rather than erroring every engine op.
361
+ return null;
362
+ }
363
+ }
364
+
365
+ // Store (or, with a null token, drop) the Graph refresh token for one backend.
366
+ // Every refresh returns a NEW refresh token, so this is called on each rotation.
367
+ // Returns false when that backend is no longer paired — a session removed
368
+ // mid-refresh must not resurrect itself as a token-only record.
369
+ export function setGraphRefreshToken(backendURL, refreshToken) {
370
+ const sessions = readSessions();
371
+ const record = sessions.find((s) => s.backendURL === backendURL);
372
+ if (!record) return false;
373
+ if (refreshToken) {
374
+ record.msGraphRefreshToken = refreshToken;
375
+ if (keychainUsable()) {
376
+ try {
377
+ keychainWrite(backendURL, refreshToken);
378
+ // Migration is this line: a previously plaintext field becomes the
379
+ // marker on the first successful keychain write.
380
+ record.msGraphRefreshToken = KEYCHAIN_MARKER;
381
+ } catch {
382
+ /* keychain unavailable — the plaintext field above stands */
383
+ }
384
+ }
385
+ } else {
386
+ if (record.msGraphRefreshToken === KEYCHAIN_MARKER && keychainUsable()) {
387
+ keychainDelete(backendURL);
388
+ }
389
+ delete record.msGraphRefreshToken;
390
+ }
391
+ writeAll(sessions, getDefaultBackend());
392
+ return true;
393
+ }
394
+
203
395
  // Back-compat writer: upsert a single session (used by tests and any v1 caller).
204
396
  export function writeSessionConfig({ backendURL, sessionToken }) {
205
397
  if (!backendURL || !sessionToken) {
@@ -141,6 +141,25 @@ export function resolveAssetExt(mimeType, buf) {
141
141
  return extFromBytes(buf) ?? declared;
142
142
  }
143
143
 
144
+ // The recolour descriptor, with a pane-free TINT REFUSAL folded in: the bytes
145
+ // on disk are then the original ones, so `applied` has to read false and the
146
+ // note has to name the one route that does bake a colour in.
147
+ function describeRecolorWithRefusal(markup, mimeType, iconColor, refusedColor) {
148
+ const described = describeRecolor(
149
+ markup,
150
+ mimeType,
151
+ refusedColor ? null : (iconColor ?? null),
152
+ );
153
+ if (!refusedColor) return described;
154
+ return {
155
+ ...described,
156
+ note:
157
+ `Saved in its ORIGINAL colours — recolouring to ${refusedColor} runs in the slidemind add-in. ` +
158
+ "Re-run this download with --to <presId> (a PowerPoint window with the slidemind pane open) " +
159
+ "to bake the colour in.",
160
+ };
161
+ }
162
+
144
163
  // Stream one result into assetsDir via the add-in's streaming route.
145
164
  //
146
165
  // Returns the same shape as `downloadOneResult`, or null when the paired add-in
@@ -156,21 +175,29 @@ async function streamOneResult({
156
175
  iconColor,
157
176
  }) {
158
177
  if (typeof client.engineDownloadToFile !== "function") return null;
178
+ // `presId: null` + a resolved backendURL is the pane-free route (the client
179
+ // picks the direct stream); a presId relays through that window's pane.
159
180
  // Write under a provisional name: the true extension is only knowable from the
160
181
  // bytes, and buffering the image just to sniff its header would defeat the
161
182
  // purpose of streaming.
162
183
  const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
163
- let res;
164
- try {
165
- res = await client.engineDownloadToFile({
184
+ const fetchOnce = (color) =>
185
+ client.engineDownloadToFile({
166
186
  presId,
167
187
  backendURL,
168
188
  searchId,
169
189
  selection,
170
190
  destPath: tempPath,
171
- iconColor: iconColor ?? null,
191
+ iconColor: color ?? null,
172
192
  onProgress: createTransferProgress(`downloading result ${selection}`),
173
193
  });
194
+ let res;
195
+ // Set when the pane-free route refused the TINT (not the asset): the download
196
+ // then repeats without a colour so the caller still gets the icon, and the
197
+ // recolour is reported as not applied instead of silently claimed.
198
+ let recolorRefused = null;
199
+ try {
200
+ res = await fetchOnce(iconColor);
174
201
  } catch (err) {
175
202
  fs.rmSync(tempPath, { force: true });
176
203
  // Route absent → this add-in predates streaming; let the caller fall back.
@@ -178,7 +205,13 @@ async function streamOneResult({
178
205
  // for a stale selection), not Express saying "no such path" — retrying that
179
206
  // on the capped path would just fail again, more slowly.
180
207
  if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
181
- throw err;
208
+ // The recolour classifier is DOM-based and lives in the add-in, so the
209
+ // pane-free route refuses a tint rather than guessing at one. Refusing the
210
+ // whole DOWNLOAD over it would be worse: the icon is fetchable, only the
211
+ // colour is not. Take the bytes and say so.
212
+ if (err?.code !== "recolor_needs_host" || !iconColor) throw err;
213
+ recolorRefused = iconColor;
214
+ res = await fetchOnce(null);
182
215
  }
183
216
 
184
217
  try {
@@ -193,7 +226,12 @@ async function streamOneResult({
193
226
  path: target,
194
227
  reference: `./assets/${filename}`,
195
228
  mimeType: res.mimeType ?? "image/svg+xml",
196
- recolor: describeRecolor(res.svgMarkup, res.mimeType, iconColor ?? null),
229
+ recolor: describeRecolorWithRefusal(
230
+ res.svgMarkup,
231
+ res.mimeType,
232
+ iconColor,
233
+ recolorRefused,
234
+ ),
197
235
  size: svgSizeFromMarkup(res.svgMarkup),
198
236
  };
199
237
  }
@@ -222,7 +260,12 @@ async function streamOneResult({
222
260
  path: target,
223
261
  reference: `./assets/${filename}`,
224
262
  mimeType: res.mimeType ?? null,
225
- recolor: describeRecolor(null, res.mimeType, iconColor ?? null),
263
+ recolor: describeRecolorWithRefusal(
264
+ null,
265
+ res.mimeType,
266
+ iconColor,
267
+ recolorRefused,
268
+ ),
226
269
  size: imageSizeFromBuffer(probe),
227
270
  };
228
271
  } catch (err) {
@@ -237,7 +280,7 @@ async function streamOneResult({
237
280
  // the rest.
238
281
  export async function downloadOneResult({
239
282
  client,
240
- presId,
283
+ presId = null,
241
284
  backendURL = null,
242
285
  searchId,
243
286
  assetsDir,
@@ -259,6 +302,16 @@ export async function downloadOneResult({
259
302
  });
260
303
  if (streamed) return streamed;
261
304
 
305
+ // The capped base64 fallback below is a PANE route. On the pane-free path
306
+ // there is no window to relay through — and an add-in that serves the direct
307
+ // routes always serves the direct stream — so a missing stream there is a real
308
+ // failure, not a reason to reach for a window that may not exist.
309
+ if (!presId) {
310
+ throw new Error(
311
+ "the add-in's engine routes returned no bytes for that result — re-run the search, then pick again",
312
+ );
313
+ }
314
+
262
315
  // Fallback for an add-in that predates the streaming route: bytes ride the
263
316
  // pane's Socket.IO ack, capped by its 10 MB frame limit.
264
317
  const res = await client.engineDownload({