@bugmole/cli 0.4.0 → 0.5.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/LICENSE +18 -4
- package/package.json +4 -4
- package/scripts/bugmole.ts +60 -0
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry-worker/core.ts +2 -0
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- package/src/registry-worker/features.ts +2 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/index.ts +25 -7
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +5 -4
- package/src/registry-worker/runner/runner.test.ts +38 -0
- package/src/registry-worker/runner/tokens.ts +4 -1
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +6 -0
- package/src/runtime/discovery-task.ts +24 -5
- package/src/runtime/flow-language.ts +21 -0
- package/src/runtime/journey-editor.ts +39 -0
- package/src/runtime/journey-graph.test.ts +42 -0
- package/src/runtime/playwright-driver.test.ts +145 -0
- package/src/runtime/playwright-driver.ts +237 -8
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/site-discovery.test.ts +135 -1
- package/src/runtime/site-discovery.ts +173 -24
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +3 -0
package/LICENSE
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
1
3
|
Copyright (c) 2026 The AI, Inc.
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
4
14
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bugmole/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"bm": "scripts/bugmole.cjs"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
-
"postinstall": "node scripts/ensure-maestro.cjs",
|
|
30
|
+
"postinstall": "node scripts/ensure-maestro.cjs && node scripts/ensure-playwright.cjs",
|
|
31
31
|
"bugmole:mcp": "NODE_OPTIONS=--max-old-space-size=512 tsx --no-cache scripts/bugmole.ts --mcp",
|
|
32
32
|
"bugmole:explore": "NODE_OPTIONS=--max-old-space-size=512 tsx scripts/bugmole.ts --mode explore",
|
|
33
33
|
"bugmole:plan": "NODE_OPTIONS=--max-old-space-size=512 tsx scripts/bugmole.ts --mode plan",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"runner:dev": "wrangler dev -c wrangler.runner.jsonc",
|
|
47
47
|
"runner:deploy": "wrangler deploy -c wrangler.runner.jsonc",
|
|
48
48
|
"sync:catalog": "node scripts/sync-plan-catalog.mjs",
|
|
49
|
-
"test": "NODE_OPTIONS=--max-old-space-size=512 tsx --test --test-concurrency=1 scripts/bugmole.test.ts src/mcp/server.test.ts projects/registry.test.ts src/integrations/testflight-feedback.test.ts src/integrations/temp-email.test.ts src/storage/object-store.test.ts src/storage/local-artifact-server.test.ts src/storage/storage-directory.test.ts src/runtime/reset.test.ts src/runtime/serve-gateway.test.ts src/registry/task-scheduling.test.ts src/registry-worker/artifacts.test.ts src/registry-worker/runs.test.ts src/registry-worker/org/routes.test.ts src/registry-worker/billing/billing-core.test.ts src/registry-worker/billing/paypal.test.ts src/registry-worker/ai/routes.test.ts src/registry-worker/runner/runner.test.ts src/registry-worker/signup/signup.test.ts src/registry-worker/notifications/notifications.test.ts src/registry-worker/github/checks.test.ts src/registry-worker/gitlab/checks.test.ts src/registry-worker/personas/personas.test.ts src/registry-worker/feedback/feedback.test.ts src/registry-worker/devices/devices.test.ts src/registry-worker/domains/domains.test.ts src/registry-worker/sso/sso.test.ts src/registry-worker/mcp/mcp.test.ts src/integrations/github-app.test.ts src/runtime/run-once.test.ts src/runtime/appium-driver.test.ts src/integrations/aws-sigv4.test.ts src/runtime/ai-work.test.ts src/billing/plan-catalog.test.ts src/mcp/write-test-cases.test.ts src/runtime/pipeline.test.ts src/runtime/planner.test.ts src/runtime/test-case-verdicts.test.ts src/runtime/journey-graph.test.ts src/runtime/journey-evidence.test.ts src/runtime/local-browser.test.ts src/runtime/serve-worker.test.ts src/runtime/cursor-driver-run.test.ts src/runtime/explorer.test.ts src/runtime/nav-crawl.test.ts src/runtime/app-understanding.test.ts src/runtime/environment.test.ts src/runtime/blocker-resolution.test.ts src/runtime/ui-reverse-engineering/coordinator.test.ts src/runtime/maestro-driver.test.ts src/runtime/playwright-driver.test.ts src/runtime/flow-language.test.ts src/runtime/web-suite.test.ts src/runtime/canonical-flow.test.ts src/runtime/executor.test.ts src/runtime/ios-screen-record.test.ts src/runtime/android-screen-record.test.ts src/runtime/reviewer.test.ts src/runtime/reporter.test.ts src/runtime/cursor-cli-driver.test.ts src/runtime/local-registry-stub.test.ts src/runtime/browser-matrix.test.ts src/runtime/browser-matrix.integration.test.ts src/runtime/failure-analysis.test.ts src/runtime/site-discovery.test.ts src/runtime/ai-exploration.test.ts src/runtime/discovery-task.test.ts",
|
|
49
|
+
"test": "NODE_OPTIONS=--max-old-space-size=512 tsx --test --test-concurrency=1 scripts/bugmole.test.ts scripts/ensure-playwright.test.ts scripts/sync-launcher-version.test.ts src/mcp/server.test.ts projects/registry.test.ts src/integrations/testflight-feedback.test.ts src/integrations/temp-email.test.ts src/storage/object-store.test.ts src/storage/local-artifact-server.test.ts src/storage/storage-directory.test.ts src/runtime/reset.test.ts src/runtime/run-job.test.ts src/runtime/serve-gateway.test.ts src/registry/task-scheduling.test.ts src/registry-worker/artifacts.test.ts src/registry-worker/runs.test.ts src/registry-worker/org/routes.test.ts src/registry-worker/billing/billing-core.test.ts src/registry-worker/billing/paypal.test.ts src/registry-worker/ai/routes.test.ts src/registry-worker/runner/runner.test.ts src/registry-worker/signup/signup.test.ts src/registry-worker/signup/attribution.test.ts src/registry-worker/notifications/notifications.test.ts src/registry-worker/github/checks.test.ts src/registry-worker/gitlab/checks.test.ts src/registry-worker/personas/personas.test.ts src/registry-worker/feedback/feedback.test.ts src/registry-worker/devices/devices.test.ts src/registry-worker/domains/domains.test.ts src/registry-worker/sso/sso.test.ts src/registry-worker/mcp/mcp.test.ts src/integrations/github-app.test.ts src/runtime/run-once.test.ts src/runtime/appium-driver.test.ts src/integrations/aws-sigv4.test.ts src/runtime/ai-work.test.ts src/billing/plan-catalog.test.ts src/mcp/write-test-cases.test.ts src/runtime/pipeline.test.ts src/runtime/planner.test.ts src/runtime/test-case-verdicts.test.ts src/runtime/journey-graph.test.ts src/runtime/journey-evidence.test.ts src/runtime/local-browser.test.ts src/runtime/serve-worker.test.ts src/runtime/cursor-driver-run.test.ts src/runtime/explorer.test.ts src/runtime/nav-crawl.test.ts src/runtime/app-understanding.test.ts src/runtime/environment.test.ts src/runtime/blocker-resolution.test.ts src/runtime/ui-reverse-engineering/coordinator.test.ts src/runtime/maestro-driver.test.ts src/runtime/playwright-driver.test.ts src/runtime/flow-language.test.ts src/runtime/web-suite.test.ts src/runtime/canonical-flow.test.ts src/runtime/executor.test.ts src/runtime/ios-screen-record.test.ts src/runtime/android-screen-record.test.ts src/runtime/reviewer.test.ts src/runtime/reporter.test.ts src/runtime/cursor-cli-driver.test.ts src/runtime/local-registry-stub.test.ts src/runtime/browser-matrix.test.ts src/runtime/browser-matrix.integration.test.ts src/runtime/failure-analysis.test.ts src/runtime/site-discovery.test.ts src/runtime/ai-exploration.test.ts src/runtime/discovery-task.test.ts",
|
|
50
50
|
"test:mcp": "NODE_OPTIONS=--max-old-space-size=512 tsx --test --test-concurrency=1 scripts/bugmole.test.ts src/mcp/server.test.ts projects/registry.test.ts src/integrations/testflight-feedback.test.ts src/storage/object-store.test.ts src/runtime/maestro-driver.test.ts src/runtime/canonical-flow.test.ts",
|
|
51
51
|
"typecheck": "NODE_OPTIONS=--max-old-space-size=512 tsc --noEmit"
|
|
52
52
|
},
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"automation"
|
|
60
60
|
],
|
|
61
61
|
"author": "",
|
|
62
|
-
"license": "
|
|
62
|
+
"license": "MIT",
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@aws-sdk/client-s3": "^3.1085.0",
|
|
65
65
|
"@aws-sdk/s3-request-presigner": "^3.1085.0",
|
package/scripts/bugmole.ts
CHANGED
|
@@ -18,6 +18,7 @@ type Mode =
|
|
|
18
18
|
| "plan"
|
|
19
19
|
| "run"
|
|
20
20
|
| "propose-diff"
|
|
21
|
+
| "discover"
|
|
21
22
|
| "review"
|
|
22
23
|
| "report"
|
|
23
24
|
| "artifacts"
|
|
@@ -472,6 +473,65 @@ async function main() {
|
|
|
472
473
|
return;
|
|
473
474
|
}
|
|
474
475
|
|
|
476
|
+
// Explores a running app like a first-time visitor and writes a flow for
|
|
477
|
+
// every page and form it finds, ready for --mode suite. With --sign-in, that
|
|
478
|
+
// flow runs first and exploration continues in its session, which is
|
|
479
|
+
// deleted afterwards.
|
|
480
|
+
if (mode === "discover") {
|
|
481
|
+
const { discoverSite } = await import("../src/runtime/site-discovery.js");
|
|
482
|
+
const baseUrl = typeof args.baseurl === "string" ? args.baseurl : process.env.BUGMOLE_BASE_URL;
|
|
483
|
+
if (!baseUrl) throw new Error("--base-url (or BUGMOLE_BASE_URL) is required for --mode discover");
|
|
484
|
+
if (typeof args.out !== "string") throw new Error("--out <directory> is required for --mode discover");
|
|
485
|
+
const outDir = path.resolve(workspaceRoot, args.out);
|
|
486
|
+
const sessionDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "bugmole-discover-"));
|
|
487
|
+
try {
|
|
488
|
+
let storageState: string | undefined;
|
|
489
|
+
if (typeof args.signin === "string") {
|
|
490
|
+
const { PlaywrightDriver } = await import("../src/runtime/playwright-driver.js");
|
|
491
|
+
storageState = path.join(sessionDir, "session-state.json");
|
|
492
|
+
const signIn = await new PlaywrightDriver({ headless: args.headed !== true }).run({
|
|
493
|
+
runId: `discover-${Date.now()}`,
|
|
494
|
+
projectId: typeof args.project === "string" ? args.project : path.basename(workspaceRoot),
|
|
495
|
+
journeyId: "sign-in",
|
|
496
|
+
flowPath: path.resolve(workspaceRoot, args.signin),
|
|
497
|
+
platform: "web",
|
|
498
|
+
target: { baseUrl },
|
|
499
|
+
rebaseOrigin: true,
|
|
500
|
+
timeoutMs: typeof args.timeout === "number" ? args.timeout : 30_000,
|
|
501
|
+
artifactsDir: path.join(sessionDir, "artifacts"),
|
|
502
|
+
sessionStatePath: storageState,
|
|
503
|
+
});
|
|
504
|
+
if (signIn.status !== "passed") throw new Error(`Sign-in flow did not pass: ${signIn.message}`);
|
|
505
|
+
console.log(`[Bugmole] Signed in with ${args.signin}`);
|
|
506
|
+
}
|
|
507
|
+
const result = await discoverSite(baseUrl, {
|
|
508
|
+
storageState,
|
|
509
|
+
openEachPage: args.openeachpage === true,
|
|
510
|
+
maxPages: typeof args.maxpages === "number" ? args.maxpages : undefined,
|
|
511
|
+
maxDepth: typeof args.maxdepth === "number" ? args.maxdepth : undefined,
|
|
512
|
+
headless: args.headed !== true,
|
|
513
|
+
onEvent: (event) => {
|
|
514
|
+
if (event.kind !== "click") console.log(`[discover] ${event.text}`);
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
await fs.promises.mkdir(outDir, { recursive: true });
|
|
518
|
+
for (const flow of result.flows) {
|
|
519
|
+
await fs.promises.writeFile(path.join(outDir, path.basename(flow.file)), flow.content, "utf8");
|
|
520
|
+
}
|
|
521
|
+
await fs.promises.writeFile(
|
|
522
|
+
path.join(outDir, "discovery.json"),
|
|
523
|
+
`${JSON.stringify({ baseUrl: result.baseUrl, pages: result.pages.map(({ route, heading, title, path: via }) => ({ route, heading, title, via })), events: result.events }, null, 2)}\n`,
|
|
524
|
+
"utf8",
|
|
525
|
+
);
|
|
526
|
+
console.log(`[Bugmole] Discovered ${result.pages.length} pages and wrote ${result.flows.length} flows to ${path.relative(workspaceRoot, outDir) || "."}`);
|
|
527
|
+
if (result.pages.length === 0) process.exitCode = 1;
|
|
528
|
+
} finally {
|
|
529
|
+
// Holds the app's cookies and tokens: never outlives the exploration.
|
|
530
|
+
await fs.promises.rm(sessionDir, { recursive: true, force: true });
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
475
535
|
const cfg = loadConfig(configPath);
|
|
476
536
|
|
|
477
537
|
if (mode === "reset") {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Downloads the browsers Bugmole runs tests in, since the `playwright` package
|
|
3
|
+
// installs only the library: without this, the first `bugmole run` or
|
|
4
|
+
// `bugmole serve` on a new machine stops with "Chromium is not installed".
|
|
5
|
+
// Runs as a postinstall step next to ensure-maestro.cjs, and like it never
|
|
6
|
+
// fails the parent `npm install` — this is best-effort.
|
|
7
|
+
//
|
|
8
|
+
// It uses the package's own Playwright, so the browsers always match the
|
|
9
|
+
// version Bugmole drives. Edge is left out: installing it needs admin rights
|
|
10
|
+
// (sudo on Linux, a system package on macOS), so it stays a one-line opt-in.
|
|
11
|
+
const { spawnSync } = require("node:child_process");
|
|
12
|
+
const path = require("node:path");
|
|
13
|
+
|
|
14
|
+
// Chromium, Firefox and WebKit (Safari) are the browsers a run targets by
|
|
15
|
+
// default; ffmpeg is what records a run's video.
|
|
16
|
+
const BROWSERS = ["chromium", "firefox", "webkit", "ffmpeg"];
|
|
17
|
+
|
|
18
|
+
function log(message) {
|
|
19
|
+
console.log(`[bugmole postinstall] ${message}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function skipReason(env = process.env) {
|
|
23
|
+
if (env.BUGMOLE_SKIP_PLAYWRIGHT_INSTALL === "true") return "BUGMOLE_SKIP_PLAYWRIGHT_INSTALL=true";
|
|
24
|
+
// A CI job that runs Bugmole's tests asks for the browsers explicitly.
|
|
25
|
+
if (env.BUGMOLE_PLAYWRIGHT_INSTALL === "true") return null;
|
|
26
|
+
// Playwright's own switch, which CI images and Docker builds already set.
|
|
27
|
+
if (env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD && env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD !== "0") return "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is set";
|
|
28
|
+
// CI installs dependencies on every job; several hundred MB of browsers
|
|
29
|
+
// each time is wasted unless the job runs tests, and then it says so.
|
|
30
|
+
if (env.CI && env.CI !== "false" && env.CI !== "0") return "running in CI";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Playwright doesn't export its CLI, so find it the way npm does: through
|
|
35
|
+
// the `bin` its package.json declares.
|
|
36
|
+
function playwrightCli() {
|
|
37
|
+
try {
|
|
38
|
+
const manifest = require.resolve("playwright/package.json");
|
|
39
|
+
const bin = require(manifest).bin;
|
|
40
|
+
const entry = typeof bin === "string" ? bin : bin && bin.playwright;
|
|
41
|
+
return entry ? path.join(path.dirname(manifest), entry) : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function main() {
|
|
48
|
+
const reason = skipReason();
|
|
49
|
+
if (reason) {
|
|
50
|
+
log(`Skipping Playwright browser install (${reason}). Set BUGMOLE_PLAYWRIGHT_INSTALL=true to install them anyway, or later: npx playwright install ${BROWSERS.join(" ")}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const cli = playwrightCli();
|
|
54
|
+
if (!cli) {
|
|
55
|
+
log("Playwright is not installed next to Bugmole, so there are no browsers to fetch yet.");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
log(`Installing Playwright browsers (${BROWSERS.join(", ")}). Already-installed ones are skipped.`);
|
|
59
|
+
const install = spawnSync(process.execPath, [cli, "install", ...BROWSERS], {
|
|
60
|
+
stdio: "inherit",
|
|
61
|
+
timeout: 10 * 60 * 1000,
|
|
62
|
+
});
|
|
63
|
+
if (install.error || install.status !== 0) {
|
|
64
|
+
log(
|
|
65
|
+
"Automatic browser install failed. Runs report the missing browser until it is installed: " +
|
|
66
|
+
`npx playwright install ${BROWSERS.join(" ")}`,
|
|
67
|
+
);
|
|
68
|
+
if (process.platform === "linux") {
|
|
69
|
+
log("On Linux the browsers also need system libraries: sudo npx playwright install-deps");
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
log("Playwright browsers are ready. For Edge as well: npx playwright install msedge (needs admin rights).");
|
|
74
|
+
if (process.platform === "linux") {
|
|
75
|
+
log("If a browser fails to start on Linux, install its system libraries: sudo npx playwright install-deps");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { BROWSERS, playwrightCli, skipReason };
|
|
80
|
+
|
|
81
|
+
if (require.main === module) {
|
|
82
|
+
try {
|
|
83
|
+
main();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
log(`Skipping Playwright browser install after unexpected error: ${error.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const { BROWSERS, playwrightCli, skipReason } = require("./ensure-playwright.cjs") as {
|
|
10
|
+
BROWSERS: string[];
|
|
11
|
+
playwrightCli: () => string | null;
|
|
12
|
+
skipReason: (env: Record<string, string | undefined>) => string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
test("installing the CLI fetches the browsers a run targets by default, plus ffmpeg for video", () => {
|
|
16
|
+
assert.deepEqual(BROWSERS, ["chromium", "firefox", "webkit", "ffmpeg"]);
|
|
17
|
+
assert.ok(!BROWSERS.includes("msedge"), "Edge needs admin rights, so it stays opt-in");
|
|
18
|
+
const pkg = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")) as { scripts: Record<string, string> };
|
|
19
|
+
assert.match(pkg.scripts.postinstall, /ensure-playwright\.cjs/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the browser download is skipped in CI and when either skip switch is set", () => {
|
|
23
|
+
assert.equal(skipReason({}), null);
|
|
24
|
+
assert.equal(skipReason({ CI: "false" }), null);
|
|
25
|
+
assert.match(skipReason({ CI: "true" }) ?? "", /CI/);
|
|
26
|
+
assert.match(skipReason({ BUGMOLE_SKIP_PLAYWRIGHT_INSTALL: "true" }) ?? "", /BUGMOLE_SKIP_PLAYWRIGHT_INSTALL/);
|
|
27
|
+
assert.match(skipReason({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" }) ?? "", /PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD/);
|
|
28
|
+
assert.equal(skipReason({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "0" }), null);
|
|
29
|
+
assert.equal(skipReason({ CI: "true", BUGMOLE_PLAYWRIGHT_INSTALL: "true" }), null, "a CI job that runs tests can ask for the browsers");
|
|
30
|
+
assert.match(skipReason({ BUGMOLE_PLAYWRIGHT_INSTALL: "true", BUGMOLE_SKIP_PLAYWRIGHT_INSTALL: "true" }) ?? "", /SKIP/, "an explicit skip still wins");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("finds Playwright's CLI even though the package doesn't export it", () => {
|
|
34
|
+
const cli = playwrightCli();
|
|
35
|
+
assert.ok(cli && cli.endsWith("cli.js"), `expected Playwright's cli.js, got ${cli}`);
|
|
36
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Pins the unscoped `bugmole` launcher (packages/bugmole) to the @bugmole/cli
|
|
3
|
+
// version being released, so `npx bugmole` always installs that exact CLI.
|
|
4
|
+
// The publish workflow runs this right before publishing the launcher.
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
|
|
8
|
+
function syncLauncher(root = path.resolve(__dirname, "..")) {
|
|
9
|
+
const cli = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
10
|
+
const launcherPath = path.join(root, "packages", "bugmole", "package.json");
|
|
11
|
+
const launcher = JSON.parse(fs.readFileSync(launcherPath, "utf8"));
|
|
12
|
+
if (cli.name !== "@bugmole/cli") throw new Error(`Expected the root package to be @bugmole/cli, found ${cli.name}`);
|
|
13
|
+
launcher.version = cli.version;
|
|
14
|
+
launcher.dependencies = { ...launcher.dependencies, "@bugmole/cli": cli.version };
|
|
15
|
+
fs.writeFileSync(launcherPath, `${JSON.stringify(launcher, null, 2)}\n`);
|
|
16
|
+
return { version: launcher.version, dependency: launcher.dependencies["@bugmole/cli"] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { syncLauncher };
|
|
20
|
+
|
|
21
|
+
if (require.main === module) {
|
|
22
|
+
const result = syncLauncher();
|
|
23
|
+
console.log(`bugmole launcher pinned to @bugmole/cli ${result.dependency}`);
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const { syncLauncher } = require("./sync-launcher-version.cjs") as { syncLauncher: (root: string) => { version: string; dependency: string } };
|
|
11
|
+
|
|
12
|
+
test("the bugmole launcher is released at the CLI's version and installs exactly that CLI", () => {
|
|
13
|
+
const root = mkdtempSync(path.join(tmpdir(), "launcher-"));
|
|
14
|
+
mkdirSync(path.join(root, "packages", "bugmole"), { recursive: true });
|
|
15
|
+
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "@bugmole/cli", version: "0.5.2" }));
|
|
16
|
+
writeFileSync(path.join(root, "packages", "bugmole", "package.json"), JSON.stringify({ name: "bugmole", version: "0.4.0", bin: { bugmole: "bin/bugmole.cjs" }, dependencies: { "@bugmole/cli": "^0.4.0" } }));
|
|
17
|
+
assert.deepEqual(syncLauncher(root), { version: "0.5.2", dependency: "0.5.2" });
|
|
18
|
+
const launcher = JSON.parse(readFileSync(path.join(root, "packages", "bugmole", "package.json"), "utf8"));
|
|
19
|
+
assert.equal(launcher.version, "0.5.2");
|
|
20
|
+
assert.equal(launcher.dependencies["@bugmole/cli"], "0.5.2");
|
|
21
|
+
assert.deepEqual(launcher.bin, { bugmole: "bin/bugmole.cjs" }, "everything else is left alone");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("the real launcher's bin matches its package name, so npx bugmole knows what to run", () => {
|
|
25
|
+
const launcher = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "packages", "bugmole", "package.json"), "utf8"));
|
|
26
|
+
assert.equal(launcher.name, "bugmole");
|
|
27
|
+
assert.ok(launcher.bin.bugmole, "npx picks the bin named after the package when there are several");
|
|
28
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Which affiliate code and campaign brought each new user. bugmole.com keeps
|
|
2
|
+
-- the first visit's ref/utm_* tags in the bm_attr cookie; the dashboard sends
|
|
3
|
+
-- them once after sign-in (POST /api/attribution). First touch wins: a row is
|
|
4
|
+
-- written once and never overwritten. Scents reads this table.
|
|
5
|
+
CREATE TABLE IF NOT EXISTS signup_attribution (
|
|
6
|
+
aegis_id TEXT PRIMARY KEY REFERENCES aegis_users(id) ON DELETE CASCADE,
|
|
7
|
+
ref TEXT,
|
|
8
|
+
utm_source TEXT,
|
|
9
|
+
utm_medium TEXT,
|
|
10
|
+
utm_campaign TEXT,
|
|
11
|
+
utm_content TEXT,
|
|
12
|
+
utm_term TEXT,
|
|
13
|
+
landing_path TEXT,
|
|
14
|
+
referrer_host TEXT,
|
|
15
|
+
first_seen_at TEXT,
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_signup_attribution_ref ON signup_attribution(ref);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_signup_attribution_campaign ON signup_attribution(utm_campaign);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- How a domain was proven: 'dns' (a TXT record, which covers the domain and
|
|
2
|
+
-- its subdomains) or 'file' (a file published on the site, which proves
|
|
3
|
+
-- control of that one host only). Rows verified before this column existed
|
|
4
|
+
-- were all DNS.
|
|
5
|
+
ALTER TABLE workspace_domains ADD COLUMN verified_by TEXT;
|
|
6
|
+
UPDATE workspace_domains SET verified_by = 'dns' WHERE status = 'verified';
|
|
@@ -16,6 +16,8 @@ export interface Env extends EmailEnv {
|
|
|
16
16
|
RUNNER_TOKEN_SECRET?: string;
|
|
17
17
|
/** Service binding to the valkyrie-runner Worker that starts cloud-run containers. */
|
|
18
18
|
RUNNER?: Fetcher;
|
|
19
|
+
/** Scents (bugmole-scents): checks that a typed referral code belongs to an affiliate. */
|
|
20
|
+
SCENTS?: Fetcher;
|
|
19
21
|
/** Cloud runs executing at once across every workspace (default 20). */
|
|
20
22
|
CLOUD_POOL_MAX?: string;
|
|
21
23
|
/** Longest a cloud run may take before it is stopped, in minutes (default 60). */
|
|
@@ -4,6 +4,7 @@ import type { Env } from "../core.js";
|
|
|
4
4
|
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
5
5
|
import {
|
|
6
6
|
domainProvesToken,
|
|
7
|
+
fileProvesToken,
|
|
7
8
|
hostMatchesVerifiedDomain,
|
|
8
9
|
needsDomainVerification,
|
|
9
10
|
normalizeDomain,
|
|
@@ -56,6 +57,31 @@ test("domainProvesToken only accepts the exact token at the exact record name",
|
|
|
56
57
|
assert.deepEqual(calls, ["_bugmole-verify.example.com", "_bugmole-verify.other.com"]);
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
test("a published file only proves the exact token, served directly, on the exact host", async () => {
|
|
61
|
+
const token = "bugmole-domain-verify=abc123";
|
|
62
|
+
const asked: string[] = [];
|
|
63
|
+
const site = (async (input: RequestInfo | URL) => {
|
|
64
|
+
const url = String(input);
|
|
65
|
+
asked.push(url);
|
|
66
|
+
if (url === "https://app.example.com/.well-known/bugmole-verification.txt") return new Response(`# Bugmole\n${token}\n`);
|
|
67
|
+
if (url === "https://moved.example.com/.well-known/bugmole-verification.txt") return new Response(null, { status: 302, headers: { location: "https://app.example.com/.well-known/bugmole-verification.txt" } });
|
|
68
|
+
if (url === "https://inline.example.com/.well-known/bugmole-verification.txt") return new Response(`not-${token}`);
|
|
69
|
+
return new Response("Not found", { status: 404 });
|
|
70
|
+
}) as unknown as typeof fetch;
|
|
71
|
+
assert.equal(await fileProvesToken("app.example.com", token, site), true);
|
|
72
|
+
assert.equal(await fileProvesToken("moved.example.com", token, site), false, "a redirect could hand the proof to another host");
|
|
73
|
+
assert.equal(await fileProvesToken("inline.example.com", token, site), false, "the token must be a line of its own");
|
|
74
|
+
assert.equal(await fileProvesToken("other.example.com", token, site), false);
|
|
75
|
+
assert.equal(await fileProvesToken("app.example.com", token, (async () => { throw new Error("offline"); }) as unknown as typeof fetch), false);
|
|
76
|
+
assert.ok(asked.every((url) => url.startsWith("https://")), "only ever over HTTPS");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("a file-verified host covers only itself; a DNS-verified domain covers its subdomains", () => {
|
|
80
|
+
assert.equal(hostMatchesVerifiedDomain("app.example.com", "app.example.com", "file"), true);
|
|
81
|
+
assert.equal(hostMatchesVerifiedDomain("admin.app.example.com", "app.example.com", "file"), false);
|
|
82
|
+
assert.equal(hostMatchesVerifiedDomain("admin.app.example.com", "app.example.com", "dns"), true);
|
|
83
|
+
});
|
|
84
|
+
|
|
59
85
|
// --- Routes and enforcement --------------------------------------------------------------------
|
|
60
86
|
|
|
61
87
|
/** A fake DoH resolver that answers real (name -> token) pairs, like a real zone would. */
|
|
@@ -108,7 +134,8 @@ test("verification checks the real TXT record and rejects a stranger's guess", a
|
|
|
108
134
|
|
|
109
135
|
const tooSoon = await call(env, "POST", `/api/workspaces/${workspaceId}/domains/${id}/verify`, { as: owner });
|
|
110
136
|
assert.equal(tooSoon.status, 400);
|
|
111
|
-
assert.equal(tooSoon.json.error, "
|
|
137
|
+
assert.equal(tooSoon.json.error, "verification_failed");
|
|
138
|
+
assert.match(tooSoon.json.message, /\.well-known\/bugmole-verification\.txt/);
|
|
112
139
|
|
|
113
140
|
const wrongValue = await call(env, "POST", `/api/workspaces/${workspaceId}/domains/${id}/verify`, { as: "user_stranger" });
|
|
114
141
|
assert.equal(wrongValue.status, 403, "verifying is also owner/admin-only");
|
|
@@ -208,3 +235,39 @@ test("adding a domain is limited so a workspace can't grow this table without bo
|
|
|
208
235
|
const overLimit = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "one-too-many.example.com" } });
|
|
209
236
|
assert.equal(overLimit.status, 422);
|
|
210
237
|
});
|
|
238
|
+
|
|
239
|
+
test("someone without DNS access verifies one subdomain by publishing a file, and only that host opens up", async () => {
|
|
240
|
+
const files = new Map<string, string>();
|
|
241
|
+
const site = (async (input: RequestInfo | URL) => {
|
|
242
|
+
const url = new URL(String(input));
|
|
243
|
+
if (url.hostname === "cloudflare-dns.com") return Response.json({ Answer: [] });
|
|
244
|
+
const body = files.get(`${url.hostname}${url.pathname}`);
|
|
245
|
+
return body === undefined ? new Response("Not found", { status: 404 }) : new Response(body);
|
|
246
|
+
}) as unknown as typeof fetch;
|
|
247
|
+
const { env, workspaceId, owner, projectId } = await workspace({ DOMAIN_VERIFY_FETCH: site } as Partial<Env>);
|
|
248
|
+
await call(env, "POST", `/api/projects/${projectId}/environments`, { as: owner, body: { id: "prod", name: "Prod", baseUrl: "https://shop.example.com/" } });
|
|
249
|
+
await call(env, "POST", `/api/projects/${projectId}/environments`, { as: owner, body: { id: "admin", name: "Admin", baseUrl: "https://admin.shop.example.com/" } });
|
|
250
|
+
|
|
251
|
+
const added = await call(env, "POST", `/api/workspaces/${workspaceId}/domains`, { as: owner, body: { domain: "shop.example.com" } });
|
|
252
|
+
assert.deepEqual(added.json.domain.file, {
|
|
253
|
+
url: "https://shop.example.com/.well-known/bugmole-verification.txt",
|
|
254
|
+
content: added.json.domain.txtRecord.value,
|
|
255
|
+
});
|
|
256
|
+
files.set("shop.example.com/.well-known/bugmole-verification.txt", `${added.json.domain.file.content}\n`);
|
|
257
|
+
const verified = await call(env, "POST", `/api/workspaces/${workspaceId}/domains/${added.json.domain.id}/verify`, { as: owner });
|
|
258
|
+
assert.equal(verified.status, 200);
|
|
259
|
+
assert.equal(verified.json.method, "file");
|
|
260
|
+
assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/domains`, { as: owner })).json.domains[0].verifiedBy, "file");
|
|
261
|
+
|
|
262
|
+
const allowed = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
263
|
+
as: owner,
|
|
264
|
+
body: { planId: "checkout", idempotencyKey: "f1", executionMode: "cloud", environmentId: "prod" },
|
|
265
|
+
});
|
|
266
|
+
assert.equal(allowed.status, 201, JSON.stringify(allowed.json));
|
|
267
|
+
const deeper = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
268
|
+
as: owner,
|
|
269
|
+
body: { planId: "checkout", idempotencyKey: "f2", executionMode: "cloud", environmentId: "admin" },
|
|
270
|
+
});
|
|
271
|
+
assert.equal(deeper.status, 403, "a file on one host says nothing about the hosts under it");
|
|
272
|
+
assert.equal(deeper.json.code, "domain_not_verified");
|
|
273
|
+
});
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// Workspace domain verification: prove control of a domain by DNS TXT record
|
|
2
|
+
// (covers subdomains) or by publishing a file on the site (that host only)
|
|
2
3
|
// before Bugmole Cloud will explore or run against a host on it. See
|
|
3
4
|
// ../domains.ts for why, and the normalization/matching/DNS-lookup helpers.
|
|
4
5
|
import {
|
|
5
6
|
domainProvesToken,
|
|
6
7
|
dohTxtLookupWith,
|
|
8
|
+
DOMAIN_VERIFY_FILE,
|
|
7
9
|
DOMAIN_VERIFY_PREFIX,
|
|
10
|
+
fileProvesToken,
|
|
8
11
|
hostMatchesVerifiedDomain,
|
|
9
12
|
needsDomainVerification,
|
|
10
13
|
newVerificationToken,
|
|
11
14
|
normalizeDomain,
|
|
12
15
|
targetHost,
|
|
13
16
|
type TxtLookup,
|
|
17
|
+
type VerificationMethod,
|
|
14
18
|
} from "../domains.js";
|
|
15
19
|
import type { AuthedRouteContext } from "../routes/index.js";
|
|
16
20
|
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
@@ -20,27 +24,37 @@ import { actorOf, recordAudit } from "../org/audit.js";
|
|
|
20
24
|
|
|
21
25
|
const MAX_DOMAINS_PER_WORKSPACE = 25;
|
|
22
26
|
|
|
27
|
+
function fetchFor(env: Env): typeof fetch {
|
|
28
|
+
return (env as Env & { DOMAIN_VERIFY_FETCH?: typeof fetch }).DOMAIN_VERIFY_FETCH ?? fetch;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
function lookupFor(env: Env): TxtLookup {
|
|
24
|
-
return dohTxtLookupWith((env
|
|
32
|
+
return dohTxtLookupWith(fetchFor(env));
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
type DomainRow = {
|
|
28
36
|
id: string;
|
|
29
37
|
domain: string;
|
|
30
38
|
status: "pending" | "verified";
|
|
39
|
+
verifiedBy?: VerificationMethod | null;
|
|
31
40
|
createdAt: string;
|
|
32
41
|
verifiedAt: string | null;
|
|
33
42
|
lastCheckError: string | null;
|
|
34
43
|
};
|
|
35
44
|
|
|
36
45
|
function withRecord(row: DomainRow, token: string) {
|
|
37
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
...row,
|
|
48
|
+
txtRecord: { name: `${DOMAIN_VERIFY_PREFIX}.${row.domain}`, value: token },
|
|
49
|
+
// For a site whose DNS the person can't change, such as one subdomain.
|
|
50
|
+
file: { url: `https://${row.domain}${DOMAIN_VERIFY_FILE}`, content: token },
|
|
51
|
+
};
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
async function listDomains({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
41
55
|
if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
42
56
|
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
|
|
57
|
+
SELECT id, domain, verification_token AS verificationToken, status, verified_by AS verifiedBy, created_at AS createdAt, verified_at AS verifiedAt, last_check_error AS lastCheckError
|
|
44
58
|
FROM workspace_domains WHERE workspace_id = ? ORDER BY created_at ASC
|
|
45
59
|
`).bind(workspaceId).all<DomainRow & { verificationToken: string }>();
|
|
46
60
|
return response({
|
|
@@ -62,7 +76,7 @@ async function addDomain({ env, principal, request }: AuthedRouteContext, worksp
|
|
|
62
76
|
}
|
|
63
77
|
const count = await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_domains WHERE workspace_id = ?").bind(workspaceId).first<{ count: number }>();
|
|
64
78
|
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 = ?")
|
|
79
|
+
const existing = await env.DB.prepare("SELECT id, verification_token AS verificationToken, status, verified_by AS verifiedBy, created_at AS createdAt, verified_at AS verifiedAt, last_check_error AS lastCheckError FROM workspace_domains WHERE workspace_id = ? AND domain = ?")
|
|
66
80
|
.bind(workspaceId, domain).first<DomainRow & { verificationToken: string }>();
|
|
67
81
|
if (existing) return response({ domain: withRecord({ ...existing, domain }, existing.verificationToken) }, 200);
|
|
68
82
|
const id = newId("dom");
|
|
@@ -83,16 +97,19 @@ async function verifyDomain({ env, principal, request }: AuthedRouteContext, wor
|
|
|
83
97
|
.bind(id, workspaceId).first<{ domain: string; verificationToken: string; status: string }>();
|
|
84
98
|
if (!row) return response({ error: "Not found" }, 404);
|
|
85
99
|
if (row.status === "verified") return response({ verified: true });
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
// Either proof will do; DNS first, since it covers more.
|
|
101
|
+
const method: VerificationMethod | null = await domainProvesToken(row.domain, row.verificationToken, lookupFor(env))
|
|
102
|
+
? "dns"
|
|
103
|
+
: await fileProvesToken(row.domain, row.verificationToken, fetchFor(env)) ? "file" : null;
|
|
104
|
+
if (!method) {
|
|
105
|
+
const message = `We couldn't find the TXT record or the file at https://${row.domain}${DOMAIN_VERIFY_FILE} yet. DNS changes can take a while to propagate — try again shortly.`;
|
|
89
106
|
await env.DB.prepare("UPDATE workspace_domains SET last_check_error = ? WHERE id = ?").bind(message, id).run();
|
|
90
|
-
return response({ error: "
|
|
107
|
+
return response({ error: "verification_failed", message }, 400);
|
|
91
108
|
}
|
|
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 });
|
|
109
|
+
await env.DB.prepare("UPDATE workspace_domains SET status = 'verified', verified_by = ?, verified_at = ?, last_check_error = NULL WHERE id = ?")
|
|
110
|
+
.bind(method, new Date().toISOString(), id).run();
|
|
111
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "domain.verified", targetType: "domain", targetId: id, data: { domain: row.domain, method } }, request);
|
|
112
|
+
return response({ verified: true, method });
|
|
96
113
|
}
|
|
97
114
|
|
|
98
115
|
async function removeDomain({ env, principal, request }: AuthedRouteContext, workspaceId: string, id: string): Promise<Response> {
|
|
@@ -115,9 +132,9 @@ async function removeDomain({ env, principal, request }: AuthedRouteContext, wor
|
|
|
115
132
|
export async function requireVerifiedDomainForCloud(env: Env, workspaceId: string, url: string): Promise<Response | null> {
|
|
116
133
|
const host = targetHost(url);
|
|
117
134
|
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;
|
|
135
|
+
const rows = await env.DB.prepare("SELECT domain, verified_by AS verifiedBy FROM workspace_domains WHERE workspace_id = ? AND status = 'verified'")
|
|
136
|
+
.bind(workspaceId).all<{ domain: string; verifiedBy: VerificationMethod | null }>();
|
|
137
|
+
if (rows.results.some((row) => hostMatchesVerifiedDomain(host, row.domain, row.verifiedBy ?? "dns"))) return null;
|
|
121
138
|
return response({
|
|
122
139
|
error: `Verify that this workspace owns ${host} before running it on Bugmole Cloud.`,
|
|
123
140
|
code: "domain_not_verified",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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.
|
|
2
|
+
// record or by publishing a file on the site, before Bugmole Cloud will explore or run against a host on it.
|
|
3
3
|
// Without this, a workspace's cloud minutes could point Bugmole's own IPs
|
|
4
4
|
// at any third-party site — using Bugmole as an anonymous crawler against a
|
|
5
5
|
// domain nobody in the workspace actually owns. Self-hosted runs and
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
import { randomToken } from "./core.js";
|
|
9
9
|
|
|
10
10
|
export const DOMAIN_VERIFY_PREFIX = "_bugmole-verify";
|
|
11
|
+
/** Where the file proof lives. Under /.well-known/ so an ordinary upload folder can't satisfy it. */
|
|
12
|
+
export const DOMAIN_VERIFY_FILE = "/.well-known/bugmole-verification.txt";
|
|
13
|
+
|
|
14
|
+
export type VerificationMethod = "dns" | "file";
|
|
11
15
|
|
|
12
16
|
export function newVerificationToken(): string {
|
|
13
17
|
return `bugmole-domain-verify=${randomToken(20)}`;
|
|
@@ -61,9 +65,15 @@ export function needsDomainVerification(host: string): boolean {
|
|
|
61
65
|
return !isPrivateHost(host);
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
/**
|
|
69
|
+
* DNS proves the whole zone, so it covers subdomains. A published file only
|
|
70
|
+
* proves whoever runs that one host — on shared hosting, the next subdomain
|
|
71
|
+
* over may belong to someone else — so it covers exactly that host.
|
|
72
|
+
*/
|
|
73
|
+
export function hostMatchesVerifiedDomain(host: string, domain: string, method: VerificationMethod = "dns"): boolean {
|
|
65
74
|
const h = host.toLowerCase();
|
|
66
75
|
const d = domain.toLowerCase();
|
|
76
|
+
if (method === "file") return h === d;
|
|
67
77
|
return h === d || h.endsWith(`.${d}`);
|
|
68
78
|
}
|
|
69
79
|
|
|
@@ -86,3 +96,31 @@ export async function domainProvesToken(domain: string, token: string, lookup: T
|
|
|
86
96
|
const records = await lookup(`${DOMAIN_VERIFY_PREFIX}.${domain}`).catch(() => [] as string[]);
|
|
87
97
|
return records.includes(token);
|
|
88
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The file proof: https://<domain>/.well-known/bugmole-verification.txt
|
|
102
|
+
* answers 200 itself (no redirect, which could hand the proof to another
|
|
103
|
+
* host) with the token on a line of its own. Bounded in time and size.
|
|
104
|
+
*/
|
|
105
|
+
export async function fileProvesToken(domain: string, token: string, doFetch: typeof fetch = fetch): Promise<boolean> {
|
|
106
|
+
try {
|
|
107
|
+
const res = await doFetch(`https://${domain}${DOMAIN_VERIFY_FILE}`, {
|
|
108
|
+
redirect: "manual",
|
|
109
|
+
headers: { accept: "text/plain", "user-agent": "Bugmole domain verification" },
|
|
110
|
+
signal: AbortSignal.timeout(10_000),
|
|
111
|
+
});
|
|
112
|
+
if (res.status !== 200 || !res.body) return false;
|
|
113
|
+
const reader = res.body.getReader();
|
|
114
|
+
const decoder = new TextDecoder();
|
|
115
|
+
let text = "";
|
|
116
|
+
while (text.length < 4096) {
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done) break;
|
|
119
|
+
text += decoder.decode(value, { stream: true });
|
|
120
|
+
}
|
|
121
|
+
await reader.cancel().catch(() => undefined);
|
|
122
|
+
return text.slice(0, 4096).split(/\r?\n/).some((line) => line.trim() === token);
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|