@bugmole/cli 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import type { Env } from "../core.js";
|
|
4
|
+
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
5
|
+
import {
|
|
6
|
+
domainProvesToken,
|
|
7
|
+
hostMatchesVerifiedDomain,
|
|
8
|
+
needsDomainVerification,
|
|
9
|
+
normalizeDomain,
|
|
10
|
+
targetHost,
|
|
11
|
+
} from "../domains.js";
|
|
12
|
+
|
|
13
|
+
// --- Pure helpers -----------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
test("normalizeDomain strips scheme, path, port and trailing dot; rejects the rest", () => {
|
|
16
|
+
assert.equal(normalizeDomain("https://Example.com/checkout?x=1"), "example.com");
|
|
17
|
+
assert.equal(normalizeDomain("example.com:8443"), "example.com");
|
|
18
|
+
assert.equal(normalizeDomain("staging.example.com."), "staging.example.com");
|
|
19
|
+
for (const bad of ["not a domain", "192.168.1.1", "::1", "http://", "a".repeat(260) + ".com"]) {
|
|
20
|
+
assert.throws(() => normalizeDomain(bad), Error, bad);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("targetHost only accepts http(s) URLs", () => {
|
|
25
|
+
assert.equal(targetHost("https://shop.example.com/cart"), "shop.example.com");
|
|
26
|
+
assert.equal(targetHost("HTTP://Shop.Example.com"), "shop.example.com");
|
|
27
|
+
assert.equal(targetHost("ftp://example.com"), null);
|
|
28
|
+
assert.equal(targetHost("not a url"), null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("private and loopback hosts need no verification; Bugmole Cloud can't reach them anyway", () => {
|
|
32
|
+
for (const host of ["localhost", "127.0.0.1", "10.0.0.5", "192.168.1.1", "172.16.0.1", "172.31.255.255", "169.254.1.1", "[::1]", "::1"]) {
|
|
33
|
+
assert.equal(needsDomainVerification(host), false, host);
|
|
34
|
+
}
|
|
35
|
+
for (const host of ["example.com", "172.32.0.1", "172.15.0.1", "8.8.8.8"]) {
|
|
36
|
+
assert.equal(needsDomainVerification(host), true, host);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a verified domain covers itself and its subdomains, not lookalikes", () => {
|
|
41
|
+
assert.equal(hostMatchesVerifiedDomain("example.com", "example.com"), true);
|
|
42
|
+
assert.equal(hostMatchesVerifiedDomain("staging.example.com", "example.com"), true);
|
|
43
|
+
assert.equal(hostMatchesVerifiedDomain("example.com", "staging.example.com"), false);
|
|
44
|
+
assert.equal(hostMatchesVerifiedDomain("notexample.com", "example.com"), false);
|
|
45
|
+
assert.equal(hostMatchesVerifiedDomain("example.com.evil.example", "example.com"), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("domainProvesToken only accepts the exact token at the exact record name", async () => {
|
|
49
|
+
const calls: string[] = [];
|
|
50
|
+
const lookup = async (name: string) => {
|
|
51
|
+
calls.push(name);
|
|
52
|
+
return name === "_bugmole-verify.example.com" ? ["something-else", "bugmole-domain-verify=abc123"] : [];
|
|
53
|
+
};
|
|
54
|
+
assert.equal(await domainProvesToken("example.com", "bugmole-domain-verify=abc123", lookup), true);
|
|
55
|
+
assert.equal(await domainProvesToken("other.com", "bugmole-domain-verify=abc123", lookup), false);
|
|
56
|
+
assert.deepEqual(calls, ["_bugmole-verify.example.com", "_bugmole-verify.other.com"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- Routes and enforcement --------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** A fake DoH resolver that answers real (name -> token) pairs, like a real zone would. */
|
|
62
|
+
function dnsWithRecords(records: Map<string, string>) {
|
|
63
|
+
return (async (input: RequestInfo | URL) => {
|
|
64
|
+
const url = new URL(String(input));
|
|
65
|
+
const name = url.searchParams.get("name") ?? "";
|
|
66
|
+
const value = records.get(name);
|
|
67
|
+
return Response.json({ Answer: value ? [{ data: `"${value}"` }] : [] });
|
|
68
|
+
}) as unknown as typeof fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function workspace(overrides: Partial<Env> = {}) {
|
|
72
|
+
const setup = createTestEnv({ FEATURE_CLOUD_RUNNERS: "on", REGISTRY_PUBLIC_URL: "https://registry.test", ...overrides } as Partial<Env>);
|
|
73
|
+
const project = await seedProject(setup.env, { storageProvider: "bugmole" });
|
|
74
|
+
await seedPlan(setup.env, project.projectId, project.owner);
|
|
75
|
+
return { ...setup, ...project };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
test("adding a domain returns a pending TXT record; only owners/admins may manage it", async () => {
|
|
79
|
+
const { env, workspaceId, owner } = await workspace();
|
|
80
|
+
const denied = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: "user_stranger", body: { domain: "example.com" } });
|
|
81
|
+
assert.equal(denied.status, 403);
|
|
82
|
+
const added = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "https://Example.com/" } });
|
|
83
|
+
assert.equal(added.status, 201, JSON.stringify(added.json));
|
|
84
|
+
assert.equal(added.json.domain.domain, "example.com");
|
|
85
|
+
assert.equal(added.json.domain.status, "pending");
|
|
86
|
+
assert.equal(added.json.domain.txtRecord.name, "_bugmole-verify.example.com");
|
|
87
|
+
assert.match(added.json.domain.txtRecord.value, /^bugmole-domain-verify=/);
|
|
88
|
+
|
|
89
|
+
const invalid = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "not a domain" } });
|
|
90
|
+
assert.equal(invalid.status, 422);
|
|
91
|
+
|
|
92
|
+
// Re-adding the same domain is idempotent, not a duplicate.
|
|
93
|
+
const again = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "example.com" } });
|
|
94
|
+
assert.equal(again.json.domain.id, added.json.domain.id);
|
|
95
|
+
|
|
96
|
+
const listed = await call(env, "GET", `/api/workspaces/${workspaceId}/domains`, { as: owner });
|
|
97
|
+
assert.equal(listed.json.domains.length, 1);
|
|
98
|
+
assert.equal(listed.json.canManage, true);
|
|
99
|
+
const memberView = await call(env, "GET", `/api/workspaces/${workspaceId}/domains`, { as: "user_stranger" });
|
|
100
|
+
assert.equal(memberView.status, 403);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("verification checks the real TXT record and rejects a stranger's guess", async () => {
|
|
104
|
+
const records = new Map<string, string>();
|
|
105
|
+
const { env, workspaceId, owner } = await workspace({ DOMAIN_VERIFY_FETCH: dnsWithRecords(records) } as Partial<Env>);
|
|
106
|
+
const added = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "example.com" } });
|
|
107
|
+
const id = added.json.domain.id;
|
|
108
|
+
|
|
109
|
+
const tooSoon = await call(env, "POST", `/api/workspaces/${workspaceId}/domains/${id}/verify`, { as: owner });
|
|
110
|
+
assert.equal(tooSoon.status, 400);
|
|
111
|
+
assert.equal(tooSoon.json.error, "dns_verification_failed");
|
|
112
|
+
|
|
113
|
+
const wrongValue = await call(env, "POST", `/api/workspaces/${workspaceId}/domains/${id}/verify`, { as: "user_stranger" });
|
|
114
|
+
assert.equal(wrongValue.status, 403, "verifying is also owner/admin-only");
|
|
115
|
+
|
|
116
|
+
records.set("_bugmole-verify.example.com", added.json.domain.txtRecord.value);
|
|
117
|
+
const verified = await call(env, "POST", `/api/workspaces/${workspaceId}/domains/${id}/verify`, { as: owner });
|
|
118
|
+
assert.equal(verified.status, 200);
|
|
119
|
+
assert.equal(verified.json.verified, true);
|
|
120
|
+
|
|
121
|
+
const listed = await call(env, "GET", `/api/workspaces/${workspaceId}/domains`, { as: owner });
|
|
122
|
+
assert.equal(listed.json.domains[0].status, "verified");
|
|
123
|
+
|
|
124
|
+
const removed = await call(env, "DELETE", `/api/workspaces/${workspaceId}/domains/${id}`, { as: owner });
|
|
125
|
+
assert.equal(removed.status, 200);
|
|
126
|
+
assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/domains`, { as: owner })).json.domains.length, 0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a cloud run against an unverified domain is refused, then allowed once verified", async () => {
|
|
130
|
+
const records = new Map<string, string>();
|
|
131
|
+
const { env, workspaceId, owner, projectId } = await workspace({ DOMAIN_VERIFY_FETCH: dnsWithRecords(records) } as Partial<Env>);
|
|
132
|
+
await call(env, "POST", `/api/projects/${projectId}/environments`, { as: owner, body: { id: "prod", name: "Prod", baseUrl: "https://shop.example.com/" } });
|
|
133
|
+
|
|
134
|
+
const blocked = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
135
|
+
as: owner,
|
|
136
|
+
body: { planId: "checkout", idempotencyKey: "k1", executionMode: "cloud", environmentId: "prod" },
|
|
137
|
+
});
|
|
138
|
+
assert.equal(blocked.status, 403, JSON.stringify(blocked.json));
|
|
139
|
+
assert.equal(blocked.json.code, "domain_not_verified");
|
|
140
|
+
assert.equal(blocked.json.domain, "shop.example.com");
|
|
141
|
+
|
|
142
|
+
const added = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "example.com" } });
|
|
143
|
+
records.set("_bugmole-verify.example.com", added.json.domain.txtRecord.value);
|
|
144
|
+
await call(env, "POST", `/api/workspaces/${workspaceId}/domains/${added.json.domain.id}/verify`, { as: owner });
|
|
145
|
+
|
|
146
|
+
const allowed = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
147
|
+
as: owner,
|
|
148
|
+
body: { planId: "checkout", idempotencyKey: "k2", executionMode: "cloud", environmentId: "prod" },
|
|
149
|
+
});
|
|
150
|
+
assert.equal(allowed.status, 201, JSON.stringify(allowed.json));
|
|
151
|
+
|
|
152
|
+
// Self-hosted runs never touch Bugmole's infrastructure, so the same
|
|
153
|
+
// unverified environment is untouched for them.
|
|
154
|
+
const selfHosted = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
155
|
+
as: owner,
|
|
156
|
+
body: { planId: "checkout", idempotencyKey: "k3", executionMode: "self", environmentId: "prod" },
|
|
157
|
+
});
|
|
158
|
+
assert.equal(selfHosted.status, 201);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a cloud run against a private address needs no verification", async () => {
|
|
162
|
+
const { env, owner, projectId } = await workspace();
|
|
163
|
+
await call(env, "POST", `/api/projects/${projectId}/environments`, { as: owner, body: { id: "local", name: "Local", baseUrl: "http://localhost:3000" } });
|
|
164
|
+
const result = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
165
|
+
as: owner,
|
|
166
|
+
body: { planId: "checkout", idempotencyKey: "k4", executionMode: "cloud", environmentId: "local" },
|
|
167
|
+
});
|
|
168
|
+
assert.equal(result.status, 201, JSON.stringify(result.json));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("cloud exploration of an unverified domain is refused the same way", async () => {
|
|
172
|
+
const { env, owner, projectId } = await workspace();
|
|
173
|
+
const result = await call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
174
|
+
as: owner,
|
|
175
|
+
body: {
|
|
176
|
+
title: "Explore",
|
|
177
|
+
description: "https://shop.example.com/",
|
|
178
|
+
taskType: "discovery",
|
|
179
|
+
idempotencyKey: "t1",
|
|
180
|
+
executionMode: "cloud",
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
assert.equal(result.status, 403, JSON.stringify(result.json));
|
|
184
|
+
assert.equal(result.json.code, "domain_not_verified");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a self-hosted discovery task is never gated by domain verification", async () => {
|
|
188
|
+
const { env, owner, projectId } = await workspace();
|
|
189
|
+
const result = await call(env, "POST", `/api/projects/${projectId}/tasks`, {
|
|
190
|
+
as: owner,
|
|
191
|
+
body: {
|
|
192
|
+
title: "Explore",
|
|
193
|
+
description: "https://shop.example.com/",
|
|
194
|
+
taskType: "discovery",
|
|
195
|
+
idempotencyKey: "t2",
|
|
196
|
+
executionMode: "self",
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
assert.equal(result.status, 201, JSON.stringify(result.json));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("adding a domain is limited so a workspace can't grow this table without bound", async () => {
|
|
203
|
+
const { env, workspaceId, owner } = await workspace();
|
|
204
|
+
for (let i = 0; i < 25; i += 1) {
|
|
205
|
+
const added = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: `site-${i}.example.com` } });
|
|
206
|
+
assert.equal(added.status, 201, `domain ${i}`);
|
|
207
|
+
}
|
|
208
|
+
const overLimit = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "one-too-many.example.com" } });
|
|
209
|
+
assert.equal(overLimit.status, 422);
|
|
210
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Workspace domain verification: prove control of a domain by DNS TXT record
|
|
2
|
+
// before Bugmole Cloud will explore or run against a host on it. See
|
|
3
|
+
// ../domains.ts for why, and the normalization/matching/DNS-lookup helpers.
|
|
4
|
+
import {
|
|
5
|
+
domainProvesToken,
|
|
6
|
+
dohTxtLookupWith,
|
|
7
|
+
DOMAIN_VERIFY_PREFIX,
|
|
8
|
+
hostMatchesVerifiedDomain,
|
|
9
|
+
needsDomainVerification,
|
|
10
|
+
newVerificationToken,
|
|
11
|
+
normalizeDomain,
|
|
12
|
+
targetHost,
|
|
13
|
+
type TxtLookup,
|
|
14
|
+
} from "../domains.js";
|
|
15
|
+
import type { AuthedRouteContext } from "../routes/index.js";
|
|
16
|
+
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
17
|
+
import type { Env } from "../core.js";
|
|
18
|
+
import { memberOfWorkspace, newId, requestJson, response } from "../core.js";
|
|
19
|
+
import { actorOf, recordAudit } from "../org/audit.js";
|
|
20
|
+
|
|
21
|
+
const MAX_DOMAINS_PER_WORKSPACE = 25;
|
|
22
|
+
|
|
23
|
+
function lookupFor(env: Env): TxtLookup {
|
|
24
|
+
return dohTxtLookupWith((env as Env & { DOMAIN_VERIFY_FETCH?: typeof fetch }).DOMAIN_VERIFY_FETCH ?? fetch);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type DomainRow = {
|
|
28
|
+
id: string;
|
|
29
|
+
domain: string;
|
|
30
|
+
status: "pending" | "verified";
|
|
31
|
+
createdAt: string;
|
|
32
|
+
verifiedAt: string | null;
|
|
33
|
+
lastCheckError: string | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function withRecord(row: DomainRow, token: string) {
|
|
37
|
+
return { ...row, txtRecord: { name: `${DOMAIN_VERIFY_PREFIX}.${row.domain}`, value: token } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function listDomains({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
41
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
42
|
+
const rows = await env.DB.prepare(`
|
|
43
|
+
SELECT id, domain, verification_token AS verificationToken, status, created_at AS createdAt, verified_at AS verifiedAt, last_check_error AS lastCheckError
|
|
44
|
+
FROM workspace_domains WHERE workspace_id = ? ORDER BY created_at ASC
|
|
45
|
+
`).bind(workspaceId).all<DomainRow & { verificationToken: string }>();
|
|
46
|
+
return response({
|
|
47
|
+
canManage: await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"),
|
|
48
|
+
domains: rows.results.map(({ verificationToken, ...row }) => withRecord(row, verificationToken)),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function addDomain({ env, principal, request }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
53
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"))) {
|
|
54
|
+
return response({ error: "Only owners and admins can add a domain" }, 403);
|
|
55
|
+
}
|
|
56
|
+
const body = await requestJson(request);
|
|
57
|
+
let domain: string;
|
|
58
|
+
try {
|
|
59
|
+
domain = normalizeDomain(typeof body?.domain === "string" ? body.domain : "");
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return response({ error: error instanceof Error ? error.message : "Enter a domain such as example.com" }, 422);
|
|
62
|
+
}
|
|
63
|
+
const count = await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_domains WHERE workspace_id = ?").bind(workspaceId).first<{ count: number }>();
|
|
64
|
+
if ((count?.count ?? 0) >= MAX_DOMAINS_PER_WORKSPACE) return response({ error: `A workspace can add at most ${MAX_DOMAINS_PER_WORKSPACE} domains.` }, 422);
|
|
65
|
+
const existing = await env.DB.prepare("SELECT id, verification_token AS verificationToken, status, created_at AS createdAt, verified_at AS verifiedAt, last_check_error AS lastCheckError FROM workspace_domains WHERE workspace_id = ? AND domain = ?")
|
|
66
|
+
.bind(workspaceId, domain).first<DomainRow & { verificationToken: string }>();
|
|
67
|
+
if (existing) return response({ domain: withRecord({ ...existing, domain }, existing.verificationToken) }, 200);
|
|
68
|
+
const id = newId("dom");
|
|
69
|
+
const token = newVerificationToken();
|
|
70
|
+
await env.DB.prepare(`
|
|
71
|
+
INSERT INTO workspace_domains (id, workspace_id, domain, verification_token, created_by)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?)
|
|
73
|
+
`).bind(id, workspaceId, domain, token, principal.aegisId).run();
|
|
74
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "domain.added", targetType: "domain", targetId: id, data: { domain } }, request);
|
|
75
|
+
return response({ domain: withRecord({ id, domain, status: "pending", createdAt: new Date().toISOString(), verifiedAt: null, lastCheckError: null }, token) }, 201);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function verifyDomain({ env, principal, request }: AuthedRouteContext, workspaceId: string, id: string): Promise<Response> {
|
|
79
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"))) {
|
|
80
|
+
return response({ error: "Only owners and admins can verify a domain" }, 403);
|
|
81
|
+
}
|
|
82
|
+
const row = await env.DB.prepare("SELECT domain, verification_token AS verificationToken, status FROM workspace_domains WHERE id = ? AND workspace_id = ?")
|
|
83
|
+
.bind(id, workspaceId).first<{ domain: string; verificationToken: string; status: string }>();
|
|
84
|
+
if (!row) return response({ error: "Not found" }, 404);
|
|
85
|
+
if (row.status === "verified") return response({ verified: true });
|
|
86
|
+
const proved = await domainProvesToken(row.domain, row.verificationToken, lookupFor(env));
|
|
87
|
+
if (!proved) {
|
|
88
|
+
const message = "We couldn't find that TXT record yet. DNS changes can take a while to propagate — try again shortly.";
|
|
89
|
+
await env.DB.prepare("UPDATE workspace_domains SET last_check_error = ? WHERE id = ?").bind(message, id).run();
|
|
90
|
+
return response({ error: "dns_verification_failed", message }, 400);
|
|
91
|
+
}
|
|
92
|
+
await env.DB.prepare("UPDATE workspace_domains SET status = 'verified', verified_at = ?, last_check_error = NULL WHERE id = ?")
|
|
93
|
+
.bind(new Date().toISOString(), id).run();
|
|
94
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "domain.verified", targetType: "domain", targetId: id, data: { domain: row.domain } }, request);
|
|
95
|
+
return response({ verified: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function removeDomain({ env, principal, request }: AuthedRouteContext, workspaceId: string, id: string): Promise<Response> {
|
|
99
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"))) {
|
|
100
|
+
return response({ error: "Only owners and admins can remove a domain" }, 403);
|
|
101
|
+
}
|
|
102
|
+
const removed = await env.DB.prepare("DELETE FROM workspace_domains WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).run();
|
|
103
|
+
if ((removed.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
|
|
104
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "domain.removed", targetType: "domain", targetId: id }, request);
|
|
105
|
+
return response({ removed: true });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Gate for cloud discovery/runs: null when the target needs no verification
|
|
110
|
+
* (not a usable URL, or a private/loopback address Bugmole Cloud could
|
|
111
|
+
* never reach anyway) or the workspace has already proven the host's
|
|
112
|
+
* domain; otherwise the 403 to return, naming the domain and its TXT record
|
|
113
|
+
* so the dashboard can walk the person straight to Manage → Domains.
|
|
114
|
+
*/
|
|
115
|
+
export async function requireVerifiedDomainForCloud(env: Env, workspaceId: string, url: string): Promise<Response | null> {
|
|
116
|
+
const host = targetHost(url);
|
|
117
|
+
if (!host || !needsDomainVerification(host)) return null;
|
|
118
|
+
const rows = await env.DB.prepare("SELECT domain FROM workspace_domains WHERE workspace_id = ? AND status = 'verified'")
|
|
119
|
+
.bind(workspaceId).all<{ domain: string }>();
|
|
120
|
+
if (rows.results.some((row) => hostMatchesVerifiedDomain(host, row.domain))) return null;
|
|
121
|
+
return response({
|
|
122
|
+
error: `Verify that this workspace owns ${host} before running it on Bugmole Cloud.`,
|
|
123
|
+
code: "domain_not_verified",
|
|
124
|
+
domain: host,
|
|
125
|
+
manageUrl: "/manage/domains",
|
|
126
|
+
}, 403);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
registerRoutes(async (context) => {
|
|
130
|
+
const { request, url } = context;
|
|
131
|
+
let params = matchPath(url.pathname, "/api/workspaces/:workspaceId/domains");
|
|
132
|
+
if (params && request.method === "GET") return listDomains(context, params.workspaceId);
|
|
133
|
+
if (params && request.method === "POST") return addDomain(context, params.workspaceId);
|
|
134
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/domains/:domainId/verify");
|
|
135
|
+
if (params && request.method === "POST") return verifyDomain(context, params.workspaceId, params.domainId);
|
|
136
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/domains/:domainId");
|
|
137
|
+
if (params && request.method === "DELETE") return removeDomain(context, params.workspaceId, params.domainId);
|
|
138
|
+
return null;
|
|
139
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Domain ownership: a workspace must prove it controls a domain, by DNS TXT
|
|
2
|
+
// record, before Bugmole Cloud will explore or run against a host on it.
|
|
3
|
+
// Without this, a workspace's cloud minutes could point Bugmole's own IPs
|
|
4
|
+
// at any third-party site — using Bugmole as an anonymous crawler against a
|
|
5
|
+
// domain nobody in the workspace actually owns. Self-hosted runs and
|
|
6
|
+
// explorations are unaffected: those execute on the customer's own machine
|
|
7
|
+
// or CI, never Bugmole's infrastructure, so there is nothing to abuse.
|
|
8
|
+
import { randomToken } from "./core.js";
|
|
9
|
+
|
|
10
|
+
export const DOMAIN_VERIFY_PREFIX = "_bugmole-verify";
|
|
11
|
+
|
|
12
|
+
export function newVerificationToken(): string {
|
|
13
|
+
return `bugmole-domain-verify=${randomToken(20)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A domain is a plain public hostname: no scheme, port, path, credentials or IP literal. */
|
|
17
|
+
export function normalizeDomain(value: string): string {
|
|
18
|
+
const domain = value
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//, "")
|
|
22
|
+
.replace(/\/.*$/, "")
|
|
23
|
+
.replace(/:\d+$/, "")
|
|
24
|
+
.replace(/\.$/, "");
|
|
25
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(domain) || domain.includes(":")) {
|
|
26
|
+
throw new Error("Enter a domain name, not an IP address");
|
|
27
|
+
}
|
|
28
|
+
if (!/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(domain)) {
|
|
29
|
+
throw new Error("Enter a domain such as example.com");
|
|
30
|
+
}
|
|
31
|
+
return domain;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The host a target URL would be reached at, or null when it isn't a usable http(s) URL. */
|
|
35
|
+
export function targetHost(value: string): string | null {
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(value);
|
|
38
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
39
|
+
return url.hostname.toLowerCase();
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PRIVATE_HOSTS = /^(localhost|127\.|10\.|192\.168\.|169\.254\.|0\.0\.0\.0|::1)/;
|
|
46
|
+
function isPrivateHost(rawHost: string): boolean {
|
|
47
|
+
// A URL's IPv6 hostname keeps its brackets (new URL("http://[::1]/").hostname === "[::1]").
|
|
48
|
+
const host = rawHost.replace(/^\[|\]$/g, "");
|
|
49
|
+
if (PRIVATE_HOSTS.test(host)) return true;
|
|
50
|
+
const octets = /^172\.(\d{1,3})\./.exec(host);
|
|
51
|
+
return Boolean(octets && Number(octets[1]) >= 16 && Number(octets[1]) <= 31);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A cloud run or exploration never actually reaches a private/loopback
|
|
56
|
+
* address (Bugmole Cloud has no route to a customer's own network), so
|
|
57
|
+
* there is nothing there to verify ownership of — the run simply fails to
|
|
58
|
+
* connect, exactly as it would from anywhere else on the public internet.
|
|
59
|
+
*/
|
|
60
|
+
export function needsDomainVerification(host: string): boolean {
|
|
61
|
+
return !isPrivateHost(host);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function hostMatchesVerifiedDomain(host: string, domain: string): boolean {
|
|
65
|
+
const h = host.toLowerCase();
|
|
66
|
+
const d = domain.toLowerCase();
|
|
67
|
+
return h === d || h.endsWith(`.${d}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type TxtLookup = (name: string) => Promise<string[]>;
|
|
71
|
+
|
|
72
|
+
/** DNS-over-HTTPS TXT lookup — Workers have no native resolver. `doFetch` is overridable in tests. */
|
|
73
|
+
export function dohTxtLookupWith(doFetch: typeof fetch = fetch): TxtLookup {
|
|
74
|
+
return async (name) => {
|
|
75
|
+
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=TXT`;
|
|
76
|
+
const res = await doFetch(url, { headers: { accept: "application/dns-json" } });
|
|
77
|
+
if (!res.ok) return [];
|
|
78
|
+
const body = await res.json() as { Answer?: Array<{ data?: string }> };
|
|
79
|
+
return (body.Answer ?? []).map((answer) => (answer.data ?? "").replace(/^"|"$/g, ""));
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const dohTxtLookup: TxtLookup = dohTxtLookupWith();
|
|
84
|
+
|
|
85
|
+
export async function domainProvesToken(domain: string, token: string, lookup: TxtLookup = dohTxtLookup): Promise<boolean> {
|
|
86
|
+
const records = await lookup(`${DOMAIN_VERIFY_PREFIX}.${domain}`).catch(() => [] as string[]);
|
|
87
|
+
return records.includes(token);
|
|
88
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Transactional email for the registry: invitations, usage alerts, billing.
|
|
2
|
+
// Brevo is the default because Aegis already sends from a verified domain
|
|
3
|
+
// there; anything implementing EmailSender can replace it (tests use a
|
|
4
|
+
// recording sender).
|
|
5
|
+
|
|
6
|
+
export type EmailMessage = {
|
|
7
|
+
to: Array<{ email: string; name?: string }>;
|
|
8
|
+
subject: string;
|
|
9
|
+
html: string;
|
|
10
|
+
text: string;
|
|
11
|
+
/** Groups related messages in the provider's logs, e.g. "usage-alert". */
|
|
12
|
+
tag?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface EmailSender {
|
|
16
|
+
send(message: EmailMessage): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type EmailEnv = {
|
|
20
|
+
EMAIL_SENDER?: EmailSender;
|
|
21
|
+
BREVO_API_KEY?: string;
|
|
22
|
+
EMAIL_FROM?: string;
|
|
23
|
+
EMAIL_FROM_NAME?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class BrevoHttpSender implements EmailSender {
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly apiKey: string,
|
|
29
|
+
private readonly from: { email: string; name: string },
|
|
30
|
+
private readonly fetchImpl: typeof fetch = fetch,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
async send(message: EmailMessage): Promise<void> {
|
|
34
|
+
const response = await this.fetchImpl("https://api.brevo.com/v3/smtp/email", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: { "api-key": this.apiKey, "content-type": "application/json", accept: "application/json" },
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
sender: this.from,
|
|
39
|
+
to: message.to,
|
|
40
|
+
subject: message.subject,
|
|
41
|
+
htmlContent: message.html,
|
|
42
|
+
textContent: message.text,
|
|
43
|
+
...(message.tag ? { tags: [message.tag] } : {}),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
// Brevo echoes the request on some errors; keep only the status and code.
|
|
48
|
+
const detail = await response.json().catch(() => null) as { code?: string } | null;
|
|
49
|
+
throw new Error(`Email provider rejected the message (${response.status}${detail?.code ? ` ${detail.code}` : ""})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drops mail when no provider is configured, so local development never needs one. */
|
|
55
|
+
class DisabledSender implements EmailSender {
|
|
56
|
+
async send(message: EmailMessage): Promise<void> {
|
|
57
|
+
console.warn(`[email] not configured; dropped "${message.subject}" to ${message.to.length} recipient(s)`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function emailSender(env: EmailEnv): EmailSender {
|
|
62
|
+
if (env.EMAIL_SENDER) return env.EMAIL_SENDER;
|
|
63
|
+
if (env.BREVO_API_KEY && env.EMAIL_FROM) {
|
|
64
|
+
return new BrevoHttpSender(env.BREVO_API_KEY, { email: env.EMAIL_FROM, name: env.EMAIL_FROM_NAME ?? "Bugmole" });
|
|
65
|
+
}
|
|
66
|
+
return new DisabledSender();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function escapeHtml(value: string): string {
|
|
70
|
+
return value
|
|
71
|
+
.replaceAll("&", "&")
|
|
72
|
+
.replaceAll("<", "<")
|
|
73
|
+
.replaceAll(">", ">")
|
|
74
|
+
.replaceAll('"', """);
|
|
75
|
+
}
|