carom-link 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.
@@ -0,0 +1,146 @@
1
+ import { DATACENTER_ASNS, DATACENTER_ORG_PATTERNS } from './patterns.js';
2
+ import { IP_CACHE_MAX, IP_CACHE_TTL_MS } from '../constants.js';
3
+
4
+ /**
5
+ * Simple LRU cache for IP lookups.
6
+ */
7
+ class LRUCache {
8
+ constructor(maxSize, ttlMs) {
9
+ this.maxSize = maxSize;
10
+ this.ttlMs = ttlMs;
11
+ this.cache = new Map();
12
+ }
13
+
14
+ get(key) {
15
+ const entry = this.cache.get(key);
16
+ if (!entry) return null;
17
+ if (Date.now() - entry.timestamp > this.ttlMs) {
18
+ this.cache.delete(key);
19
+ return null;
20
+ }
21
+ // Move to end (most recently used)
22
+ this.cache.delete(key);
23
+ this.cache.set(key, entry);
24
+ return entry.value;
25
+ }
26
+
27
+ set(key, value) {
28
+ if (this.cache.size >= this.maxSize) {
29
+ // Delete oldest entry
30
+ const firstKey = this.cache.keys().next().value;
31
+ this.cache.delete(firstKey);
32
+ }
33
+ this.cache.set(key, { value, timestamp: Date.now() });
34
+ }
35
+ }
36
+
37
+ const ipCache = new LRUCache(IP_CACHE_MAX, IP_CACHE_TTL_MS);
38
+
39
+ /**
40
+ * Check if an IP is a private/reserved address.
41
+ */
42
+ function isPrivateIp(ip) {
43
+ if (!ip) return true;
44
+ return (
45
+ ip === '127.0.0.1' ||
46
+ ip === '::1' ||
47
+ ip.startsWith('10.') ||
48
+ ip.startsWith('172.16.') || ip.startsWith('172.17.') || ip.startsWith('172.18.') ||
49
+ ip.startsWith('172.19.') || ip.startsWith('172.2') || ip.startsWith('172.30.') ||
50
+ ip.startsWith('172.31.') ||
51
+ ip.startsWith('192.168.') ||
52
+ ip.startsWith('fc') || ip.startsWith('fd') ||
53
+ ip.startsWith('fe80')
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Look up IP information using the free ip-api.com service.
59
+ * Returns { asn, org, isDatacenter, country, isp }
60
+ */
61
+ async function fetchIpInfo(ip) {
62
+ try {
63
+ const controller = new AbortController();
64
+ const timeout = setTimeout(() => controller.abort(), 3000);
65
+
66
+ const response = await fetch(
67
+ `http://ip-api.com/json/${ip}?fields=status,message,country,isp,org,as,hosting`,
68
+ { signal: controller.signal }
69
+ );
70
+ clearTimeout(timeout);
71
+
72
+ if (!response.ok) return null;
73
+
74
+ const data = await response.json();
75
+ if (data.status !== 'success') return null;
76
+
77
+ // Parse ASN number from the "as" field (format: "AS16509 Amazon.com, Inc.")
78
+ let asnNumber = null;
79
+ if (data.as) {
80
+ const match = data.as.match(/^AS(\d+)/);
81
+ if (match) asnNumber = parseInt(match[1], 10);
82
+ }
83
+
84
+ const org = (data.org || data.isp || '').toLowerCase();
85
+ const isKnownAsn = asnNumber ? DATACENTER_ASNS.has(asnNumber) : false;
86
+ const isKnownOrg = DATACENTER_ORG_PATTERNS.some(p => org.includes(p));
87
+
88
+ return {
89
+ asn: asnNumber,
90
+ org: data.org || data.isp || '',
91
+ isDatacenter: data.hosting === true || isKnownAsn || isKnownOrg,
92
+ country: data.country || '',
93
+ isp: data.isp || '',
94
+ raw: data,
95
+ };
96
+ } catch {
97
+ // Network error, timeout, etc. — fail open
98
+ return null;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Look up IP information with caching.
104
+ * Returns { asn, org, isDatacenter } or null if lookup fails.
105
+ */
106
+ export async function lookupIp(ip) {
107
+ if (!ip || isPrivateIp(ip)) {
108
+ return { asn: null, org: 'private', isDatacenter: false };
109
+ }
110
+
111
+ // Check cache
112
+ const cached = ipCache.get(ip);
113
+ if (cached) return cached;
114
+
115
+ // Fetch from API
116
+ const result = await fetchIpInfo(ip);
117
+ if (result) {
118
+ ipCache.set(ip, result);
119
+ }
120
+
121
+ return result;
122
+ }
123
+
124
+ /**
125
+ * Extract the real client IP from the request,
126
+ * respecting X-Forwarded-For and other proxy headers.
127
+ */
128
+ export function getClientIp(req) {
129
+ // X-Forwarded-For can be comma-separated; take the first (original client)
130
+ const xff = req.headers['x-forwarded-for'];
131
+ if (xff) {
132
+ const first = xff.split(',')[0].trim();
133
+ if (first) return first;
134
+ }
135
+
136
+ // Other common headers
137
+ const realIp = req.headers['x-real-ip'];
138
+ if (realIp) return realIp;
139
+
140
+ // CF-Connecting-IP (Cloudflare)
141
+ const cfIp = req.headers['cf-connecting-ip'];
142
+ if (cfIp) return cfIp;
143
+
144
+ // Fall back to socket
145
+ return req.socket?.remoteAddress || req.ip || '';
146
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Built-in bot detection patterns and datacenter ASN database.
3
+ * These are the default rules that ship with carom.
4
+ */
5
+
6
+ // User-Agent substrings that indicate bots/crawlers/scanners
7
+ // All matched case-insensitively
8
+ export const BOT_UA_PATTERNS = [
9
+ // Generic bot tokens
10
+ 'bot', 'crawl', 'spider', 'scrape',
11
+ // Preview/prefetch (carriers & messaging apps)
12
+ 'preview', 'prefetch', 'fetch', 'unfurl',
13
+ // Scanning & monitoring
14
+ 'scan', 'check', 'monitor', 'probe', 'validator',
15
+ // Headless browsers & automation
16
+ 'headless', 'headlesschrome', 'phantom', 'phantomjs',
17
+ 'selenium', 'puppeteer', 'playwright', 'webdriver',
18
+ // CLI tools
19
+ 'wget', 'curl', 'httpie',
20
+ // HTTP libraries
21
+ 'python-requests', 'python-urllib', 'python/',
22
+ 'java/', 'apache-httpclient', 'okhttp',
23
+ 'go-http-client', 'go-http',
24
+ 'node-fetch', 'axios', 'undici', 'got/',
25
+ 'libwww', 'lwp-', 'perl/',
26
+ 'ruby/', 'faraday', 'typhoeus',
27
+ 'php/', 'guzzle',
28
+ 'dart/', 'http.client',
29
+ 'rust/', 'reqwest', 'hyper/',
30
+ // Social media crawlers
31
+ 'facebookexternalhit', 'facebot',
32
+ 'twitterbot',
33
+ 'linkedinbot',
34
+ 'whatsapp',
35
+ 'telegrambot',
36
+ 'slackbot', 'slack-imgproxy',
37
+ 'discordbot',
38
+ 'pinterestbot',
39
+ 'applebot',
40
+ // Search engines (detected but may be whitelisted)
41
+ 'googlebot', 'google-inspectiontool',
42
+ 'bingbot', 'msnbot',
43
+ 'yandexbot', 'baiduspider',
44
+ 'duckduckbot', 'sogou',
45
+ // SEO & marketing tools
46
+ 'semrush', 'ahrefs', 'moz/', 'majestic',
47
+ 'screaming frog', 'sitebulb',
48
+ // Security scanners
49
+ 'nmap', 'masscan', 'zgrab', 'nuclei',
50
+ 'qualys', 'nessus', 'nikto',
51
+ // Uptime monitors
52
+ 'uptimerobot', 'pingdom', 'statuscake',
53
+ 'site24x7', 'datadog',
54
+ // Other known bots
55
+ 'mediapartners-google', 'adsbot-google',
56
+ 'google-read-aloud', 'feedfetcher',
57
+ 'ia_archiver', 'archive.org',
58
+ ];
59
+
60
+ // Known datacenter/cloud provider ASNs
61
+ // Traffic from these is likely automated, not a human on a phone
62
+ export const DATACENTER_ASNS = new Set([
63
+ // Amazon Web Services
64
+ 16509, 14618, 7224, 8987,
65
+ // Google Cloud
66
+ 15169, 396982,
67
+ // Microsoft Azure
68
+ 8075, 8068, 8069, 12076,
69
+ // DigitalOcean
70
+ 14061, 393406,
71
+ // Cloudflare
72
+ 13335, 209242,
73
+ // Akamai
74
+ 20940, 16625, 32787,
75
+ // Linode / Akamai Cloud
76
+ 63949,
77
+ // OVHcloud
78
+ 16276,
79
+ // Hetzner
80
+ 24940, 213230,
81
+ // Vultr
82
+ 20473,
83
+ // Oracle Cloud
84
+ 31898,
85
+ // IBM / SoftLayer
86
+ 36351,
87
+ // Rackspace
88
+ 33070, 10532,
89
+ // Scaleway
90
+ 12876,
91
+ // UpCloud
92
+ 202053,
93
+ // Contabo
94
+ 40021,
95
+ // Leaseweb
96
+ 60781, 28753, 16265,
97
+ // ColoCrossing
98
+ 36352,
99
+ // QuadraNet
100
+ 8100,
101
+ // Choopa / Vultr
102
+ 20473,
103
+ // Fastly
104
+ 54113,
105
+ // Netlify
106
+ 400587,
107
+ // Vercel
108
+ 209242,
109
+ // Render
110
+ 398101,
111
+ // Railway
112
+ 400587,
113
+ // Fly.io
114
+ 40509,
115
+ ]);
116
+
117
+ // Known datacenter org name fragments (fallback if ASN not in set)
118
+ export const DATACENTER_ORG_PATTERNS = [
119
+ 'amazon', 'aws', 'ec2',
120
+ 'google cloud', 'google llc',
121
+ 'microsoft', 'azure',
122
+ 'digitalocean',
123
+ 'cloudflare',
124
+ 'akamai',
125
+ 'linode',
126
+ 'ovh',
127
+ 'hetzner',
128
+ 'vultr', 'choopa',
129
+ 'oracle',
130
+ 'ibm', 'softlayer',
131
+ 'rackspace',
132
+ 'scaleway',
133
+ 'contabo',
134
+ 'leaseweb',
135
+ 'fastly',
136
+ 'datacenter', 'data center',
137
+ 'hosting',
138
+ 'colocation', 'colo',
139
+ 'server',
140
+ ];
141
+
142
+ // Bots that should be allowed through (whitelisted)
143
+ // These are verified via reverse DNS, not just UA string
144
+ export const ALLOWED_BOTS = [
145
+ {
146
+ name: 'Googlebot',
147
+ uaPattern: 'googlebot',
148
+ reverseDnsSuffix: '.googlebot.com',
149
+ },
150
+ {
151
+ name: 'Google (other)',
152
+ uaPattern: 'google',
153
+ reverseDnsSuffix: '.google.com',
154
+ },
155
+ {
156
+ name: 'Bingbot',
157
+ uaPattern: 'bingbot',
158
+ reverseDnsSuffix: '.search.msn.com',
159
+ },
160
+ ];
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Generates the benign HTML page served to detected bots.
3
+ * Designed to look like a normal, innocuous web page so carriers
4
+ * don't flag the domain as suspicious.
5
+ */
6
+
7
+ /**
8
+ * Generate a safe page HTML string.
9
+ */
10
+ export function generateSafePage({ title, description, brand, url }) {
11
+ const safeTitle = escapeHtml(title || 'Welcome');
12
+ const safeDescription = escapeHtml(description || 'Visit our website for more information.');
13
+ const safeBrand = escapeHtml(brand || 'Website');
14
+ const safeUrl = escapeHtml(url || '');
15
+
16
+ return `<!DOCTYPE html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="UTF-8">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
21
+ <title>${safeTitle} | ${safeBrand}</title>
22
+ <meta name="description" content="${safeDescription}">
23
+ <meta name="robots" content="noindex, nofollow">
24
+
25
+ <!-- Open Graph tags for carrier preview cards -->
26
+ <meta property="og:title" content="${safeTitle}">
27
+ <meta property="og:description" content="${safeDescription}">
28
+ <meta property="og:type" content="website">
29
+ <meta property="og:site_name" content="${safeBrand}">
30
+
31
+ <!-- Twitter Card -->
32
+ <meta name="twitter:card" content="summary">
33
+ <meta name="twitter:title" content="${safeTitle}">
34
+ <meta name="twitter:description" content="${safeDescription}">
35
+
36
+ <style>
37
+ * { margin: 0; padding: 0; box-sizing: border-box; }
38
+ body {
39
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
40
+ background: #f8f9fa;
41
+ color: #333;
42
+ min-height: 100vh;
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ }
47
+ .container {
48
+ text-align: center;
49
+ padding: 2rem;
50
+ max-width: 480px;
51
+ }
52
+ .brand {
53
+ font-size: 1.5rem;
54
+ font-weight: 700;
55
+ color: #2c3e50;
56
+ margin-bottom: 1rem;
57
+ }
58
+ .message {
59
+ font-size: 1rem;
60
+ color: #666;
61
+ line-height: 1.6;
62
+ }
63
+ .footer {
64
+ margin-top: 2rem;
65
+ font-size: 0.8rem;
66
+ color: #999;
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div class="container">
72
+ <div class="brand">${safeBrand}</div>
73
+ <p class="message">${safeDescription}</p>
74
+ <div class="footer">&copy; ${new Date().getFullYear()} ${safeBrand}</div>
75
+ </div>
76
+ </body>
77
+ </html>`;
78
+ }
79
+
80
+ /**
81
+ * Generate the interstitial challenge page (for interstitial mode).
82
+ * This page uses JavaScript to redirect, proving the client can execute JS.
83
+ */
84
+ export function generateInterstitialPage({ slug, token, destinationUrl, brand }) {
85
+ const safeBrand = escapeHtml(brand || 'Website');
86
+
87
+ return `<!DOCTYPE html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="UTF-8">
91
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
92
+ <title>Redirecting... | ${safeBrand}</title>
93
+ <meta name="robots" content="noindex, nofollow">
94
+ <!-- Fallback for non-JS: slow meta refresh to safe page (bots land here) -->
95
+ <meta http-equiv="refresh" content="5;url=/${escapeHtml(slug)}?_safe=1">
96
+ <style>
97
+ * { margin: 0; padding: 0; box-sizing: border-box; }
98
+ body {
99
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
100
+ background: #f8f9fa;
101
+ color: #333;
102
+ min-height: 100vh;
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ }
107
+ .container { text-align: center; padding: 2rem; }
108
+ .spinner {
109
+ width: 32px; height: 32px;
110
+ border: 3px solid #e0e0e0;
111
+ border-top-color: #3498db;
112
+ border-radius: 50%;
113
+ animation: spin 0.8s linear infinite;
114
+ margin: 0 auto 1rem;
115
+ }
116
+ @keyframes spin { to { transform: rotate(360deg); } }
117
+ .message { color: #666; font-size: 0.95rem; }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <div class="container">
122
+ <div class="spinner"></div>
123
+ <p class="message">Redirecting you securely...</p>
124
+ </div>
125
+ <script>
126
+ // JS-capable browsers redirect immediately with the signed token
127
+ (function() {
128
+ try {
129
+ window.location.replace('/${escapeHtml(slug)}?_t=${escapeHtml(token)}');
130
+ } catch(e) {
131
+ // Fallback: meta refresh will handle it
132
+ }
133
+ })();
134
+ </script>
135
+ </body>
136
+ </html>`;
137
+ }
138
+
139
+ function escapeHtml(str) {
140
+ return String(str)
141
+ .replace(/&/g, '&amp;')
142
+ .replace(/</g, '&lt;')
143
+ .replace(/>/g, '&gt;')
144
+ .replace(/"/g, '&quot;')
145
+ .replace(/'/g, '&#39;');
146
+ }
@@ -0,0 +1,67 @@
1
+ import { createHmac, randomBytes } from 'crypto';
2
+ import { TOKEN_EXPIRY_SECONDS, TOKEN_SECRET_LENGTH } from '../constants.js';
3
+
4
+ // Generate a random secret on startup (or load from config)
5
+ let _secret = null;
6
+
7
+ function getSecret() {
8
+ if (!_secret) {
9
+ _secret = randomBytes(TOKEN_SECRET_LENGTH).toString('hex');
10
+ }
11
+ return _secret;
12
+ }
13
+
14
+ /**
15
+ * Set the HMAC secret (used when loading from config/env).
16
+ */
17
+ export function setTokenSecret(secret) {
18
+ _secret = secret;
19
+ }
20
+
21
+ /**
22
+ * Generate a signed token for a slug.
23
+ * Token format: timestamp.hmac
24
+ */
25
+ export function generateToken(slug) {
26
+ const timestamp = Math.floor(Date.now() / 1000);
27
+ const payload = `${slug}:${timestamp}`;
28
+ const hmac = createHmac('sha256', getSecret())
29
+ .update(payload)
30
+ .digest('hex')
31
+ .substring(0, 16); // Truncate for URL-friendliness
32
+ return `${timestamp}.${hmac}`;
33
+ }
34
+
35
+ /**
36
+ * Validate a token for a slug.
37
+ * Returns true if the token is valid and not expired.
38
+ */
39
+ export function validateToken(slug, token) {
40
+ if (!token) return false;
41
+
42
+ const parts = token.split('.');
43
+ if (parts.length !== 2) return false;
44
+
45
+ const [timestampStr, providedHmac] = parts;
46
+ const timestamp = parseInt(timestampStr, 10);
47
+ if (isNaN(timestamp)) return false;
48
+
49
+ // Check expiry
50
+ const now = Math.floor(Date.now() / 1000);
51
+ if (now - timestamp > TOKEN_EXPIRY_SECONDS) return false;
52
+
53
+ // Verify HMAC
54
+ const payload = `${slug}:${timestamp}`;
55
+ const expectedHmac = createHmac('sha256', getSecret())
56
+ .update(payload)
57
+ .digest('hex')
58
+ .substring(0, 16);
59
+
60
+ // Constant-time comparison
61
+ if (providedHmac.length !== expectedHmac.length) return false;
62
+ let match = true;
63
+ for (let i = 0; i < expectedHmac.length; i++) {
64
+ if (providedHmac[i] !== expectedHmac[i]) match = false;
65
+ }
66
+ return match;
67
+ }
package/src/config.js ADDED
@@ -0,0 +1,152 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { DEFAULT_DATA_DIR, CONFIG_FILENAME, DEFAULT_CONFIG } from './constants.js';
4
+
5
+ let _configDir = null;
6
+
7
+ /**
8
+ * Deep merge two objects. Source values override target values.
9
+ */
10
+ function deepMerge(target, source) {
11
+ const result = { ...target };
12
+ for (const key of Object.keys(source)) {
13
+ if (
14
+ source[key] &&
15
+ typeof source[key] === 'object' &&
16
+ !Array.isArray(source[key]) &&
17
+ target[key] &&
18
+ typeof target[key] === 'object'
19
+ ) {
20
+ result[key] = deepMerge(target[key], source[key]);
21
+ } else {
22
+ result[key] = source[key];
23
+ }
24
+ }
25
+ return result;
26
+ }
27
+
28
+ /**
29
+ * Get a nested value from an object using dot notation.
30
+ */
31
+ function getNestedValue(obj, path) {
32
+ return path.split('.').reduce((curr, key) => curr?.[key], obj);
33
+ }
34
+
35
+ /**
36
+ * Set a nested value on an object using dot notation.
37
+ */
38
+ function setNestedValue(obj, path, value) {
39
+ const keys = path.split('.');
40
+ const last = keys.pop();
41
+ let curr = obj;
42
+ for (const key of keys) {
43
+ if (curr[key] === undefined || typeof curr[key] !== 'object') {
44
+ curr[key] = {};
45
+ }
46
+ curr = curr[key];
47
+ }
48
+ // Try to parse as number/boolean
49
+ if (value === 'true') value = true;
50
+ else if (value === 'false') value = false;
51
+ else if (!isNaN(value) && value !== '') value = Number(value);
52
+ curr[last] = value;
53
+ }
54
+
55
+ /**
56
+ * Get the config directory.
57
+ */
58
+ function getConfigDir(dataDir) {
59
+ return dataDir || _configDir || process.env.CAROM_DATA_DIR || DEFAULT_DATA_DIR;
60
+ }
61
+
62
+ /**
63
+ * Set the config directory (called early by CLI).
64
+ */
65
+ export function setConfigDir(dir) {
66
+ _configDir = dir;
67
+ }
68
+
69
+ /**
70
+ * Read config file, merged with defaults and env vars.
71
+ */
72
+ export function loadConfig(dataDir) {
73
+ const dir = getConfigDir(dataDir);
74
+ const configPath = join(dir, CONFIG_FILENAME);
75
+
76
+ let fileConfig = {};
77
+ if (existsSync(configPath)) {
78
+ try {
79
+ fileConfig = JSON.parse(readFileSync(configPath, 'utf8'));
80
+ } catch {
81
+ // Corrupt config file, use defaults
82
+ }
83
+ }
84
+
85
+ // Start with defaults, overlay file config
86
+ let config = deepMerge(DEFAULT_CONFIG, fileConfig);
87
+
88
+ // Env vars override everything
89
+ if (process.env.PORT) config.port = parseInt(process.env.PORT, 10);
90
+ if (process.env.ADMIN_PORT) config.adminPort = parseInt(process.env.ADMIN_PORT, 10);
91
+ if (process.env.HOST) config.host = process.env.HOST;
92
+ if (process.env.BASE_URL) config.baseUrl = process.env.BASE_URL;
93
+ if (process.env.API_KEY) config.apiKey = process.env.API_KEY;
94
+ if (process.env.SHIELD_ENABLED !== undefined) config.shield.enabled = process.env.SHIELD_ENABLED === 'true';
95
+ if (process.env.SHIELD_THRESHOLD) config.shield.threshold = parseInt(process.env.SHIELD_THRESHOLD, 10);
96
+ if (process.env.SHIELD_MODE) config.shield.mode = process.env.SHIELD_MODE;
97
+ if (process.env.SAFE_PAGE_TITLE) config.shield.safePage.title = process.env.SAFE_PAGE_TITLE;
98
+ if (process.env.SAFE_PAGE_DESCRIPTION) config.shield.safePage.description = process.env.SAFE_PAGE_DESCRIPTION;
99
+ if (process.env.SAFE_PAGE_BRAND) config.shield.safePage.brand = process.env.SAFE_PAGE_BRAND;
100
+
101
+ return config;
102
+ }
103
+
104
+ /**
105
+ * Save config to file.
106
+ */
107
+ export function saveConfig(config, dataDir) {
108
+ const dir = getConfigDir(dataDir);
109
+ mkdirSync(dir, { recursive: true });
110
+ const configPath = join(dir, CONFIG_FILENAME);
111
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
112
+ }
113
+
114
+ /**
115
+ * Get a specific config value by dot-notation key.
116
+ */
117
+ export function getConfigValue(key, dataDir) {
118
+ const config = loadConfig(dataDir);
119
+ return getNestedValue(config, key);
120
+ }
121
+
122
+ /**
123
+ * Set a specific config value by dot-notation key and persist.
124
+ */
125
+ export function setConfigValue(key, value, dataDir) {
126
+ const dir = getConfigDir(dataDir);
127
+ const configPath = join(dir, CONFIG_FILENAME);
128
+
129
+ // Read raw file config (not merged with env/defaults)
130
+ let fileConfig = {};
131
+ if (existsSync(configPath)) {
132
+ try {
133
+ fileConfig = JSON.parse(readFileSync(configPath, 'utf8'));
134
+ } catch {
135
+ fileConfig = {};
136
+ }
137
+ }
138
+
139
+ setNestedValue(fileConfig, key, value);
140
+ saveConfig(fileConfig, dataDir);
141
+
142
+ // Return the full merged config
143
+ return loadConfig(dataDir);
144
+ }
145
+
146
+ /**
147
+ * Reset config to defaults.
148
+ */
149
+ export function resetConfig(dataDir) {
150
+ saveConfig({}, dataDir);
151
+ return loadConfig(dataDir);
152
+ }