slidemind 0.2.4 → 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.
@@ -1,6 +1,9 @@
1
- // Minimal JSON-over-HTTP transport shared by setup-claim redemption and session
2
- // management. Validates real TLS certificates; loopback development may use a
3
- // self-signed HTTPS certificate or plain HTTP.
1
+ // Minimal JSON-over-HTTP transport shared by setup-claim redemption, session
2
+ // management and the Entra token endpoint. Validates real TLS certificates;
3
+ // loopback development may use a self-signed HTTPS certificate or plain HTTP.
4
+ //
5
+ // `form` covers OAuth's one deviation: the token endpoint answers JSON but
6
+ // insists on an x-www-form-urlencoded request body.
4
7
 
5
8
  import http from "node:http";
6
9
  import https from "node:https";
@@ -9,13 +12,19 @@ function isLoopbackHostname(hostname) {
9
12
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
10
13
  }
11
14
 
12
- export function httpJson(method, urlStr, { body = null, token = null } = {}) {
15
+ export function httpJson(method, urlStr, { body = null, form = null, token = null } = {}) {
13
16
  const url = new URL(urlStr);
14
17
  const transport = url.protocol === "https:" ? https : http;
15
- const payload = body != null ? JSON.stringify(body) : null;
18
+ const payload = form != null
19
+ ? new URLSearchParams(form).toString()
20
+ : body != null
21
+ ? JSON.stringify(body)
22
+ : null;
16
23
  const headers = { accept: "application/json" };
17
24
  if (payload) {
18
- headers["content-type"] = "application/json";
25
+ headers["content-type"] = form != null
26
+ ? "application/x-www-form-urlencoded"
27
+ : "application/json";
19
28
  headers["content-length"] = Buffer.byteLength(payload);
20
29
  }
21
30
  if (token) headers.authorization = `Bearer ${token}`;
@@ -0,0 +1,75 @@
1
+ // Credential containment for everything this process prints.
2
+ //
3
+ // The CLI holds four kinds of secret — the per-backend `sessionToken`, a
4
+ // Microsoft Graph refresh token, a short-lived Graph access token, and the
5
+ // one-time `smc1_…` setup claim. None of them is ever printed on purpose, and
6
+ // an audit found no path that does. What it did find is a repeating shape that
7
+ // could: a server-controlled RESPONSE body used verbatim as an error detail
8
+ // (`setup-claim.mjs` redeem failures, the Entra token endpoint, every non-2xx
9
+ // bridge call), plus `unknown command: <argv>` echoing a mis-pasted claim.
10
+ // Patching those four sites would leave the fifth one to the next route added.
11
+ //
12
+ // So the containment is value-based and lives at the output boundary instead:
13
+ // this process KNOWS its own secrets, so scrubbing is exact — no regex guessing
14
+ // at what "looks like a token", no false positives on ordinary text. Register a
15
+ // value when it enters memory; `bin/slidemind.mjs` wraps stdout/stderr once so
16
+ // every line, JSON payload and error message passes through `scrub()` on its
17
+ // way out.
18
+ //
19
+ // The replacement reveals ZERO characters of the secret but stays correlatable:
20
+ // the same credential always renders the same tag, so two occurrences in one
21
+ // message are recognisably the same thing — enough to debug with, useless to
22
+ // anyone reading over a shoulder or a pasted transcript.
23
+
24
+ import crypto from "node:crypto";
25
+
26
+ // Below this length a value cannot be told apart from ordinary prose, and
27
+ // scrubbing it would corrupt unrelated output. Every real credential here is
28
+ // far longer; a test fixture that is not simply goes unscrubbed, which is why
29
+ // the containment tests use realistic lengths.
30
+ const MIN_SECRET_LENGTH = 8;
31
+
32
+ const secrets = new Set();
33
+
34
+ // Stable, non-reversible tag for one secret. Four hex characters of sha256 is
35
+ // ~65k buckets: plenty to tell two credentials apart in a single message, far
36
+ // too little to attack the value behind it.
37
+ export function tagFor(value) {
38
+ const hash = crypto.createHash("sha256").update(String(value)).digest("hex");
39
+ return `«secret:${hash.slice(0, 4)}»`;
40
+ }
41
+
42
+ // Add a live secret. Safe to call repeatedly with the same value (a rotating
43
+ // refresh token registers its replacement on every refresh, and the old one
44
+ // stays registered — a stale credential in a printed line is still a leak).
45
+ export function register(value) {
46
+ if (typeof value !== "string") return;
47
+ if (value.length < MIN_SECRET_LENGTH) return;
48
+ secrets.add(value);
49
+ }
50
+
51
+ // Replace every registered secret in `text` with its tag. The common case is an
52
+ // empty set (nothing has been loaded yet) or a handful of entries — one session
53
+ // token plus at most two Graph tokens per connected backend — so this returns
54
+ // the input untouched rather than allocating when there is nothing to do.
55
+ export function scrub(text) {
56
+ if (secrets.size === 0) return text;
57
+ let out = text;
58
+ for (const secret of secrets) {
59
+ if (out.includes(secret)) out = out.replaceAll(secret, tagFor(secret));
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Test-only: the containment suite drives several fake deployments in one
65
+ // process and must not have one case's credentials scrubbed out of another's
66
+ // output.
67
+ export function resetSecrets() {
68
+ secrets.clear();
69
+ }
70
+
71
+ // How many secrets are currently registered. Test-only introspection — nothing
72
+ // in the CLI branches on this.
73
+ export function secretCount() {
74
+ return secrets.size;
75
+ }
@@ -16,6 +16,7 @@
16
16
  import os from "node:os";
17
17
 
18
18
  import { httpJson } from "./http-json.mjs";
19
+ import { register as registerSecret } from "./secrets.mjs";
19
20
 
20
21
  export const SETUP_CLAIM_PREFIX = "smc1_";
21
22
 
@@ -69,6 +70,11 @@ export function decodeSetupClaim(raw) {
69
70
 
70
71
  const secret = typeof parsed.s === "string" ? parsed.s : "";
71
72
  if (!secret) throw invalid("this setup command carries no connection secret.");
73
+ // Both halves: the secret itself, and the whole claim — the blob is a
74
+ // container for the secret, so a message that echoes the claim (a redeem
75
+ // failure quoting the request, an argv echo) leaks it just as surely.
76
+ registerSecret(secret);
77
+ registerSecret(text);
72
78
 
73
79
  let url;
74
80
  try {
@@ -32,6 +32,7 @@ import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import { fileURLToPath, pathToFileURL } from "node:url";
34
34
 
35
+ import { FAVICON_LINK } from "./gate-server.mjs";
35
36
  import { lintSlidesOrThrow } from "./lint.mjs";
36
37
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
37
38
  import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
@@ -195,7 +196,8 @@ const HTML_TEMPLATE = `<!doctype html>
195
196
  <head>
196
197
  <meta charset="utf-8" />
197
198
  <meta name="viewport" content="width=device-width, initial-scale=1" />
198
- <title>__TITLE__</title>
199
+ <title>__TITLE__ — slidemind</title>
200
+ ${FAVICON_LINK}
199
201
  <!-- Arimo: open-source Arial-metric-compatible font. We alias it to
200
202
  "Arial" so master text pinned to Arial gets a font with the same
201
203
  horizontal extents PowerPoint/LibreOffice use, instead of macOS
@@ -13,6 +13,10 @@
13
13
  import crypto from "node:crypto";
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const GATE_SESSION_PATH = path.join(__dirname, "gate-session.js");
16
20
 
17
21
  // Tab-reuse timing contract, shared by both gates and injected into their
18
22
  // overlays as CFG.pollMs / CFG.pollTtlMs:
@@ -20,6 +24,19 @@ import path from "node:path";
20
24
  // open-browser grace window;
21
25
  // POLL_TTL_MS > the gates' 30-min decision timeout so an abandoned tab stops
22
26
  // polling only after the server side has certainly given up.
27
+ // One favicon for every page slidemind opens in a browser (gates, previews,
28
+ // sign-in): an inline data URI, so no server needs a /favicon.ico route and
29
+ // the static preview file stays self-contained.
30
+ export const FAVICON_LINK =
31
+ '<link rel="icon" href="data:image/svg+xml,' +
32
+ encodeURIComponent(
33
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' +
34
+ '<rect width="32" height="32" rx="7" fill="#10131b"/>' +
35
+ '<rect x="6.5" y="9.5" width="19" height="13" rx="3" fill="none" stroke="#a9c2ff" stroke-width="2"/>' +
36
+ "</svg>",
37
+ ) +
38
+ '">';
39
+
23
40
  export const GRACE_MS = 1500;
24
41
  export const POLL_MS = 1000;
25
42
  export const POLL_TTL_MS = 35 * 60 * 1000;
@@ -77,6 +94,138 @@ export function serveStaticFile({ roots, pathname, res }) {
77
94
  return false;
78
95
  }
79
96
 
97
+ // Read a JSON POST body off a gate request and hand the parsed object to `cb`
98
+ // (an unparseable/absent body becomes `{}` — a gate normalizes its own fields
99
+ // anyway). Shared by every gate POST route (plan decision, plan backlog, and
100
+ // the preview gate's decision + backlog next), so the 5 MB flood guard lives in
101
+ // exactly one place. The guard matters: these servers bind loopback but any
102
+ // page in the user's browser can POST to a known localhost port.
103
+ export function readJsonBody(req, cb) {
104
+ // setEncoding matters: without it Node hands over Buffers, and `raw += chunk`
105
+ // stringifies each one on its own — a multi-byte character split across a
106
+ // chunk boundary (the plan gate's editedPlan is 50-60 KB of German markdown)
107
+ // comes out as U+FFFD. With the encoding set, the stream decoder holds the
108
+ // partial sequence back until the next chunk completes it.
109
+ req.setEncoding("utf8");
110
+ let raw = "";
111
+ let bytes = 0;
112
+ req.on("data", (chunk) => {
113
+ raw += chunk;
114
+ // The flood guard counts BYTES, not UTF-16 code units — the limit is about
115
+ // memory, and non-ASCII text costs more bytes than it does string length.
116
+ bytes += Buffer.byteLength(chunk, "utf8");
117
+ if (bytes > 5e6) req.destroy();
118
+ });
119
+ req.on("end", () => {
120
+ let payload;
121
+ try {
122
+ payload = JSON.parse(raw || "{}");
123
+ } catch {
124
+ payload = {};
125
+ }
126
+ cb(payload && typeof payload === "object" ? payload : {});
127
+ });
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // shared gate ROUTES + comment normalization
132
+ //
133
+ // Both gates ran byte-identical copies of the backlog route, the approve-409
134
+ // guard and the row-normalization preamble. One copy, two per-gate field shapes.
135
+ // ---------------------------------------------------------------------------
136
+
137
+ // The identity + text core every gate row shares: `id` dedupes a client retry
138
+ // of the same batch, `round` says which round the comment was written against
139
+ // (a queued row is delivered a round later, so the two can differ), and a row
140
+ // without text is dropped — an empty row prints as an instruction-free bullet.
141
+ // `fields(c)` contributes the gate's own anchor columns.
142
+ export function normalizeGateComments(raw, fields) {
143
+ if (!Array.isArray(raw)) return [];
144
+ const seen = new Set();
145
+ const out = [];
146
+ for (const c of raw) {
147
+ if (!c || typeof c !== "object") continue;
148
+ const text = typeof c.text === "string" ? c.text.trim() : "";
149
+ if (!text) continue;
150
+ const id = typeof c.id === "string" && c.id ? c.id : crypto.randomUUID();
151
+ if (seen.has(id)) continue;
152
+ seen.add(id);
153
+ out.push({
154
+ id,
155
+ round: Number.isFinite(c.round) ? Number(c.round) : 1,
156
+ ...(fields ? fields(c) : {}),
157
+ text,
158
+ at: typeof c.at === "string" && c.at ? c.at : new Date().toISOString(),
159
+ });
160
+ }
161
+ return out;
162
+ }
163
+
164
+ // The reloaded tab flushes its queued rows here. Non-empty ⇒ that run never
165
+ // parks: it resolves at once as a drain round. (The page only POSTs when it HAS
166
+ // a backlog, so the empty branch is just a defensive ack.)
167
+ //
168
+ // `isSuperseded` (the gate's settled check): once another tab's drain or
169
+ // decision has resolved this run, a late backlog must NOT be acked "drained" —
170
+ // the client would flip its rows to "sent" while gate.resolve is a no-op and
171
+ // the reviewer's comments silently vanish. "superseded" keeps them queued, and
172
+ // the tab's heartbeat poll carries them into the next run.
173
+ export function serveBacklogPost(req, res, { normalize, onDrained, isSuperseded = () => false }) {
174
+ readJsonBody(req, (payload) => {
175
+ const comments = normalize(payload.comments);
176
+ const state = !comments.length ? "parked" : isSuperseded() ? "superseded" : "drained";
177
+ res.writeHead(200, { "Content-Type": MIME[".json"] });
178
+ res.end(JSON.stringify({ ok: state !== "superseded", state }));
179
+ if (state === "drained") onDrained(comments);
180
+ });
181
+ }
182
+
183
+ // The decision POST, with the shared 409 guard: once this run has settled —
184
+ // a backlog drained (the displayed round is superseded by comments the agent
185
+ // has already been told to work) or another tab's decision landed — a late
186
+ // decision of EITHER kind is refused. Acking a late "comments" with 200 would
187
+ // mark them sent while gate.resolve is a no-op; the 409 keeps them queued
188
+ // client-side for the next round.
189
+ export function serveDecisionPost(req, res, { normalize, isSuperseded, onDecision }) {
190
+ readJsonBody(req, (payload) => {
191
+ const decision = payload.decision === "comments" ? "comments" : "approve";
192
+ if (isSuperseded()) {
193
+ res.writeHead(409, { "Content-Type": MIME[".json"] });
194
+ res.end(JSON.stringify({ ok: false, error: "backlog_pending" }));
195
+ return;
196
+ }
197
+ res.writeHead(200, { "Content-Type": MIME[".json"] });
198
+ res.end(JSON.stringify({ ok: true }));
199
+ onDecision(decision, payload, decision === "comments" ? normalize(payload.comments) : []);
200
+ });
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // browser bundling
205
+ // ---------------------------------------------------------------------------
206
+
207
+ // Turn a dependency-free ESM module's source into plain browser JS that runs
208
+ // inside a shared IIFE scope: drop single-line `import … from "…";` statements
209
+ // and strip the leading `export ` keyword from declarations. The imported names
210
+ // are provided by an earlier module in the concatenation, so removing the
211
+ // import line just leaves the reference resolving to that top-level binding.
212
+ export function stripModuleSyntax(src) {
213
+ return String(src || "")
214
+ .replace(/^\s*import\s+[^;]*?from\s*["'][^"']+["'];?\s*$/gm, "")
215
+ .replace(/^export\s+/gm, "");
216
+ }
217
+
218
+ // gate-session.js as a browser IIFE exposing window.__SLIDEMIND_GATE__. Both
219
+ // gate pages inline this BEFORE their overlay script — the overlays' whole
220
+ // ledger/backlog/round machinery is that one shared factory.
221
+ export function bundleGateSession() {
222
+ return (
223
+ "(function () {\n" +
224
+ stripModuleSyntax(fs.readFileSync(GATE_SESSION_PATH, "utf8")) +
225
+ "\nwindow.__SLIDEMIND_GATE__ = { createGateSession: createGateSession };\n})();\n"
226
+ );
227
+ }
228
+
80
229
  // The decision promise + timeout wrapper both gates block on. `resolve(value)`
81
230
  // settles the internal promise once (a Promise never re-settles, so repeat calls
82
231
  // are no-ops). `waitForDecision({ timeoutMs })` returns the promise directly when
@@ -91,13 +240,20 @@ export function createDecisionGate({
91
240
  timeoutMessage = "review timed out — no decision received",
92
241
  } = {}) {
93
242
  let resolveDecision;
243
+ let isSettled = false;
94
244
  const decisionPromise = new Promise((res) => {
95
245
  resolveDecision = res;
96
246
  });
97
247
  return {
98
248
  resolve(value) {
249
+ isSettled = true;
99
250
  resolveDecision(value);
100
251
  },
252
+ // Whether a decision/drain already resolved this run — the POST routes
253
+ // refuse late input on it, because a resolve after the first is a no-op.
254
+ settled() {
255
+ return isSettled;
256
+ },
101
257
  waitForDecision({ timeoutMs } = {}) {
102
258
  if (!timeoutMs) return decisionPromise;
103
259
  return new Promise((resolve, reject) => {