@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.
Files changed (173) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +7 -4
  3. package/scripts/bugmole-admin.mjs +194 -0
  4. package/scripts/bugmole-admin.test.mjs +62 -0
  5. package/scripts/bugmole.test.ts +94 -2
  6. package/scripts/bugmole.ts +290 -43
  7. package/scripts/ensure-playwright.cjs +87 -0
  8. package/scripts/ensure-playwright.test.ts +36 -0
  9. package/scripts/sync-byok.d.mts +2 -0
  10. package/scripts/sync-byok.mjs +18 -0
  11. package/scripts/sync-launcher-version.cjs +24 -0
  12. package/scripts/sync-launcher-version.test.ts +28 -0
  13. package/scripts/sync-plan-catalog.d.mts +3 -0
  14. package/scripts/sync-plan-catalog.mjs +16 -6
  15. package/spec/domain_rules.yaml +139 -0
  16. package/spec/roles.yaml +20 -0
  17. package/spec/test-case-results.schema.json +20 -4
  18. package/spec/test-cases.schema.json +131 -16
  19. package/src/billing/plan-catalog.test.ts +82 -2
  20. package/src/billing/plan-catalog.ts +212 -9
  21. package/src/integrations/jira.ts +264 -0
  22. package/src/mcp/roles-and-review.test.ts +111 -0
  23. package/src/mcp/server.ts +358 -22
  24. package/src/mcp/write-test-cases.test.ts +68 -0
  25. package/src/registry/control-plane-client.ts +121 -6
  26. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  27. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  28. package/src/registry/migrations/0036_task_approval.sql +11 -0
  29. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  30. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  31. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  32. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  33. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  34. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  35. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  36. package/src/registry/migrations/0044_project_identity.sql +31 -0
  37. package/src/registry/project-identity.test.ts +99 -0
  38. package/src/registry/project-identity.ts +206 -0
  39. package/src/registry/roles.test.ts +66 -0
  40. package/src/registry/roles.ts +201 -0
  41. package/src/registry/task-scheduling.test.ts +94 -0
  42. package/src/registry/task-scheduling.ts +199 -2
  43. package/src/registry/test-case-revisions.test.ts +57 -0
  44. package/src/registry/test-case-revisions.ts +132 -0
  45. package/src/registry-worker/ai/routes.ts +3 -3
  46. package/src/registry-worker/artifacts.ts +23 -4
  47. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  48. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  49. package/src/registry-worker/billing/enforcement.ts +67 -12
  50. package/src/registry-worker/billing/paypal/api.ts +14 -0
  51. package/src/registry-worker/billing/paypal/client.ts +9 -0
  52. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  53. package/src/registry-worker/billing/paypal.test.ts +108 -1
  54. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  55. package/src/registry-worker/billing/provider.ts +8 -0
  56. package/src/registry-worker/billing/routes.ts +2 -1
  57. package/src/registry-worker/billing/subscriptions.ts +114 -4
  58. package/src/registry-worker/core.ts +24 -0
  59. package/src/registry-worker/devices/policy.ts +4 -5
  60. package/src/registry-worker/devices/routes.ts +8 -8
  61. package/src/registry-worker/domains/domains.test.ts +64 -1
  62. package/src/registry-worker/domains/routes.ts +32 -15
  63. package/src/registry-worker/domains.ts +40 -2
  64. package/src/registry-worker/feature-access.ts +165 -0
  65. package/src/registry-worker/feature-flags/admin.ts +300 -0
  66. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  67. package/src/registry-worker/feature-flags/routes.ts +102 -0
  68. package/src/registry-worker/features.ts +11 -0
  69. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  70. package/src/registry-worker/feedback/routes.ts +2 -2
  71. package/src/registry-worker/feedback/website.ts +77 -0
  72. package/src/registry-worker/flags.ts +80 -18
  73. package/src/registry-worker/github/checks.ts +4 -4
  74. package/src/registry-worker/hooks.ts +9 -0
  75. package/src/registry-worker/identity/oidc.test.ts +103 -0
  76. package/src/registry-worker/identity/oidc.ts +176 -0
  77. package/src/registry-worker/index.ts +494 -173
  78. package/src/registry-worker/jira/connection.ts +111 -0
  79. package/src/registry-worker/jira/jira.test.ts +404 -0
  80. package/src/registry-worker/jira/routes.ts +432 -0
  81. package/src/registry-worker/jira/workflow.ts +577 -0
  82. package/src/registry-worker/jobs/retention.ts +21 -5
  83. package/src/registry-worker/mcp/tools.ts +2 -1
  84. package/src/registry-worker/notifications/alerts.ts +4 -4
  85. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  86. package/src/registry-worker/notifications/routes.ts +9 -10
  87. package/src/registry-worker/notifications/teams.ts +6 -2
  88. package/src/registry-worker/org/routes.test.ts +24 -1
  89. package/src/registry-worker/org/routes.ts +9 -8
  90. package/src/registry-worker/projects/identity.ts +194 -0
  91. package/src/registry-worker/projects/inactivity.ts +162 -0
  92. package/src/registry-worker/projects/projects.test.ts +283 -0
  93. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  94. package/src/registry-worker/proposals/routes.ts +183 -0
  95. package/src/registry-worker/roles/roles.test.ts +84 -0
  96. package/src/registry-worker/roles/routes.ts +141 -0
  97. package/src/registry-worker/runner/dispatch.ts +1 -0
  98. package/src/registry-worker/runner/routes.ts +22 -5
  99. package/src/registry-worker/runner/runner.test.ts +59 -0
  100. package/src/registry-worker/runner/tokens.ts +12 -1
  101. package/src/registry-worker/secrets/crypto.ts +135 -0
  102. package/src/registry-worker/secrets/routes.ts +296 -0
  103. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  104. package/src/registry-worker/signup/attribution.test.ts +147 -0
  105. package/src/registry-worker/signup/attribution.ts +194 -0
  106. package/src/registry-worker/signup/policy.ts +2 -2
  107. package/src/registry-worker/signup/routes.ts +12 -3
  108. package/src/registry-worker/sso/membership.ts +4 -2
  109. package/src/registry-worker/sso/routes.ts +24 -8
  110. package/src/registry-worker/sso/sso.test.ts +3 -2
  111. package/src/registry-worker/task-approval.test.ts +67 -0
  112. package/src/registry-worker/task-resume.test.ts +196 -0
  113. package/src/registry-worker/task-review.test.ts +121 -0
  114. package/src/runtime/ai-exploration.test.ts +34 -1
  115. package/src/runtime/ai-exploration.ts +97 -3
  116. package/src/runtime/appium-driver.ts +7 -1
  117. package/src/runtime/apply-proposals.test.ts +74 -0
  118. package/src/runtime/apply-proposals.ts +41 -0
  119. package/src/runtime/cloud-secrets.test.ts +201 -0
  120. package/src/runtime/cloud-secrets.ts +210 -0
  121. package/src/runtime/config-validate.ts +12 -9
  122. package/src/runtime/cursor-driver-run.ts +14 -3
  123. package/src/runtime/discovery-task.test.ts +26 -1
  124. package/src/runtime/discovery-task.ts +87 -7
  125. package/src/runtime/executor.ts +18 -10
  126. package/src/runtime/explorer.test.ts +32 -0
  127. package/src/runtime/explorer.ts +48 -0
  128. package/src/runtime/flow-language.ts +43 -8
  129. package/src/runtime/init-wizard.ts +112 -6
  130. package/src/runtime/journey-editor.ts +78 -2
  131. package/src/runtime/journey-graph.test.ts +60 -0
  132. package/src/runtime/local-registry-stub.test.ts +64 -0
  133. package/src/runtime/local-registry-stub.ts +255 -7
  134. package/src/runtime/local-vault.ts +65 -0
  135. package/src/runtime/pipeline.test.ts +23 -0
  136. package/src/runtime/pipeline.ts +61 -20
  137. package/src/runtime/planner.test.ts +24 -1
  138. package/src/runtime/planner.ts +88 -23
  139. package/src/runtime/playwright-driver.test.ts +164 -0
  140. package/src/runtime/playwright-driver.ts +286 -15
  141. package/src/runtime/project-identity.test.ts +161 -0
  142. package/src/runtime/project-identity.ts +232 -0
  143. package/src/runtime/project-roles.test.ts +107 -0
  144. package/src/runtime/project-roles.ts +158 -0
  145. package/src/runtime/propose-cli.ts +66 -0
  146. package/src/runtime/record-run-verdicts.test.ts +58 -0
  147. package/src/runtime/record-run-verdicts.ts +38 -4
  148. package/src/runtime/reporter.ts +1 -1
  149. package/src/runtime/reset.ts +2 -0
  150. package/src/runtime/roles-cli.test.ts +47 -0
  151. package/src/runtime/roles-cli.ts +60 -0
  152. package/src/runtime/run-job.test.ts +21 -0
  153. package/src/runtime/run-job.ts +8 -0
  154. package/src/runtime/run-once.ts +68 -5
  155. package/src/runtime/scenario-matrix.test.ts +41 -0
  156. package/src/runtime/scenario-matrix.ts +98 -0
  157. package/src/runtime/secret-driver.ts +77 -0
  158. package/src/runtime/secret-redaction.ts +69 -0
  159. package/src/runtime/secret-sources.test.ts +442 -0
  160. package/src/runtime/secret-sources.ts +176 -0
  161. package/src/runtime/secrets-cli.ts +146 -0
  162. package/src/runtime/serve-worker.ts +64 -6
  163. package/src/runtime/site-discovery.test.ts +181 -4
  164. package/src/runtime/site-discovery.ts +248 -29
  165. package/src/runtime/vault-federation.test.ts +37 -0
  166. package/src/runtime/vault-federation.ts +128 -0
  167. package/src/runtime/web-suite.test.ts +65 -0
  168. package/src/runtime/web-suite.ts +8 -2
  169. package/src/storage/create-object-store.ts +5 -1
  170. package/src/storage/object-store.ts +12 -1
  171. package/src/storage/test-case-results.test.ts +64 -0
  172. package/src/storage/test-case-results.ts +49 -0
  173. 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
- /** Locks the newest projects beyond the plan's limit and unlocks what fits again. */
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 { flagOn } from "../flags.js";
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
- if (!(await flagOn(env, workspaceId, "device_clouds"))) return refused("Device clouds aren't available for this workspace yet.", "device_clouds_unavailable");
46
- const { features } = await getEntitlements(env, workspaceId);
47
- if (!features.deviceClouds) return refused("Real devices and device clouds are included in Business.", "plan_feature", 402);
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 { getEntitlements } from "../billing/entitlements.js";
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 { features } = await getEntitlements(env, workspaceId);
41
- if (!features.deviceClouds) return response({ error: "Device clouds are included in Business.", code: "plan_feature", upgradeUrl: "/billing" }, 402);
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 { features } = await getEntitlements(env, workspaceId);
125
+ const available = await featureEnabled(env, workspaceId, "device_clouds");
125
126
  return response({
126
- available: features.deviceClouds,
127
+ available,
127
128
  canManage: await memberOfWorkspace(env, principal, workspaceId, "workspace.manage"),
128
- bugmole: { available: features.deviceClouds && Boolean(bugmoleDeviceCloud(env)) },
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
- const { features } = await getEntitlements(env, workspaceId);
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, "dns_verification_failed");
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 as Env & { DOMAIN_VERIFY_FETCH?: typeof fetch }).DOMAIN_VERIFY_FETCH ?? fetch);
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 { ...row, txtRecord: { name: `${DOMAIN_VERIFY_PREFIX}.${row.domain}`, value: token } };
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
- 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.";
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: "dns_verification_failed", message }, 400);
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
- export function hostMatchesVerifiedDomain(host: string, domain: string): boolean {
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
+ }