parallelclaw 1.0.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/HELP.md +600 -0
  3. package/LICENSE +21 -0
  4. package/MULTI_MACHINE.md +152 -0
  5. package/README.md +417 -0
  6. package/README.ru.md +740 -0
  7. package/SYNC.md +844 -0
  8. package/bot/README.md +173 -0
  9. package/bot/config.js +66 -0
  10. package/bot/inbox.js +153 -0
  11. package/bot/index.js +294 -0
  12. package/bot/nexara.js +61 -0
  13. package/bot/poll.js +304 -0
  14. package/bot/search.js +155 -0
  15. package/bot/telegram.js +96 -0
  16. package/ingest.js +2712 -0
  17. package/lib/cli/index.js +1987 -0
  18. package/lib/config.js +220 -0
  19. package/lib/db-init.js +158 -0
  20. package/lib/hook/install.js +268 -0
  21. package/lib/import-telegram.js +158 -0
  22. package/lib/ingest-file.js +779 -0
  23. package/lib/notify-click-action.js +281 -0
  24. package/lib/openclaw-channel.js +643 -0
  25. package/lib/parse-cursor.js +172 -0
  26. package/lib/parse-obsidian.js +256 -0
  27. package/lib/parse-telegram-html.js +384 -0
  28. package/lib/parse.js +175 -0
  29. package/lib/render-markdown.js +0 -0
  30. package/lib/store-doc/canonicalize.js +116 -0
  31. package/lib/store-doc/detect.js +209 -0
  32. package/lib/store-doc/extract-title.js +162 -0
  33. package/lib/sync/auth.js +80 -0
  34. package/lib/sync/cert.js +144 -0
  35. package/lib/sync/cli.js +906 -0
  36. package/lib/sync/client.js +138 -0
  37. package/lib/sync/config.js +130 -0
  38. package/lib/sync/pair.js +145 -0
  39. package/lib/sync/pull.js +158 -0
  40. package/lib/sync/push.js +305 -0
  41. package/lib/sync/replicate.js +335 -0
  42. package/lib/sync/server.js +224 -0
  43. package/lib/sync/service.js +726 -0
  44. package/lib/tasks.js +215 -0
  45. package/lib/telegram-decisions.js +165 -0
  46. package/lib/telegram-discovery.js +373 -0
  47. package/lib/telegram-notify.js +272 -0
  48. package/lib/telegram-pending.js +200 -0
  49. package/lib/web/index.js +265 -0
  50. package/lib/web/routes/conversation.js +193 -0
  51. package/lib/web/routes/conversations.js +180 -0
  52. package/lib/web/routes/dashboard.js +175 -0
  53. package/lib/web/routes/pending.js +277 -0
  54. package/lib/web/routes/settings.js +226 -0
  55. package/lib/web/static/style.css +393 -0
  56. package/lib/web/templates.js +234 -0
  57. package/package.json +84 -0
  58. package/server.js +3816 -0
  59. package/skills/install-memex/README.md +109 -0
  60. package/skills/install-memex/SKILL.md +342 -0
  61. package/skills/install-memex/examples.md +294 -0
  62. package/skills/install-memex-claw/SKILL.md +423 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Pattern detection for memex_store_document.
3
+ *
4
+ * When the agent passes content to memex_store_document, memex sniffs it
5
+ * for known failure signatures (Cloudflare challenge, Perplexity-private,
6
+ * paywalls, …) and returns actionable warnings.
7
+ *
8
+ * Each detector returns either null or an object:
9
+ * { type, blocking, message }
10
+ *
11
+ * `blocking: true` → memex returns stored:false to the agent. Use only for
12
+ * clear-cut failures where storing the content would pollute the corpus.
13
+ * `blocking: false` → memex stores the content but appends the warning so
14
+ * the agent can decide whether to surface it to the user.
15
+ *
16
+ * Patterns may grow over time as new failure modes appear in real use.
17
+ * Single-purpose regexes — order matters (more specific first).
18
+ */
19
+
20
+ const CLOUDFLARE_PATTERNS = [
21
+ /Just a moment\.\.\./i,
22
+ /Verifying you are human/i,
23
+ /cf-turnstile/i,
24
+ /cf_chl_opt/i,
25
+ /__cf_bm/i, // Cloudflare's bot-management cookie name in challenge pages
26
+ /Checking your browser before accessing/i,
27
+ ];
28
+
29
+ const PERPLEXITY_PRIVATE_PATTERNS = [
30
+ /This thread is private/i,
31
+ /Sign in if you are the owner of this thread/i,
32
+ ];
33
+
34
+ const LOGIN_REQUIRED_PATTERNS = [
35
+ /Sign in to continue/i,
36
+ /Please log in to continue/i,
37
+ /Login required/i,
38
+ /<form[^>]+action=["']\/?(login|signin|sign-in|auth)/i,
39
+ /You must be logged in/i,
40
+ ];
41
+
42
+ const PAYWALL_PATTERNS = [
43
+ /Subscribe to (read|continue)/i,
44
+ /Continue reading with subscription/i,
45
+ /\bpaywall\b/i,
46
+ /You've reached your free article limit/i,
47
+ /metered access/i,
48
+ /Become a (member|subscriber)/i,
49
+ ];
50
+
51
+ /**
52
+ * Detect a Cloudflare bot-challenge page. Blocking.
53
+ */
54
+ function detectCloudflareChallenge(content, _url) {
55
+ for (const re of CLOUDFLARE_PATTERNS) {
56
+ if (re.test(content)) {
57
+ return {
58
+ type: 'cloudflare-challenge',
59
+ blocking: true,
60
+ message:
61
+ 'The content you passed looks like a Cloudflare bot-challenge page, not the actual content. The page is protected by Cloudflare and your direct fetch was blocked.\n\n' +
62
+ 'Retry by prepending https://r.jina.ai/ to the URL:\n\n' +
63
+ ' WebFetch("https://r.jina.ai/" + originalUrl)\n\n' +
64
+ 'Jina AI Reader runs a real browser server-side and bypasses Cloudflare. Returns clean markdown. Free for personal use, no API key needed.\n\n' +
65
+ 'If Jina ALSO fails or returns "private/login required", the page needs authentication — Jina can\'t bypass that.',
66
+ };
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Detect a private Perplexity thread. Blocking — Jina can't help here,
74
+ * the user needs to make the thread public first.
75
+ */
76
+ function detectPerplexityPrivate(content, url) {
77
+ // Only flag if we have a URL hint that it's Perplexity, OR if the message
78
+ // text is unambiguously Perplexity's phrasing.
79
+ const isPerplexityUrl =
80
+ typeof url === 'string' && /perplexity\.ai/i.test(url);
81
+
82
+ let matched = false;
83
+ for (const re of PERPLEXITY_PRIVATE_PATTERNS) {
84
+ if (re.test(content)) {
85
+ matched = true;
86
+ break;
87
+ }
88
+ }
89
+ if (!matched) return null;
90
+ if (!isPerplexityUrl && !/perplexity/i.test(content)) {
91
+ // Same phrasing might appear on other sites — only act if we're confident
92
+ return null;
93
+ }
94
+
95
+ return {
96
+ type: 'perplexity-private',
97
+ blocking: true,
98
+ message:
99
+ 'This Perplexity thread is marked private — even Jina Reader can\'t access it (this is an authentication wall, not Cloudflare bot protection).\n\n' +
100
+ 'Tell the user: "To save this Perplexity thread to memex, you need to make it public first:\n' +
101
+ ' 1. Open the thread in Perplexity\n' +
102
+ ' 2. Click Share (top right)\n' +
103
+ ' 3. Toggle \'Public link\' on\n' +
104
+ ' 4. Copy the new shareable URL Perplexity shows\n' +
105
+ ' 5. Send me THAT URL — it\'ll work"\n\n' +
106
+ 'The URL in the user\'s address bar (perplexity.ai/search/<id>) is the owner\'s private URL, not the shareable one.',
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Suspiciously short content from a URL that should be substantive.
112
+ * Non-blocking — we store it, but warn.
113
+ */
114
+ function detectSuspiciouslySmall(content, url) {
115
+ const trimmed = (content || '').trim();
116
+ // Threshold: documents shorter than 200 chars are almost certainly noise
117
+ // (error pages, redirects, JS-only stubs). Pasted snippets can legitimately
118
+ // be that short, so only flag when we have a URL (suggesting a fetch was
119
+ // attempted) — pastes get a free pass.
120
+ if (!url) return null;
121
+ if (trimmed.length >= 200) return null;
122
+ return {
123
+ type: 'suspiciously-small',
124
+ blocking: false,
125
+ message:
126
+ `The content you passed is very short (${trimmed.length} chars). ` +
127
+ 'The page might have been blocked, redirect-failed, or be JS-rendered with no SSR. ' +
128
+ 'Stored as-is — consider verifying with the user that this is what they expected.',
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Login required (form / prompt). Non-blocking but worth flagging.
134
+ */
135
+ function detectLoginRequired(content, _url) {
136
+ for (const re of LOGIN_REQUIRED_PATTERNS) {
137
+ if (re.test(content)) {
138
+ return {
139
+ type: 'login-required',
140
+ blocking: false,
141
+ message:
142
+ 'The page appears to require login (sign-in prompt / login form detected). ' +
143
+ 'The content you stored may be a login page, not the actual content the user wanted. ' +
144
+ 'Ask the user to paste the content manually if this isn\'t what they expected.',
145
+ };
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /**
152
+ * Paywall / subscription-gated content. Non-blocking.
153
+ */
154
+ function detectPaywalled(content, _url) {
155
+ for (const re of PAYWALL_PATTERNS) {
156
+ if (re.test(content)) {
157
+ return {
158
+ type: 'paywalled',
159
+ blocking: false,
160
+ message:
161
+ 'The page appears to be paywalled (subscription/payment prompt detected). ' +
162
+ 'The content stored may just be the teaser. ' +
163
+ 'If the user has full access, they can paste the complete article manually.',
164
+ };
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Returns array of warnings sorted with blocking warnings first.
172
+ * If the first warning is blocking, memex should refuse the store
173
+ * and return that warning to the agent.
174
+ *
175
+ * Detectors run in this order (more-specific first):
176
+ * 1. cloudflare-challenge (blocking)
177
+ * 2. perplexity-private (blocking)
178
+ * 3. suspiciously-small (non-blocking)
179
+ * 4. login-required (non-blocking)
180
+ * 5. paywalled (non-blocking)
181
+ */
182
+ export function detectIssues(content, url) {
183
+ const safeContent = typeof content === 'string' ? content : '';
184
+ const warnings = [];
185
+
186
+ // Blocking first — stop on first hit so we surface the most actionable.
187
+ const blocking =
188
+ detectCloudflareChallenge(safeContent, url) ||
189
+ detectPerplexityPrivate(safeContent, url);
190
+ if (blocking) {
191
+ warnings.push(blocking);
192
+ return warnings;
193
+ }
194
+
195
+ // Non-blocking — collect all that match.
196
+ for (const fn of [detectSuspiciouslySmall, detectLoginRequired, detectPaywalled]) {
197
+ const w = fn(safeContent, url);
198
+ if (w) warnings.push(w);
199
+ }
200
+
201
+ return warnings;
202
+ }
203
+
204
+ /**
205
+ * Convenience: is any warning blocking?
206
+ */
207
+ export function isBlocked(warnings) {
208
+ return Array.isArray(warnings) && warnings.some((w) => w.blocking);
209
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Extract a title from fetched page content.
3
+ *
4
+ * Strategy (first hit wins):
5
+ * 0. Strip Jina Reader prefix block if present (Jina prepends
6
+ * `Title: …\nURL Source: …\nPublished Time: …\nMarkdown Content:\n`
7
+ * to its output; the literal "Title:" line is often useless boilerplate
8
+ * like "Title: Perplexity" rather than the actual thread title)
9
+ * 1. Markdown H1 — `# Title text`
10
+ * 2. Markdown H2 — `## Title text` (Perplexity threads start with H2)
11
+ * 3. HTML <title> — `<title>Page Title</title>`
12
+ * 4. HTML <h1> — `<h1>Page Title</h1>`
13
+ * 5. First non-empty line if short enough to look like a title
14
+ * 6. URL slug fallback — last meaningful path segment, decoded
15
+ * 7. Domain fallback — just the domain name
16
+ * 8. "Untitled document"
17
+ *
18
+ * Returns a trimmed string up to MAX_LEN characters. Always returns a
19
+ * non-empty string (worst case "Untitled document").
20
+ */
21
+
22
+ const MAX_LEN = 200;
23
+
24
+ function trimTitle(s) {
25
+ if (!s) return '';
26
+ let t = String(s).replace(/\s+/g, ' ').trim();
27
+ if (t.length > MAX_LEN) t = t.slice(0, MAX_LEN).trim() + '…';
28
+ return t;
29
+ }
30
+
31
+ /**
32
+ * Jina AI Reader (r.jina.ai/<url>) wraps every page in a metadata
33
+ * prefix:
34
+ *
35
+ * Title: <browser tab title>
36
+ *
37
+ * URL Source: <original URL>
38
+ *
39
+ * Published Time: <date>
40
+ *
41
+ * Markdown Content:
42
+ * <actual page markdown follows here>
43
+ *
44
+ * The "Title:" line is frequently a generic app shell ("Perplexity",
45
+ * "Twitter / X", "GitHub") rather than the actual document title — so
46
+ * we strip the whole prefix and run title extraction against the real
47
+ * markdown body. The actual H1/H2 inside is what we want.
48
+ *
49
+ * Detection is keyed on "URL Source: http" near the top — that line
50
+ * is unique to Jina's output format. If it's not present, content is
51
+ * returned unchanged (non-Jina source).
52
+ */
53
+ function stripJinaPrefix(content) {
54
+ // Quick gate: look for URL Source line in the first ~500 chars
55
+ if (!/^URL Source:\s*https?:\/\//m.test(content.slice(0, 500))) {
56
+ return content;
57
+ }
58
+ // Find the "Markdown Content:" delimiter and slice everything after it
59
+ const m = content.match(/^Markdown Content:\s*\n/m);
60
+ if (!m) return content;
61
+ return content.slice(m.index + m[0].length);
62
+ }
63
+
64
+ function fromMarkdownH1(content) {
65
+ // Single # at start of line, then space(s), then text.
66
+ const m = content.match(/^[ \t]*#[ \t]+([^\r\n]+?)[ \t]*$/m);
67
+ return m ? trimTitle(m[1]) : '';
68
+ }
69
+
70
+ function fromMarkdownH2(content) {
71
+ // ## at start of line — used as fallback when H1 absent
72
+ // (Perplexity, Jina-fetched Twitter threads, many blog "subtopic" layouts).
73
+ const m = content.match(/^[ \t]*##[ \t]+([^\r\n]+?)[ \t]*$/m);
74
+ return m ? trimTitle(m[1]) : '';
75
+ }
76
+
77
+ function fromHtmlTitle(content) {
78
+ const m = content.match(/<title[^>]*>([^<]+)<\/title>/i);
79
+ return m ? trimTitle(decodeEntities(m[1])) : '';
80
+ }
81
+
82
+ function fromHtmlH1(content) {
83
+ // Inner text only — strip nested tags like <span>...</span>
84
+ const m = content.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
85
+ if (!m) return '';
86
+ const inner = m[1].replace(/<[^>]+>/g, '');
87
+ return trimTitle(decodeEntities(inner));
88
+ }
89
+
90
+ function fromFirstLine(content) {
91
+ // First non-empty line, but only if it looks like a heading
92
+ // (short-ish, no markdown junk).
93
+ const lines = content.split(/\r?\n/);
94
+ for (const raw of lines) {
95
+ const line = raw.trim();
96
+ if (!line) continue;
97
+ // Skip leading markdown decorators / metadata
98
+ if (/^[#\-=*>|`]/.test(line)) continue;
99
+ if (line.length > 0 && line.length <= 120) {
100
+ return trimTitle(line);
101
+ }
102
+ // First substantive line is too long — give up on this strategy
103
+ break;
104
+ }
105
+ return '';
106
+ }
107
+
108
+ function fromUrlSlug(rawUrl) {
109
+ if (!rawUrl) return '';
110
+ try {
111
+ const u = new URL(rawUrl);
112
+ // Last meaningful path segment
113
+ const segs = u.pathname.split('/').filter(Boolean);
114
+ if (segs.length) {
115
+ const slug = decodeURIComponent(segs[segs.length - 1])
116
+ .replace(/[-_]+/g, ' ')
117
+ .replace(/\.(html?|md|pdf|txt)$/i, '')
118
+ .trim();
119
+ if (slug) return trimTitle(slug);
120
+ }
121
+ // No useful path — fall through to domain
122
+ return trimTitle(u.hostname.replace(/^www\./, ''));
123
+ } catch (_) {
124
+ return '';
125
+ }
126
+ }
127
+
128
+ // Minimal HTML-entity decode for &amp; &lt; &gt; &quot; &apos; &#39; &#nnn;
129
+ function decodeEntities(s) {
130
+ if (!s) return s;
131
+ return String(s)
132
+ .replace(/&amp;/g, '&')
133
+ .replace(/&lt;/g, '<')
134
+ .replace(/&gt;/g, '>')
135
+ .replace(/&quot;/g, '"')
136
+ .replace(/&apos;/g, "'")
137
+ .replace(/&#39;/g, "'")
138
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) =>
139
+ String.fromCodePoint(parseInt(hex, 16))
140
+ )
141
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)));
142
+ }
143
+
144
+ /**
145
+ * @param {string} content - fetched page content
146
+ * @param {string|null} url - source URL (used for slug fallback)
147
+ * @returns {string} a non-empty trimmed title
148
+ */
149
+ export function extractTitle(content, url) {
150
+ const safe = typeof content === 'string' ? content : '';
151
+ const body = stripJinaPrefix(safe);
152
+
153
+ return (
154
+ fromMarkdownH1(body) ||
155
+ fromMarkdownH2(body) ||
156
+ fromHtmlTitle(body) ||
157
+ fromHtmlH1(body) ||
158
+ fromFirstLine(body) ||
159
+ fromUrlSlug(url) ||
160
+ 'Untitled document'
161
+ );
162
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Bearer-token auth for the sync server.
3
+ *
4
+ * Every protected endpoint requires:
5
+ * Authorization: Bearer <hex-token>
6
+ *
7
+ * The token is the same one baked into the pair blob the client received
8
+ * (256-bit random, hex-encoded). We compare in constant time to avoid
9
+ * timing oracles.
10
+ *
11
+ * On failure: 401 with JSON {error: "unauthorized"} — no detail leaked.
12
+ *
13
+ * Why bearer (not OAuth/mTLS):
14
+ * - Days vs weeks to ship for the same security model
15
+ * - One device pair = one token; rotation by re-pairing
16
+ * - mTLS is reasonable to layer on if the user fronts with Caddy
17
+ */
18
+
19
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
20
+
21
+ /**
22
+ * Generate a 256-bit (32-byte) bearer token, hex-encoded.
23
+ * Output is 64 chars [0-9a-f].
24
+ */
25
+ export function generateBearerToken() {
26
+ return randomBytes(32).toString('hex');
27
+ }
28
+
29
+ /**
30
+ * Extract the bearer token from an Authorization header.
31
+ * Returns the hex string or null if the header is missing/malformed.
32
+ */
33
+ export function parseAuthHeader(headerValue) {
34
+ if (!headerValue || typeof headerValue !== 'string') return null;
35
+ const m = headerValue.match(/^Bearer\s+([0-9a-fA-F]{8,})\s*$/);
36
+ return m ? m[1].toLowerCase() : null;
37
+ }
38
+
39
+ /**
40
+ * Constant-time string comparison. Returns true iff the two strings
41
+ * are equal as bytes. We hex-decode both to fixed-length buffers so
42
+ * timingSafeEqual sees same-length inputs (it throws otherwise).
43
+ *
44
+ * If either token is malformed hex or wrong length, returns false
45
+ * without throwing.
46
+ */
47
+ export function tokensMatch(expected, provided) {
48
+ if (!expected || !provided) return false;
49
+ if (expected.length !== provided.length) return false;
50
+ let bufExpected, bufProvided;
51
+ try {
52
+ bufExpected = Buffer.from(expected, 'hex');
53
+ bufProvided = Buffer.from(provided, 'hex');
54
+ } catch (_) {
55
+ return false;
56
+ }
57
+ if (bufExpected.length !== bufProvided.length) return false;
58
+ // timingSafeEqual requires same-length Buffers
59
+ return timingSafeEqual(bufExpected, bufProvided);
60
+ }
61
+
62
+ /**
63
+ * Middleware used by lib/sync/server.js: checks Authorization header
64
+ * against the server-configured token. Calls `next()` on success,
65
+ * writes 401 + ends the response on failure.
66
+ *
67
+ * Usage:
68
+ * if (!requireBearer(req, res, expectedToken)) return;
69
+ * // ... handler proceeds
70
+ */
71
+ export function requireBearer(req, res, expectedToken) {
72
+ const provided = parseAuthHeader(req.headers.authorization);
73
+ if (!provided || !tokensMatch(expectedToken, provided)) {
74
+ res.statusCode = 401;
75
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
76
+ res.end(JSON.stringify({ error: 'unauthorized' }));
77
+ return false;
78
+ }
79
+ return true;
80
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Self-signed TLS cert generation + fingerprint computation for sync server.
3
+ *
4
+ * Design choice: every sync server gets its OWN self-signed cert. We don't
5
+ * use Let's Encrypt because the typical sync deployment has no public DNS
6
+ * name (it's behind Tailscale, an SSH tunnel, or addressed by raw IP).
7
+ *
8
+ * Clients pin the cert fingerprint baked into the pair blob — same trust
9
+ * model as Plex device pairing or Tailscale's node identity. If the server's
10
+ * cert ever changes (e.g. you ran `memex sync rotate-cert`), pre-existing
11
+ * clients refuse to connect until they're re-paired. This is the right
12
+ * default: silent cert changes are how MITM happens.
13
+ *
14
+ * Cert files live in ~/.memex/ alongside the DB:
15
+ * sync-cert.pem — the cert (public)
16
+ * sync-key.pem — the private key (mode 0600)
17
+ *
18
+ * Validity period: 10 years. Self-signed; no renewal needed in practice
19
+ * since clients pin the fingerprint, not the CA chain or expiry.
20
+ */
21
+
22
+ import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { createHash } from 'node:crypto';
25
+ import selfsigned from 'selfsigned';
26
+
27
+ /**
28
+ * Generate a fresh self-signed cert + key, write them to disk (with 0600
29
+ * on the key file), return { certPath, keyPath, fingerprint }.
30
+ *
31
+ * If files already exist, overwrites them — this is the "rotate" path.
32
+ * Callers who want idempotent "generate-if-missing" should use ensureCert().
33
+ *
34
+ * Async because selfsigned ≥5.0 dropped the sync API.
35
+ */
36
+ export async function generateCert({ certPath, keyPath, commonName = 'memex-sync' }) {
37
+ if (!certPath || !keyPath) {
38
+ throw new Error('generateCert: certPath and keyPath are required');
39
+ }
40
+ mkdirSync(dirname(certPath), { recursive: true });
41
+ mkdirSync(dirname(keyPath), { recursive: true });
42
+
43
+ // 10-year validity, 2048-bit RSA. SubjectAltName covers localhost +
44
+ // the conventional .local mDNS form, so the same cert works for SSH
45
+ // tunnel localhost connections and LAN-discovery hosts without
46
+ // extra alt names. We don't bother with public DNS names because
47
+ // we're pinning by fingerprint anyway.
48
+ const now = new Date();
49
+ const tenYears = new Date(now.getTime() + 365 * 10 * 24 * 60 * 60 * 1000);
50
+ const attrs = [{ name: 'commonName', value: commonName }];
51
+ const opts = {
52
+ notBeforeDate: now,
53
+ notAfterDate: tenYears,
54
+ keySize: 2048,
55
+ algorithm: 'sha256',
56
+ extensions: [
57
+ {
58
+ name: 'subjectAltName',
59
+ altNames: [
60
+ { type: 2, value: 'localhost' },
61
+ { type: 2, value: commonName },
62
+ { type: 2, value: `${commonName}.local` },
63
+ ],
64
+ },
65
+ ],
66
+ };
67
+
68
+ const pems = await selfsigned.generate(attrs, opts);
69
+ // pems = { private, public, cert, fingerprint }
70
+
71
+ writeFileSync(certPath, pems.cert);
72
+ writeFileSync(keyPath, pems.private);
73
+ // Best-effort 0600 on the key; non-fatal on platforms where chmod doesn't apply.
74
+ try { chmodSync(keyPath, 0o600); } catch (_) { /* windows etc. */ }
75
+
76
+ // Compute our own SHA-256 fingerprint — selfsigned reports SHA-1 historically
77
+ // and we want consistent sha256:hex for pair-blob pinning.
78
+ const fingerprint = sha256FingerprintFromPem(pems.cert);
79
+
80
+ return { certPath, keyPath, fingerprint, cert: pems.cert, key: pems.private };
81
+ }
82
+
83
+ /**
84
+ * Idempotent: if cert+key already exist on disk, returns the existing
85
+ * fingerprint without regenerating. Otherwise creates new ones.
86
+ *
87
+ * Use this on `memex sync server start` to avoid silently rotating
88
+ * a cert that paired clients depend on.
89
+ *
90
+ * Async because generateCert is async.
91
+ */
92
+ export async function ensureCert({ certPath, keyPath, commonName = 'memex-sync' }) {
93
+ if (existsSync(certPath) && existsSync(keyPath)) {
94
+ const fingerprint = sha256FingerprintFromFile(certPath);
95
+ return { certPath, keyPath, fingerprint, reused: true };
96
+ }
97
+ const fresh = await generateCert({ certPath, keyPath, commonName });
98
+ return { ...fresh, reused: false };
99
+ }
100
+
101
+ /**
102
+ * Read a PEM-encoded cert from disk and compute its SHA-256 fingerprint
103
+ * in the "sha256:AA:BB:CC:..." form that's standard in TLS tooling
104
+ * (OpenSSL, browsers, Tailscale).
105
+ */
106
+ export function sha256FingerprintFromFile(certPath) {
107
+ const pem = readFileSync(certPath, 'utf-8');
108
+ return sha256FingerprintFromPem(pem);
109
+ }
110
+
111
+ export function sha256FingerprintFromPem(pem) {
112
+ // Strip PEM header/footer and decode base64 → raw DER bytes.
113
+ const body = pem
114
+ .replace(/-----BEGIN CERTIFICATE-----/g, '')
115
+ .replace(/-----END CERTIFICATE-----/g, '')
116
+ .replace(/\s+/g, '');
117
+ const der = Buffer.from(body, 'base64');
118
+ const hash = createHash('sha256').update(der).digest('hex').toUpperCase();
119
+ // Format as "sha256:AA:BB:..." (every two hex chars colon-joined)
120
+ const colonized = hash.match(/.{2}/g).join(':');
121
+ return `sha256:${colonized}`;
122
+ }
123
+
124
+ /**
125
+ * Compare a (possibly user-supplied) fingerprint against a server's
126
+ * actual one, tolerant of formatting variations:
127
+ * - case-insensitive
128
+ * - "sha256:" prefix optional
129
+ * - colon-separation optional
130
+ *
131
+ * Returns true iff the underlying 32 hex bytes match.
132
+ */
133
+ export function fingerprintsMatch(a, b) {
134
+ return normalizeFingerprint(a) === normalizeFingerprint(b);
135
+ }
136
+
137
+ function normalizeFingerprint(s) {
138
+ if (!s) return '';
139
+ let v = String(s).toLowerCase();
140
+ v = v.replace(/^sha256:/, '');
141
+ v = v.replace(/:/g, '');
142
+ v = v.replace(/\s+/g, '');
143
+ return v;
144
+ }