@tobq/boardclip 0.0.0-stage → 2.1.108

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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+
6
+ const DEFAULT_MAX_EVENTS = 500;
7
+ const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
8
+ const DEFAULT_TAIL_BYTES = 256 * 1024;
9
+
10
+ function sanitize(value, depth = 0) {
11
+ if (value == null) return value;
12
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
13
+ if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 500)}...` : value;
14
+ if (depth > 3) return '[depth]';
15
+ if (Array.isArray(value)) return value.slice(0, 50).map(item => sanitize(item, depth + 1));
16
+ if (typeof value === 'object') {
17
+ const result = {};
18
+ for (const [key, inner] of Object.entries(value).slice(0, 80)) {
19
+ result[key] = sanitize(inner, depth + 1);
20
+ }
21
+ return result;
22
+ }
23
+ return String(value);
24
+ }
25
+
26
+ class Diagnostics {
27
+ constructor({ filePath, enabled = false, maxEvents = DEFAULT_MAX_EVENTS, maxFileBytes = DEFAULT_MAX_FILE_BYTES } = {}) {
28
+ this.filePath = filePath;
29
+ this.enabled = !!enabled;
30
+ this.maxEvents = maxEvents;
31
+ this.maxFileBytes = maxFileBytes;
32
+ this.events = [];
33
+ this.startedAt = new Date().toISOString();
34
+ this.fileTrimmedAt = 0;
35
+ }
36
+
37
+ setEnabled(enabled) {
38
+ const next = !!enabled;
39
+ if (this.enabled === next) return;
40
+ this.enabled = next;
41
+ this.record('diagnostics.toggle', { enabled: next }, { forceFile: true });
42
+ }
43
+
44
+ isEnabled() {
45
+ return this.enabled;
46
+ }
47
+
48
+ record(event, details = {}, { forceFile = false } = {}) {
49
+ const entry = {
50
+ ts: new Date().toISOString(),
51
+ event,
52
+ ...sanitize(details),
53
+ };
54
+ this.events.push(entry);
55
+ if (this.events.length > this.maxEvents) this.events.splice(0, this.events.length - this.maxEvents);
56
+ if (this.enabled || forceFile) this.append(entry);
57
+ return entry;
58
+ }
59
+
60
+ slow(event, ms, details = {}, thresholdMs = 100) {
61
+ if (ms < thresholdMs) return null;
62
+ return this.record(event, { ...details, ms: Math.round(ms), slow: true }, { forceFile: true });
63
+ }
64
+
65
+ append(entry) {
66
+ if (!this.filePath) return;
67
+ try {
68
+ fs.appendFileSync(this.filePath, `${JSON.stringify(entry)}${os.EOL}`);
69
+ this.trimFileIfNeeded();
70
+ } catch {}
71
+ }
72
+
73
+ trimFileIfNeeded() {
74
+ if (!this.filePath || Date.now() - this.fileTrimmedAt < 30000) return;
75
+ this.fileTrimmedAt = Date.now();
76
+ try {
77
+ const stats = fs.statSync(this.filePath);
78
+ if (stats.size <= this.maxFileBytes) return;
79
+ const keepBytes = Math.floor(this.maxFileBytes / 2);
80
+ const fd = fs.openSync(this.filePath, 'r');
81
+ const buffer = Buffer.alloc(keepBytes);
82
+ fs.readSync(fd, buffer, 0, keepBytes, stats.size - keepBytes);
83
+ fs.closeSync(fd);
84
+ fs.writeFileSync(this.filePath, buffer.toString('utf8').replace(/^[^\n]*\n?/, ''));
85
+ fs.appendFileSync(this.filePath, `${JSON.stringify({ ts: new Date().toISOString(), event: 'diagnostics.truncated' })}${os.EOL}`);
86
+ } catch {}
87
+ }
88
+
89
+ fileTail(maxBytes = DEFAULT_TAIL_BYTES) {
90
+ if (!this.filePath) return '';
91
+ try {
92
+ const stats = fs.statSync(this.filePath);
93
+ const bytes = Math.min(stats.size, maxBytes);
94
+ const fd = fs.openSync(this.filePath, 'r');
95
+ const buffer = Buffer.alloc(bytes);
96
+ fs.readSync(fd, buffer, 0, bytes, stats.size - bytes);
97
+ fs.closeSync(fd);
98
+ return buffer.toString('utf8');
99
+ } catch {
100
+ return '';
101
+ }
102
+ }
103
+
104
+ snapshot(extra = {}) {
105
+ let file = { path: this.filePath || null, exists: false };
106
+ try {
107
+ const stats = fs.statSync(this.filePath);
108
+ file = { path: this.filePath, exists: true, size: stats.size, mtime: stats.mtime.toISOString() };
109
+ } catch {}
110
+ return {
111
+ generated_at: new Date().toISOString(),
112
+ started_at: this.startedAt,
113
+ enabled: this.enabled,
114
+ file,
115
+ recent_events: [...this.events],
116
+ ...sanitize(extra),
117
+ };
118
+ }
119
+ }
120
+
121
+ module.exports = { Diagnostics, sanitize };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ // File names for image clips dragged out of BoardClip (popup rows, the image
4
+ // viewer). The drag hands the OS a temporary COPY named after the clip's title
5
+ // (else "BoardClip image <local date time>"), never the content-addressed
6
+ // original: a drop into a folder on the same drive is a MOVE by default, which
7
+ // would pull the image out from under its clip. Pure, so it is unit-tested.
8
+
9
+ const pad = (n) => String(n).padStart(2, '0');
10
+
11
+ function localStamp(tsSeconds) {
12
+ const d = new Date((Number(tsSeconds) || 0) * 1000);
13
+ if (!Number.isFinite(d.getTime())) return 'unknown time';
14
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}.${pad(d.getMinutes())}.${pad(d.getSeconds())}`;
15
+ }
16
+
17
+ // Windows-safe: no reserved characters, no trailing dots/spaces, no reserved
18
+ // device names, bounded length.
19
+ function safeBaseName(text) {
20
+ let s = String(text || '')
21
+ .replace(/[\u0000-\u001f<>:"/\\|?*]+/g, ' ')
22
+ .replace(/\s+/g, ' ')
23
+ .trim()
24
+ .slice(0, 80)
25
+ .replace(/[. ]+$/, '');
26
+ if (/^(con|prn|aux|nul|com\d|lpt\d)$/i.test(s)) s = `${s} image`;
27
+ return s;
28
+ }
29
+
30
+ function extensionOf(item) {
31
+ const m = /\.([a-z0-9]{1,5})$/i.exec(String(item && item.image || ''));
32
+ return m ? `.${m[1].toLowerCase()}` : '.png';
33
+ }
34
+
35
+ // One name per item, unique (case-insensitively) within the drag.
36
+ function dragFileNames(items) {
37
+ const used = new Set();
38
+ return (items || []).map((item) => {
39
+ const base = safeBaseName(item && item.title) || `BoardClip image ${localStamp(item && item.ts)}`;
40
+ const ext = extensionOf(item);
41
+ let name = `${base}${ext}`;
42
+ for (let n = 2; used.has(name.toLowerCase()); n += 1) name = `${base} (${n})${ext}`;
43
+ used.add(name.toLowerCase());
44
+ return name;
45
+ });
46
+ }
47
+
48
+ module.exports = { dragFileNames, safeBaseName, localStamp };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function pathPartsFromRoot(targetPath) {
7
+ const resolved = path.resolve(targetPath);
8
+ const parsed = path.parse(resolved);
9
+ const rel = path.relative(parsed.root, resolved);
10
+ if (!rel) return [];
11
+ return rel.split(path.sep).filter(Boolean).reduce((parts, part) => {
12
+ parts.push(path.join(parts.length ? parts[parts.length - 1] : parsed.root, part));
13
+ return parts;
14
+ }, []);
15
+ }
16
+
17
+ function findDirectoryBlocker(targetDir) {
18
+ for (const part of pathPartsFromRoot(targetDir)) {
19
+ try {
20
+ if (fs.existsSync(part) && !fs.statSync(part).isDirectory()) return part;
21
+ } catch {}
22
+ }
23
+ return '';
24
+ }
25
+
26
+ function moveFileAside(filePath) {
27
+ const target = `${filePath}.file-conflict-${Date.now()}`;
28
+ fs.renameSync(filePath, target);
29
+ return target;
30
+ }
31
+
32
+ function ensureDirectory(dir) {
33
+ try {
34
+ if (fs.existsSync(dir)) {
35
+ if (fs.statSync(dir).isDirectory()) return { ok: true };
36
+ const moved = moveFileAside(dir);
37
+ fs.mkdirSync(dir, { recursive: true });
38
+ return { ok: true, moved, repaired: true };
39
+ }
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ return { ok: true };
42
+ } catch (error) {
43
+ if (!error || error.code !== 'ENOTDIR') throw error;
44
+ const blocker = findDirectoryBlocker(dir);
45
+ if (!blocker) throw error;
46
+ const moved = moveFileAside(blocker);
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ return { ok: true, moved, repaired: true };
49
+ }
50
+ }
51
+
52
+ module.exports = {
53
+ ensureDirectory,
54
+ findDirectoryBlocker,
55
+ pathPartsFromRoot,
56
+ };
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+ // Single source of truth for recognizing Google Drive "forked" copies of the
3
+ // sync files. When a rename-over-existing lands in a Drive-synced folder, Drive
4
+ // orphans the old object and creates a duplicate, disambiguating the name
5
+ // DIFFERENTLY per client:
6
+ // macOS / Drive web: "clipboard-history (1).json" (parenthesized)
7
+ // Windows Drive File Stream: "clipboard-history 2.json" (space+number, before ext)
8
+ // "clipboard-history.json 2.json" (space+number, after ext)
9
+ // plus leaked atomic-write temps ("<name>.json.<pid>.<ts>.tmp"). The heal MUST
10
+ // match EVERY variant or a device stays permanently split (matching only the
11
+ // parenthesized form once stranded thousands of Windows-forked clips). These
12
+ // patterns NEVER match the canonical "<name>.json".
13
+ function forkFileRe(base) {
14
+ const b = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+ const dup = '(?:\\(\\d+\\)|\\d+)'; // "(1)" (mac/web) or "2" (Windows File Stream)
16
+ return new RegExp(`^${b} ${dup}\\.json$|^${b}\\.json ${dup}\\.json$`);
17
+ }
18
+ function leakedTmpRe(base) {
19
+ const b = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ return new RegExp(`^${b}\\.json\\.\\d+\\.\\d+(?: (?:\\(\\d+\\)|\\d+))?\\.tmp$`);
21
+ }
22
+
23
+ const FORK_HISTORY_RE = forkFileRe('clipboard-history');
24
+ const FORK_SETTINGS_RE = forkFileRe('clipboard-settings');
25
+ const LEAKED_HISTORY_TMP_RE = leakedTmpRe('clipboard-history');
26
+ const LEAKED_SETTINGS_TMP_RE = leakedTmpRe('clipboard-settings');
27
+
28
+ const isHistoryFork = (n) => FORK_HISTORY_RE.test(n) || LEAKED_HISTORY_TMP_RE.test(n);
29
+ const isSettingsFork = (n) => FORK_SETTINGS_RE.test(n) || LEAKED_SETTINGS_TMP_RE.test(n);
30
+
31
+ module.exports = {
32
+ forkFileRe, leakedTmpRe,
33
+ FORK_HISTORY_RE, FORK_SETTINGS_RE, LEAKED_HISTORY_TMP_RE, LEAKED_SETTINGS_TMP_RE,
34
+ isHistoryFork, isSettingsFork,
35
+ };
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+
3
+ // Bounded liveness for paths that may sit on a NETWORK or CLOUD mount.
4
+ //
5
+ // A wedged mount does not fail, it BLOCKS. On 2026-09-13 Google DriveFS hung and
6
+ // `fs.existsSync('G:\\My Drive')` never returned: the calling thread sat in an
7
+ // uninterruptible kernel wait (even `timeout 8 ls` could not kill it, and
8
+ // PowerShell itself hung enumerating drives). On Electron's main thread that is
9
+ // the whole app - BoardClip would not launch, the popup rendered blank, and one
10
+ // sync pass held `insideSync` for 29 minutes while `sync.skip_inside_sync`
11
+ // repeated every 30 s.
12
+ //
13
+ // Three rules, kept here so no call site has to remember them:
14
+ // 1. never touch such a path synchronously;
15
+ // 2. never wait on one without a deadline;
16
+ // 3. never run two probes of the same path at once - a hung probe keeps a
17
+ // libuv threadpool thread for ever (there are 4 by default), so retrying a
18
+ // wedged mount is how one bad drive letter takes every async read down
19
+ // with it.
20
+
21
+ const DEFAULT_TIMEOUT_MS = 2000;
22
+ const DEFAULT_BACKOFF_MS = 30 * 1000;
23
+ const DEFAULT_MAX_BACKOFF_MS = 5 * 60 * 1000;
24
+
25
+ // Resolves true/false, never rejects, and never outlives `timeoutMs`. The
26
+ // underlying stat may stay pending for ever on a wedged mount; that is the one
27
+ // leaked threadpool thread `createPathHealth` then refuses to duplicate.
28
+ function probePath(target, { timeoutMs = DEFAULT_TIMEOUT_MS, fsp = require('fs').promises } = {}) {
29
+ if (!target) return Promise.resolve(false);
30
+ return new Promise(resolve => {
31
+ let settled = false;
32
+ let timer = null;
33
+ const finish = value => {
34
+ if (settled) return;
35
+ settled = true;
36
+ if (timer) clearTimeout(timer);
37
+ resolve(value);
38
+ };
39
+ timer = setTimeout(() => finish(false), timeoutMs);
40
+ if (timer.unref) timer.unref();
41
+ try {
42
+ fsp.stat(target).then(() => finish(true), () => finish(false));
43
+ } catch {
44
+ finish(false);
45
+ }
46
+ });
47
+ }
48
+
49
+ // Tracks which paths are answering. Unknown paths are optimistically usable, so
50
+ // a healthy machine behaves exactly as it always did; only a path that has
51
+ // actually failed is hidden, and only until a single-flight probe says it is
52
+ // back.
53
+ function createPathHealth({
54
+ timeoutMs = DEFAULT_TIMEOUT_MS,
55
+ backoffMs = DEFAULT_BACKOFF_MS,
56
+ maxBackoffMs = DEFAULT_MAX_BACKOFF_MS,
57
+ now = Date.now,
58
+ probe = null,
59
+ onTransition = null,
60
+ } = {}) {
61
+ const runProbe = probe || (target => probePath(target, { timeoutMs }));
62
+ const state = new Map();
63
+
64
+ function entry(target) {
65
+ let found = state.get(target);
66
+ if (!found) {
67
+ found = { healthy: true, failures: 0, retryAt: 0, pending: null };
68
+ state.set(target, found);
69
+ }
70
+ return found;
71
+ }
72
+
73
+ function transition(target, healthy, reason) {
74
+ const found = entry(target);
75
+ const was = found.healthy;
76
+ found.healthy = !!healthy;
77
+ if (found.healthy) {
78
+ found.failures = 0;
79
+ found.retryAt = 0;
80
+ } else {
81
+ found.failures += 1;
82
+ found.retryAt = now() + Math.min(maxBackoffMs, backoffMs * Math.pow(2, found.failures - 1));
83
+ }
84
+ if (was !== found.healthy && typeof onTransition === 'function') {
85
+ try {
86
+ onTransition({
87
+ path: target,
88
+ healthy: found.healthy,
89
+ failures: found.failures,
90
+ retryAt: found.retryAt,
91
+ reason: reason || '',
92
+ });
93
+ } catch {}
94
+ }
95
+ return found.healthy;
96
+ }
97
+
98
+ return {
99
+ // Synchronous, no I/O: safe to call on any hot path.
100
+ usable(target) {
101
+ const found = state.get(target);
102
+ return !found || found.healthy;
103
+ },
104
+ // Re-probes a failed path once its backoff has expired. Returns the current
105
+ // verdict immediately (never blocks) when a probe is already outstanding.
106
+ check(target) {
107
+ const found = entry(target);
108
+ if (found.pending) return Promise.resolve(found.healthy);
109
+ if (found.healthy) return Promise.resolve(true);
110
+ if (now() < found.retryAt) return Promise.resolve(false);
111
+ found.pending = Promise.resolve()
112
+ .then(() => runProbe(target))
113
+ .then(
114
+ ok => { found.pending = null; return transition(target, !!ok, 'probe'); },
115
+ error => { found.pending = null; return transition(target, false, (error && error.message) || 'probe failed'); }
116
+ );
117
+ return found.pending;
118
+ },
119
+ markFailure(target, reason) { return transition(target, false, reason); },
120
+ markSuccess(target) { return transition(target, true, 'ok'); },
121
+ forget(target) { state.delete(target); },
122
+ snapshot() {
123
+ const out = {};
124
+ for (const [target, found] of state) {
125
+ out[target] = { healthy: found.healthy, failures: found.failures, retryAt: found.retryAt, probing: !!found.pending };
126
+ }
127
+ return out;
128
+ },
129
+ };
130
+ }
131
+
132
+ module.exports = { probePath, createPathHealth, DEFAULT_TIMEOUT_MS };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+
3
+ // Sends each popup renderer only what changed in history since the state it
4
+ // already holds. Re-sending the whole history on every change cost the owner's
5
+ // popup ~1 s per refresh (13.9k clips, 70 MB of text cloned over IPC and
6
+ // re-indexed on the renderer's only thread), and a refresh fires on every
7
+ // capture, sync merge and settings save - typing froze behind each one.
8
+ //
9
+ // The feed remembers, per renderer, the revision + per-clip stamps it last
10
+ // sent. A request carrying exactly that revision gets a delta: the full id
11
+ // order plus only the clips whose stamp changed. Anything else (a fresh or
12
+ // reloaded renderer, a missed reply) gets a full snapshot, so a renderer can
13
+ // never apply a delta to state it does not have.
14
+
15
+ // Everything the renderer shows or acts on, minus the blob-sized bodies. Text
16
+ // changes always change the id (content-addressed); rich formats count by size.
17
+ function rendererStamp(item) {
18
+ if (!item) return '';
19
+ const { text, html, rtf, ...rest } = item;
20
+ rest.htmlLen = typeof html === 'string' ? html.length : 0;
21
+ rest.rtfLen = typeof rtf === 'string' ? rtf.length : 0;
22
+ return JSON.stringify(rest);
23
+ }
24
+
25
+ function createHistoryFeed() {
26
+ const sent = new Map(); // renderer key -> { revision, stamps: Map(id -> stamp) }
27
+
28
+ // items: the renderer view of history (each with id + rev), in history order.
29
+ function stateFor(key, knownRevision, revision, items) {
30
+ if (knownRevision === revision) return { revision, unchanged: true };
31
+ const list = items || [];
32
+ const stamps = new Map();
33
+ for (const item of list) stamps.set(item.id, rendererStamp(item));
34
+ const prev = sent.get(key);
35
+ sent.set(key, { revision, stamps });
36
+ if (!prev || prev.revision !== knownRevision) return { revision, items: list };
37
+ return {
38
+ revision,
39
+ delta: true,
40
+ order: list.map(item => item.id),
41
+ items: list.filter(item => prev.stamps.get(item.id) !== stamps.get(item.id)),
42
+ };
43
+ }
44
+
45
+ return { stateFor, forget: (key) => sent.delete(key) };
46
+ }
47
+
48
+ module.exports = { createHistoryFeed, rendererStamp };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ // Shared HMAC request authentication.
4
+ //
5
+ // Used by BOTH the LAN P2P sync server (main.js) and the local MCP control
6
+ // channel (lib/control-server.js). Both are request/response channels that need
7
+ // to prove "this caller knows the shared secret" and reject replayed/stale
8
+ // requests, so the signing scheme lives here once instead of being duplicated.
9
+ //
10
+ // Signature is HMAC-SHA256 over `${method}\n${path}\n${timestamp}\n${bodyHash}`.
11
+ // The body hash binds the signature to the exact payload; the timestamp window
12
+ // bounds replay. Verification is constant-time.
13
+
14
+ const crypto = require('crypto');
15
+
16
+ const DEFAULT_WINDOW_MS = 60 * 1000;
17
+
18
+ function bodyHash(buffer) {
19
+ return crypto.createHash('sha256').update(buffer || Buffer.alloc(0)).digest('hex');
20
+ }
21
+
22
+ function sign(secret, method, requestPath, timestamp, hashOfBody) {
23
+ return crypto
24
+ .createHmac('sha256', String(secret || ''))
25
+ .update(`${method}\n${requestPath}\n${timestamp}\n${hashOfBody || ''}`)
26
+ .digest('hex');
27
+ }
28
+
29
+ function timingSafeEqualHex(a, b) {
30
+ try {
31
+ const bufA = Buffer.from(String(a || ''), 'hex');
32
+ const bufB = Buffer.from(String(b || ''), 'hex');
33
+ if (bufA.length === 0 || bufA.length !== bufB.length) return false;
34
+ return crypto.timingSafeEqual(bufA, bufB);
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ // Verify a signed request. `bodyHashHex` may be passed directly, or `body`
41
+ // (a Buffer) to hash here. `now`/`windowMs` are injectable for testing.
42
+ function verify(secret, {
43
+ method,
44
+ path: requestPath,
45
+ timestamp,
46
+ signature,
47
+ body,
48
+ bodyHashHex,
49
+ now = Date.now(),
50
+ windowMs = DEFAULT_WINDOW_MS,
51
+ } = {}) {
52
+ const ts = Number(timestamp) || 0;
53
+ if (!ts || Math.abs(now - ts) > windowMs) return false;
54
+ const hashOfBody = bodyHashHex != null ? bodyHashHex : bodyHash(body || Buffer.alloc(0));
55
+ const expected = sign(secret, method, requestPath, ts, hashOfBody);
56
+ return timingSafeEqualHex(signature, expected);
57
+ }
58
+
59
+ // Build the headers a client sends. Mirrors the `x-boardclip-*` header names the
60
+ // P2P client already uses, so both channels speak the same wire format.
61
+ function signedHeaders(secret, deviceId, method, requestPath, body = Buffer.alloc(0), now = Date.now()) {
62
+ const timestamp = now;
63
+ const hashOfBody = bodyHash(body);
64
+ return {
65
+ 'x-boardclip-device': deviceId || '',
66
+ 'x-boardclip-ts': String(timestamp),
67
+ 'x-boardclip-sig': sign(secret, method, requestPath, timestamp, hashOfBody),
68
+ };
69
+ }
70
+
71
+ module.exports = {
72
+ DEFAULT_WINDOW_MS,
73
+ bodyHash,
74
+ sign,
75
+ verify,
76
+ signedHeaders,
77
+ timingSafeEqualHex,
78
+ };