red-pen-hub 0.7.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.
package/src/server.js ADDED
@@ -0,0 +1,861 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * Red Pen Hub - the PRO combined project dashboard.
6
+ *
7
+ * A local-only Express app that aggregates Red Pen review notes from every
8
+ * project and every surface into one board. It reads note stores via adapters
9
+ * and normalizes them to the shared Red Pen note model (see RED-PEN-SPEC.md in
10
+ * red-pen-express). No SaaS, no phone-home: it only reads stores you point it at.
11
+ *
12
+ * The board is driven by a CURATED source list in sources.json. The PRO
13
+ * "Add Projects" flow scans a dev directory, detects every Red Pen install
14
+ * regardless of platform, and lets you pick which to add:
15
+ * - file stores (.redpen/notes.json) -> Express, static export, Ableton
16
+ * - WordPress (wp-config.php + the wp-red-pen plugin) -> wp-rest adapter
17
+ *
18
+ * Write-back (v0.3): resolve / reopen a note from the board and the Hub pushes
19
+ * the status to its real source - file store, connected push store, or the WP
20
+ * plugin's REST status route (Application Password auth).
21
+ */
22
+
23
+ const express = require('express');
24
+ const http = require('http');
25
+ const fs = require('fs');
26
+ const os = require('os');
27
+ const path = require('path');
28
+ const crypto = require('crypto');
29
+ const { execFileSync } = require('child_process');
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Runtime data directory. The Hub writes ALL of its state - sources.json (which
33
+ // holds the hub token, the PRO license key, and live WP Application Passwords),
34
+ // plus the connected/ push stores - into a PER-USER data dir, NOT next to its own
35
+ // code. That separation is what lets the Hub run from a read-only npm cache
36
+ // (`npx red-pen-hub`) or a baked single-file executable, where the app folder
37
+ // itself is not writable.
38
+ // Windows: %APPDATA%\red-pen-hub
39
+ // macOS/Linux: $XDG_CONFIG_HOME/red-pen-hub (else ~/.config/red-pen-hub)
40
+ // Override with the RED_PEN_HUB_DATA env var for portable / testing setups.
41
+ // PKG_DIR is the (possibly read-only) app folder - used only for shipped assets
42
+ // and the one-time migration of legacy in-app state.
43
+ // ---------------------------------------------------------------------------
44
+ const PKG_DIR = path.resolve(__dirname, '..');
45
+ function resolveDataDir() {
46
+ if (process.env.RED_PEN_HUB_DATA) return path.resolve(process.env.RED_PEN_HUB_DATA);
47
+ const home = os.homedir();
48
+ if (process.platform === 'win32') {
49
+ return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'red-pen-hub');
50
+ }
51
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'red-pen-hub');
52
+ }
53
+ const DATA_DIR = resolveDataDir();
54
+ const CONFIG_PATH = path.join(DATA_DIR, 'sources.json');
55
+ const CONNECTED_DIR = path.join(DATA_DIR, 'connected');
56
+
57
+ function ensureDataDir() {
58
+ try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (e) { /* surfaced on first write */ }
59
+ try { fs.mkdirSync(CONNECTED_DIR, { recursive: true }); } catch (e) { /* ignore */ }
60
+ }
61
+
62
+ // One-time migration: versions <= 0.6.5 stored state next to the code. On an
63
+ // in-place upgrade, if the new data dir has no sources.json yet but a legacy
64
+ // in-app one exists, carry it (and the connected/ stores) over so every source,
65
+ // the hub token, and the saved license key survive the move. Best-effort and
66
+ // idempotent - it only ever fills gaps, never overwrites new-location state.
67
+ function migrateLegacyState() {
68
+ const legacyCfg = path.join(PKG_DIR, 'sources.json');
69
+ if (legacyCfg !== CONFIG_PATH && !fs.existsSync(CONFIG_PATH) && fs.existsSync(legacyCfg)) {
70
+ try {
71
+ fs.copyFileSync(legacyCfg, CONFIG_PATH);
72
+ try { fs.chmodSync(CONFIG_PATH, 0o600); } catch (e) { /* best-effort */ }
73
+ console.log('Red Pen Hub: migrated sources.json into ' + DATA_DIR);
74
+ } catch (e) { /* best-effort */ }
75
+ }
76
+ const legacyConn = path.join(PKG_DIR, 'connected');
77
+ if (legacyConn !== CONNECTED_DIR && fs.existsSync(legacyConn)) {
78
+ try {
79
+ for (const f of fs.readdirSync(legacyConn)) {
80
+ if (!f.endsWith('.json')) continue;
81
+ const dest = path.join(CONNECTED_DIR, f);
82
+ if (!fs.existsSync(dest)) fs.copyFileSync(path.join(legacyConn, f), dest);
83
+ }
84
+ } catch (e) { /* best-effort */ }
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Config (read + write - the Add Projects flow persists here)
90
+ // ---------------------------------------------------------------------------
91
+ function loadConfig() {
92
+ // Real, saved config lives in the per-user data dir. A fresh install has none
93
+ // and starts empty (no baked-in sources, no machine-specific scan path) - the
94
+ // shipped sources.example.json is documentation only, never loaded as state.
95
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); }
96
+ catch (e) { return {}; }
97
+ }
98
+
99
+ function saveConfig(cfg) {
100
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
101
+ // sources.json holds live WP Application Passwords + the hub token; restrict it to
102
+ // the owner. Best-effort: on Windows chmod maps to limited ACL bits, but it is a
103
+ // no-op-safe hardening on POSIX and never throws the request.
104
+ try { fs.chmodSync(CONFIG_PATH, 0o600); } catch (e) { /* best-effort */ }
105
+ }
106
+
107
+ ensureDataDir();
108
+ migrateLegacyState();
109
+ let cfg = loadConfig();
110
+ const PORT = process.env.PORT || cfg.port || 3900;
111
+
112
+ // A shared token sites use to push to this Hub (the "connect" credential).
113
+ // Generated once, persisted, never phoned home anywhere.
114
+ if (!cfg.hubToken) { cfg.hubToken = crypto.randomBytes(16).toString('hex'); saveConfig(cfg); }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // PRO license gate (offline Ed25519 signed key). The SAME key that unlocks the
118
+ // WordPress plugin unlocks the Hub - verified locally against the embedded public
119
+ // key, no phone-home, no expiry, no revocation. Unlicensed, the board aggregates
120
+ // up to FREE_PROJECT_CAP projects (a real try-before-buy); a valid key lifts the
121
+ // cap to unlimited. The public key can only verify, never mint - safe to ship.
122
+ // ---------------------------------------------------------------------------
123
+ const PRO_PUBKEY_B64 = 'wFHUq3h8K8Kw8zKfpN7sjFT49529e8ACkbXJYmnR8h4=';
124
+ const FREE_PROJECT_CAP = 3;
125
+ // Reconstruct an Ed25519 SPKI public key from the raw 32 bytes (12-byte DER prefix
126
+ // + the raw key), so Node's crypto verifies the SAME keys PHP's libsodium signs against.
127
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
128
+ function b64urlToBuf(s) { return Buffer.from(String(s || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64'); }
129
+ // Verify a license key ("base64url(email|YYYYMMDD).base64url(sig)"). Returns { email } or null.
130
+ function verifyLicense(key) {
131
+ key = String(key || '').trim();
132
+ if (!key || (key.split('.').length !== 2)) return null;
133
+ try {
134
+ const [pEnc, sEnc] = key.split('.');
135
+ const payload = b64urlToBuf(pEnc), sig = b64urlToBuf(sEnc);
136
+ const rawPub = Buffer.from(PRO_PUBKEY_B64, 'base64');
137
+ if (sig.length !== 64 || rawPub.length !== 32) return null;
138
+ const pubKey = crypto.createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, rawPub]), format: 'der', type: 'spki' });
139
+ if (!crypto.verify(null, payload, pubKey, sig)) return null;
140
+ const email = payload.toString('utf8').split('|')[0];
141
+ return email ? { email } : null;
142
+ } catch (e) { return null; }
143
+ }
144
+ // Live license status off the stored key (single source of truth, no drift).
145
+ function licenseStatus() {
146
+ const info = verifyLicense(cfg.licenseKey || '');
147
+ return { licensed: !!info, email: info ? info.email : null, projectCap: FREE_PROJECT_CAP };
148
+ }
149
+ // Default directory the Add Projects scanner prefills. Once a user has saved a
150
+ // scan root (or has any config), that wins; on a truly fresh install there is no
151
+ // machine to guess, so fall back to the working directory the Hub was launched
152
+ // from - for `npx red-pen-hub` that is the dev's own project folder.
153
+ function scanRoots() {
154
+ const roots = cfg.scanRoots || cfg.discover;
155
+ if (roots && roots.length) return roots;
156
+ return [process.cwd()];
157
+ }
158
+ function sourcesList() { return Array.isArray(cfg.sources) ? cfg.sources : []; }
159
+
160
+ // Repo path map: project name -> local working-tree path. The Hub runs git checks
161
+ // there so the Sources tab can show each project's clean / dirty git status.
162
+ function repoPaths() { return (cfg.repoPaths && typeof cfg.repoPaths === 'object') ? cfg.repoPaths : {}; }
163
+
164
+ function gitStatusFor(repoPath) {
165
+ if (!repoPath) return { hasPath: false };
166
+ if (!fs.existsSync(repoPath)) return { hasPath: true, exists: false, path: repoPath };
167
+ function git(args) {
168
+ return execFileSync('git', ['-C', repoPath].concat(args), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
169
+ }
170
+ try {
171
+ if (git(['rev-parse', '--is-inside-work-tree']) !== 'true') {
172
+ return { hasPath: true, exists: true, path: repoPath, isRepo: false };
173
+ }
174
+ let branch = '(detached)';
175
+ try { branch = git(['rev-parse', '--abbrev-ref', 'HEAD']); } catch (e) { /* detached */ }
176
+ const porcelain = git(['status', '--porcelain']);
177
+ const dirty = porcelain ? porcelain.split('\n').filter(Boolean).length : 0;
178
+ return { hasPath: true, exists: true, path: repoPath, isRepo: true, branch: branch, dirty: dirty, clean: dirty === 0 };
179
+ } catch (e) {
180
+ return { hasPath: true, exists: true, path: repoPath, isRepo: false, error: String((e && e.message) || e).slice(0, 200) };
181
+ }
182
+ }
183
+
184
+ // A stable key per source so the UI can dedupe / remove.
185
+ function sourceKey(s) {
186
+ return (s.type === 'wp-rest' ? 'wp:' + s.url : 'file:' + path.resolve(s.path)).toLowerCase();
187
+ }
188
+
189
+ // Directories we never descend into during a scan.
190
+ const SKIP_DIRS = new Set([
191
+ 'node_modules', '.git', 'vendor', 'wp-admin', 'wp-includes',
192
+ 'dist', 'build', '.cache', '_debug'
193
+ ]);
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Normalization - everything becomes the shared Red Pen note model + metadata
197
+ // ---------------------------------------------------------------------------
198
+ // The id the board uses to address a note for write-back. Must match the raw
199
+ // note exactly so setStatusInFile() can find it again - keep this the single
200
+ // source of truth for note identity (used by normalizeNote + write-back).
201
+ function noteId(raw) {
202
+ raw = raw || {};
203
+ return String(raw.id != null ? raw.id : (raw.created != null ? raw.created : Math.abs(hashStr(JSON.stringify(raw)))));
204
+ }
205
+
206
+ function normalizeNote(raw, meta) {
207
+ raw = raw || {};
208
+ return {
209
+ id: noteId(raw),
210
+ body: raw.body || raw.text || raw.note || '',
211
+ type: raw.type || 'note',
212
+ typeColor: raw.typeColor || '',
213
+ priority: raw.priority || 'normal',
214
+ // WP emits both a raw post_status (wprp_resolved) and a normalized statusKey
215
+ // (open/progress/resolved); prefer the normalized one so the board buckets it
216
+ // correctly. file/static stores have no statusKey, so the fallback preserves them.
217
+ status: raw.statusKey || raw.status || 'open',
218
+ page: raw.page || raw.url || '',
219
+ url: raw.url || raw.page || '',
220
+ anchor: raw.anchor || raw.selector || '',
221
+ createdAt: raw.createdAt || raw.created || raw.date || null,
222
+ // Only accept a real timestamp string here. WP sends a boolean `resolved` flag
223
+ // which must NOT become the Resolved date (new Date(true) is epoch+1ms).
224
+ resolvedAt: raw.resolvedAt || (typeof raw.resolved === 'string' ? raw.resolved : null),
225
+ // Identity + thread signal the source surfaces already populate (WP: author/assignee/
226
+ // resolvedBy + a replies array). Carried through so the board can attribute and count
227
+ // them; blank/0 on surfaces that don't supply them, never faked.
228
+ author: raw.author || '',
229
+ assignee: raw.assigneeName || (typeof raw.assignee === 'string' ? raw.assignee : '') || '',
230
+ resolvedBy: raw.resolvedBy || '',
231
+ replyCount: Array.isArray(raw.replies) ? raw.replies.length : (+raw.replyCount || 0),
232
+ // Forward-compatible fields no surface emits yet: severity (impact, distinct from
233
+ // priority/scheduling) and duplicate/related links. Carried + rendered only when
234
+ // present so they light up the moment a surface starts sending them.
235
+ severity: raw.severity || '',
236
+ dupeOf: raw.dupeOf || raw.duplicateOf || '',
237
+ relatedTo: raw.relatedTo || '',
238
+ project: meta.project,
239
+ surface: meta.surface,
240
+ source: meta.source,
241
+ sourceKey: meta.key || ''
242
+ };
243
+ }
244
+
245
+ function hashStr(s) {
246
+ let h = 0;
247
+ for (let i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; }
248
+ return h;
249
+ }
250
+
251
+ function notesFromJson(parsed) {
252
+ if (Array.isArray(parsed)) return parsed;
253
+ if (parsed && Array.isArray(parsed.notes)) return parsed.notes;
254
+ return [];
255
+ }
256
+
257
+ function readJsonFile(file) {
258
+ try { return notesFromJson(JSON.parse(fs.readFileSync(file, 'utf8'))); }
259
+ catch (e) { return []; }
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Adapters
264
+ // ---------------------------------------------------------------------------
265
+ function readFileSource(file, meta) {
266
+ return readJsonFile(file).map((n) => normalizeNote(n, meta));
267
+ }
268
+
269
+ async function readWpRestSource(src) {
270
+ const meta = { project: src.label || src.url, surface: 'wordpress', source: src.url, key: sourceKey(src) };
271
+ const base = String(src.url).replace(/\/+$/, '');
272
+ // scope=all pulls every note on the site (all targets), not just the target=0
273
+ // site-wide default - otherwise page/post-attached notes never reach the board.
274
+ const endpoint = base + '/wp-json/wprp/v1/notes?scope=all';
275
+ const headers = { 'Accept': 'application/json' };
276
+ if (src.user && src.appPassword) {
277
+ const token = Buffer.from(src.user + ':' + src.appPassword).toString('base64');
278
+ headers['Authorization'] = 'Basic ' + token;
279
+ }
280
+ const controller = new AbortController();
281
+ const timer = setTimeout(() => controller.abort(), 6000);
282
+ try {
283
+ const res = await safeFetch(endpoint, { headers }, controller.signal);
284
+ if (res.status === 401 || res.status === 403) {
285
+ return { meta, notes: [], error: 'auth (needs an Application Password)' };
286
+ }
287
+ if (!res.ok) return { meta, notes: [], error: 'HTTP ' + res.status + ' (check site URL / plugin active)' };
288
+ const text = await res.text();
289
+ let data;
290
+ try { data = JSON.parse(text); }
291
+ catch (e) { return { meta, notes: [], error: 'not a REST endpoint (check the site URL)' }; }
292
+ return { meta, notes: notesFromJson(data).map((n) => normalizeNote(n, meta)) };
293
+ } catch (e) {
294
+ return { meta, notes: [], error: e.name === 'AbortError' ? 'timeout (site not responding)' : String(e.message || e) };
295
+ } finally {
296
+ clearTimeout(timer);
297
+ }
298
+ }
299
+
300
+ // Connected stores - projects that PUSH to this Hub via /api/ingest.
301
+ // They self-register (one JSON per project under connected/) on first push,
302
+ // so no "add" step is needed for a connected site.
303
+ function readConnectedStores() {
304
+ let files;
305
+ try { files = fs.readdirSync(CONNECTED_DIR).filter((f) => f.endsWith('.json')); }
306
+ catch (e) { return []; }
307
+ return files.map((f) => {
308
+ try {
309
+ const data = JSON.parse(fs.readFileSync(path.join(CONNECTED_DIR, f), 'utf8'));
310
+ const key = 'conn:' + f.replace(/\.json$/, '');
311
+ const meta = { project: data.project, surface: data.surface || 'wordpress', source: 'connected', key: key };
312
+ const notes = (Array.isArray(data.notes) ? data.notes : []).map((n) => normalizeNote(n, meta));
313
+ return { key: key, label: data.project, surface: meta.surface, notes, lastSync: data.lastSync };
314
+ } catch (e) { return null; }
315
+ }).filter(Boolean);
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Aggregation - curated pull sources + connected push stores
320
+ // ---------------------------------------------------------------------------
321
+ async function aggregate() {
322
+ // Fetch all pull sources concurrently - WP fetches are time-bound so one slow
323
+ // site cannot stall the board.
324
+ const results = await Promise.all(sourcesList().map(async (src) => {
325
+ if (src.type === 'wp-rest') {
326
+ const r = await readWpRestSource(src);
327
+ return { notes: r.notes, source: { key: sourceKey(src), label: r.meta.project, surface: 'wordpress', source: src.url, count: r.notes.length, error: r.error } };
328
+ }
329
+ const meta = { project: src.label || src.path, surface: src.surface || 'file', source: src.path, key: sourceKey(src) };
330
+ const got = readFileSource(src.path, meta);
331
+ return { notes: got, source: { key: sourceKey(src), label: meta.project, surface: meta.surface, source: src.path, count: got.length } };
332
+ }));
333
+ const notes = [];
334
+ const sources = [];
335
+ for (const r of results) { notes.push(...r.notes); sources.push(r.source); }
336
+ // Connected (pushed) projects
337
+ for (const c of readConnectedStores()) {
338
+ notes.push(...c.notes);
339
+ sources.push({ key: c.key, label: c.label, surface: c.surface, source: 'connected (pushed)', count: c.notes.length, connected: true, lastSync: c.lastSync });
340
+ }
341
+ return { notes, sources, generatedAt: new Date().toISOString() };
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Scanning (the Add Projects flow) - detect every Red Pen install under a root
346
+ // ---------------------------------------------------------------------------
347
+ function findRedpenStores(root, depth, out) {
348
+ if (depth > 7) return out;
349
+ let ents;
350
+ try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { return out; }
351
+ for (const e of ents) {
352
+ if (!e.isDirectory()) continue;
353
+ if (SKIP_DIRS.has(e.name)) continue;
354
+ const dir = path.join(root, e.name);
355
+ if (e.name === '.redpen') {
356
+ const nf = path.join(dir, 'notes.json');
357
+ if (fs.existsSync(nf)) out.push(nf);
358
+ } else {
359
+ findRedpenStores(dir, depth + 1, out);
360
+ }
361
+ }
362
+ return out;
363
+ }
364
+
365
+ function findWpInstalls(root, depth, out) {
366
+ if (depth > 7) return out;
367
+ let ents;
368
+ try { ents = fs.readdirSync(root, { withFileTypes: true }); } catch (e) { return out; }
369
+ const names = new Set(ents.map((e) => e.name));
370
+ if (names.has('wp-config.php')) {
371
+ // This is a WordPress root - record if the Red Pen plugin is present, don't descend.
372
+ const plugin = path.join(root, 'wp-content', 'plugins', 'wp-red-pen', 'wp-red-pen.php');
373
+ if (fs.existsSync(plugin)) out.push(root);
374
+ return out;
375
+ }
376
+ for (const e of ents) {
377
+ if (!e.isDirectory()) continue;
378
+ if (SKIP_DIRS.has(e.name) || e.name === '.redpen') continue;
379
+ findWpInstalls(path.join(root, e.name), depth + 1, out);
380
+ }
381
+ return out;
382
+ }
383
+
384
+ // project label = path from the scan root down to the project (minus /.redpen/notes.json)
385
+ function labelFromStore(file, root) {
386
+ let rel = path.relative(root, file).replace(/[\\/]\.redpen[\\/]notes\.json$/i, '');
387
+ rel = rel.split(path.sep).join('/');
388
+ return rel || path.basename(path.dirname(path.dirname(file)));
389
+ }
390
+
391
+ // express if a package.json sits anywhere from the store's project dir up to root, else static
392
+ function inferSurface(file, root) {
393
+ let dir = path.dirname(path.dirname(file)); // dir holding .redpen
394
+ const stop = path.resolve(root);
395
+ for (let i = 0; i < 8; i++) {
396
+ if (fs.existsSync(path.join(dir, 'package.json'))) return 'express';
397
+ if (path.resolve(dir) === stop) break;
398
+ const up = path.dirname(dir);
399
+ if (up === dir) break;
400
+ dir = up;
401
+ }
402
+ return 'static';
403
+ }
404
+
405
+ const HTDOCS = 'c:\\xampp\\htdocs\\';
406
+ function guessLocalUrl(installDir) {
407
+ const abs = path.resolve(installDir);
408
+ if (abs.toLowerCase().startsWith(HTDOCS)) {
409
+ const rel = abs.slice(HTDOCS.length).split(path.sep).map(encodeURIComponent).join('/');
410
+ return 'http://localhost/' + rel;
411
+ }
412
+ return '';
413
+ }
414
+
415
+ function scan(root) {
416
+ const abs = path.resolve(root);
417
+ const existing = new Set(sourcesList().map(sourceKey));
418
+ const candidates = [];
419
+
420
+ for (const file of findRedpenStores(abs, 0, [])) {
421
+ const key = ('file:' + path.resolve(file)).toLowerCase();
422
+ candidates.push({
423
+ key, type: 'file', label: labelFromStore(file, abs),
424
+ surface: inferSurface(file, abs), path: file,
425
+ count: readJsonFile(file).length, already: existing.has(key)
426
+ });
427
+ }
428
+ for (const install of findWpInstalls(abs, 0, [])) {
429
+ const url = guessLocalUrl(install);
430
+ const key = ('wp:' + url).toLowerCase();
431
+ candidates.push({
432
+ key, type: 'wp-rest', label: path.basename(install),
433
+ surface: 'wordpress', url, install, needsAuth: true, already: existing.has(key)
434
+ });
435
+ }
436
+ candidates.sort((a, b) => a.label.localeCompare(b.label));
437
+ return { root: abs, candidates };
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Write-back - resolve / reopen a note at its real source of truth
442
+ // ---------------------------------------------------------------------------
443
+ function sourceByKey(key) {
444
+ return sourcesList().find((s) => sourceKey(s) === key) || null;
445
+ }
446
+
447
+ // Flip a note's status inside a raw notes file, preserving the file's shape
448
+ // (a bare array, or a { notes: [...] } wrapper). Used for both file sources and
449
+ // connected push stores. For a connected store, pass recordOverride=true so the
450
+ // change is remembered in `_hubOverrides` and re-applied when the site pushes again
451
+ // (applyOverrides on ingest) - otherwise the next push would silently revert it.
452
+ function setStatusInFile(file, id, status, recordOverride) {
453
+ let parsed;
454
+ try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
455
+ catch (e) { return { ok: false, error: 'could not read store' }; }
456
+ const arr = Array.isArray(parsed) ? parsed : (Array.isArray(parsed.notes) ? parsed.notes : null);
457
+ if (!arr) return { ok: false, error: 'unexpected store shape' };
458
+ const note = arr.find((n) => noteId(n) === id);
459
+ if (!note) return { ok: false, error: 'note not found in store' };
460
+ note.status = status;
461
+ // Stamp a resolved timestamp so the board can show when a note was closed;
462
+ // clear it if the note is reopened.
463
+ if (status === 'resolved') { note.resolvedAt = note.resolvedAt || new Date().toISOString(); }
464
+ else { delete note.resolvedAt; }
465
+ if (recordOverride && parsed && !Array.isArray(parsed)) {
466
+ parsed._hubOverrides = parsed._hubOverrides || {};
467
+ parsed._hubOverrides[id] = { status: status, resolvedAt: note.resolvedAt || null, at: new Date().toISOString() };
468
+ }
469
+ try { fs.writeFileSync(file, JSON.stringify(parsed, null, 2) + '\n', 'utf8'); }
470
+ catch (e) { return { ok: false, error: 'could not write store' }; }
471
+ return { ok: true };
472
+ }
473
+
474
+ // Re-apply the Hub's local status changes on top of a fresh push from a connected
475
+ // site. For each override: if the note is gone, or the site has caught up (its pushed
476
+ // status already matches the override), drop the override; otherwise force the Hub's
477
+ // status/resolvedAt onto the incoming note and keep the override until the site agrees.
478
+ // Returns the (mutated) notes plus the surviving overrides to persist.
479
+ function applyOverrides(notes, overrides) {
480
+ if (!overrides) return { notes: notes, overrides: {} };
481
+ const byId = {};
482
+ notes.forEach((n) => { byId[noteId(n)] = n; });
483
+ const surviving = {};
484
+ Object.keys(overrides).forEach((id) => {
485
+ const ov = overrides[id];
486
+ const n = byId[id];
487
+ if (!n) return; // note removed on the site - drop it
488
+ if (String(n.status || 'open') === String(ov.status)) return; // site caught up - drop it
489
+ n.status = ov.status;
490
+ if (ov.status === 'resolved') { if (ov.resolvedAt) n.resolvedAt = ov.resolvedAt; }
491
+ else { delete n.resolvedAt; }
492
+ surviving[id] = ov;
493
+ });
494
+ return { notes: notes, overrides: surviving };
495
+ }
496
+
497
+ // Push a status change to a WordPress source via the wp-red-pen plugin's
498
+ // POST /notes/{id}/status route, authenticated with the source's Application Password.
499
+ async function setStatusWp(src, id, status) {
500
+ if (!src.user || !src.appPassword) return { ok: false, error: 'no Application Password set for this source' };
501
+ const base = String(src.url).replace(/\/+$/, '');
502
+ const endpoint = base + '/wp-json/wprp/v1/notes/' + encodeURIComponent(id) + '/status';
503
+ const token = Buffer.from(src.user + ':' + src.appPassword).toString('base64');
504
+ const ctrl = new AbortController();
505
+ const timer = setTimeout(() => ctrl.abort(), 6000);
506
+ try {
507
+ const res = await safeFetch(endpoint, {
508
+ method: 'POST',
509
+ headers: { 'Authorization': 'Basic ' + token, 'Content-Type': 'application/json' },
510
+ body: JSON.stringify({ status: status })
511
+ }, ctrl.signal);
512
+ if (res.status === 401 || res.status === 403) return { ok: false, error: 'auth (Application Password lacks edit rights)' };
513
+ if (!res.ok) return { ok: false, error: 'HTTP ' + res.status };
514
+ return { ok: true };
515
+ } catch (e) {
516
+ return { ok: false, error: e.name === 'AbortError' ? 'timeout (site not responding)' : String(e.message || e) };
517
+ } finally {
518
+ clearTimeout(timer);
519
+ }
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Security helpers
524
+ // ---------------------------------------------------------------------------
525
+ // Constant-time compare for the hub token - it is the app's only auth boundary and
526
+ // is injected into the served board page, so a timing side-channel is worth closing.
527
+ function tokenEq(a, b) {
528
+ a = String(a == null ? '' : a);
529
+ b = String(b == null ? '' : b);
530
+ if (!b || a.length !== b.length) return false;
531
+ try { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } catch (e) { return false; }
532
+ }
533
+
534
+ // Host allow-list: the Hub binds loopback only, so any request whose Host header is not
535
+ // localhost/127.0.0.1/[::1] is a DNS-rebinding attempt (a hostile page resolving its own
536
+ // name to 127.0.0.1) or misrouted. Rejecting it protects GET / (which injects the token)
537
+ // and /api/ingest (token-body-gated, exempt from the header gate).
538
+ function isLoopbackHost(hostHeader) {
539
+ let h = String(hostHeader || '').trim().toLowerCase();
540
+ if (!h) return false;
541
+ const m = h.match(/^\[([^\]]+)\](?::\d+)?$/); // [::1]:3900
542
+ if (m) { h = m[1]; } else { h = h.replace(/:\d+$/, ''); }
543
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1';
544
+ }
545
+
546
+ // The Hub deliberately fetches LOCAL dev sites (localhost / LAN WordPress installs), so
547
+ // blocking internal addresses would break its core job. What we DO enforce on any URL
548
+ // derived from source/note data: it must parse and be http(s) (no file:/data:/gopher:),
549
+ // and redirects are followed manually with per-hop re-validation and a small cap - so a
550
+ // site cannot 30x the Hub onto a hostile scheme or into a redirect loop.
551
+ async function safeFetch(rawUrl, opts, signal) {
552
+ let url = String(rawUrl || '');
553
+ for (let hop = 0; hop < 4; hop++) {
554
+ let u;
555
+ try { u = new URL(url); } catch (e) { throw new Error('invalid URL'); }
556
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('URL must be http or https');
557
+ const res = await fetch(u.href, Object.assign({ redirect: 'manual', signal: signal }, opts || {}));
558
+ if (res.status >= 300 && res.status < 400) {
559
+ const loc = res.headers.get('location');
560
+ if (!loc) return res;
561
+ url = new URL(loc, u).href; // re-validate the redirect target on the next pass
562
+ continue;
563
+ }
564
+ return res;
565
+ }
566
+ throw new Error('too many redirects');
567
+ }
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // Server
571
+ // ---------------------------------------------------------------------------
572
+ const app = express();
573
+ // Reject non-loopback Host headers before anything else (DNS-rebinding guard).
574
+ app.use((req, res, next) => {
575
+ if (!isLoopbackHost(req.headers.host)) return res.status(403).json({ error: 'forbidden host' });
576
+ next();
577
+ });
578
+ app.use(express.json());
579
+ app.use(express.static(path.join(__dirname, 'public')));
580
+
581
+ // Token gate: every /api route requires the hub token in an x-hub-token header,
582
+ // EXCEPT /api/ingest (which authenticates via the token in its JSON body so external
583
+ // sites can push cross-origin) and CORS preflights. The board page is served with the
584
+ // token injected, so its same-origin fetches pass; anything else on the box does not.
585
+ app.use('/api', (req, res, next) => {
586
+ if (req.path === '/ingest' || req.method === 'OPTIONS') return next();
587
+ if (!tokenEq(req.get('x-hub-token'), cfg.hubToken)) {
588
+ return res.status(401).json({ error: 'unauthorized' });
589
+ }
590
+ next();
591
+ });
592
+
593
+ // Resolve a "conn:<slug>" board key to its store file under CONNECTED_DIR, hardened
594
+ // against path traversal. Returns '' if the slug is missing or would escape the dir.
595
+ function connFile(key) {
596
+ const slug = String(key || '').replace(/^conn:/, '');
597
+ if (!slug || !/^[a-z0-9-]+$/.test(slug)) return '';
598
+ const p = path.resolve(CONNECTED_DIR, slug + '.json');
599
+ const base = path.resolve(CONNECTED_DIR) + path.sep;
600
+ return p.slice(0, base.length) === base ? p : '';
601
+ }
602
+
603
+ app.get('/api/notes', async (req, res) => {
604
+ try {
605
+ const data = await aggregate();
606
+ const lic = licenseStatus();
607
+ data.license = { licensed: lic.licensed, licensedTo: lic.email, projectCap: FREE_PROJECT_CAP, totalProjects: data.sources.length, capped: false };
608
+ // Unlicensed: cap the board to the first FREE_PROJECT_CAP projects (keep their notes only).
609
+ // Sources are still fully configurable on the Sources tab; the cap is only on what the board aggregates.
610
+ if (!lic.licensed && data.sources.length > FREE_PROJECT_CAP) {
611
+ const keep = new Set(data.sources.slice(0, FREE_PROJECT_CAP).map((s) => s.key));
612
+ data.sources = data.sources.filter((s) => keep.has(s.key));
613
+ data.notes = data.notes.filter((n) => keep.has(n.sourceKey));
614
+ data.license.capped = true;
615
+ }
616
+ res.json(data);
617
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
618
+ });
619
+
620
+ // PRO license: read status, or save a key. The key is verified LOCALLY (offline Ed25519);
621
+ // saving persists it to sources.json (0600). Same key as the WordPress plugin.
622
+ app.get('/api/license', (req, res) => { res.json(licenseStatus()); });
623
+ app.post('/api/license', (req, res) => {
624
+ const key = (req.body && typeof req.body.key === 'string') ? req.body.key.trim() : '';
625
+ cfg.licenseKey = key;
626
+ saveConfig(cfg);
627
+ const info = verifyLicense(key);
628
+ res.json({ licensed: !!info, email: info ? info.email : null, projectCap: FREE_PROJECT_CAP, saved: true });
629
+ });
630
+
631
+ // List curated sources + the default scan root for the modal.
632
+ app.get('/api/sources', (req, res) => {
633
+ res.json({
634
+ sources: sourcesList().map((s) => ({ key: sourceKey(s), type: s.type, label: s.label, surface: s.surface || (s.type === 'wp-rest' ? 'wordpress' : 'file'), path: s.path, url: s.url })),
635
+ scanRoots: scanRoots()
636
+ });
637
+ });
638
+
639
+ // Scan a directory for Red Pen installs of every platform.
640
+ app.get('/api/scan', (req, res) => {
641
+ const root = req.query.root || scanRoots()[0];
642
+ try { res.json(scan(root)); }
643
+ catch (e) { res.status(500).json({ error: String(e.message || e) }); }
644
+ });
645
+
646
+ // Add selected sources (the confirm step). Body: { add: [source, ...] }.
647
+ app.post('/api/sources', (req, res) => {
648
+ const add = Array.isArray(req.body && req.body.add) ? req.body.add : [];
649
+ const have = new Set(sourcesList().map(sourceKey));
650
+ const next = sourcesList().slice();
651
+ let added = 0;
652
+ for (const s of add) {
653
+ const clean = s.type === 'wp-rest'
654
+ ? { type: 'wp-rest', label: s.label || s.url, url: s.url, user: s.user || '', appPassword: s.appPassword || '', surface: 'wordpress' }
655
+ : { type: 'file', label: s.label || s.path, path: s.path, surface: s.surface || 'file' };
656
+ if (!clean.url && !clean.path) continue;
657
+ // File sources are read (and written on status change) directly off disk, so pin the
658
+ // path to a resolved, existing location at add time - the board can only ever touch
659
+ // paths that were deliberately registered here, never an arbitrary one from a request.
660
+ if (clean.type === 'file') {
661
+ const rp = path.resolve(String(clean.path));
662
+ if (!fs.existsSync(rp)) continue;
663
+ clean.path = rp;
664
+ }
665
+ const k = sourceKey(clean);
666
+ if (have.has(k)) continue;
667
+ have.add(k); next.push(clean); added++;
668
+ }
669
+ cfg.sources = next;
670
+ saveConfig(cfg);
671
+ res.json({ added, sources: next.map((s) => ({ key: sourceKey(s), type: s.type, label: s.label })) });
672
+ });
673
+
674
+ // Remove a source by key. Body: { key }.
675
+ app.post('/api/sources/remove', (req, res) => {
676
+ const key = req.body && req.body.key;
677
+ cfg.sources = sourcesList().filter((s) => sourceKey(s) !== key);
678
+ saveConfig(cfg);
679
+ res.json({ removed: key, sources: cfg.sources.map((s) => ({ key: sourceKey(s), type: s.type, label: s.label })) });
680
+ });
681
+
682
+ // The connect credentials a site needs to push to this Hub.
683
+ app.get('/api/connect-info', (req, res) => {
684
+ res.json({ url: 'http://localhost:' + PORT, token: cfg.hubToken });
685
+ });
686
+
687
+ // Repo-path map the Hub owns - powers the passive git-status indicator in the Sources tab.
688
+ app.get('/api/repo-paths', (req, res) => { res.json({ paths: repoPaths() }); });
689
+ app.post('/api/repo-paths', (req, res) => {
690
+ const b = req.body || {};
691
+ cfg.repoPaths = repoPaths();
692
+ if (b.project) {
693
+ const p = String(b.path || '').trim();
694
+ if (p) cfg.repoPaths[b.project] = p; else delete cfg.repoPaths[b.project];
695
+ } else if (b.paths && typeof b.paths === 'object') {
696
+ cfg.repoPaths = b.paths;
697
+ }
698
+ saveConfig(cfg);
699
+ res.json({ paths: cfg.repoPaths });
700
+ });
701
+
702
+ // Git status for a set of projects (passive clean/dirty indicator). Body: { projects: [names] }.
703
+ app.post('/api/git-status', (req, res) => {
704
+ const projects = Array.isArray(req.body && req.body.projects) ? req.body.projects : [];
705
+ const map = repoPaths();
706
+ const out = {};
707
+ projects.forEach((proj) => { out[proj] = gitStatusFor(map[proj]); });
708
+ res.json({ status: out });
709
+ });
710
+
711
+ // A connected site pushes its notes here. Body: { token, project, surface, notes }.
712
+ // CORS for browser-based pushes (the static red-pen.js surface posts cross-origin
713
+ // from a site's own origin to this Hub on :3900). Token-gated + local-only, so a
714
+ // permissive origin is safe here. Preflight OPTIONS gets a bare 204.
715
+ app.use('/api/ingest', (req, res, next) => {
716
+ res.header('Access-Control-Allow-Origin', '*');
717
+ res.header('Access-Control-Allow-Methods', 'POST, OPTIONS');
718
+ res.header('Access-Control-Allow-Headers', 'Content-Type');
719
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
720
+ next();
721
+ });
722
+
723
+ app.post('/api/ingest', (req, res) => {
724
+ const b = req.body || {};
725
+ if (!tokenEq(b.token, cfg.hubToken)) return res.status(401).json({ error: 'invalid token' });
726
+ if (!b.project) return res.status(400).json({ error: 'project required' });
727
+ const slug = String(b.project).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
728
+ const file = path.join(CONNECTED_DIR, slug + '.json');
729
+ // Re-apply any Hub-side status changes (resolve/reopen/start) recorded for this store
730
+ // so a push from the site doesn't silently revert them; keep the ones the site hasn't
731
+ // caught up to yet.
732
+ let prevOverrides = null;
733
+ try { prevOverrides = (JSON.parse(fs.readFileSync(file, 'utf8')) || {})._hubOverrides || null; } catch (e) { /* first push */ }
734
+ const merged = applyOverrides(Array.isArray(b.notes) ? b.notes : [], prevOverrides);
735
+ const payload = {
736
+ project: b.project,
737
+ surface: b.surface || 'wordpress',
738
+ notes: merged.notes,
739
+ lastSync: new Date().toISOString()
740
+ };
741
+ if (Object.keys(merged.overrides).length) payload._hubOverrides = merged.overrides;
742
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2));
743
+ // Two-way sync: hand the pushing site back the status changes the Hub made to its
744
+ // notes (the surviving overrides), so the origin can apply them locally and reflect a
745
+ // Hub-side resolve/reopen/start. Each entry is { status, resolvedAt }.
746
+ res.json({ ok: true, project: b.project, count: payload.notes.length, held: Object.keys(merged.overrides).length, changes: merged.overrides });
747
+ });
748
+
749
+ // Write-back: set a note's status at its source. Body: { key, id, status }.
750
+ // key is the note's sourceKey (conn:<slug> | file:<path> | wp:<url>); status is
751
+ // one of open|progress|resolved. Routes to the right adapter by key prefix.
752
+ app.post('/api/note-status', async (req, res) => {
753
+ const b = req.body || {};
754
+ const key = String(b.key || '');
755
+ const id = String(b.id || '');
756
+ const status = String(b.status || '');
757
+ if (!key || !id || !status) return res.status(400).json({ error: 'key, id and status required' });
758
+ if (!new Set(['open', 'progress', 'resolved']).has(status)) return res.status(400).json({ error: 'invalid status' });
759
+ try {
760
+ if (key.indexOf('conn:') === 0) {
761
+ const file = connFile(key);
762
+ if (!file) return res.status(400).json({ error: 'invalid key' });
763
+ const r = setStatusInFile(file, id, status, true);
764
+ return r.ok ? res.json({ ok: true, local: true }) : res.status(400).json(r);
765
+ }
766
+ const src = sourceByKey(key);
767
+ if (!src) return res.status(404).json({ error: 'unknown source' });
768
+ if (src.type === 'wp-rest') {
769
+ const r = await setStatusWp(src, id, status);
770
+ return r.ok ? res.json({ ok: true }) : res.status(400).json(r);
771
+ }
772
+ const r = setStatusInFile(path.resolve(src.path), id, status);
773
+ return r.ok ? res.json({ ok: true }) : res.status(400).json(r);
774
+ } catch (e) {
775
+ res.status(500).json({ error: String(e.message || e) });
776
+ }
777
+ });
778
+
779
+ // Forget a connected project. Body: { key } (the "conn:<slug>" key from the board).
780
+ app.post('/api/connected/remove', (req, res) => {
781
+ const key = (req.body && req.body.key) || '';
782
+ const file = connFile(key);
783
+ if (file) { try { fs.unlinkSync(file); } catch (e) { /* gone */ } }
784
+ res.json({ removed: key });
785
+ });
786
+
787
+ // Refresh a connected source's display name from the live WP site title.
788
+ // Discovers the site's REST root (the Link rel="https://api.w.org/" header on any
789
+ // of its pages) and reads its name. Renames the store file to the new name's slug
790
+ // so a future push updates the same store instead of creating a duplicate.
791
+ app.post('/api/refresh-name', async (req, res) => {
792
+ const key = (req.body && req.body.key) || '';
793
+ const slug = String(key).replace(/^conn:/, '');
794
+ const file = connFile(key);
795
+ if (!file) return res.status(400).json({ error: 'invalid key' });
796
+ let store;
797
+ try { store = JSON.parse(fs.readFileSync(file, 'utf8')); }
798
+ catch (e) { return res.status(404).json({ error: 'not a connected source' }); }
799
+ // A base URL to discover the WP REST root: the stored home, else any note's url.
800
+ let base = store.home || '';
801
+ if (!base) { for (const n of (store.notes || [])) { if (n && n.url) { base = n.url; break; } } }
802
+ if (!base) return res.json({ updated: false, error: 'no site URL to check (source has no notes yet)' });
803
+ const ctrl = new AbortController();
804
+ const timer = setTimeout(() => ctrl.abort(), 6000);
805
+ try {
806
+ let root = '';
807
+ try {
808
+ const r0 = await safeFetch(base, {}, ctrl.signal);
809
+ const link = r0.headers.get('link') || '';
810
+ const m = link.match(/<([^>]+)>;\s*rel="https:\/\/api\.w\.org\/"/);
811
+ root = m ? m[1] : base.replace(/\/?$/, '/') + 'wp-json/';
812
+ } catch (e) { root = base.replace(/\/?$/, '/') + 'wp-json/'; }
813
+ const r = await safeFetch(root, {}, ctrl.signal);
814
+ const data = await r.json();
815
+ const name = (data && typeof data.name === 'string') ? data.name.trim() : '';
816
+ if (!name) return res.json({ updated: false, error: 'the site did not return a name' });
817
+ if (name === store.project) return res.json({ updated: false, name });
818
+ store.project = name;
819
+ const newSlug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
820
+ fs.writeFileSync(path.join(CONNECTED_DIR, newSlug + '.json'), JSON.stringify(store, null, 2));
821
+ if (newSlug !== slug) { try { fs.unlinkSync(file); } catch (e) { /* ignore */ } }
822
+ res.json({ updated: true, name: name, slug: newSlug });
823
+ } catch (e) {
824
+ res.json({ updated: false, error: String((e && e.message) || e).slice(0, 200) });
825
+ } finally { clearTimeout(timer); }
826
+ });
827
+
828
+ app.get('/', (req, res) => {
829
+ // Inject the hub token so the board's same-origin fetches can pass the /api gate.
830
+ let html = fs.readFileSync(path.join(__dirname, 'public', 'board.html'), 'utf8');
831
+ html = html.replace('%%HUB_TOKEN%%', String(cfg.hubToken || '').replace(/[^a-zA-Z0-9]/g, ''));
832
+ res.type('html').send(html);
833
+ });
834
+
835
+ // Bind to loopback only - the Hub aggregates local project notes and has no login,
836
+ // so it must never be reachable from the LAN. Bind BOTH loopback stacks (IPv4
837
+ // 127.0.0.1 AND IPv6 ::1): on Windows/Node `localhost` commonly resolves to ::1
838
+ // first, so an IPv4-only bind leaves http://localhost:PORT unreachable (it hangs)
839
+ // even though the Hub is up on 127.0.0.1. Two servers share the same (token-gated)
840
+ // app; both are loopback-only, so the LAN is still never reachable.
841
+ const LOOPBACKS = ['127.0.0.1', '::1'];
842
+ let hubBound = 0;
843
+ LOOPBACKS.forEach((host) => {
844
+ http.createServer(app).listen(PORT, host)
845
+ .on('listening', () => {
846
+ if (!hubBound++) {
847
+ console.log('Red Pen Hub running at http://localhost:' + PORT);
848
+ console.log('Data dir: ' + DATA_DIR);
849
+ console.log('Curated sources: ' + sourcesList().length + ' | default scan root: ' + scanRoots()[0]);
850
+ }
851
+ })
852
+ .on('error', (e) => {
853
+ // A missing stack (e.g. no IPv6 on this box) is fine to skip - the other bind covers localhost.
854
+ if (e.code === 'EADDRNOTAVAIL' || e.code === 'EAFNOSUPPORT' || e.code === 'EINVAL') { return; }
855
+ if (e.code === 'EADDRINUSE') {
856
+ console.error('Red Pen Hub: port ' + PORT + ' already in use on ' + host + ' - is another Hub already running?');
857
+ return;
858
+ }
859
+ throw e;
860
+ });
861
+ });