@bugmole/cli 0.4.0 → 0.6.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 +7 -4
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- 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/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +494 -173
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +87 -7
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -23,7 +23,7 @@ import { newId } from "../core.js";
|
|
|
23
23
|
import { emailSender, escapeHtml } from "../email/sender.js";
|
|
24
24
|
import { planChangedHooks } from "../hooks.js";
|
|
25
25
|
import { actorOf, recordAudit } from "../org/audit.js";
|
|
26
|
-
import { getWorkspacePlan, type WorkspacePlan } from "./entitlements.js";
|
|
26
|
+
import { getEntitlements, getWorkspacePlan, type WorkspacePlan } from "./entitlements.js";
|
|
27
27
|
import { periodAnchor, summarize, usagePeriod, usedInPeriod } from "./ledger.js";
|
|
28
28
|
import {
|
|
29
29
|
BillingUnavailableError,
|
|
@@ -156,7 +156,7 @@ async function sendBillingEmail(env: Env, workspaceId: string, subject: string,
|
|
|
156
156
|
const recipients = await billingRecipients(env, workspaceId);
|
|
157
157
|
if (row?.billing_email && !recipients.some((r) => r.email === row.billing_email)) recipients.push({ email: row.billing_email });
|
|
158
158
|
if (!recipients.length) return;
|
|
159
|
-
const link = `${(env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "")}/billing`;
|
|
159
|
+
const link = `${(env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "")}/billing?${new URLSearchParams({ workspace: workspaceId })}`;
|
|
160
160
|
try {
|
|
161
161
|
await emailSender(env).send({
|
|
162
162
|
to: recipients,
|
|
@@ -172,11 +172,14 @@ async function sendBillingEmail(env: Env, workspaceId: string, subject: string,
|
|
|
172
172
|
|
|
173
173
|
// --- Projects over the limit -----------------------------------------------------------------
|
|
174
174
|
|
|
175
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Locks the projects beyond the plan's limit and unlocks what fits again. The
|
|
177
|
+
* projects an owner chose to keep come first; otherwise the oldest stay active.
|
|
178
|
+
*/
|
|
176
179
|
export async function applyProjectLimit(env: Env, workspaceId: string, entitlements: Entitlements): Promise<{ locked: number }> {
|
|
177
180
|
const rows = await env.DB.prepare(`
|
|
178
181
|
SELECT id, locked_at AS lockedAt FROM projects
|
|
179
|
-
WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at ASC, rowid ASC
|
|
182
|
+
WHERE workspace_id = ? AND deleted_at IS NULL AND inactive_at IS NULL ORDER BY kept_at IS NULL, created_at ASC, rowid ASC
|
|
180
183
|
`).bind(workspaceId).all<{ id: string; lockedAt: string | null }>();
|
|
181
184
|
const limit = entitlements.projects ?? Number.POSITIVE_INFINITY;
|
|
182
185
|
const now = new Date().toISOString();
|
|
@@ -193,6 +196,47 @@ export async function applyProjectLimit(env: Env, workspaceId: string, entitleme
|
|
|
193
196
|
return { locked };
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
/**
|
|
200
|
+
* An owner picks which projects stay active within the plan's limit; the rest
|
|
201
|
+
* become read-only (nothing is deleted). The choice is remembered, so a later
|
|
202
|
+
* downgrade locks the others rather than the newest.
|
|
203
|
+
*/
|
|
204
|
+
export async function chooseActiveProjects(
|
|
205
|
+
env: Env,
|
|
206
|
+
principal: Principal,
|
|
207
|
+
workspaceId: string,
|
|
208
|
+
projectIds: string[],
|
|
209
|
+
request?: Request,
|
|
210
|
+
): Promise<{ active: string[]; locked: string[] }> {
|
|
211
|
+
const { projects: limit, plan } = await getEntitlements(env, workspaceId);
|
|
212
|
+
const rows = await env.DB.prepare("SELECT id FROM projects WHERE workspace_id = ? AND deleted_at IS NULL")
|
|
213
|
+
.bind(workspaceId).all<{ id: string }>();
|
|
214
|
+
const inWorkspace = new Set(rows.results.map((row) => row.id));
|
|
215
|
+
const chosen = [...new Set(projectIds)];
|
|
216
|
+
if (!chosen.length) throw new BillingError(422, "no_projects", "Choose at least one project to keep active.");
|
|
217
|
+
const foreign = chosen.find((id) => !inWorkspace.has(id));
|
|
218
|
+
if (foreign) throw new BillingError(422, "unknown_project", `Project ${foreign} isn't in this workspace.`);
|
|
219
|
+
if (limit !== null && chosen.length > limit) {
|
|
220
|
+
throw new BillingError(422, "over_limit", `Your plan includes ${limit} project${limit === 1 ? "" : "s"}; choose at most ${limit}.`);
|
|
221
|
+
}
|
|
222
|
+
const now = new Date().toISOString();
|
|
223
|
+
await env.DB.batch([
|
|
224
|
+
env.DB.prepare("UPDATE projects SET kept_at = NULL WHERE workspace_id = ?").bind(workspaceId),
|
|
225
|
+
...chosen.map((id) => env.DB.prepare("UPDATE projects SET kept_at = ? WHERE id = ? AND workspace_id = ?").bind(now, id, workspaceId)),
|
|
226
|
+
]);
|
|
227
|
+
await applyProjectLimit(env, workspaceId, entitlementsFor(plan.plan, plan.overrides));
|
|
228
|
+
const after = await env.DB.prepare("SELECT id, locked_at AS lockedAt FROM projects WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at ASC, rowid ASC")
|
|
229
|
+
.bind(workspaceId).all<{ id: string; lockedAt: string | null }>();
|
|
230
|
+
const result = {
|
|
231
|
+
active: after.results.filter((row) => !row.lockedAt).map((row) => row.id),
|
|
232
|
+
locked: after.results.filter((row) => row.lockedAt).map((row) => row.id),
|
|
233
|
+
};
|
|
234
|
+
await recordAudit(env, actorOf(principal), {
|
|
235
|
+
workspaceId, action: "billing.projects_chosen", targetType: "workspace", targetId: workspaceId, data: { kept: chosen, ...result },
|
|
236
|
+
}, request);
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
196
240
|
// --- Invoices --------------------------------------------------------------------------------
|
|
197
241
|
|
|
198
242
|
async function nextInvoiceNumber(env: Env, workspaceId: string, at: Date): Promise<string> {
|
|
@@ -513,6 +557,70 @@ export async function cancelAtPeriodEnd(env: Env, principal: Principal, workspac
|
|
|
513
557
|
return getWorkspacePlan(env, workspaceId);
|
|
514
558
|
}
|
|
515
559
|
|
|
560
|
+
export const RESUME_REASON = "Resumed on Bugmole";
|
|
561
|
+
|
|
562
|
+
export type ResumeResult =
|
|
563
|
+
| { status: "resumed"; plan: WorkspacePlan }
|
|
564
|
+
| { status: "checkout_required"; plan: PaidPlan; interval: BillingInterval; message: string };
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Undoes a pending cancellation (or a scheduled plan change) before the paid
|
|
568
|
+
* period ends, so the current plan keeps renewing.
|
|
569
|
+
*
|
|
570
|
+
* Cancelling stops renewal at PayPal right away (POST .../cancel), and PayPal
|
|
571
|
+
* can't restart a CANCELLED subscription: only a SUSPENDED one can be
|
|
572
|
+
* activated again. So when the subscription is still live (or only paused) it
|
|
573
|
+
* is kept; when PayPal already ended it, the answer is "checkout_required":
|
|
574
|
+
* the customer approves a new subscription for the same plan that starts when
|
|
575
|
+
* the paid period ends, so nothing is charged twice.
|
|
576
|
+
*/
|
|
577
|
+
export async function resumeSubscription(env: Env, principal: Principal, workspaceId: string, request?: Request, now = new Date()): Promise<ResumeResult> {
|
|
578
|
+
const row = await planRow(env, workspaceId);
|
|
579
|
+
if (!row || !isPaidPlan(row.plan) || row.status === "canceled") throw new BillingError(409, "not_subscribed", "This workspace isn't on a paid plan.");
|
|
580
|
+
if (!row.cancel_at_period_end) throw new BillingError(409, "not_cancelled", "This plan is already set to renew.");
|
|
581
|
+
if (row.current_period_end && Date.parse(row.current_period_end) <= now.getTime()) {
|
|
582
|
+
throw new BillingError(409, "period_ended", "The paid period has ended. Choose a plan to subscribe again.");
|
|
583
|
+
}
|
|
584
|
+
const interval: BillingInterval = row.billing_interval === "year" ? "year" : "month";
|
|
585
|
+
const provider = row.provider && row.provider_subscription_id ? billingProvider(row.provider) : null;
|
|
586
|
+
if (provider) {
|
|
587
|
+
let remote = await provider.getSubscription(env, row.provider_subscription_id!);
|
|
588
|
+
if (remote?.status === "past_due") {
|
|
589
|
+
await provider.reactivateSubscription(env, remote.id, RESUME_REASON);
|
|
590
|
+
remote = await provider.getSubscription(env, remote.id);
|
|
591
|
+
}
|
|
592
|
+
if (remote?.status !== "active") {
|
|
593
|
+
await recordAudit(env, actorOf(principal), {
|
|
594
|
+
workspaceId, action: "billing.resume_needs_checkout", targetType: "workspace", targetId: workspaceId,
|
|
595
|
+
data: { plan: row.plan, interval, providerStatus: remote?.status ?? null },
|
|
596
|
+
}, request);
|
|
597
|
+
return {
|
|
598
|
+
status: "checkout_required",
|
|
599
|
+
plan: row.plan,
|
|
600
|
+
interval,
|
|
601
|
+
message: "PayPal ended the old subscription when the plan was cancelled, and a cancelled PayPal subscription can't be restarted. "
|
|
602
|
+
+ `Approve a new ${PLANS[row.plan].name} subscription in PayPal; it starts when the current period ends, so nothing is charged twice.`,
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// The subscription is still live: stop ending it, and drop any scheduled change.
|
|
607
|
+
const scheduled = await env.DB.prepare("SELECT id, provider, provider_subscription_id AS sub FROM billing_checkouts WHERE workspace_id = ? AND status = 'scheduled'")
|
|
608
|
+
.bind(workspaceId).all<{ id: string; provider: string; sub: string | null }>();
|
|
609
|
+
for (const change of scheduled.results) {
|
|
610
|
+
if (change.sub) await billingProvider(change.provider).cancelSubscription(env, change.sub, RESUME_REASON).catch(() => undefined);
|
|
611
|
+
await env.DB.prepare("UPDATE billing_checkouts SET status = 'abandoned' WHERE id = ?").bind(change.id).run();
|
|
612
|
+
}
|
|
613
|
+
await env.DB.prepare("UPDATE workspace_plans SET cancel_at_period_end = 0, pending_plan = NULL, updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
|
|
614
|
+
.bind(workspaceId).run();
|
|
615
|
+
await recordAudit(env, actorOf(principal), {
|
|
616
|
+
workspaceId, action: "billing.resumed", targetType: "workspace", targetId: workspaceId, data: { plan: row.plan, interval },
|
|
617
|
+
}, request);
|
|
618
|
+
await sendBillingEmail(env, workspaceId, "Your Bugmole plan will renew", [
|
|
619
|
+
`Your ${PLANS[row.plan].name} plan renews on ${row.current_period_end?.slice(0, 10) ?? "the end of the period"} as usual.`,
|
|
620
|
+
], "billing-resumed");
|
|
621
|
+
return { status: "resumed", plan: await getWorkspacePlan(env, workspaceId) };
|
|
622
|
+
}
|
|
623
|
+
|
|
516
624
|
// --- Keeping in step with the provider -------------------------------------------------------
|
|
517
625
|
|
|
518
626
|
async function moveToFree(env: Env, workspaceId: string, reason: string): Promise<void> {
|
|
@@ -552,8 +660,10 @@ export async function syncWorkspaceBilling(env: Env, workspaceId: string, now =
|
|
|
552
660
|
if (remote.status === "past_due" && row.status === "active") {
|
|
553
661
|
await env.DB.prepare("UPDATE workspace_plans SET status = 'past_due', grace_until = ?, updated_at = CURRENT_TIMESTAMP WHERE workspace_id = ?")
|
|
554
662
|
.bind(new Date(now.getTime() + GRACE_DAYS * DAY).toISOString(), workspaceId).run();
|
|
663
|
+
const manage = provider.manageUrl(env);
|
|
555
664
|
await sendBillingEmail(env, workspaceId, "Your Bugmole payment didn't go through", [
|
|
556
665
|
`PayPal couldn't collect your renewal. Update your payment method in PayPal within ${GRACE_DAYS} days to keep using Bugmole Cloud.`,
|
|
666
|
+
...(manage ? [`Change the card or bank for this subscription under PayPal's automatic payments: ${manage}`] : []),
|
|
557
667
|
"Runs on your own machines and CI keep working either way.",
|
|
558
668
|
], "billing-payment-failed");
|
|
559
669
|
}
|
|
@@ -14,14 +14,38 @@ export interface Env extends EmailEnv {
|
|
|
14
14
|
DASHBOARD_URL?: string;
|
|
15
15
|
/** Signs the short-lived tokens hosted runners use; see runner/tokens.ts. */
|
|
16
16
|
RUNNER_TOKEN_SECRET?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Root key for project secrets (sign-in for cloud runs): 32 random bytes,
|
|
19
|
+
* base64. Without it the secrets routes answer 503. See secrets/crypto.ts.
|
|
20
|
+
*/
|
|
21
|
+
PROJECT_SECRETS_KEY?: string;
|
|
22
|
+
/** Version of PROJECT_SECRETS_KEY (default 1); raise it when rotating. */
|
|
23
|
+
PROJECT_SECRETS_KEY_VERSION?: string;
|
|
24
|
+
/** The key being rotated away from, kept until no value still uses it. */
|
|
25
|
+
PROJECT_SECRETS_KEY_PREVIOUS?: string;
|
|
26
|
+
PROJECT_SECRETS_KEY_PREVIOUS_VERSION?: string;
|
|
27
|
+
/**
|
|
28
|
+
* RSA private key (PKCS#8 PEM) that signs runners' identity tokens, which
|
|
29
|
+
* customers' clouds trust instead of a stored key. See identity/oidc.ts.
|
|
30
|
+
*/
|
|
31
|
+
OIDC_SIGNING_KEY?: string;
|
|
32
|
+
/** The issuer those tokens name (default https://api.bugmole.com). */
|
|
33
|
+
OIDC_ISSUER?: string;
|
|
17
34
|
/** Service binding to the valkyrie-runner Worker that starts cloud-run containers. */
|
|
18
35
|
RUNNER?: Fetcher;
|
|
36
|
+
/** Scents (bugmole-scents): checks that a typed referral code belongs to an affiliate. */
|
|
37
|
+
SCENTS?: Fetcher;
|
|
19
38
|
/** Cloud runs executing at once across every workspace (default 20). */
|
|
20
39
|
CLOUD_POOL_MAX?: string;
|
|
21
40
|
/** Longest a cloud run may take before it is stopped, in minutes (default 60). */
|
|
22
41
|
CLOUD_RUN_MAX_MINUTES?: string;
|
|
23
42
|
/** This registry's public origin, handed to runners (e.g. https://api.bugmole.com). */
|
|
24
43
|
REGISTRY_PUBLIC_URL?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Bearer token for the platform-operator routes (/api/platform/…, see
|
|
46
|
+
* feature-flags/admin.ts). A secret; those routes answer 503 without it.
|
|
47
|
+
*/
|
|
48
|
+
PLATFORM_ADMIN_TOKEN?: string;
|
|
25
49
|
/** Rollout flags; see flags.ts. */
|
|
26
50
|
[key: `FEATURE_${string}`]: string | undefined;
|
|
27
51
|
}
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
import type { CloudDevice } from "../../runtime/device-clouds/types.js";
|
|
3
3
|
import type { Env } from "../core.js";
|
|
4
4
|
import { response } from "../core.js";
|
|
5
|
-
import { getEntitlements } from "../billing/entitlements.js";
|
|
6
5
|
import { canSpend } from "../billing/ledger.js";
|
|
7
|
-
import {
|
|
6
|
+
import { blockedBy, featureState } from "../feature-access.js";
|
|
8
7
|
|
|
9
8
|
export type BugmoleDeviceEnv = {
|
|
10
9
|
BUGMOLE_DEVICE_FARM_ACCESS_KEY_ID?: string;
|
|
@@ -42,9 +41,9 @@ export async function deviceTargetsError(
|
|
|
42
41
|
executionMode: "self" | "cloud",
|
|
43
42
|
): Promise<Response | null> {
|
|
44
43
|
if (!workspaceId) return refused("This project isn't in a workspace", "no_workspace");
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (
|
|
44
|
+
const blocked = blockedBy(await featureState(env, workspaceId, "device_clouds"));
|
|
45
|
+
if (blocked === "rollout") return refused("Device clouds aren't available for this workspace yet.", "device_clouds_unavailable");
|
|
46
|
+
if (blocked === "plan") return refused("Real devices and device clouds are included in Business.", "plan_feature", 402);
|
|
48
47
|
for (const target of targets) {
|
|
49
48
|
if (target.provider === "bugmole") {
|
|
50
49
|
if (executionMode !== "cloud") return refused("Bugmole's own devices are only used by runs on Bugmole Cloud.", "bugmole_devices_need_cloud");
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "../../integrations/device-farm.js";
|
|
16
16
|
import type { Env, Principal } from "../core.js";
|
|
17
17
|
import { memberOfProject, memberOfWorkspace, newId, requestJson, response, sha256 } from "../core.js";
|
|
18
|
-
import {
|
|
18
|
+
import { blockedBy, featureEnabled, featureState } from "../feature-access.js";
|
|
19
19
|
import { canSpend, recordUsage } from "../billing/ledger.js";
|
|
20
20
|
import { registerJob } from "../jobs/index.js";
|
|
21
21
|
import { actorOf, auditProject, recordAudit } from "../org/audit.js";
|
|
@@ -37,8 +37,9 @@ function providerFetch(env: Env): Fetcher {
|
|
|
37
37
|
|
|
38
38
|
async function manageable(env: Env, principal: Principal, workspaceId: string): Promise<Response | null> {
|
|
39
39
|
if (!(await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"))) return response({ error: "Only owners and admins can manage device clouds" }, 403);
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
40
|
+
const blocked = blockedBy(await featureState(env, workspaceId, "device_clouds"));
|
|
41
|
+
if (blocked === "rollout") return response({ error: "Device clouds aren't available for this workspace yet.", code: "device_clouds_unavailable" }, 422);
|
|
42
|
+
if (blocked === "plan") return response({ error: "Device clouds are included in Business.", code: "plan_feature", upgradeUrl: "/billing" }, 402);
|
|
42
43
|
if (!canStoreSecrets(env)) return response({ error: "Device clouds can't be connected right now." }, 503);
|
|
43
44
|
return null;
|
|
44
45
|
}
|
|
@@ -121,11 +122,11 @@ async function listConnections({ env, principal }: AuthedRouteContext, workspace
|
|
|
121
122
|
SELECT id, provider, label, username, project_arn AS projectArn, created_at AS createdAt, last_verified_at AS lastVerifiedAt
|
|
122
123
|
FROM device_cloud_credentials WHERE workspace_id = ? AND revoked_at IS NULL ORDER BY created_at ASC
|
|
123
124
|
`).bind(workspaceId).all();
|
|
124
|
-
const
|
|
125
|
+
const available = await featureEnabled(env, workspaceId, "device_clouds");
|
|
125
126
|
return response({
|
|
126
|
-
available
|
|
127
|
+
available,
|
|
127
128
|
canManage: await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"),
|
|
128
|
-
bugmole: { available:
|
|
129
|
+
bugmole: { available: available && Boolean(bugmoleDeviceCloud(env)) },
|
|
129
130
|
connections: rows.results,
|
|
130
131
|
});
|
|
131
132
|
}
|
|
@@ -175,8 +176,7 @@ async function devicesFor({ env, principal }: AuthedRouteContext, workspaceId: s
|
|
|
175
176
|
try {
|
|
176
177
|
if (id === "bugmole") {
|
|
177
178
|
const own = bugmoleDeviceCloud(env);
|
|
178
|
-
|
|
179
|
-
if (!own || !features.deviceClouds) return response({ error: "Bugmole's device cloud isn't available for this workspace" }, 402);
|
|
179
|
+
if (!own || !(await featureEnabled(env, workspaceId, "device_clouds"))) return response({ error: "Bugmole's device cloud isn't available for this workspace" }, 402);
|
|
180
180
|
return response({ devices: unique(await catalog(env, { provider: "aws_device_farm", username: own.credentials.accessKeyId, secret: own.credentials.secretAccessKey, projectArn: own.projectArn })) });
|
|
181
181
|
}
|
|
182
182
|
const credential = await credentialFor(env, workspaceId, id);
|
|
@@ -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
|
+
}
|