@bugmole/cli 0.5.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 (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. 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,6 +14,23 @@ 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;
19
36
  /** Scents (bugmole-scents): checks that a typed referral code belongs to an affiliate. */
@@ -24,6 +41,11 @@ export interface Env extends EmailEnv {
24
41
  CLOUD_RUN_MAX_MINUTES?: string;
25
42
  /** This registry's public origin, handed to runners (e.g. https://api.bugmole.com). */
26
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;
27
49
  /** Rollout flags; see flags.ts. */
28
50
  [key: `FEATURE_${string}`]: string | undefined;
29
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);
@@ -0,0 +1,165 @@
1
+ // The one answer to "may this workspace use feature X?". Every layer is
2
+ // evaluated in a fixed order, and the first one that decides wins:
3
+ //
4
+ // 1. Global kill switch FEATURE_<NAME>=off (or shadow) → off, "flag_off"
5
+ // 2. Beta rollout FEATURE_<NAME>=beta needs a workspace_flags opt-in,
6
+ // otherwise off, "beta_not_enabled"
7
+ // 3. Workspace override an unexpired workspace_feature_overrides row forces
8
+ // it on or off (trials, comps, incidents), "workspace_override"
9
+ // 4. Plan the plan's catalog features, "plan"; changed by the
10
+ // contract overrides on workspace_plans, "enterprise_override"
11
+ //
12
+ // Features with no plan gate are on for every plan once the rollout allows
13
+ // them (reason "plan"). See docs/feature-flags.md.
14
+ import { LIMIT_KEYS, PLANS, PLAN_IDS, entitlementsFor, type LimitKey, type PlanId } from "../billing/plan-catalog.js";
15
+ import type { Env } from "./core.js";
16
+ import { FEATURES, FEATURE_DEFS, flagMode, type Feature, type FlagMode } from "./flags.js";
17
+ import { freePlan, getWorkspacePlan, type WorkspacePlan } from "./billing/entitlements.js";
18
+
19
+ export type FeatureReason = "plan" | "flag_off" | "beta_not_enabled" | "workspace_override" | "enterprise_override";
20
+
21
+ export type FeatureState = {
22
+ feature: Feature;
23
+ enabled: boolean;
24
+ reason: FeatureReason;
25
+ /** The cheapest plan that includes it, for plan-gated features (null when no self-serve plan does). */
26
+ requiredPlan?: PlanId;
27
+ /** The global rollout mode it was evaluated under. */
28
+ mode: FlagMode;
29
+ /** When a workspace override decided, when it lapses (null: until cleared). */
30
+ overrideExpiresAt?: string | null;
31
+ };
32
+
33
+ export type FeatureOverride = { feature: Feature; enabled: boolean; expiresAt: string | null; reason: string | null; setBy: string; updatedAt: string };
34
+
35
+ export type FeatureInputs = {
36
+ env: Omit<Env, "DB"> | Record<string, unknown>;
37
+ plan: WorkspacePlan;
38
+ /** Features this workspace opted into while they are in beta. */
39
+ betaOptIns: ReadonlySet<Feature>;
40
+ overrides: readonly FeatureOverride[];
41
+ now?: Date;
42
+ };
43
+
44
+ /** The cheapest plan whose catalog features include this one. */
45
+ export function requiredPlanFor(feature: Feature): PlanId | undefined {
46
+ const key = FEATURE_DEFS[feature].planFeature;
47
+ if (!key) return undefined;
48
+ return PLAN_IDS.find((plan) => PLANS[plan].features[key]);
49
+ }
50
+
51
+ export function overrideActive(override: Pick<FeatureOverride, "expiresAt">, now = new Date()): boolean {
52
+ return !override.expiresAt || Date.parse(override.expiresAt) > now.getTime();
53
+ }
54
+
55
+ /** Pure evaluation of every feature; the async loaders below feed it. */
56
+ export function evaluateFeatures(inputs: FeatureInputs): Record<Feature, FeatureState> {
57
+ const now = inputs.now ?? new Date();
58
+ const entitlements = entitlementsFor(inputs.plan.plan, inputs.plan.overrides);
59
+ const contract = inputs.plan.overrides.features ?? {};
60
+ const result = {} as Record<Feature, FeatureState>;
61
+ for (const feature of FEATURES) {
62
+ const def = FEATURE_DEFS[feature];
63
+ const mode = flagMode(inputs.env as Parameters<typeof flagMode>[0], feature);
64
+ const requiredPlan = requiredPlanFor(feature);
65
+ const base = { feature, mode, ...(def.planFeature ? { requiredPlan } : {}) } as Pick<FeatureState, "feature" | "mode" | "requiredPlan">;
66
+ if (mode === "off" || mode === "shadow") {
67
+ result[feature] = { ...base, enabled: false, reason: "flag_off" };
68
+ continue;
69
+ }
70
+ if (mode === "beta" && !inputs.betaOptIns.has(feature)) {
71
+ result[feature] = { ...base, enabled: false, reason: "beta_not_enabled" };
72
+ continue;
73
+ }
74
+ const override = inputs.overrides.find((row) => row.feature === feature && overrideActive(row, now));
75
+ if (override) {
76
+ result[feature] = { ...base, enabled: override.enabled, reason: "workspace_override", overrideExpiresAt: override.expiresAt };
77
+ continue;
78
+ }
79
+ if (!def.planFeature) {
80
+ result[feature] = { ...base, enabled: true, reason: "plan" };
81
+ continue;
82
+ }
83
+ const contractSet = contract[def.planFeature] !== undefined;
84
+ result[feature] = { ...base, enabled: entitlements.features[def.planFeature], reason: contractSet ? "enterprise_override" : "plan" };
85
+ }
86
+ return result;
87
+ }
88
+
89
+ async function betaOptIns(env: Env, workspaceId: string): Promise<Set<Feature>> {
90
+ const rows = await env.DB.prepare("SELECT flag FROM workspace_flags WHERE workspace_id = ? AND enabled = 1")
91
+ .bind(workspaceId).all<{ flag: string }>();
92
+ return new Set(rows.results.map((row) => row.flag).filter((flag): flag is Feature => (FEATURES as readonly string[]).includes(flag)));
93
+ }
94
+
95
+ /** Override rows for a workspace, expired ones included. Tolerates a registry not yet migrated. */
96
+ export async function loadOverrides(env: Env, workspaceId: string): Promise<FeatureOverride[]> {
97
+ try {
98
+ const rows = await env.DB.prepare(`
99
+ SELECT feature, enabled, expires_at AS expiresAt, reason, set_by AS setBy, updated_at AS updatedAt
100
+ FROM workspace_feature_overrides WHERE workspace_id = ?
101
+ `).bind(workspaceId).all<{ feature: string; enabled: number; expiresAt: string | null; reason: string | null; setBy: string; updatedAt: string }>();
102
+ return rows.results
103
+ .filter((row) => (FEATURES as readonly string[]).includes(row.feature))
104
+ .map((row) => ({ ...row, feature: row.feature as Feature, enabled: row.enabled === 1 }));
105
+ } catch (error) {
106
+ console.error("[features] workspace_feature_overrides unavailable; is migration 0043 applied?", error);
107
+ return [];
108
+ }
109
+ }
110
+
111
+ export type EffectiveFeatures = {
112
+ plan: WorkspacePlan;
113
+ features: Record<Feature, FeatureState>;
114
+ };
115
+
116
+ /** Every feature for one workspace (three small reads). A null workspace is a Free workspace with no opt-ins. */
117
+ export async function effectiveFeatures(env: Env, workspaceId: string | null | undefined, now = new Date()): Promise<EffectiveFeatures> {
118
+ if (!workspaceId) {
119
+ const plan = freePlan("");
120
+ return { plan, features: evaluateFeatures({ env, plan, betaOptIns: new Set(), overrides: [], now }) };
121
+ }
122
+ const [plan, optIns, overrides] = await Promise.all([
123
+ getWorkspacePlan(env, workspaceId),
124
+ betaOptIns(env, workspaceId),
125
+ loadOverrides(env, workspaceId),
126
+ ]);
127
+ return { plan, features: evaluateFeatures({ env, plan, betaOptIns: optIns, overrides, now }) };
128
+ }
129
+
130
+ export async function featureState(env: Env, workspaceId: string | null | undefined, feature: Feature): Promise<FeatureState> {
131
+ return (await effectiveFeatures(env, workspaceId)).features[feature];
132
+ }
133
+
134
+ export async function featureEnabled(env: Env, workspaceId: string | null | undefined, feature: Feature): Promise<boolean> {
135
+ return (await featureState(env, workspaceId, feature)).enabled;
136
+ }
137
+
138
+ /**
139
+ * Why a feature is off, in the terms gates answer with: "rollout" (not
140
+ * available to this workspace yet, or switched off for it) or "plan" (an
141
+ * upgrade would unlock it, so a 402). Null when it is on.
142
+ */
143
+ export function blockedBy(state: FeatureState): "rollout" | "plan" | null {
144
+ if (state.enabled) return null;
145
+ return state.reason === "plan" || state.reason === "enterprise_override" ? "plan" : "rollout";
146
+ }
147
+
148
+ export type { LimitKey };
149
+ /** A catalog limit (LIMIT_KEYS). Enforcement reads the same entitlementsFor() values. */
150
+ export type LimitState = { limit: number | null; unlimited: boolean; used?: number };
151
+
152
+ /** A workspace's limits: its plan's catalog limits with contract overrides applied. */
153
+ export function limitsFor(plan: WorkspacePlan): Record<LimitKey, LimitState> {
154
+ const entitlements = entitlementsFor(plan.plan, plan.overrides);
155
+ return Object.fromEntries(LIMIT_KEYS.map((key) => {
156
+ const limit = entitlements[key];
157
+ return [key, { limit, unlimited: limit === null }];
158
+ })) as Record<LimitKey, LimitState>;
159
+ }
160
+
161
+ /** Where a workspace's plan comes from, for ops and the dashboard. */
162
+ export function planSource(plan: WorkspacePlan): "default" | "subscription" | "contract" {
163
+ if (plan.provider) return "subscription";
164
+ return plan.plan === "free" ? "default" : "contract";
165
+ }