@worca/app 1.5.0 → 1.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +7 -1
  2. package/docker/compose.broker.yml +79 -0
  3. package/docker/compose.isolation.yml +40 -0
  4. package/package.json +2 -1
  5. package/src/broker/config.mjs +200 -0
  6. package/src/broker/copilot.mjs +124 -0
  7. package/src/broker/limits.mjs +88 -0
  8. package/src/broker/main.mjs +129 -0
  9. package/src/broker/scrub.mjs +45 -0
  10. package/src/broker/service.mjs +558 -0
  11. package/src/broker/slots.mjs +180 -0
  12. package/src/broker/store.mjs +171 -0
  13. package/src/broker/tokens.mjs +116 -0
  14. package/src/broker/ui/page.css +54 -0
  15. package/src/broker/ui/page.html +25 -0
  16. package/src/broker/ui/page.mjs +202 -0
  17. package/src/broker/ui-server.mjs +217 -0
  18. package/src/broker/usage.mjs +108 -0
  19. package/src/broker/vault.mjs +37 -0
  20. package/src/cli/models.mjs +46 -14
  21. package/src/cli/render.mjs +21 -0
  22. package/src/cli/runs.mjs +296 -0
  23. package/src/cli/worca-cc.mjs +14 -2
  24. package/src/core/agent-pool.mjs +76 -0
  25. package/src/core/artifacts.mjs +40 -8
  26. package/src/core/ask/events.mjs +11 -0
  27. package/src/core/ask/html-text.mjs +113 -0
  28. package/src/core/ask/limits.mjs +5 -0
  29. package/src/core/ask/mcp-stdio.mjs +74 -20
  30. package/src/core/ask/prompt.mjs +25 -6
  31. package/src/core/ask/spawn.mjs +55 -5
  32. package/src/core/ask/store.mjs +1 -1
  33. package/src/core/ask/tools.mjs +66 -1
  34. package/src/core/ask/turn.mjs +52 -3
  35. package/src/core/ask/web-access.mjs +26 -0
  36. package/src/core/ask/web-deps.mjs +56 -0
  37. package/src/core/ask/web-fetch.mjs +271 -0
  38. package/src/core/ask/web-proposal.mjs +76 -0
  39. package/src/core/auto/classify.mjs +20 -8
  40. package/src/core/auto/runnable.mjs +28 -0
  41. package/src/core/billing.mjs +53 -0
  42. package/src/core/bridge/errors.mjs +77 -5
  43. package/src/core/bridge/openrouter.mjs +59 -0
  44. package/src/core/bridge/provider-ops.mjs +165 -9
  45. package/src/core/bridge/providers/endpoint.mjs +112 -7
  46. package/src/core/bridge/registry.mjs +13 -0
  47. package/src/core/bridge/server.mjs +16 -3
  48. package/src/core/bridge/telemetry.mjs +44 -12
  49. package/src/core/bridge/translate/common.mjs +31 -0
  50. package/src/core/bridge/translate/request.mjs +4 -1
  51. package/src/core/bridge/translate/response.mjs +3 -0
  52. package/src/core/bridge/translate/schema-keywords.mjs +75 -0
  53. package/src/core/bridge/translate/stream.mjs +50 -4
  54. package/src/core/bridge/upstream.mjs +102 -17
  55. package/src/core/broker-boot.mjs +57 -0
  56. package/src/core/broker-client.mjs +206 -0
  57. package/src/core/broker-guard.mjs +112 -0
  58. package/src/core/broker-routing.mjs +138 -0
  59. package/src/core/claude-auth.mjs +29 -0
  60. package/src/core/claude-runner.mjs +200 -9
  61. package/src/core/config.mjs +9 -12
  62. package/src/core/failure-policy.mjs +4 -2
  63. package/src/core/git-info.mjs +49 -5
  64. package/src/core/github-credentials.mjs +44 -1
  65. package/src/core/graph/script-runner.mjs +3 -1
  66. package/src/core/list-prices.mjs +29 -0
  67. package/src/core/mcp-secrets.mjs +80 -0
  68. package/src/core/metrics/sync.mjs +2 -1
  69. package/src/core/model-env.mjs +72 -0
  70. package/src/core/model-test.mjs +17 -5
  71. package/src/core/onboarding.mjs +12 -6
  72. package/src/core/openrouter-free.mjs +159 -0
  73. package/src/core/orchestrator.mjs +100 -12
  74. package/src/core/policy/effective.mjs +18 -1
  75. package/src/core/policy/local.mjs +4 -1
  76. package/src/core/policy/registry.mjs +12 -2
  77. package/src/core/preflight.mjs +116 -0
  78. package/src/core/recoverable-error.mjs +95 -0
  79. package/src/core/recovery-backoff.mjs +84 -0
  80. package/src/core/redact.mjs +25 -0
  81. package/src/core/run-context.mjs +6 -0
  82. package/src/core/run-harness.mjs +112 -30
  83. package/src/core/run-report.mjs +2 -1
  84. package/src/core/settings.mjs +90 -5
  85. package/src/core/title.mjs +7 -2
  86. package/src/core/web-allowlist.mjs +95 -0
  87. package/ui/public/app.js +244 -43
  88. package/ui/public/ask-model.mjs +1 -0
  89. package/ui/public/ask-panel.mjs +160 -23
  90. package/ui/public/bridge-view.mjs +175 -6
  91. package/ui/public/chat-settings-view.mjs +52 -1
  92. package/ui/public/credential-badges.mjs +63 -0
  93. package/ui/public/credentials-view.mjs +57 -0
  94. package/ui/public/index.html +33 -6
  95. package/ui/public/models-view.mjs +19 -1
  96. package/ui/public/openrouter-free-view.mjs +118 -0
  97. package/ui/public/stats-view.mjs +56 -0
  98. package/ui/public/style.css +99 -50
  99. package/ui/public/team-policy-view.mjs +2 -1
  100. package/ui/public/ws-seq.mjs +24 -0
  101. package/ui/server.mjs +319 -23
@@ -164,7 +164,8 @@ function templateShape(template, auto) {
164
164
  if (!template) return null;
165
165
  const id = template.id || '';
166
166
  const name = template.name || '';
167
- const builtin = STOCK_WORKFLOW_NAMES.has(id) && auto?.via !== 'created';
167
+ // A classifier fallback records what it saved on `saved` (via is 'fallback').
168
+ const builtin = STOCK_WORKFLOW_NAMES.has(id) && auto?.via !== 'created' && auto?.saved !== 'created';
168
169
  const out = { builtin, id: builtin ? id : null };
169
170
  if (builtin) out.name = STOCK_WORKFLOW_NAMES.get(id) ?? '';
170
171
  else { out.id = id; out.name = name; }
@@ -38,7 +38,8 @@
38
38
  // bootstrap value or a plain scalar toggle, so a table would buy nothing.
39
39
  //
40
40
  // IMPORTANT: this module imports NOTHING from the core graph (Node builtins
41
- // plus the zero-import model-env.mjs leaf only). projects.mjs imports it, so
41
+ // plus the zero-import model-env.mjs leaf and the web-allowlist.mjs leaf, which
42
+ // imports only node:net and node:url). projects.mjs imports it, so
42
43
  // importing projects.mjs back would make worcaHome() -> getWorcaRoot() ->
43
44
  // projects.mjs an infinite cycle.
44
45
  //
@@ -57,6 +58,7 @@ import {
57
58
  UPSTREAM_PROVIDERS, COPILOT_ACCOUNT_TYPES, DEFAULT_PROVIDER_CONCURRENCY, MAX_PROVIDER_CONCURRENCY,
58
59
  COPILOT_TERMS_VERSION, isUpstreamBaseUrl,
59
60
  } from './model-env.mjs';
61
+ import { normalizeDomainList, normalizeDomainPattern, domainError, DOMAIN_LIST_MAX, RESERVED_KEY_VAR } from './web-allowlist.mjs';
60
62
 
61
63
  /**
62
64
  * The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
@@ -568,8 +570,10 @@ export function assertCostLimitInputs(inputs = {}) {
568
570
  // dollar cap (--max-budget-usd). For the budget key the literal `null` is a
569
571
  // STORED value meaning "no cap" (the flag is omitted), while '' / undefined clear
570
572
  // the key back to the default — the two semantics the design assigns to that key.
571
- export const DEFAULT_ASK_MAX_TURNS = 40;
572
- export const DEFAULT_ASK_MAX_BUDGET_USD = 2;
573
+ // The defaults are 400 turns and NO cost cap: the default budget is itself `null`,
574
+ // so an absent or invalid stored budget also means "no cap".
575
+ export const DEFAULT_ASK_MAX_TURNS = 400;
576
+ export const DEFAULT_ASK_MAX_BUDGET_USD = null;
573
577
 
574
578
  const isAskMaxTurns = (v) => Number.isSafeInteger(v) && v >= 1 && v <= 500;
575
579
  const isAskMaxBudget = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0.1 && v <= 100;
@@ -583,13 +587,13 @@ export function askMaxTurns() {
583
587
  return DEFAULT_ASK_MAX_TURNS;
584
588
  }
585
589
 
586
- /** --max-budget-usd for one chat turn: number 0.1..100, or null = no cap; absent/invalid ⇒ the default (loudly). */
590
+ /** --max-budget-usd for one chat turn: number 0.1..100, or null = no cap; absent/invalid ⇒ the default, no cap (loudly). */
587
591
  export function askMaxBudgetUsd() {
588
592
  const v = readSettings().askMaxBudgetUsd;
589
593
  if (v === undefined) return DEFAULT_ASK_MAX_BUDGET_USD;
590
594
  if (v === null) return null;
591
595
  if (isAskMaxBudget(v)) return v;
592
- console.warn(`[worca] invalid askMaxBudgetUsd ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_BUDGET_USD})`);
596
+ console.warn(`[worca] invalid askMaxBudgetUsd ${JSON.stringify(v)} — using the default (${DEFAULT_ASK_MAX_BUDGET_USD ?? 'no cap'})`);
593
597
  return DEFAULT_ASK_MAX_BUDGET_USD;
594
598
  }
595
599
 
@@ -630,6 +634,86 @@ export async function setAskMaxBudgetUsd(input) {
630
634
  return { askMaxBudgetUsd: askMaxBudgetUsd() };
631
635
  }
632
636
 
637
+ // ── Ask Worca web access (docs/guardrails.md "Web access") ─────────────────────────────────
638
+ // `askWeb` = { enabled, allowedDomains, search? }. Off by default; the allowlist is enforced by
639
+ // worca's own MCP server (web-fetch.mjs). The search key is only ever a ${VAR} reference read from
640
+ // worca's environment — a literal key is refused so settings.json never holds one.
641
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
642
+
643
+ function normalizeAskWebSearch(s) {
644
+ if (s === null || s === undefined || s === '') return null;
645
+ if (!isObj(s)) throw new Error('askWeb.search must be an object or null');
646
+ const url = typeof s.url === 'string' ? s.url.trim() : '';
647
+ if (!url) return null; // an empty URL field = search off
648
+ let u; try { u = new URL(url.replace('{query}', 'q').replace('{key}', 'k')); } catch { throw new Error('askWeb.search.url is not a valid URL'); }
649
+ if (u.protocol !== 'https:') throw new Error('askWeb.search.url must be an https URL');
650
+ if (u.username || u.password) throw new Error('askWeb.search.url must not carry credentials');
651
+ if (!url.includes('{query}')) throw new Error('askWeb.search.url must contain {query}');
652
+ if (url.length > 500) throw new Error('askWeb.search.url is longer than 500 characters');
653
+ const key = typeof s.key === 'string' ? s.key.trim() : '';
654
+ const keyVar = key ? modelEnvRef(key) : null;
655
+ if (key && !keyVar) throw new Error('askWeb.search.key must be a ${VAR} reference — worca never stores a search API key in settings.json');
656
+ if (keyVar && RESERVED_KEY_VAR.test(keyVar)) throw new Error(`askWeb.search.key: ${keyVar} is a reserved variable name`);
657
+ const keyHeader = typeof s.keyHeader === 'string' ? s.keyHeader.trim() : '';
658
+ if (keyHeader && !/^[A-Za-z0-9-]{1,64}$/.test(keyHeader)) throw new Error('askWeb.search.keyHeader must be an HTTP header name');
659
+ const keyPrefix = typeof s.keyPrefix === 'string' ? s.keyPrefix : '';
660
+ if (keyPrefix.length > 20 || /[\r\n]/.test(keyPrefix)) throw new Error('askWeb.search.keyPrefix must be at most 20 characters on one line');
661
+ if ((keyHeader || url.includes('{key}')) && !keyVar) throw new Error('askWeb.search.key is required when a key header or {key} is used');
662
+ return { url, key, keyVar, keyHeader, keyPrefix };
663
+ }
664
+
665
+ /** Throws a 400-able message; returns the normalized value. */
666
+ export function assertAskWebInput(input) {
667
+ if (!isObj(input)) throw new Error('askWeb must be an object');
668
+ if (typeof input.enabled !== 'boolean') throw new Error('askWeb.enabled must be true or false');
669
+ if (input.anyHost !== undefined && typeof input.anyHost !== 'boolean') throw new Error('askWeb.anyHost must be true or false');
670
+ if (!Array.isArray(input.allowedDomains)) throw new Error('askWeb.allowedDomains must be a list');
671
+ if (input.allowedDomains.length > DOMAIN_LIST_MAX) throw new Error(`askWeb.allowedDomains holds at most ${DOMAIN_LIST_MAX} entries`);
672
+ const { domains, invalid } = normalizeDomainList(input.allowedDomains);
673
+ if (invalid.length) throw new Error(`askWeb.allowedDomains: ${domainError(invalid[0])}`);
674
+ return { enabled: input.enabled, anyHost: input.anyHost === true, allowedDomains: domains, search: normalizeAskWebSearch(input.search) };
675
+ }
676
+
677
+ let askWebWarned = null;
678
+ /** The local Ask web settings; invalid stored data falls back to off (never throws). */
679
+ export function askWeb() {
680
+ const raw = readSettings().askWeb;
681
+ const off = { enabled: false, anyHost: false, allowedDomains: [], search: null };
682
+ if (raw === undefined || raw === null) return off;
683
+ try { return assertAskWebInput(isObj(raw) ? { enabled: raw.enabled, anyHost: raw.anyHost ?? false, allowedDomains: raw.allowedDomains ?? [], search: raw.search ?? null } : raw); }
684
+ catch (err) {
685
+ // Read on every turn and settings view: warn once per distinct problem, not on every read.
686
+ if (err.message !== askWebWarned) { askWebWarned = err.message; console.warn(`[worca] invalid askWeb setting (${err.message}) — web access stays off`); }
687
+ return off;
688
+ }
689
+ }
690
+
691
+ /** Stores exactly what the user saved; `null` clears the key (back to "unset" = off). */
692
+ export async function setAskWeb(input) {
693
+ const settings = readSettings();
694
+ if (input === null) { delete settings.askWeb; await persistSettings(settings); return { askWeb: askWeb() }; }
695
+ const next = assertAskWebInput(input);
696
+ settings.askWeb = {
697
+ enabled: next.enabled,
698
+ ...(next.anyHost ? { anyHost: true } : {}),
699
+ allowedDomains: next.allowedDomains,
700
+ ...(next.search ? { search: { url: next.search.url, key: next.search.key, keyHeader: next.search.keyHeader, keyPrefix: next.search.keyPrefix } } : {}),
701
+ };
702
+ await persistSettings(settings);
703
+ return { askWeb: askWeb() };
704
+ }
705
+
706
+ /** The web card's "Always allow": one exact host joins the stored allowlist; everything else is kept. */
707
+ export async function addAskWebHost(host) {
708
+ const h = normalizeDomainPattern(host);
709
+ if (!h || h.startsWith('*.')) throw new Error(`askWeb: "${host}" is not an exact host name`);
710
+ const cur = askWeb();
711
+ if (cur.allowedDomains.includes(h)) return { askWeb: cur };
712
+ const s = cur.search;
713
+ return setAskWeb({ enabled: cur.enabled, anyHost: cur.anyHost, allowedDomains: [...cur.allowedDomains, h],
714
+ search: s ? { url: s.url, key: s.key, keyHeader: s.keyHeader, keyPrefix: s.keyPrefix } : null });
715
+ }
716
+
633
717
  /** Write (or clear) a USD cap key. @throws {Error} unless positive finite number (or empty). */
634
718
  async function setUsdCap(key, input) {
635
719
  assertUsdCapInput(key, input);
@@ -770,6 +854,7 @@ export const SETTINGS_POST_KEYS = Object.freeze([
770
854
  'root', 'projectsRoot', 'chat',
771
855
  'pipelineCostLimitUsd', 'totalCostLimitUsd', 'costLimitResetPeriod', 'humanRateUsdPerHour',
772
856
  'askMaxTurns', 'askMaxBudgetUsd',
857
+ 'askWeb', // Ask Worca web access { enabled, allowedDomains, search }
773
858
  'debugSpawnEnabled',
774
859
  'titleModel', 'hideBuiltinModels',
775
860
  'theme',
@@ -3,6 +3,7 @@ import { runClaude } from './claude-runner.mjs';
3
3
  import { resolveModelEnv, catalogHasModel } from './config.mjs';
4
4
  import { titleModel as storedTitleModel } from './settings.mjs';
5
5
  import { AUX_EFFORT } from './model-env.mjs';
6
+ import { withRecoveryRetry } from './recovery-backoff.mjs';
6
7
 
7
8
  // The last-resort title model: the BUILT-IN Haiku id (config.mjs PREDEFINED_MODELS),
8
9
  // not the dated API id it used to be — a global entry that shadows the built-in
@@ -128,7 +129,10 @@ export async function generateTitle(prompt, opts = {}) {
128
129
  try { opts.onError({ model, error }); } catch { /* a logging sink must never fail the caller */ }
129
130
  };
130
131
  try {
131
- const { text: out } = await runClaude({
132
+ // A provider 429 (a shared free pool) is retried with the recovery backoff;
133
+ // nothing else is — a title is cosmetic, and an unspawnable CLI (stamped
134
+ // network) would only make the run wait for it.
135
+ const { text: out } = await withRecoveryRetry(() => runClaude({
132
136
  cwd: opts.cwd || process.cwd(),
133
137
  systemPrompt: SYSTEM,
134
138
  prompt: `Write the title for this task:\n\n${text.slice(0, 4000)}`,
@@ -141,6 +145,7 @@ export async function generateTitle(prompt, opts = {}) {
141
145
  permissionMode: opts.permissionMode || 'acceptEdits',
142
146
  allowedTools: [], // empty → no --allowedTools flag → claude defaults; pure text gen
143
147
  signal: opts.signal,
148
+ spawnKind: 'aux', // credential broker: a short-lived token (broker-client.mjs)
144
149
  // Title generation runs DURING a pipeline run, so it must honor the same
145
150
  // guardrails as the run itself. All three are undefined when absent, and
146
151
  // runClaude treats undefined as "not passed" — existing callers are unchanged.
@@ -161,7 +166,7 @@ export async function generateTitle(prompt, opts = {}) {
161
166
  disableSlashCommands: opts.disableSlashCommands,
162
167
  mcpConfigPath: opts.mcpConfigPath,
163
168
  onEvent: () => {},
164
- });
169
+ }), { classes: ['rate_limit'], signal: opts.signal });
165
170
  const title = sanitizeTitle(out);
166
171
  if (!title) { report(new Error('the model returned an empty reply')); return ''; }
167
172
  if (isRefusalTitle(title)) { report(new Error(`the model did not write a title: ${title.slice(0, 120)}`)); return ''; }
@@ -0,0 +1,95 @@
1
+ // Ask Worca web allowlist patterns: an exact host ("docs.example.com") or "*.example.com"
2
+ // (subdomains only — add "example.com" too for the apex). Leaf module: settings, team policy
3
+ // and the Ask MCP child all validate with the same rules.
4
+ import { isIP } from 'node:net';
5
+ import { domainToASCII } from 'node:url';
6
+
7
+ export const DOMAIN_LIST_MAX = 200;
8
+ /** The effective list of the "any public host" switch (settings askWeb.anyHost). Never a typed entry. */
9
+ export const ANY_HOST = '*';
10
+ /** Env var names a search-key `${VAR}` may never name (worca, Claude and runtime credentials). */
11
+ export const RESERVED_KEY_VAR = /^(WORCA_|ORCH_|ANTHROPIC_|CLAUDE_|NODE_|PATH$|HOME$)/i; // case-insensitive: Windows env lookup is
12
+ const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
13
+ // A wildcard over one of these would allow hosts that strangers own — and a stranger's host sees
14
+ // every URL Ask requests from it. Not the whole Public Suffix List: the shared-hosting suffixes
15
+ // people actually paste, plus the ccTLD second levels (co.uk, com.au, …) matched by shape below.
16
+ const SHARED_SUFFIXES = new Set([
17
+ 'github.io', 'githubusercontent.com', 'gitlab.io', 'bitbucket.io', 'vercel.app', 'now.sh', 'netlify.app', 'netlify.com',
18
+ 'pages.dev', 'workers.dev', 'trycloudflare.com', 'herokuapp.com', 'onrender.com', 'fly.dev', 'railway.app', 'up.railway.app',
19
+ 'web.app', 'firebaseapp.com', 'appspot.com', 'run.app', 'cloudfunctions.net', 'azurewebsites.net', 'azurestaticapps.net',
20
+ 'blob.core.windows.net', 'cloudfront.net', 'amazonaws.com', 's3.amazonaws.com', 'ngrok.io', 'ngrok.app', 'ngrok-free.app',
21
+ 'loca.lt', 'glitch.me', 'repl.co', 'replit.app', 'replit.dev', 'surge.sh', 'blogspot.com', 'wordpress.com', 'tumblr.com',
22
+ 'deno.dev', 'val.run', 'codesandbox.io', 'csb.app', 'stackblitz.io', 'webcontainer.io', 'pythonanywhere.com', 'neocities.org',
23
+ ]);
24
+ const CC_SECOND_LEVEL = new Set(['co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne', 'go', 'gob', 'gv', 'mil', 'nic', 'ltd', 'plc', 'sch', 'nom', 'info', 'biz']);
25
+ const isSharedSuffix = (s) => {
26
+ if (SHARED_SUFFIXES.has(s)) return true;
27
+ const l = s.split('.');
28
+ return l.length === 2 && l[1].length === 2 && CC_SECOND_LEVEL.has(l[0]);
29
+ };
30
+
31
+ function parsePattern(raw) {
32
+ if (typeof raw !== 'string') return { error: 'bad' };
33
+ let s = raw.trim().toLowerCase().replace(/\.$/, '');
34
+ let wildcard = false;
35
+ if (s.startsWith('*.')) { wildcard = true; s = s.slice(2); }
36
+ if (!s || /[^a-z0-9.\-\u0080-￿]/.test(s)) return { error: 'bad' };
37
+ s = domainToASCII(s);
38
+ if (!s || s.length > 253 || isIP(s)) return { error: 'bad' };
39
+ const labels = s.split('.');
40
+ if (labels.length < 2 || !labels.every((l) => LABEL.test(l))) return { error: 'bad' };
41
+ if (/^\d+$/.test(labels.at(-1))) return { error: 'bad' };
42
+ if (wildcard && isSharedSuffix(s)) return { error: 'shared' };
43
+ return { pattern: wildcard ? `*.${s}` : s };
44
+ }
45
+
46
+ export function normalizeDomainPattern(raw) { return parsePattern(raw).pattern || null; }
47
+
48
+ /** Why `raw` is not a usable allowlist entry, or null when it is. */
49
+ export function domainError(raw) {
50
+ const r = parsePattern(raw);
51
+ if (r.pattern) return null;
52
+ return r.error === 'shared'
53
+ ? `"${raw}" covers a domain where anyone can host a site — list the exact host instead (e.g. name.${String(raw).trim().replace(/^\*\./, '')})`
54
+ : `"${raw}" is not a host name or *.host pattern`;
55
+ }
56
+
57
+ export function normalizeDomainList(list) {
58
+ const domains = []; const invalid = [];
59
+ for (const raw of Array.isArray(list) ? list : []) {
60
+ const d = normalizeDomainPattern(raw);
61
+ if (!d) invalid.push(String(raw));
62
+ else if (!domains.includes(d)) domains.push(d);
63
+ }
64
+ return { domains, invalid };
65
+ }
66
+
67
+ export function mergeDomainLists(...lists) {
68
+ return normalizeDomainList(lists.flatMap((l) => (Array.isArray(l) ? l : []))).domains;
69
+ }
70
+
71
+ /** The entries of `list` that `cap` fully covers; a null cap leaves the list as is. A wildcard
72
+ * survives only under an equal-or-broader wildcard — an exact cap entry never covers one. */
73
+ export function capDomainList(list, cap) {
74
+ const any = Array.isArray(list) && list.includes(ANY_HOST);
75
+ const mine = any ? [ANY_HOST] : normalizeDomainList(list).domains;
76
+ if (!Array.isArray(cap)) return mine;
77
+ const c = normalizeDomainList(cap).domains;
78
+ if (any) return c; // "any host" under a cap = exactly the cap
79
+ return mine.filter((p) => {
80
+ if (!p.startsWith('*.')) return hostAllowed(p, c);
81
+ const base = p.slice(2);
82
+ return c.some((q) => q.startsWith('*.') && (base === q.slice(2) || base.endsWith(`.${q.slice(2)}`)));
83
+ });
84
+ }
85
+
86
+ export function hostAllowed(host, patterns) {
87
+ const h = String(host ?? '').toLowerCase().replace(/\.$/, '');
88
+ if (!h) return false;
89
+ for (const p of patterns || []) {
90
+ if (p === ANY_HOST) return true;
91
+ if (p.startsWith('*.')) { if (h.endsWith(`.${p.slice(2)}`)) return true; }
92
+ else if (h === p) return true;
93
+ }
94
+ return false;
95
+ }