@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.
- package/README.md +7 -1
- package/docker/compose.broker.yml +79 -0
- package/docker/compose.isolation.yml +40 -0
- package/package.json +2 -1
- package/src/broker/config.mjs +200 -0
- package/src/broker/copilot.mjs +124 -0
- package/src/broker/limits.mjs +88 -0
- package/src/broker/main.mjs +129 -0
- package/src/broker/scrub.mjs +45 -0
- package/src/broker/service.mjs +558 -0
- package/src/broker/slots.mjs +180 -0
- package/src/broker/store.mjs +171 -0
- package/src/broker/tokens.mjs +116 -0
- package/src/broker/ui/page.css +54 -0
- package/src/broker/ui/page.html +25 -0
- package/src/broker/ui/page.mjs +202 -0
- package/src/broker/ui-server.mjs +217 -0
- package/src/broker/usage.mjs +108 -0
- package/src/broker/vault.mjs +37 -0
- package/src/cli/models.mjs +46 -14
- package/src/cli/render.mjs +21 -0
- package/src/cli/runs.mjs +296 -0
- package/src/cli/worca-cc.mjs +14 -2
- package/src/core/agent-pool.mjs +76 -0
- package/src/core/artifacts.mjs +40 -8
- package/src/core/ask/events.mjs +11 -0
- package/src/core/ask/html-text.mjs +113 -0
- package/src/core/ask/limits.mjs +5 -0
- package/src/core/ask/mcp-stdio.mjs +74 -20
- package/src/core/ask/prompt.mjs +25 -6
- package/src/core/ask/spawn.mjs +55 -5
- package/src/core/ask/store.mjs +1 -1
- package/src/core/ask/tools.mjs +66 -1
- package/src/core/ask/turn.mjs +52 -3
- package/src/core/ask/web-access.mjs +26 -0
- package/src/core/ask/web-deps.mjs +56 -0
- package/src/core/ask/web-fetch.mjs +271 -0
- package/src/core/ask/web-proposal.mjs +76 -0
- package/src/core/auto/classify.mjs +20 -8
- package/src/core/auto/runnable.mjs +28 -0
- package/src/core/billing.mjs +53 -0
- package/src/core/bridge/errors.mjs +77 -5
- package/src/core/bridge/openrouter.mjs +59 -0
- package/src/core/bridge/provider-ops.mjs +165 -9
- package/src/core/bridge/providers/endpoint.mjs +112 -7
- package/src/core/bridge/registry.mjs +13 -0
- package/src/core/bridge/server.mjs +16 -3
- package/src/core/bridge/telemetry.mjs +44 -12
- package/src/core/bridge/translate/common.mjs +31 -0
- package/src/core/bridge/translate/request.mjs +4 -1
- package/src/core/bridge/translate/response.mjs +3 -0
- package/src/core/bridge/translate/schema-keywords.mjs +75 -0
- package/src/core/bridge/translate/stream.mjs +50 -4
- package/src/core/bridge/upstream.mjs +102 -17
- package/src/core/broker-boot.mjs +57 -0
- package/src/core/broker-client.mjs +206 -0
- package/src/core/broker-guard.mjs +112 -0
- package/src/core/broker-routing.mjs +138 -0
- package/src/core/claude-auth.mjs +29 -0
- package/src/core/claude-runner.mjs +200 -9
- package/src/core/config.mjs +9 -12
- package/src/core/failure-policy.mjs +4 -2
- package/src/core/git-info.mjs +49 -5
- package/src/core/github-credentials.mjs +44 -1
- package/src/core/graph/script-runner.mjs +3 -1
- package/src/core/list-prices.mjs +29 -0
- package/src/core/mcp-secrets.mjs +80 -0
- package/src/core/metrics/sync.mjs +2 -1
- package/src/core/model-env.mjs +72 -0
- package/src/core/model-test.mjs +17 -5
- package/src/core/onboarding.mjs +12 -6
- package/src/core/openrouter-free.mjs +159 -0
- package/src/core/orchestrator.mjs +100 -12
- package/src/core/policy/effective.mjs +18 -1
- package/src/core/policy/local.mjs +4 -1
- package/src/core/policy/registry.mjs +12 -2
- package/src/core/preflight.mjs +116 -0
- package/src/core/recoverable-error.mjs +95 -0
- package/src/core/recovery-backoff.mjs +84 -0
- package/src/core/redact.mjs +25 -0
- package/src/core/run-context.mjs +6 -0
- package/src/core/run-harness.mjs +112 -30
- package/src/core/run-report.mjs +2 -1
- package/src/core/settings.mjs +90 -5
- package/src/core/title.mjs +7 -2
- package/src/core/web-allowlist.mjs +95 -0
- package/ui/public/app.js +244 -43
- package/ui/public/ask-model.mjs +1 -0
- package/ui/public/ask-panel.mjs +160 -23
- package/ui/public/bridge-view.mjs +175 -6
- package/ui/public/chat-settings-view.mjs +52 -1
- package/ui/public/credential-badges.mjs +63 -0
- package/ui/public/credentials-view.mjs +57 -0
- package/ui/public/index.html +33 -6
- package/ui/public/models-view.mjs +19 -1
- package/ui/public/openrouter-free-view.mjs +118 -0
- package/ui/public/stats-view.mjs +56 -0
- package/ui/public/style.css +99 -50
- package/ui/public/team-policy-view.mjs +2 -1
- package/ui/public/ws-seq.mjs +24 -0
- package/ui/server.mjs +319 -23
package/src/core/run-report.mjs
CHANGED
|
@@ -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
|
-
|
|
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; }
|
package/src/core/settings.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
572
|
-
|
|
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',
|
package/src/core/title.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|