slidemind 0.2.4 → 0.3.1

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) {