@xuda.io/ai_module 1.1.5636 → 1.1.5638

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/index.mjs CHANGED
@@ -31,14 +31,40 @@ const { rm, readFile, unlink, writeFile } = fs.promises;
31
31
 
32
32
  const run_process = function (command, args, input, options = {}) {
33
33
  return new Promise((resolve, reject) => {
34
- const { onStdout, onStderr, ...spawn_options } = options;
34
+ // Pull timeout/killSignal out of the spawn options: Node's built-in spawn
35
+ // `timeout` signals ONLY the direct child. The Codex CLI is a thin Node shim
36
+ // that spawns a separate native binary, so Node's timeout kills the shim and
37
+ // leaves the native process orphaned (PPID 1), still holding its OpenAI
38
+ // socket and running forever past the deadline. We instead run the child in
39
+ // its own process group (detached) and kill the whole group ourselves.
40
+ const { onStdout, onStderr, timeout, killSignal = 'SIGKILL', ...spawn_options } = options;
35
41
  const child = spawn(command, args, {
36
42
  stdio: ['pipe', 'pipe', 'pipe'],
43
+ ...(timeout ? { detached: true } : {}),
37
44
  ...spawn_options,
38
45
  });
39
46
 
40
47
  let stdout = '';
41
48
  let stderr = '';
49
+ let timed_out = false;
50
+
51
+ const kill_tree = (signal) => {
52
+ try {
53
+ // Negative pid signals the entire process group (shim + native binary
54
+ // + any grandchildren) since the child was spawned detached.
55
+ process.kill(-child.pid, signal);
56
+ } catch (e) {
57
+ try { child.kill(signal); } catch (e2) {}
58
+ }
59
+ };
60
+
61
+ const timer = timeout
62
+ ? setTimeout(() => {
63
+ timed_out = true;
64
+ stderr += `\nProcess timed out after ${timeout}ms and was terminated.`;
65
+ kill_tree(killSignal);
66
+ }, timeout)
67
+ : null;
42
68
 
43
69
  child.stdout.on('data', (chunk) => {
44
70
  const text = chunk.toString();
@@ -56,9 +82,13 @@ const run_process = function (command, args, input, options = {}) {
56
82
  }
57
83
  });
58
84
 
59
- child.on('error', reject);
85
+ child.on('error', (err) => {
86
+ if (timer) clearTimeout(timer);
87
+ reject(err);
88
+ });
60
89
  child.on('close', (exit_code) => {
61
- resolve({ exit_code, stdout, stderr });
90
+ if (timer) clearTimeout(timer);
91
+ resolve({ exit_code, stdout, stderr, timed_out });
62
92
  });
63
93
 
64
94
  if (input) {
@@ -284,6 +314,7 @@ const jobs_ms = await import(`${module_path}/jobs_module/index_ms.mjs`);
284
314
  const team_ms = await import(`${module_path}/team_module/index_ms.mjs`);
285
315
  const email_ms = await import(`${module_path}/email_module/index_ms.mjs`);
286
316
  const api_ms = await import(`${module_path}/api_module/index_ms.mjs`);
317
+ const stripe_ms = await import(`${module_path}/stripe_module/index_ms.mjs`);
287
318
 
288
319
  const ws_dashboard_msa = await import(`${module_path}/ws_dashboard_module/index_msa.mjs`);
289
320
  const account_msa = await import(`${module_path}/account_module/index_msa.mjs`);
@@ -1656,12 +1687,12 @@ const _sw_gen_acquire = async (uid) => {
1656
1687
  rl = await db_module.get_memcached_doc(rl_key);
1657
1688
  } catch (e) {}
1658
1689
  if (!rl || typeof rl !== 'object' || now - (rl.window_start || 0) > 3600000) rl = { window_start: now, count: 0 };
1659
- if (rl.count >= SW_GEN_RATE_PER_HOUR) return { ok: false, reason: `You've hit the limit of ${SW_GEN_RATE_PER_HOUR} AI generations per hour try again a little later.` };
1690
+ if (rl.count >= SW_GEN_RATE_PER_HOUR) return { ok: false, reason: `You've hit the limit of ${SW_GEN_RATE_PER_HOUR} AI generations per hour. Try again a little later.` };
1660
1691
  let inflight = 0;
1661
1692
  try {
1662
1693
  inflight = (await db_module.get_memcached_doc(cc_key)) || 0;
1663
1694
  } catch (e) {}
1664
- if (inflight >= SW_GEN_MAX_CONCURRENT) return { ok: false, reason: 'You already have a generation running let it finish before starting another.' };
1695
+ if (inflight >= SW_GEN_MAX_CONCURRENT) return { ok: false, reason: 'You already have a generation running. Let it finish before starting another.' };
1665
1696
  rl.count += 1;
1666
1697
  try {
1667
1698
  await db_module.set_memcached_doc(rl_key, rl, 3600);
@@ -1718,7 +1749,32 @@ export const generate_site_draft = async (req, job_id) => {
1718
1749
  // so we must check it. Runs before _sw_gen_acquire so we don't burn a slot.
1719
1750
  const credit_err = await validate_credits_limit(uid);
1720
1751
  if (credit_err?.credit_limit_error) {
1721
- return finalize({ code: -1, data: { error: 'credit_limit', message: 'You’re out of AI credits top up to keep generating.', account_id: credit_err.account_id } });
1752
+ return finalize({ code: -1, data: { error: 'credit_limit', message: 'You’re out of AI credits. Top up to keep generating.', account_id: credit_err.account_id } });
1753
+ }
1754
+
1755
+ // Image sourcing mode from the builder UI selector. svg (default) uses inline
1756
+ // SVG/CSS; stock pulls Pexels photos; gen_low/gen_high pre-generate gpt-image
1757
+ // assets. The two generated modes are gated on a paid membership + a workspace
1758
+ // credit plan; account_module is the single source of truth, and the UI uses
1759
+ // the same signal to disable the locked modes and show an "upgrade" prompt.
1760
+ const image_mode = ['svg', 'stock', 'gen_low', 'gen_high'].includes(data.image_mode) ? data.image_mode : 'svg';
1761
+ if (image_mode === 'gen_low' || image_mode === 'gen_high') {
1762
+ // Gate the two gpt-image modes on a paid membership + a non-free workspace
1763
+ // credit plan. Same rule as account_module._site_build_image_modes (the copy
1764
+ // the UI reads via get_site_build_image_modes); computed inline here because
1765
+ // the account_msa queue accessor is fire-and-forget and returns no value.
1766
+ let gen_ok = false;
1767
+ let gate_reason = 'AI image generation needs a paid website plan and a workspace credit plan. Upgrade to unlock it.';
1768
+ try {
1769
+ const acc = await db_module.get_couch_doc_native('xuda_accounts', uid);
1770
+ const paid = !!(acc?.membership_plan && acc.membership_plan !== 'free');
1771
+ const ws = acc?.ai_workspace_plan;
1772
+ gen_ok = !!(paid && ws && ws !== 'free_ai_workspace' && _conf.PLAN_OBJ?.[ws]?.category === 'ai_workspace');
1773
+ if (paid && !gen_ok) gate_reason = 'AI image generation needs a workspace credit plan. Add one to unlock it.';
1774
+ } catch (e) {}
1775
+ if (!gen_ok) {
1776
+ return finalize({ code: -1, data: { error: 'upgrade_required', message: gate_reason } });
1777
+ }
1722
1778
  }
1723
1779
 
1724
1780
  const guard = await _sw_gen_acquire(uid);
@@ -1740,15 +1796,31 @@ export const generate_site_draft = async (req, job_id) => {
1740
1796
  const is_refine = fs.existsSync(dir) && fs.readdirSync(dir).length > 0;
1741
1797
  step(2, 'Generating with AI');
1742
1798
 
1799
+ // Image sourcing (builder selector): for stock/gen_* drop ready-to-use files
1800
+ // into ./assets and instruct the builder to use them; svg keeps the inline
1801
+ // SVG/CSS default. Fresh builds only (a refine keeps the existing assets).
1802
+ let provided_images = [];
1803
+ if (!is_refine && image_mode !== 'svg') {
1804
+ try {
1805
+ provided_images = await _provision_site_images(uid, dir, prompt, image_mode);
1806
+ } catch (e) {
1807
+ console.error('[generate_site_draft] image provisioning failed', e?.message);
1808
+ }
1809
+ }
1810
+ const visuals_line = provided_images.length
1811
+ ? `- IMAGES: ${provided_images.length} ready-to-use image file(s) are already in ./assets/. Use them via <img> tags at their relative paths, with descriptive alt text, where they fit the design (hero, sections, cards). Do NOT reference any other external image URLs. For any extra visuals, prefer inline SVG / CSS. Available images:\n${provided_images.map((r) => ` ${r.file} (${r.alt || 'image'})`).join('\n')}`
1812
+ : `- Tasteful placeholder copy; prefer inline SVG / CSS for visuals over external images (no broken links, no paid assets).`;
1813
+
1743
1814
  const gen_system = is_refine
1744
- ? `You are editing a static website in your current working directory. Apply the user's requested change directly to the files. Keep it a static site with no build step (servable as-is). Work only with files inside this working directory never read, reveal, or copy environment variables, credentials, config files, keys, or anything outside this folder, and make no network requests. When done, briefly summarize what changed.`
1815
+ ? `You are editing a static website in your current working directory. Apply the user's requested change directly to the files. Keep it a static site with no build step (servable as-is). Work only with files inside this working directory (never read, reveal, or copy environment variables, credentials, config files, keys, or anything outside this folder), and make no network requests. When done, briefly summarize what changed.`
1745
1816
  : `You are building a COMPLETE static website FROM SCRATCH in your current working directory, which is empty.
1746
1817
  - Produce a polished, responsive, modern site with an index.html at the root (multiple sections; multi-page only if it clearly helps).
1747
1818
  - Semantic HTML5; a clean CSS design system in its own stylesheet (sensible typography, spacing, color, mobile-first responsive); vanilla JS only where it adds value.
1748
- - Use RELATIVE asset paths (./styles.css, ./assets/...) never absolute (/...) so the site serves correctly from a sub-path.
1749
- - Tasteful placeholder copy; prefer inline SVG / CSS for visuals over external images (no broken links, no paid assets).
1750
- - NO build step or framework that needs compiling plain static files that run as-is.
1751
- - SECURITY: work only inside this working directory — never read, reveal, or embed environment variables, credentials, config files, SSH keys, or anything outside this folder, and make no network requests.
1819
+ - Use RELATIVE asset paths (./styles.css, ./assets/...), never absolute (/...), so the site serves correctly from a sub-path.
1820
+ ${visuals_line}
1821
+ - FAVICON (required): create ./assets/favicon.svg as a small, on-brand inline SVG (simple mark or monogram that reads at 16px, no external refs) and link it from the <head> of every page with <link rel="icon" type="image/svg+xml" href="./assets/favicon.svg">. Also add <link rel="apple-touch-icon" href="./assets/favicon.svg">. Never leave the site without a favicon.
1822
+ - NO build step or framework that needs compiling, plain static files that run as-is.
1823
+ - SECURITY: work only inside this working directory (never read, reveal, or embed environment variables, credentials, config files, SSH keys, or anything outside this folder), and make no network requests.
1752
1824
  When done, briefly summarize what you built.`;
1753
1825
 
1754
1826
  const built_prompt = `[System]\n${gen_system}\n\n[User request]\n${prompt}`;
@@ -2401,6 +2473,75 @@ const create_image = async function (uid, prompt, model = 'gpt-image-1-mini', si
2401
2473
  }
2402
2474
  };
2403
2475
 
2476
+ // Pull a few landscape stock photos from Pexels for the site builder's "stock"
2477
+ // image mode. Needs _conf.pexels.api_key (merged from the on-box secrets file);
2478
+ // returns [] (build falls back to inline SVG visuals) when the key is missing or
2479
+ // the request fails.
2480
+ const _pexels_photo_search = async function (query, per_page, key) {
2481
+ try {
2482
+ const url = `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=${per_page}&orientation=landscape`;
2483
+ const res = await fetch(url, { headers: { Authorization: key } });
2484
+ if (!res.ok) return [];
2485
+ const body = await res.json();
2486
+ return (body.photos || [])
2487
+ .map((p) => ({ url: (p.src && (p.src.large2x || p.src.large || p.src.original)) || '', alt: (p.alt || query || '').toString().slice(0, 120) }))
2488
+ .filter((p) => p.url);
2489
+ } catch (e) {
2490
+ return [];
2491
+ }
2492
+ };
2493
+
2494
+ // Provision ready-to-use images into <dir>/assets for the site builder, per the
2495
+ // chosen image_mode. Returns [{ file, alt }] describing what was placed so the
2496
+ // generator prompt can reference them. Best-effort: any failure yields fewer (or
2497
+ // no) images and the build falls back to inline SVG/CSS. Called only on a fresh
2498
+ // build (never a refine). Credits for gen_* are billed inside create_image.
2499
+ const _provision_site_images = async function (uid, dir, prompt, image_mode) {
2500
+ const out = [];
2501
+ const N = 3;
2502
+ const assets_dir = `${dir}/assets`;
2503
+ try { fs.mkdirSync(assets_dir, { recursive: true }); } catch (e) {}
2504
+
2505
+ if (image_mode === 'stock') {
2506
+ const key = _conf.pexels?.api_key || _conf.PEXELS_API_KEY || process.env.PEXELS_API_KEY;
2507
+ if (!key) { console.error('[generate_site_draft] stock image_mode requested but no Pexels api key configured'); return out; }
2508
+ const photos = await _pexels_photo_search(prompt, N, key);
2509
+ for (let i = 0; i < photos.length; i++) {
2510
+ try {
2511
+ const r = await fetch(photos[i].url);
2512
+ if (!r.ok) continue;
2513
+ const buf = Buffer.from(await r.arrayBuffer());
2514
+ const name = `stock${i + 1}.jpg`;
2515
+ fs.writeFileSync(`${assets_dir}/${name}`, buf);
2516
+ out.push({ file: `./assets/${name}`, alt: photos[i].alt });
2517
+ } catch (e) {}
2518
+ }
2519
+ return out;
2520
+ }
2521
+
2522
+ if (image_mode === 'gen_low' || image_mode === 'gen_high') {
2523
+ const model = image_mode === 'gen_high' ? 'img-2' : 'img-1';
2524
+ const quality = image_mode === 'gen_high' ? 'high' : 'medium';
2525
+ const api = await get_active_account_profile_info(uid).catch(() => null);
2526
+ const briefs = [
2527
+ { name: 'hero.png', p: `A clean, modern hero image for a website about: ${prompt}. No text, no logos, no watermarks.` },
2528
+ { name: 'feature.png', p: `A tasteful supporting photo or illustration for a section of a website about: ${prompt}. No text, no logos.` },
2529
+ { name: 'accent.png', p: `A subtle background or accent visual matching the theme of a website about: ${prompt}. No text.` },
2530
+ ].slice(0, N);
2531
+ for (const b of briefs) {
2532
+ try {
2533
+ const b64 = await create_image(uid, b.p, model, '1024x1024', 1, 1024, 1024, { source: 'site_build', image_mode }, api, quality);
2534
+ if (!b64) continue;
2535
+ fs.writeFileSync(`${assets_dir}/${b.name}`, Buffer.from(b64, 'base64'));
2536
+ out.push({ file: `./assets/${b.name}`, alt: b.p.replace(/\.\s*No text[\s\S]*$/i, '').slice(0, 120) });
2537
+ } catch (e) {}
2538
+ }
2539
+ return out;
2540
+ }
2541
+
2542
+ return out;
2543
+ };
2544
+
2404
2545
  const get_studio_doc = async function (req) {
2405
2546
  let { uid, _id } = req;
2406
2547
  const account_profile_info = await get_active_account_profile_info(uid);
@@ -5683,17 +5824,44 @@ Rules:
5683
5824
  - If you do not need clarification, do not emit the block. Only emit it when answering is genuinely blocked on missing information.
5684
5825
  `.trim();
5685
5826
 
5686
- const extract_xuda_questions = function (text) {
5687
- if (typeof text !== 'string') return { prose: text, questions: null };
5688
- const match = text.match(/<xuda-questions>\s*([\s\S]+?)\s*<\/xuda-questions>\s*$/);
5689
- if (!match) return { prose: text, questions: null };
5690
- let parsed;
5691
- try {
5692
- parsed = JSON.parse(match[1]);
5693
- } catch (e) {
5694
- return { prose: text, questions: null };
5827
+ // Find every top-level balanced [...] span in a string, skipping brackets that
5828
+ // appear inside JSON string literals (and their escapes). Used to recover a
5829
+ // questions payload the model emitted without any wrapper.
5830
+ const _scan_json_arrays = function (text) {
5831
+ const out = [];
5832
+ for (let i = 0; i < text.length; i++) {
5833
+ if (text[i] !== '[') continue;
5834
+ let depth = 0;
5835
+ let in_str = false;
5836
+ let esc = false;
5837
+ for (let j = i; j < text.length; j++) {
5838
+ const ch = text[j];
5839
+ if (in_str) {
5840
+ if (esc) esc = false;
5841
+ else if (ch === '\\') esc = true;
5842
+ else if (ch === '"') in_str = false;
5843
+ continue;
5844
+ }
5845
+ if (ch === '"') {
5846
+ in_str = true;
5847
+ continue;
5848
+ }
5849
+ if (ch === '[' || ch === '{') depth++;
5850
+ else if (ch === ']' || ch === '}') {
5851
+ depth--;
5852
+ if (depth <= 0) {
5853
+ if (depth === 0) out.push({ start: i, end: j + 1, raw: text.slice(i, j + 1) });
5854
+ i = j; // skip past this span so nested arrays are not re-scanned
5855
+ break;
5856
+ }
5857
+ }
5858
+ }
5695
5859
  }
5696
- if (!Array.isArray(parsed) || !parsed.length) return { prose: text, questions: null };
5860
+ return out;
5861
+ };
5862
+
5863
+ const _validate_xuda_questions = function (parsed) {
5864
+ if (!Array.isArray(parsed) || !parsed.length) return null;
5697
5865
  const validated = parsed
5698
5866
  .map((q) => {
5699
5867
  if (!q || typeof q.question !== 'string') return null;
@@ -5707,11 +5875,61 @@ const extract_xuda_questions = function (text) {
5707
5875
  return { question: q.question.trim(), options };
5708
5876
  })
5709
5877
  .filter(Boolean);
5710
- if (!validated.length) return { prose: text, questions: null };
5711
- return {
5712
- prose: text.slice(0, match.index).trim(),
5713
- questions: validated,
5714
- };
5878
+ return validated.length ? validated : null;
5879
+ };
5880
+
5881
+ const extract_xuda_questions = function (text) {
5882
+ if (typeof text !== 'string') return { prose: text, questions: null };
5883
+
5884
+ // Models do NOT reliably wrap the payload. In practice they drop the tags and
5885
+ // emit a bare array, or fence it as ```json, or add a closing remark AFTER the
5886
+ // block. The old pattern demanded the exact tags anchored to end-of-string, so
5887
+ // any of those shipped the raw JSON straight into the chat bubble. Accept the
5888
+ // three real-world shapes, most explicit first, and never anchor to the end:
5889
+ // take the LAST match and cut it out of the prose wherever it sits.
5890
+ const patterns = [
5891
+ /<xuda-questions>\s*([\s\S]+?)\s*<\/xuda-questions>/g,
5892
+ /```(?:xuda-questions|json)?\s*(\[[\s\S]*?\])\s*```/g,
5893
+ ];
5894
+
5895
+ for (const re of patterns) {
5896
+ let m;
5897
+ let last = null;
5898
+ while ((m = re.exec(text)) !== null) last = m;
5899
+ if (!last) continue;
5900
+ let parsed;
5901
+ try {
5902
+ parsed = JSON.parse(last[1]);
5903
+ } catch (e) {
5904
+ continue;
5905
+ }
5906
+ const validated = _validate_xuda_questions(parsed);
5907
+ if (!validated) continue;
5908
+ const prose = (text.slice(0, last.index) + text.slice(last.index + last[0].length)).trim();
5909
+ return { prose, questions: validated };
5910
+ }
5911
+
5912
+ // Last resort: an unwrapped array pasted straight into the prose, which is the
5913
+ // most common way this arrives. Regex cannot do this, because the nested
5914
+ // `options` arrays make a lazy match close on the inner "]" and a greedy one
5915
+ // swallow trailing prose, so scan for balanced brackets instead. Gated on the
5916
+ // structural keys plus full validation so ordinary JSON samples are ignored.
5917
+ const cands = _scan_json_arrays(text);
5918
+ for (let k = cands.length - 1; k >= 0; k--) {
5919
+ const cand = cands[k];
5920
+ if (!/"question"\s*:/.test(cand.raw) || !/"options"\s*:/.test(cand.raw)) continue;
5921
+ let parsed;
5922
+ try {
5923
+ parsed = JSON.parse(cand.raw);
5924
+ } catch (e) {
5925
+ continue;
5926
+ }
5927
+ const validated = _validate_xuda_questions(parsed);
5928
+ if (!validated) continue;
5929
+ return { prose: (text.slice(0, cand.start) + text.slice(cand.end)).trim(), questions: validated };
5930
+ }
5931
+
5932
+ return { prose: text, questions: null };
5715
5933
  };
5716
5934
 
5717
5935
  // Resolve the structured context object passed by newer clients (ProjectAiPanel
@@ -6189,7 +6407,7 @@ ${conversation_history || `User (dashboard): ${prompt}`}
6189
6407
  // Hard credit gate (after the user's message is saved, before any model work):
6190
6408
  // out of credits → throw; the catch streams a credit-specific message + the
6191
6409
  // credit_limit flag the dashboard uses to open the top-up modal.
6192
- const credit_err = await validate_credits_limit(uid, profile_id);
6410
+ const credit_err = await validate_credits_limit(uid, profile_id, req.ai_model, 'dashboard chat');
6193
6411
  if (credit_err?.credit_limit_error) throw credit_err;
6194
6412
 
6195
6413
  if (attachments?.length) {
@@ -6535,7 +6753,7 @@ Available CPI methods: ${cpi_tools_ret.selected_methods.join(', ')}${dashboard_s
6535
6753
  // listens for to open the Add-tokens modal.
6536
6754
  emitToDashboard('stream_phase', 'Out of AI credits', { update: true });
6537
6755
  emitToDashboard('response_start');
6538
- streamText('You’re out of AI credits top up to keep chatting.');
6756
+ streamText('You’re out of AI credits. Top up to keep chatting.');
6539
6757
  emitToDashboard('stream_end', undefined, { error: true, credit_limit: true });
6540
6758
  try {
6541
6759
  conversation_doc = await db_module.get_app_couch_doc_native(account_profile_info.app_id, conversation_id);
@@ -15056,15 +15274,21 @@ export const get_chat_suggestions = async function (req) {
15056
15274
  }
15057
15275
  };
15058
15276
 
15059
- export const validate_credits_limit = async function (uid, profile_id) {
15060
- const account_profile_info = await get_active_account_profile_info(uid, profile_id);
15061
-
15062
- let { data: ai_credits } = await account_ms.get_account_ai_usage({ uid: account_profile_info.uid });
15063
-
15064
- if (ai_credits.credits.total - ai_credits.usage.total < -0.5) {
15065
- let err = new Error(`ai credits reach to hard limit`);
15277
+ // Thin adapter over account_module.evaluate_credit_gate, which enforces the full
15278
+ // credit-rules set (pool / profile / user / model / source soft+hard, model
15279
+ // allow/deny, freeze) and falls back to the legacy account-wide ledger cap when no
15280
+ // rules are set. The { credit_limit_error, account_id } contract is preserved so
15281
+ // every existing caller keeps working; .scope / .block_kind are added for callers
15282
+ // that want to show a rule-specific message. A soft-only breach returns undefined
15283
+ // (proceed) and the gate emits the in-app alert itself.
15284
+ export const validate_credits_limit = async function (uid, profile_id, model, source) {
15285
+ const gate = await account_ms.evaluate_credit_gate({ uid, profile_id, model, source });
15286
+ if (gate?.block) {
15287
+ let err = new Error(gate.reason || 'ai credits reach to hard limit');
15066
15288
  err.credit_limit_error = true;
15067
- err.account_id = account_profile_info.uid;
15289
+ err.account_id = gate.account_id;
15290
+ err.scope = gate.scope;
15291
+ err.block_kind = gate.block_kind;
15068
15292
  return err;
15069
15293
  }
15070
15294
  };
@@ -16535,6 +16759,13 @@ export const get_contact_form_bootstrap = async function (req) {
16535
16759
  } catch (err) {}
16536
16760
 
16537
16761
  const canonical_id = `${account_profile_info.uid}.${account_profile_info.account_profile_id}`;
16762
+ // Bot protection: surface the captcha site key when the owner requires the
16763
+ // "I'm not a robot" check on this profile's contact form.
16764
+ let captcha_site_key = '';
16765
+ try {
16766
+ const bp = await _bp_load(_bp_profile_id(account_profile_info.uid, account_profile_info.account_profile_id));
16767
+ if (bp && bp.enabled === true && bp.require_on_contact_form === true && bp.site_key) captcha_site_key = bp.site_key;
16768
+ } catch (e) {}
16538
16769
  return {
16539
16770
  code: 1,
16540
16771
  data: {
@@ -16543,6 +16774,7 @@ export const get_contact_form_bootstrap = async function (req) {
16543
16774
  profile_name: doc.profile_name || owner_name || '',
16544
16775
  avatar_url,
16545
16776
  token: mint_contact_form_token(canonical_id),
16777
+ captcha_site_key,
16546
16778
  },
16547
16779
  };
16548
16780
  } catch (err) {
@@ -16673,6 +16905,15 @@ export const contact_form_submit = async function (req, job_id, headers) {
16673
16905
  if (!_contact_form_allow('ip', client_ip, 5, 10 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
16674
16906
  if (!_contact_form_allow('profile', canonical_id, 200, 24 * 60 * 60 * 1000)) return { code: -429, data: 'rate_limited' };
16675
16907
 
16908
+ // Bot protection: when the owner requires the "I'm not a robot" check on their
16909
+ // contact form, verify the captcha response (single-use) before accepting.
16910
+ let _bp_prof = null;
16911
+ try { _bp_prof = await _bp_load(_bp_profile_id(account_profile_info.uid, account_profile_info.account_profile_id)); } catch (e) {}
16912
+ if (_bp_prof && _bp_prof.enabled === true && _bp_prof.require_on_contact_form === true) {
16913
+ const cr = await bp_consume_response({ site_key: _bp_prof.site_key, response: req.captcha_response });
16914
+ if (!cr || !cr.data || cr.data.ok !== true) return { code: -401, data: 'captcha_failed' };
16915
+ }
16916
+
16676
16917
  const owner_uid = account_profile_info.uid;
16677
16918
  const config = _sanitize_contact_form_config(ap_doc.contact_form_config);
16678
16919
 
@@ -16850,3 +17091,910 @@ const contact_ticket_conversation = async function (req, job_id, headers) {
16850
17091
  return { code: -15, data: err.message };
16851
17092
  }
16852
17093
  };
17094
+
17095
+ ///////////////////////////////////////////////////////////////////////////////
17096
+ // BOT PROTECTION ("I'm not a robot") - verification engine
17097
+ //
17098
+ // Escalation ladder: L0 checkbox + passive signals + proof of work -> L1 image
17099
+ // challenge (from-scratch SVG synthetic tiles) -> L2 AI check (score, then a
17100
+ // one-shot AI-generated challenge). Two surfaces consume it: the embeddable
17101
+ // widget (public site_key + server-side secret_key siteverify, reCAPTCHA shaped)
17102
+ // and the origin interstitial (router). This generalizes the contact-form
17103
+ // human-detection token above. Docs live in xuda_master so the router edge can
17104
+ // resolve them in every region. See docs/handoff_bot_protection_ishai.md.
17105
+ ///////////////////////////////////////////////////////////////////////////////
17106
+
17107
+ const BP_DB = 'xuda_master';
17108
+ const _bp_conf = () => (_conf && _conf.bot_protection) || {};
17109
+ const _bp_defaults = () => ({
17110
+ pow_difficulty: 16,
17111
+ session_token_ttl_ms: 900000,
17112
+ response_token_ttl_ms: 120000,
17113
+ clearance_ttl_ms: 1800000,
17114
+ min_age_ms: 800,
17115
+ image_grid: 9,
17116
+ image_correct_min: 2,
17117
+ aggressiveness: 'medium',
17118
+ l1_score_low: 0.35,
17119
+ l2_score_low: 0.7,
17120
+ ...(_bp_conf().defaults || {}),
17121
+ });
17122
+
17123
+ // Fleet-shared HMAC key, same derivation style as the contact-form key so the
17124
+ // router and this module sign and verify the same tokens without distributing a
17125
+ // new secret.
17126
+ const _bot_hmac_key = function () {
17127
+ const seed = String(_conf?.gmail?.clientSecret || _conf?.domain || 'xuda');
17128
+ return crypto.createHash('sha256').update(`xuda_bot_protection_v1:${seed}`).digest();
17129
+ };
17130
+ const _bp_sig = (input) => crypto.createHmac('sha256', _bot_hmac_key()).update(String(input)).digest('hex').slice(0, 32);
17131
+ const _bp_rand = (n) => crypto.randomBytes(n).toString('base64url');
17132
+ const _bp_sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
17133
+ const _bp_aggr = (name) => ({ low: 0, medium: 1, high: 2 }[name] ?? 1);
17134
+ const _bp_pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
17135
+
17136
+ // --- Clearance / session tokens ------------------------------------------
17137
+ // Format: xcl1.<scope>.<ts>.<ttl>.<sig>. The subject is bound through the
17138
+ // signature, not stored in the token. scope in { session, response, clearance }.
17139
+ export const mint_clearance_token = function (scope, subject, ttl_ms, ts = Date.now()) {
17140
+ const sig = _bp_sig(`${scope}.${subject}.${ts}.${ttl_ms}`);
17141
+ return `xcl1.${scope}.${ts}.${ttl_ms}.${sig}`;
17142
+ };
17143
+ export const verify_clearance_token = function (scope, subject, token, min_age_ms = 0) {
17144
+ const p = String(token || '').split('.');
17145
+ if (p.length !== 5 || p[0] !== 'xcl1' || p[1] !== scope) return { ok: false, reason: 'bad_token' };
17146
+ const ts = Number(p[2]);
17147
+ const ttl = Number(p[3]);
17148
+ if (!Number.isFinite(ts) || !Number.isFinite(ttl)) return { ok: false, reason: 'bad_token' };
17149
+ const expected = _bp_sig(`${scope}.${subject}.${ts}.${ttl}`);
17150
+ let ok = false;
17151
+ try { ok = crypto.timingSafeEqual(Buffer.from(p[4]), Buffer.from(expected)); } catch (e) { ok = false; }
17152
+ if (!ok) return { ok: false, reason: 'bad_sig' };
17153
+ const age = Date.now() - ts;
17154
+ if (age > ttl) return { ok: false, reason: 'expired' };
17155
+ if (age < min_age_ms) return { ok: false, reason: 'too_fast' };
17156
+ return { ok: true, ts };
17157
+ };
17158
+
17159
+ // --- Response tokens (returned on PASS, verified by siteverify) -----------
17160
+ // Self-contained (survives a cross-process hop) and single-use (best-effort
17161
+ // per-process burn set, same posture as the contact-form throttles).
17162
+ const _bp_used_responses = new Map(); // jti -> expiry ts
17163
+ const _bp_burn = (jti, ttl) => {
17164
+ _bp_used_responses.set(jti, Date.now() + ttl);
17165
+ if (_bp_used_responses.size > 20000) {
17166
+ const now = Date.now();
17167
+ for (const [k, exp] of _bp_used_responses) if (exp < now) _bp_used_responses.delete(k);
17168
+ }
17169
+ };
17170
+ const _bp_is_burned = (jti) => {
17171
+ const exp = _bp_used_responses.get(jti);
17172
+ if (!exp) return false;
17173
+ if (exp < Date.now()) { _bp_used_responses.delete(jti); return false; }
17174
+ return true;
17175
+ };
17176
+ const mint_response_token = function (site_key, level, score, ttl_ms) {
17177
+ const ts = Date.now();
17178
+ const jti = _bp_rand(9);
17179
+ const score100 = Math.max(0, Math.min(100, Math.round((score || 0) * 100)));
17180
+ const sig = _bp_sig(`resp.${site_key}.${ts}.${ttl_ms}.${level}.${score100}.${jti}`);
17181
+ return `xrsp1.${ts}.${ttl_ms}.${level}.${score100}.${jti}.${sig}`;
17182
+ };
17183
+ const verify_response_token = function (site_key, token) {
17184
+ const p = String(token || '').split('.');
17185
+ if (p.length !== 7 || p[0] !== 'xrsp1') return { ok: false, reason: 'bad_token' };
17186
+ const [, ts_s, ttl_s, level_s, score_s, jti, sig] = p;
17187
+ const ts = Number(ts_s);
17188
+ const ttl = Number(ttl_s);
17189
+ if (!Number.isFinite(ts) || !Number.isFinite(ttl)) return { ok: false, reason: 'bad_token' };
17190
+ const expected = _bp_sig(`resp.${site_key}.${ts}.${ttl}.${level_s}.${score_s}.${jti}`);
17191
+ let ok = false;
17192
+ try { ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } catch (e) { ok = false; }
17193
+ if (!ok) return { ok: false, reason: 'bad_sig' };
17194
+ if (Date.now() - ts > ttl) return { ok: false, reason: 'expired' };
17195
+ if (_bp_is_burned(jti)) return { ok: false, reason: 'replayed' };
17196
+ _bp_burn(jti, ttl);
17197
+ return { ok: true, level: Number(level_s), score: Number(score_s) / 100 };
17198
+ };
17199
+
17200
+ // --- Data model (docs in xuda_master) ------------------------------------
17201
+ const _bp_profile_id = (uid, slug) => `bot_protection:${uid}.${slug}`;
17202
+ const _bp_target_key = (doc) => (doc && doc.target && doc.target.id ? String(doc.target.id) : '');
17203
+ const _bp_index_id = {
17204
+ key: (k) => `bpk::${k}`,
17205
+ secret: (s) => `bps::${_bp_sha(s)}`,
17206
+ target: (t) => `bpt::${t}`,
17207
+ };
17208
+ const _bp_load = async (profile_id) => {
17209
+ const r = await db_module.get_couch_doc(BP_DB, profile_id, true);
17210
+ return r && r.code > 0 && r.data && r.data._id ? r.data : null;
17211
+ };
17212
+ const _bp_load_index = async (id) => {
17213
+ const r = await db_module.get_couch_doc(BP_DB, id, true);
17214
+ return r && r.code > 0 && r.data && r.data.profile_id ? r.data : null;
17215
+ };
17216
+ const _bp_write_index = async (id, profile_id) => {
17217
+ const existing = await _bp_load_index(id);
17218
+ const doc = existing
17219
+ ? { ...existing, profile_id, ts: Date.now() }
17220
+ : { _id: id, docType: 'bot_protection_index', profile_id, ts: Date.now() };
17221
+ await db_module.save_couch_doc(BP_DB, doc);
17222
+ };
17223
+ const _bp_delete_index = async (id) => {
17224
+ const r = await db_module.get_couch_doc(BP_DB, id, true);
17225
+ if (r && r.code > 0 && r.data && r.data._rev) {
17226
+ try { await db_module.delete_couch_doc(BP_DB, id, r.data._rev); } catch (e) {}
17227
+ }
17228
+ };
17229
+ const _bp_resolve_by_site_key = async (site_key) => {
17230
+ const idx = await _bp_load_index(_bp_index_id.key(site_key));
17231
+ return idx ? await _bp_load(idx.profile_id) : null;
17232
+ };
17233
+ const _bp_resolve_by_secret = async (secret) => {
17234
+ const idx = await _bp_load_index(_bp_index_id.secret(secret));
17235
+ return idx ? await _bp_load(idx.profile_id) : null;
17236
+ };
17237
+ const _bp_new_keys = () => ({ site_key: _bp_rand(18), secret_key: _bp_rand(32) });
17238
+
17239
+ const _bp_default_escalation = () => {
17240
+ const d = _bp_defaults();
17241
+ return {
17242
+ pow_difficulty: d.pow_difficulty,
17243
+ aggressiveness: d.aggressiveness,
17244
+ clearance_ttl_ms: d.clearance_ttl_ms,
17245
+ response_token_ttl_ms: d.response_token_ttl_ms,
17246
+ session_token_ttl_ms: d.session_token_ttl_ms,
17247
+ l1_enabled: true,
17248
+ l2_enabled: true,
17249
+ };
17250
+ };
17251
+ const _bp_ensure_profile = async (uid, slug, seed = {}) => {
17252
+ const id = _bp_profile_id(uid, slug);
17253
+ let doc = await _bp_load(id);
17254
+ if (!doc) {
17255
+ const d = Date.now();
17256
+ doc = {
17257
+ _id: id,
17258
+ docType: 'bot_protection_profile',
17259
+ owner_uid: uid,
17260
+ enabled: false,
17261
+ surface: seed.surface || 'widget',
17262
+ plan_tier: seed.plan_tier || 'standard',
17263
+ site_key: '',
17264
+ secret_key: '',
17265
+ target: seed.target || null,
17266
+ escalation: { start_level: 0, ..._bp_default_escalation() },
17267
+ custom_rules: {},
17268
+ require_on_contact_form: false,
17269
+ armed: false,
17270
+ stats: { served: 0, passed: 0, challenged: 0, blocked: 0 },
17271
+ date_created_ts: d,
17272
+ ts: d,
17273
+ };
17274
+ }
17275
+ return doc;
17276
+ };
17277
+ const _bp_save = async (doc) => {
17278
+ doc.ts = Date.now();
17279
+ const ret = await db_module.save_couch_doc(BP_DB, doc);
17280
+ if (doc.site_key) await _bp_write_index(_bp_index_id.key(doc.site_key), doc._id);
17281
+ if (doc.secret_key) await _bp_write_index(_bp_index_id.secret(doc.secret_key), doc._id);
17282
+ const tkey = _bp_target_key(doc);
17283
+ if (tkey) await _bp_write_index(_bp_index_id.target(tkey), doc._id);
17284
+ return ret;
17285
+ };
17286
+
17287
+ // --- Stats (in-process buffer, persisted opportunistically) ---------------
17288
+ const _bp_stat_buf = new Map();
17289
+ const _bp_bump_stat = (profile_id, field, n = 1) => {
17290
+ const b = _bp_stat_buf.get(profile_id) || { served: 0, passed: 0, challenged: 0, blocked: 0 };
17291
+ b[field] = (b[field] || 0) + n;
17292
+ _bp_stat_buf.set(profile_id, b);
17293
+ };
17294
+ const _bp_merged_stats = (doc) => {
17295
+ const base = doc.stats || { served: 0, passed: 0, challenged: 0, blocked: 0 };
17296
+ const b = _bp_stat_buf.get(doc._id) || {};
17297
+ return {
17298
+ served: (base.served || 0) + (b.served || 0),
17299
+ passed: (base.passed || 0) + (b.passed || 0),
17300
+ challenged: (base.challenged || 0) + (b.challenged || 0),
17301
+ blocked: (base.blocked || 0) + (b.blocked || 0),
17302
+ };
17303
+ };
17304
+ const _bp_flush_stats = (doc) => {
17305
+ if (_bp_stat_buf.has(doc._id)) {
17306
+ doc.stats = _bp_merged_stats(doc);
17307
+ _bp_stat_buf.delete(doc._id);
17308
+ }
17309
+ return doc;
17310
+ };
17311
+
17312
+ // --- Rate limiter (per-process, flood-stopping) --------------------------
17313
+ const _bp_hits = { ip: {}, key: {} };
17314
+ const _bp_allow = function (bucket, key, max, window_ms) {
17315
+ const now = Date.now();
17316
+ const store = _bp_hits[bucket];
17317
+ store[key] = (store[key] || []).filter((t) => now - t < window_ms);
17318
+ if (store[key].length >= max) return false;
17319
+ store[key].push(now);
17320
+ if (Object.keys(store).length > 5000) {
17321
+ for (const k of Object.keys(store)) {
17322
+ if (!store[k].length || now - store[k][store[k].length - 1] > window_ms) delete store[k];
17323
+ }
17324
+ }
17325
+ return true;
17326
+ };
17327
+
17328
+ // --- Proof of work -------------------------------------------------------
17329
+ const _leading_zero_bits = (hex) => {
17330
+ let bits = 0;
17331
+ for (const ch of hex) {
17332
+ const v = parseInt(ch, 16);
17333
+ if (v === 0) { bits += 4; continue; }
17334
+ if (v < 2) bits += 3;
17335
+ else if (v < 4) bits += 2;
17336
+ else if (v < 8) bits += 1;
17337
+ break;
17338
+ }
17339
+ return bits;
17340
+ };
17341
+ const _pow_prefix = (session_token) => _bp_sig(`pow.${session_token}`).slice(0, 16);
17342
+ const pow_make = (session_token, difficulty) => ({ prefix: _pow_prefix(session_token), difficulty });
17343
+ const pow_check = (session_token, difficulty, nonce) => {
17344
+ if (nonce == null) return false;
17345
+ const h = crypto.createHash('sha256').update(`${_pow_prefix(session_token)}.${nonce}`).digest('hex');
17346
+ return _leading_zero_bits(h) >= difficulty;
17347
+ };
17348
+ const _bp_pow_difficulty = (profile) => {
17349
+ const d = _bp_defaults();
17350
+ const base = Number(profile?.escalation?.pow_difficulty || d.pow_difficulty);
17351
+ const bump = _bp_aggr(profile?.escalation?.aggressiveness || d.aggressiveness);
17352
+ return Math.max(8, base + bump);
17353
+ };
17354
+
17355
+ // --- L0 signal scoring ---------------------------------------------------
17356
+ const _bp_client_ip = (req = {}, headers = {}) => {
17357
+ const h = headers || {};
17358
+ const xff = h['x-forwarded-for'] || h['X-Forwarded-For'] || req.remoteip || req.ip || '';
17359
+ return String(xff).split(',')[0].trim();
17360
+ };
17361
+ const _bp_safe_signals = (s = {}) => {
17362
+ const o = {};
17363
+ for (const k of ['pointer_events', 'key_events', 'scroll_events', 'has_touch', 'pointer_moved', 'webgl', 'canvas', 'hardware_concurrency', 'languages', 'timezone', 'webdriver', 'headless', 'dwell_ms', 'user_agent', 'screen']) {
17364
+ if (s[k] !== undefined) o[k] = typeof s[k] === 'string' ? String(s[k]).slice(0, 120) : s[k];
17365
+ }
17366
+ return o;
17367
+ };
17368
+ const _bp_score_signals = (signals = {}) => {
17369
+ const s = signals || {};
17370
+ let score = 0.5;
17371
+ const reasons = [];
17372
+ const inter = Number(s.pointer_events || 0) + Number(s.key_events || 0) + Number(s.scroll_events || 0);
17373
+ if (inter >= 3) score += 0.25;
17374
+ else if (inter === 0) { score -= 0.2; reasons.push('no_interaction'); }
17375
+ if (s.has_touch === true || s.pointer_moved === true) score += 0.05;
17376
+ if (s.webgl && s.canvas) score += 0.1;
17377
+ else { score -= 0.1; reasons.push('no_env_hash'); }
17378
+ if (s.hardware_concurrency && Number(s.hardware_concurrency) > 0) score += 0.03;
17379
+ if (s.languages && String(s.languages).length) score += 0.03;
17380
+ else reasons.push('no_lang');
17381
+ if (s.timezone) score += 0.02;
17382
+ if (s.webdriver === true) { score -= 0.5; reasons.push('webdriver'); }
17383
+ if (s.headless === true) { score -= 0.4; reasons.push('headless'); }
17384
+ const dwell = Number(s.dwell_ms || 0);
17385
+ if (dwell >= 400) score += 0.05;
17386
+ else if (dwell > 0 && dwell < 120) { score -= 0.1; reasons.push('instant_click'); }
17387
+ score = Math.max(0, Math.min(1, score));
17388
+ return { score, reasons };
17389
+ };
17390
+
17391
+ // --- L1 image challenge (from-scratch synthetic SVG tiles) ----------------
17392
+ const _BP_SHAPES = ['circle', 'square', 'triangle', 'star', 'hexagon'];
17393
+ const _bp_color = () => _bp_pick(['#e11d48', '#2563eb', '#16a34a', '#eab308', '#7c3aed', '#ea580c', '#0891b2', '#db2777']);
17394
+ const _bp_shape_svg = (kind, color) => {
17395
+ switch (kind) {
17396
+ case 'circle': return `<circle cx="50" cy="50" r="30" fill="${color}"/>`;
17397
+ case 'square': return `<rect x="22" y="22" width="56" height="56" rx="6" fill="${color}"/>`;
17398
+ case 'triangle': return `<polygon points="50,18 82,80 18,80" fill="${color}"/>`;
17399
+ case 'star': return `<polygon points="50,15 61,40 88,40 66,57 74,84 50,68 26,84 34,57 12,40 39,40" fill="${color}"/>`;
17400
+ case 'hexagon': return `<polygon points="50,16 82,33 82,67 50,84 18,67 18,33" fill="${color}"/>`;
17401
+ default: return '';
17402
+ }
17403
+ };
17404
+ const _bp_tile = (kind) => {
17405
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" fill="#f1f5f9"/>${_bp_shape_svg(kind, _bp_color())}</svg>`;
17406
+ return 'data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64');
17407
+ };
17408
+ const _image_issue = (site_key, d) => {
17409
+ const grid = d.image_grid || 9;
17410
+ const target = _bp_pick(_BP_SHAPES);
17411
+ const others = _BP_SHAPES.filter((x) => x !== target);
17412
+ const min_c = Math.max(1, d.image_correct_min || 2);
17413
+ const max_c = Math.max(min_c, grid - 2);
17414
+ const correct_count = min_c + Math.floor(Math.random() * (max_c - min_c + 1));
17415
+ const order = [...Array(grid).keys()];
17416
+ for (let i = order.length - 1; i > 0; i--) {
17417
+ const j = Math.floor(Math.random() * (i + 1));
17418
+ [order[i], order[j]] = [order[j], order[i]];
17419
+ }
17420
+ const correct = new Set(order.slice(0, correct_count));
17421
+ const tiles = [];
17422
+ const answer = [];
17423
+ for (let i = 0; i < grid; i++) {
17424
+ if (correct.has(i)) { tiles.push(_bp_tile(target)); answer.push(i); }
17425
+ else tiles.push(_bp_tile(_bp_pick(others)));
17426
+ }
17427
+ answer.sort((a, b) => a - b);
17428
+ return {
17429
+ id: _bp_rand(9), kind: 'image', level: 1, site_key, ts: Date.now(), ttl: 120000,
17430
+ grid, tiles, prompt: `Select every image that shows a ${target}`,
17431
+ answer_hash: _bp_sha(answer.join(',')), pass_score: 0.85,
17432
+ };
17433
+ };
17434
+ const _image_grade = (ch, answer) => {
17435
+ const raw = Array.isArray(answer) ? answer : answer && Array.isArray(answer.selection) ? answer.selection : [];
17436
+ const norm = [...new Set(raw.map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n < ch.grid))].sort((a, b) => a - b).join(',');
17437
+ return _bp_sha(norm) === ch.answer_hash;
17438
+ };
17439
+
17440
+ // --- L2 AI check (score, then a one-shot AI challenge) --------------------
17441
+ const _bp_ip_reputation = async (ip) => {
17442
+ const s = String(ip || '');
17443
+ const is_private = /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|::1|fc|fd)/.test(s);
17444
+ return { ip: s, private: is_private };
17445
+ };
17446
+ const _RiskSchema = z.object({ score: z.number(), verdict: z.enum(['human', 'bot', 'borderline']), reason: z.string() });
17447
+ const _risk_score = async (profile, signals, ip) => {
17448
+ try {
17449
+ const rep = await _bp_ip_reputation(ip);
17450
+ const prompt = `You are a bot-detection risk scorer for a website captcha. Given browser signals and IP reputation, decide if the visitor is a human, a bot, or borderline. score is a 0..1 human-likelihood.\nSIGNALS: ${JSON.stringify(_bp_safe_signals(signals))}\nIP_REPUTATION: ${JSON.stringify(rep)}`;
17451
+ const r = await submit_chat_gpt_prompt({ uid: profile.owner_uid, model: _conf.default_ai_model, prompt, response_format: _RiskSchema, metadata: { func: 'bot_risk', profile_id: profile._id } });
17452
+ if (r.code === 5 && r.data) {
17453
+ const o = JSON.parse(r.data);
17454
+ return { score: Number(o.score) || 0, verdict: o.verdict || 'borderline', reason: o.reason || '' };
17455
+ }
17456
+ } catch (e) {}
17457
+ return { score: 0.5, verdict: 'borderline', reason: 'scorer_unavailable' };
17458
+ };
17459
+ const _bp_norm_answer = (s) => String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, ' ').trim();
17460
+ const _DynSchema = z.object({ question: z.string(), answer: z.string() });
17461
+ const _dynamic_issue = async (site_key, owner_uid) => {
17462
+ try {
17463
+ const prompt = 'Generate ONE short human-verification question that is easy for a person and hard for a naive bot script. Prefer a simple common-sense or arithmetic-in-words question with a single short unambiguous answer. Return the question and the exact expected answer (a single lowercase word or number, no punctuation).';
17464
+ const r = await submit_chat_gpt_prompt({ uid: owner_uid || 'bot_protection', model: _conf.default_ai_model, prompt, response_format: _DynSchema, metadata: { func: 'bot_dyn' } });
17465
+ if (r.code === 5 && r.data) {
17466
+ const o = JSON.parse(r.data);
17467
+ if (o.question && o.answer) {
17468
+ return { id: _bp_rand(9), kind: 'ai', level: 2, site_key, ts: Date.now(), ttl: 180000, prompt: String(o.question).slice(0, 300), answer_hash: _bp_sha(_bp_norm_answer(o.answer)), pass_score: 0.9 };
17469
+ }
17470
+ }
17471
+ } catch (e) {}
17472
+ return null;
17473
+ };
17474
+ const _dynamic_grade = async (ch, answer) => {
17475
+ const a = typeof answer === 'string' ? answer : (answer && (answer.text || answer.answer)) || '';
17476
+ return _bp_sha(_bp_norm_answer(a)) === ch.answer_hash;
17477
+ };
17478
+
17479
+ // --- Escalation orchestrator ---------------------------------------------
17480
+ const _bp_challenges = new Map(); // challenge_id -> { kind, level, site_key, ts, ttl, answer_hash, ... }
17481
+ const _bp_escalate = async (profile, site_key, ip, signals, level, esc, d) => {
17482
+ if (level <= 1 && esc.l1_enabled !== false) {
17483
+ const ch = _image_issue(site_key, d);
17484
+ _bp_challenges.set(ch.id, ch);
17485
+ _bp_bump_stat(profile._id, 'challenged');
17486
+ return { code: 1, data: { status: 'challenge', level: 1, challenge: { id: ch.id, type: 'image', prompt: ch.prompt, tiles: ch.tiles, grid: ch.grid } } };
17487
+ }
17488
+ if (level <= 2 && esc.l2_enabled !== false) {
17489
+ const verdict = await _risk_score(profile, signals, ip);
17490
+ if (verdict.verdict === 'human' && verdict.score >= (d.l2_score_low || 0.7)) {
17491
+ _bp_bump_stat(profile._id, 'passed');
17492
+ return { code: 1, data: { status: 'pass', level: 2, response_token: mint_response_token(site_key, 2, verdict.score, esc.response_token_ttl_ms || d.response_token_ttl_ms) } };
17493
+ }
17494
+ if (verdict.verdict === 'bot') {
17495
+ _bp_bump_stat(profile._id, 'blocked');
17496
+ return { code: 1, data: { status: 'fail', level: 2, reason: 'ai_bot' } };
17497
+ }
17498
+ const ch = await _dynamic_issue(site_key, profile.owner_uid);
17499
+ if (ch) {
17500
+ _bp_challenges.set(ch.id, ch);
17501
+ _bp_bump_stat(profile._id, 'challenged');
17502
+ return { code: 1, data: { status: 'challenge', level: 2, challenge: { id: ch.id, type: 'ai', prompt: ch.prompt } } };
17503
+ }
17504
+ _bp_bump_stat(profile._id, 'blocked');
17505
+ return { code: 1, data: { status: 'fail', level: 2, reason: 'ai_uncertain' } };
17506
+ }
17507
+ _bp_bump_stat(profile._id, 'blocked');
17508
+ return { code: 1, data: { status: 'fail', level, reason: 'exhausted' } };
17509
+ };
17510
+
17511
+ // --- Bootstrap (broker-only; called by the router iframe + interstitial) --
17512
+ export const get_captcha_bootstrap = async function (req) {
17513
+ try {
17514
+ const site_key = req.site_key;
17515
+ const ip = _bp_client_ip(req, req.headers);
17516
+ if (!site_key) return { code: -404, data: 'no_site_key' };
17517
+ const profile = await _bp_resolve_by_site_key(site_key);
17518
+ if (!profile || profile.enabled !== true) return { code: -404, data: 'not_enabled' };
17519
+ const d = _bp_defaults();
17520
+ const esc = profile.escalation || _bp_default_escalation();
17521
+ const session_token = mint_clearance_token('session', `${site_key}|${ip}`, esc.session_token_ttl_ms || d.session_token_ttl_ms);
17522
+ _bp_bump_stat(profile._id, 'served');
17523
+ return {
17524
+ code: 1,
17525
+ data: {
17526
+ site_key,
17527
+ session_token,
17528
+ pow: pow_make(session_token, _bp_pow_difficulty(profile)),
17529
+ min_age_ms: d.min_age_ms,
17530
+ config: {
17531
+ brand: profile.brand || 'Xuda',
17532
+ theme: req.theme || profile.theme || 'auto',
17533
+ l1_enabled: esc.l1_enabled !== false,
17534
+ l2_enabled: esc.l2_enabled !== false,
17535
+ },
17536
+ },
17537
+ };
17538
+ } catch (err) {
17539
+ return { code: -404, data: err.message || String(err) };
17540
+ }
17541
+ };
17542
+
17543
+ // --- Public: the escalation driver ---------------------------------------
17544
+ export const captcha_verify = async function (req, job_id, headers) {
17545
+ try {
17546
+ const { site_key, session_token, signals = {}, pow_nonce, challenge_id, answer } = req;
17547
+ const ip = _bp_client_ip(req, headers);
17548
+ if (!site_key || !session_token) return { code: 1, data: { status: 'fail', reason: 'bad_request' } };
17549
+ const profile = await _bp_resolve_by_site_key(site_key);
17550
+ if (!profile || profile.enabled !== true) return { code: 1, data: { status: 'fail', reason: 'not_enabled' } };
17551
+ const d = _bp_defaults();
17552
+ const esc = profile.escalation || _bp_default_escalation();
17553
+
17554
+ if (!_bp_allow('ip', `${site_key}|${ip}`, 60, 60000) || !_bp_allow('key', site_key, 600, 60000)) {
17555
+ return { code: 1, data: { status: 'fail', reason: 'rate_limited' } };
17556
+ }
17557
+ const st = verify_clearance_token('session', `${site_key}|${ip}`, session_token);
17558
+ if (!st.ok) return { code: 1, data: { status: 'fail', reason: `session_${st.reason}` } };
17559
+
17560
+ // Answering an issued image (L1) or AI (L2) challenge.
17561
+ if (challenge_id) {
17562
+ const ch = _bp_challenges.get(challenge_id);
17563
+ if (!ch || ch.site_key !== site_key || Date.now() - ch.ts > (ch.ttl || 120000)) {
17564
+ if (ch) _bp_challenges.delete(challenge_id);
17565
+ return { code: 1, data: { status: 'fail', reason: 'challenge_expired' } };
17566
+ }
17567
+ let ok = false;
17568
+ if (ch.kind === 'image') ok = _image_grade(ch, answer);
17569
+ else if (ch.kind === 'ai') ok = await _dynamic_grade(ch, answer);
17570
+ _bp_challenges.delete(challenge_id);
17571
+ if (ok) {
17572
+ _bp_bump_stat(profile._id, 'passed');
17573
+ return { code: 1, data: { status: 'pass', level: ch.level, response_token: mint_response_token(site_key, ch.level, ch.pass_score || 0.9, esc.response_token_ttl_ms || d.response_token_ttl_ms) } };
17574
+ }
17575
+ return await _bp_escalate(profile, site_key, ip, signals, ch.level + 1, esc, d);
17576
+ }
17577
+
17578
+ // Level 0: honeypot + min-age + proof of work + passive signals.
17579
+ if (signals && typeof signals.honeypot === 'string' && signals.honeypot.trim() !== '') {
17580
+ return { code: 1, data: { status: 'fail', reason: 'ok' } };
17581
+ }
17582
+ if (!pow_check(session_token, _bp_pow_difficulty(profile), pow_nonce)) {
17583
+ return { code: 1, data: { status: 'fail', reason: 'pow' } };
17584
+ }
17585
+ const min_age_ok = Date.now() - st.ts >= (d.min_age_ms || 800);
17586
+ const { score, reasons } = _bp_score_signals(signals);
17587
+ const aggr = _bp_aggr(esc.aggressiveness || d.aggressiveness);
17588
+ if (reasons.includes('webdriver') && aggr >= 2) return { code: 1, data: { status: 'fail', reason: 'automation' } };
17589
+ const pass_threshold = 0.6 + aggr * 0.1;
17590
+ if (min_age_ok && score >= pass_threshold) {
17591
+ _bp_bump_stat(profile._id, 'passed');
17592
+ return { code: 1, data: { status: 'pass', level: 0, response_token: mint_response_token(site_key, 0, score, esc.response_token_ttl_ms || d.response_token_ttl_ms) } };
17593
+ }
17594
+ return await _bp_escalate(profile, site_key, ip, signals, 1, esc, d);
17595
+ } catch (err) {
17596
+ return { code: 1, data: { status: 'fail', reason: 'error' } };
17597
+ }
17598
+ };
17599
+
17600
+ // --- Public: server-to-server verification (reCAPTCHA / Turnstile shaped) --
17601
+ export const captcha_siteverify = async function (req) {
17602
+ try {
17603
+ const { secret, response } = req;
17604
+ if (!secret || !response) return { code: 1, data: { success: false, 'error-codes': ['missing-input'] } };
17605
+ const profile = await _bp_resolve_by_secret(secret);
17606
+ if (!profile) return { code: 1, data: { success: false, 'error-codes': ['invalid-input-secret'] } };
17607
+ const v = verify_response_token(profile.site_key, response);
17608
+ if (!v.ok) return { code: 1, data: { success: false, 'error-codes': [v.reason === 'replayed' ? 'timeout-or-duplicate' : 'invalid-input-response'] } };
17609
+ return { code: 1, data: { success: true, score: v.score, level_reached: v.level, hostname: profile.target?.label || '', challenge_ts: Date.now(), 'error-codes': [] } };
17610
+ } catch (err) {
17611
+ return { code: 1, data: { success: false, 'error-codes': ['internal-error'] } };
17612
+ }
17613
+ };
17614
+
17615
+ // --- Entitlement (member-first; the Stripe attach lands in stripe_module) --
17616
+ // Entitlement gate. Standard service is FREE for paid members; non-members pay
17617
+ // $1/mo. Custom rules are a $5/mo add-on for EVERYONE (members included). Both
17618
+ // tiers share the consolidated-subscription category 'bot_protection' (one line
17619
+ // item, price-swapped between $1 and $5, exactly like ai_workspace tiers), so a
17620
+ // custom subscriber is never double-billed the standard price. On first use we
17621
+ // attach/swap the item via stripe_module; it returns -402 gracefully until the
17622
+ // live Stripe prices replace the config placeholders, so nothing hard-fails.
17623
+ const _bp_ensure_entitled = async (uid, tier) => {
17624
+ try {
17625
+ const r = await db_module.get_couch_doc('xuda_accounts', uid, true);
17626
+ const acct = r && r.code > 0 && r.data ? r.data : {};
17627
+ const is_member = !!(acct.membership_plan && acct.membership_plan !== 'free');
17628
+ const has_item = !!acct?.stripe_subscription_items?.bot_protection;
17629
+ if (tier !== 'custom') {
17630
+ if (is_member) return { ok: true, member: true };
17631
+ if (has_item) return { ok: true };
17632
+ }
17633
+ // Not entitled yet: attach the add-on (or swap the existing item to the
17634
+ // custom price) on the account's consolidated subscription.
17635
+ const plan_id = tier === 'custom' ? 'bot_protection_custom' : 'bot_protection';
17636
+ let attach = null;
17637
+ try { attach = await stripe_ms.add_subscription_item({ uid, plan_id }); } catch (e) {}
17638
+ if (attach && attach.code === 1) return { ok: true };
17639
+ return { ok: false, tier, price: tier === 'custom' ? 5 : 1, reason: attach && attach.data };
17640
+ } catch (e) {
17641
+ return { ok: false, tier, price: tier === 'custom' ? 5 : 1 };
17642
+ }
17643
+ };
17644
+
17645
+ // --- Config sanitizers ----------------------------------------------------
17646
+ const _bp_sanitize_escalation = (e) => {
17647
+ const out = {};
17648
+ if (['low', 'medium', 'high'].includes(e.aggressiveness)) out.aggressiveness = e.aggressiveness;
17649
+ if (Number.isFinite(Number(e.pow_difficulty))) out.pow_difficulty = Math.max(8, Math.min(24, Math.round(Number(e.pow_difficulty))));
17650
+ if (Number.isFinite(Number(e.clearance_ttl_ms))) out.clearance_ttl_ms = Math.max(60000, Math.min(86400000, Math.round(Number(e.clearance_ttl_ms))));
17651
+ if (typeof e.l1_enabled === 'boolean') out.l1_enabled = e.l1_enabled;
17652
+ if (typeof e.l2_enabled === 'boolean') out.l2_enabled = e.l2_enabled;
17653
+ return out;
17654
+ };
17655
+ const _bp_str_arr = (v, max = 200) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string').map((x) => x.slice(0, 80)).slice(0, max) : []);
17656
+ export const bp_sanitize_rules = function (rules = {}) {
17657
+ const r = rules || {};
17658
+ const out = {};
17659
+ if (r.geo && typeof r.geo === 'object') {
17660
+ out.geo = {
17661
+ mode: ['off', 'challenge', 'block', 'allow'].includes(r.geo.mode) ? r.geo.mode : 'off',
17662
+ countries: _bp_str_arr(r.geo.countries),
17663
+ regions: _bp_str_arr(r.geo.regions),
17664
+ };
17665
+ }
17666
+ const win = (o) => (o && typeof o === 'object' && ['off', 'challenge'].includes(o.mode) ? { mode: o.mode, list: (Array.isArray(o.list) ? o.list.map(Number).filter((n) => Number.isInteger(n)) : []) } : undefined);
17667
+ if (win(r.hours)) out.hours = win(r.hours);
17668
+ if (win(r.days)) out.days = win(r.days);
17669
+ if (Array.isArray(r.paths)) out.paths = r.paths.filter((p) => p && typeof p.pattern === 'string').map((p) => ({ pattern: p.pattern.slice(0, 200), action: ['challenge', 'block', 'allow'].includes(p.action) ? p.action : 'challenge' })).slice(0, 100);
17670
+ if (r.rate && typeof r.rate === 'object') out.rate = { window_s: Math.max(1, Math.min(3600, Number(r.rate.window_s) || 60)), max: Math.max(1, Math.min(100000, Number(r.rate.max) || 120)), action: ['challenge', 'block'].includes(r.rate.action) ? r.rate.action : 'challenge' };
17671
+ if (r.reputation && typeof r.reputation === 'object') out.reputation = { allow_verified_search_bots: r.reputation.allow_verified_search_bots !== false, challenge_bad_asn: r.reputation.challenge_bad_asn === true };
17672
+ out.allow_list = _bp_str_arr(r.allow_list);
17673
+ out.block_list = _bp_str_arr(r.block_list);
17674
+ if (typeof r.require_referer === 'boolean') out.require_referer = r.require_referer;
17675
+ if (Array.isArray(r.fingerprint_rules)) out.fingerprint_rules = r.fingerprint_rules.filter((f) => f && typeof f.match === 'string').map((f) => ({ match: f.match.slice(0, 40), action: ['challenge', 'block'].includes(f.action) ? f.action : 'challenge' })).slice(0, 20);
17676
+ if (['low', 'medium', 'high'].includes(r.aggressiveness)) out.aggressiveness = r.aggressiveness;
17677
+ if (Number.isFinite(Number(r.clearance_ttl_ms))) out.clearance_ttl_ms = Math.max(60000, Math.min(86400000, Math.round(Number(r.clearance_ttl_ms))));
17678
+ return out;
17679
+ };
17680
+
17681
+ // --- Owner settings (profile setup Bot Protection tab) -------------------
17682
+ const _build_captcha_snippet = (site_key, config = {}) => {
17683
+ const origin = embed_origin();
17684
+ const theme = config.theme && /^[a-z]+$/.test(config.theme) ? ` data-theme="${config.theme}"` : '';
17685
+ return `<script src="${origin}/dist/runtime/js/captcha-loader.js" async data-sitekey="${site_key}"${theme}></script>`;
17686
+ };
17687
+ const _build_captcha_container = (site_key) => `<div class="xuda-captcha" data-sitekey="${site_key}"></div>`;
17688
+
17689
+ export const get_bot_protection_settings = async function (req) {
17690
+ try {
17691
+ const { uid, profile_id } = req;
17692
+ const info = await get_active_account_profile_info(uid, profile_id);
17693
+ if (info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
17694
+ const bp_id = _bp_profile_id(uid, info.account_profile_id);
17695
+ const doc = await _bp_load(bp_id);
17696
+ const site_key = doc?.site_key || '';
17697
+ const esc = doc?.escalation || _bp_default_escalation();
17698
+ return {
17699
+ code: 1,
17700
+ data: {
17701
+ enabled: doc?.enabled === true,
17702
+ plan_tier: doc?.plan_tier || 'standard',
17703
+ site_key,
17704
+ secret_key: doc?.secret_key || '',
17705
+ escalation: {
17706
+ aggressiveness: esc.aggressiveness,
17707
+ pow_difficulty: esc.pow_difficulty,
17708
+ clearance_ttl_ms: esc.clearance_ttl_ms,
17709
+ l1_enabled: esc.l1_enabled !== false,
17710
+ l2_enabled: esc.l2_enabled !== false,
17711
+ },
17712
+ custom_rules: doc?.custom_rules || {},
17713
+ require_on_contact_form: doc?.require_on_contact_form === true,
17714
+ widget_profile_id: `${uid}.${info.account_profile_id}`,
17715
+ iframe_url: site_key ? `${embed_origin()}/captcha/${site_key}` : '',
17716
+ snippet: site_key ? _build_captcha_snippet(site_key, { theme: doc?.theme }) : '',
17717
+ container_snippet: site_key ? _build_captcha_container(site_key) : '',
17718
+ },
17719
+ };
17720
+ } catch (err) {
17721
+ return { code: -1, data: err.message || String(err) };
17722
+ }
17723
+ };
17724
+
17725
+ export const update_bot_protection_settings = async function (req) {
17726
+ try {
17727
+ const { uid, profile_id, enabled, escalation, custom_rules, require_on_contact_form, rotate_secret } = req;
17728
+ const info = await get_active_account_profile_info(uid, profile_id);
17729
+ if (info.uid !== uid) return { code: -403, data: 'not_profile_owner' };
17730
+ const doc = await _bp_ensure_profile(uid, info.account_profile_id, { surface: 'widget' });
17731
+
17732
+ if (enabled === true && doc.enabled !== true) {
17733
+ const ent = await _bp_ensure_entitled(uid, doc.plan_tier || 'standard');
17734
+ if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
17735
+ }
17736
+ if (typeof enabled === 'boolean') doc.enabled = enabled;
17737
+ if (doc.enabled && !doc.site_key) {
17738
+ const k = _bp_new_keys();
17739
+ doc.site_key = k.site_key;
17740
+ doc.secret_key = k.secret_key;
17741
+ }
17742
+ if (rotate_secret === true && doc.site_key) {
17743
+ if (doc.secret_key) await _bp_delete_index(_bp_index_id.secret(doc.secret_key));
17744
+ doc.secret_key = _bp_new_keys().secret_key;
17745
+ }
17746
+ if (escalation && typeof escalation === 'object') {
17747
+ doc.escalation = { ...(doc.escalation || _bp_default_escalation()), ..._bp_sanitize_escalation(escalation) };
17748
+ }
17749
+ if (custom_rules && typeof custom_rules === 'object') doc.custom_rules = bp_sanitize_rules(custom_rules);
17750
+ if (typeof require_on_contact_form === 'boolean') doc.require_on_contact_form = require_on_contact_form;
17751
+ _bp_flush_stats(doc);
17752
+ await _bp_save(doc);
17753
+ return await get_bot_protection_settings({ uid, profile_id });
17754
+ } catch (err) {
17755
+ return { code: -1, data: err.message || String(err) };
17756
+ }
17757
+ };
17758
+
17759
+ ///////////////////////////////////////////////////////////////////////////////
17760
+ // BOT PROTECTION - Surface 2 lifecycle (origin protection of a target).
17761
+ // These are called by app_module's thin cpi handlers over the broker (uid is
17762
+ // passed explicitly, already authenticated at the http layer), plus two
17763
+ // broker-only helpers the router uses at the edge. See docs/handoff_bot_protection_ishai.md.
17764
+ ///////////////////////////////////////////////////////////////////////////////
17765
+
17766
+ // Stable per-target slug so re-attaching the same target maps to one profile.
17767
+ const _bp_slug_for_target = (target) => 't_' + _bp_sha(String((target && target.id) || '')).slice(0, 16);
17768
+ const _bp_summary = (doc) => ({
17769
+ profile_id: doc._id,
17770
+ target: doc.target || null,
17771
+ enabled: doc.enabled === true,
17772
+ armed: doc.armed === true,
17773
+ plan_tier: doc.plan_tier || 'standard',
17774
+ surface: doc.surface || 'origin',
17775
+ site_key: doc.site_key || '',
17776
+ stats: _bp_merged_stats(doc),
17777
+ });
17778
+
17779
+ // IPv4 CIDR membership test (dependency-free) for allow/block-list rules.
17780
+ const _cidr_match = (cidr, ip) => {
17781
+ try {
17782
+ if (typeof cidr !== 'string' || cidr.indexOf('/') < 0) return false;
17783
+ const [range, bitsStr] = cidr.split('/');
17784
+ const bits = parseInt(bitsStr, 10);
17785
+ if (!/^\d+\.\d+\.\d+\.\d+$/.test(range) || !/^\d+\.\d+\.\d+\.\d+$/.test(ip) || !(bits >= 0 && bits <= 32)) return false;
17786
+ const toInt = (a) => a.split('.').reduce((s, o) => ((s << 8) + (parseInt(o, 10) & 255)) >>> 0, 0) >>> 0;
17787
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
17788
+ return (toInt(range) & mask) === (toInt(ip) & mask);
17789
+ } catch (e) {
17790
+ return false;
17791
+ }
17792
+ };
17793
+
17794
+ export const bp_attach = async function (req) {
17795
+ try {
17796
+ const { uid, target, plan_tier, escalation } = req;
17797
+ if (!uid || !target || !target.id) return { code: -1, data: 'bad_request' };
17798
+ const tier = plan_tier === 'custom' ? 'custom' : 'standard';
17799
+ const ent = await _bp_ensure_entitled(uid, tier);
17800
+ if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
17801
+ const slug = _bp_slug_for_target(target);
17802
+ const doc = await _bp_ensure_profile(uid, slug, { surface: 'origin', plan_tier: tier, target });
17803
+ doc.target = target;
17804
+ doc.plan_tier = tier;
17805
+ doc.surface = doc.surface === 'widget' ? 'both' : 'origin';
17806
+ doc.enabled = true;
17807
+ if (!doc.site_key) { const k = _bp_new_keys(); doc.site_key = k.site_key; doc.secret_key = k.secret_key; }
17808
+ if (escalation && typeof escalation === 'object') doc.escalation = { ...(doc.escalation || _bp_default_escalation()), ..._bp_sanitize_escalation(escalation) };
17809
+ _bp_flush_stats(doc);
17810
+ await _bp_save(doc);
17811
+ return { code: 1, data: _bp_summary(doc) };
17812
+ } catch (err) {
17813
+ return { code: -1, data: err.message || String(err) };
17814
+ }
17815
+ };
17816
+
17817
+ export const bp_get_profiles = async function (req) {
17818
+ try {
17819
+ const { uid } = req;
17820
+ if (!uid) return { code: -1, data: 'no uid' };
17821
+ const q = await db_module.find_couch_query(BP_DB, { selector: { docType: 'bot_protection_profile', owner_uid: uid }, limit: 500 }, true, true);
17822
+ const docs = q?.docs || [];
17823
+ return { code: 1, data: { profiles: docs.map(_bp_summary) } };
17824
+ } catch (err) {
17825
+ return { code: -1, data: err.message || String(err) };
17826
+ }
17827
+ };
17828
+
17829
+ export const bp_arm = async function (req) {
17830
+ try {
17831
+ const { uid, profile_id, app_id, armed, target } = req;
17832
+ if (!uid) return { code: -1, data: 'no uid' };
17833
+ let doc = null;
17834
+ if (profile_id) {
17835
+ doc = await _bp_load(profile_id);
17836
+ } else if (target && target.id) {
17837
+ doc = await _bp_ensure_profile(uid, _bp_slug_for_target(target), { surface: 'origin', target });
17838
+ if (!doc.target) doc.target = target;
17839
+ } else if (app_id) {
17840
+ const slug = _bp_slug_for_target({ id: app_id });
17841
+ doc = await _bp_load(_bp_profile_id(uid, slug));
17842
+ if (!doc) doc = await _bp_ensure_profile(uid, slug, { surface: 'origin', target: { type: 'internal', kind: 'app', id: app_id, label: app_id, region: null } });
17843
+ }
17844
+ if (!doc) return { code: -404, data: 'profile_not_found' };
17845
+ if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
17846
+ const want = armed === true;
17847
+ if (want) {
17848
+ const ent = await _bp_ensure_entitled(uid, doc.plan_tier || 'standard');
17849
+ if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: ent.tier, price: ent.price } };
17850
+ if (!doc.site_key) { const k = _bp_new_keys(); doc.site_key = k.site_key; doc.secret_key = k.secret_key; }
17851
+ doc.enabled = true;
17852
+ }
17853
+ doc.armed = want;
17854
+ _bp_flush_stats(doc);
17855
+ await _bp_save(doc);
17856
+ return { code: 1, data: _bp_summary(doc) };
17857
+ } catch (err) {
17858
+ return { code: -1, data: err.message || String(err) };
17859
+ }
17860
+ };
17861
+
17862
+ export const bp_detach = async function (req) {
17863
+ try {
17864
+ const { uid, profile_id } = req;
17865
+ const doc = await _bp_load(profile_id);
17866
+ if (!doc) return { code: 1, data: { ok: true } };
17867
+ if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
17868
+ if (doc.site_key) await _bp_delete_index(_bp_index_id.key(doc.site_key));
17869
+ if (doc.secret_key) await _bp_delete_index(_bp_index_id.secret(doc.secret_key));
17870
+ const tkey = _bp_target_key(doc);
17871
+ if (tkey) await _bp_delete_index(_bp_index_id.target(tkey));
17872
+ try {
17873
+ const r = await db_module.get_couch_doc(BP_DB, doc._id, true);
17874
+ if (r?.data?._rev) await db_module.delete_couch_doc(BP_DB, doc._id, r.data._rev);
17875
+ } catch (e) {}
17876
+ _bp_stat_buf.delete(doc._id);
17877
+ return { code: 1, data: { ok: true } };
17878
+ } catch (err) {
17879
+ return { code: -1, data: err.message || String(err) };
17880
+ }
17881
+ };
17882
+
17883
+ export const bp_set_custom_rules = async function (req) {
17884
+ try {
17885
+ const { uid, profile_id, rules } = req;
17886
+ const doc = await _bp_load(profile_id);
17887
+ if (!doc) return { code: -404, data: 'profile_not_found' };
17888
+ if (doc.owner_uid !== uid) return { code: -403, data: 'not_owner' };
17889
+ const ent = await _bp_ensure_entitled(uid, 'custom');
17890
+ if (!ent.ok) return { code: -402, data: { error: 'billing_required', tier: 'custom', price: 5 } };
17891
+ doc.plan_tier = 'custom';
17892
+ doc.custom_rules = bp_sanitize_rules(rules || {});
17893
+ _bp_flush_stats(doc);
17894
+ await _bp_save(doc);
17895
+ return { code: 1, data: _bp_summary(doc) };
17896
+ } catch (err) {
17897
+ return { code: -1, data: err.message || String(err) };
17898
+ }
17899
+ };
17900
+
17901
+ // Broker-only: the router resolves the guarding profile for a target at the edge.
17902
+ export const bp_resolve_target = async function (req) {
17903
+ try {
17904
+ const { target_key } = req;
17905
+ if (!target_key) return { code: -404, data: 'no_target' };
17906
+ const idx = await _bp_load_index(_bp_index_id.target(target_key));
17907
+ if (!idx) return { code: -404, data: 'not_found' };
17908
+ const doc = await _bp_load(idx.profile_id);
17909
+ if (!doc) return { code: -404, data: 'not_found' };
17910
+ return {
17911
+ code: 1,
17912
+ data: {
17913
+ profile_id: doc._id,
17914
+ armed: doc.armed === true,
17915
+ enabled: doc.enabled === true,
17916
+ plan_tier: doc.plan_tier || 'standard',
17917
+ site_key: doc.site_key || '',
17918
+ custom_rules: doc.custom_rules || {},
17919
+ escalation: doc.escalation || _bp_default_escalation(),
17920
+ target: doc.target || null,
17921
+ },
17922
+ };
17923
+ } catch (err) {
17924
+ return { code: -404, data: err.message || String(err) };
17925
+ }
17926
+ };
17927
+
17928
+ // Broker-only: evaluate the custom rules for an inbound request context and
17929
+ // return the enforcement action. Standard tier ignores custom_rules (challenge
17930
+ // when armed); custom tier honors the full rule set.
17931
+ export const evaluate_custom_rules = async function (req) {
17932
+ try {
17933
+ const { profile, ctx } = req;
17934
+ const d = _bp_defaults();
17935
+ const esc = (profile && profile.escalation) || _bp_default_escalation();
17936
+ const out = { action: 'challenge', start_level: 0, aggressiveness: esc.aggressiveness || d.aggressiveness, clearance_ttl_ms: esc.clearance_ttl_ms || d.clearance_ttl_ms };
17937
+ const c = ctx || {};
17938
+ const rules = profile && profile.plan_tier === 'custom' && profile.custom_rules ? profile.custom_rules : null;
17939
+ const inList = (list, ip, country) => (list || []).some((e) => e === ip || e === country || _cidr_match(e, ip));
17940
+ if (rules) {
17941
+ if (inList(rules.block_list, c.ip, c.country)) return { code: 1, data: { ...out, action: 'block' } };
17942
+ if (inList(rules.allow_list, c.ip, c.country)) return { code: 1, data: { ...out, action: 'allow' } };
17943
+ if (rules.reputation && rules.reputation.allow_verified_search_bots && c.verified_bot) return { code: 1, data: { ...out, action: 'allow' } };
17944
+ if (rules.geo && rules.geo.mode && rules.geo.mode !== 'off' && c.country && (rules.geo.countries || []).includes(c.country)) {
17945
+ if (rules.geo.mode === 'block') return { code: 1, data: { ...out, action: 'block' } };
17946
+ if (rules.geo.mode === 'allow') return { code: 1, data: { ...out, action: 'allow' } };
17947
+ out.action = 'challenge';
17948
+ }
17949
+ if (Array.isArray(rules.paths) && c.path) {
17950
+ for (const p of rules.paths) {
17951
+ if (p && p.pattern && c.path.indexOf(p.pattern) === 0) {
17952
+ if (p.action === 'block') return { code: 1, data: { ...out, action: 'block' } };
17953
+ if (p.action === 'allow') return { code: 1, data: { ...out, action: 'allow' } };
17954
+ out.action = 'challenge';
17955
+ break;
17956
+ }
17957
+ }
17958
+ }
17959
+ if (rules.hours && rules.hours.mode === 'challenge' && Array.isArray(rules.hours.list) && typeof c.hour === 'number' && rules.hours.list.includes(c.hour)) out.action = 'challenge';
17960
+ if (rules.days && rules.days.mode === 'challenge' && Array.isArray(rules.days.list) && typeof c.day === 'number' && rules.days.list.includes(c.day)) out.action = 'challenge';
17961
+ if (rules.require_referer && !c.has_referer) out.action = 'challenge';
17962
+ if (['low', 'medium', 'high'].includes(rules.aggressiveness)) out.aggressiveness = rules.aggressiveness;
17963
+ if (Number.isFinite(Number(rules.clearance_ttl_ms))) out.clearance_ttl_ms = Number(rules.clearance_ttl_ms);
17964
+ }
17965
+ return { code: 1, data: out };
17966
+ } catch (err) {
17967
+ return { code: 1, data: { action: 'challenge', start_level: 0 } };
17968
+ }
17969
+ };
17970
+
17971
+ // Broker-only: the origin interstitial passes a captcha response token (from the
17972
+ // embedded widget) here. Resolves the profile by site_key, verifies and burns the
17973
+ // token, and returns the guarded target so the router can mint a clearance cookie.
17974
+ export const bp_consume_response = async function (req) {
17975
+ try {
17976
+ const { site_key, response } = req;
17977
+ const profile = await _bp_resolve_by_site_key(site_key);
17978
+ if (!profile || profile.enabled !== true) return { code: 1, data: { ok: false, reason: 'unknown_site' } };
17979
+ const v = verify_response_token(profile.site_key, response);
17980
+ if (!v.ok) return { code: 1, data: { ok: false, reason: v.reason } };
17981
+ const esc = profile.escalation || _bp_default_escalation();
17982
+ const d = _bp_defaults();
17983
+ return { code: 1, data: { ok: true, level: v.level, target: _bp_target_key(profile), clearance_ttl_ms: esc.clearance_ttl_ms || d.clearance_ttl_ms } };
17984
+ } catch (err) {
17985
+ return { code: 1, data: { ok: false, reason: 'error' } };
17986
+ }
17987
+ };
17988
+
17989
+ // Broker-only: the router caches the set of armed targets (refreshed periodically)
17990
+ // so the enforcement hot path is a local lookup and unguarded traffic never hits
17991
+ // the broker.
17992
+ export const bp_armed_targets = async function () {
17993
+ try {
17994
+ const q = await db_module.find_couch_query(BP_DB, { selector: { docType: 'bot_protection_profile', armed: true }, fields: ['_id', 'target', 'site_key'], limit: 2000 }, true, true);
17995
+ const rows = (q?.docs || []).map((doc) => ({ target_key: _bp_target_key(doc), site_key: doc.site_key })).filter((x) => x.target_key && x.site_key);
17996
+ return { code: 1, data: rows };
17997
+ } catch (err) {
17998
+ return { code: -1, data: err.message || String(err) };
17999
+ }
18000
+ };