@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
@@ -0,0 +1,102 @@
1
+ // GET /api/workspaces/:id/features — what the dashboard may show a member:
2
+ // every feature, whether it is on for this workspace, why, and the plan that
3
+ // would unlock it. Read-only; changes go through the platform routes in admin.ts.
4
+ import { PLANS } from "../../billing/plan-catalog.js";
5
+ import { memberOfWorkspace, response } from "../core.js";
6
+ import type { Env } from "../core.js";
7
+ import { entitlementMode } from "../billing/enforcement.js";
8
+ import { usageSummary } from "../billing/ledger.js";
9
+ import {
10
+ type EffectiveFeatures,
11
+ type FeatureState,
12
+ type LimitKey,
13
+ type LimitState,
14
+ effectiveFeatures,
15
+ limitsFor,
16
+ planSource,
17
+ } from "../feature-access.js";
18
+ import { FEATURES, FEATURE_DEFS, type Feature } from "../flags.js";
19
+ import { type AuthedRouteContext, matchPath, registerRoutes } from "../routes/index.js";
20
+
21
+ export type FeatureView = {
22
+ feature: Feature;
23
+ label: string;
24
+ enabled: boolean;
25
+ reason: FeatureState["reason"];
26
+ /** Cheapest plan that includes it; null when it isn't plan-gated. */
27
+ requiredPlan: string | null;
28
+ /** The plan-catalog key (as in GET /usage limits.features), when plan-gated. */
29
+ planFeature: string | null;
30
+ /** Set when a workspace override decided and it lapses. */
31
+ expiresAt: string | null;
32
+ };
33
+
34
+ export function featureView(state: FeatureState): FeatureView {
35
+ const def = FEATURE_DEFS[state.feature];
36
+ return {
37
+ feature: state.feature,
38
+ label: def.label,
39
+ enabled: state.enabled,
40
+ reason: state.reason,
41
+ requiredPlan: state.requiredPlan ?? null,
42
+ planFeature: def.planFeature ?? null,
43
+ expiresAt: state.reason === "workspace_override" ? state.overrideExpiresAt ?? null : null,
44
+ };
45
+ }
46
+
47
+ export type LimitUsage = Partial<Record<LimitKey, number>>;
48
+
49
+ export function featuresBody(workspaceId: string, evaluated: EffectiveFeatures, used: LimitUsage = {}, limitsEnforced: "enforce" | "shadow" | "off" = "enforce") {
50
+ const features = Object.fromEntries(FEATURES.map((feature) => [feature, featureView(evaluated.features[feature])])) as Record<Feature, FeatureView>;
51
+ const planFeatures = Object.fromEntries(
52
+ Object.values(features).filter((view) => view.planFeature).map((view) => [view.planFeature, view]),
53
+ ) as Record<string, FeatureView>;
54
+ return {
55
+ workspaceId,
56
+ plan: { id: evaluated.plan.plan, name: PLANS[evaluated.plan.plan].name, status: evaluated.plan.status },
57
+ source: planSource(evaluated.plan),
58
+ features,
59
+ planFeatures,
60
+ limits: Object.fromEntries(Object.entries(limitsFor(evaluated.plan)).map(([key, state]) => {
61
+ const usedValue = used[key as LimitKey];
62
+ return [key, usedValue === undefined ? state : { ...state, used: usedValue }];
63
+ })) as Record<LimitKey, LimitState>,
64
+ /** "enforce" unless FEATURE_ENTITLEMENTS (or an override) has limits in shadow mode or off. */
65
+ limitsEnforced,
66
+ };
67
+ }
68
+
69
+ /** Current usage against the limits that have a meter: projects, cloud minutes and AI credits this period. */
70
+ export async function limitUsage(env: Env, workspaceId: string): Promise<LimitUsage> {
71
+ const [{ summary }, projects] = await Promise.all([
72
+ usageSummary(env, workspaceId),
73
+ // Paused (inactive) projects don't count toward the limit.
74
+ env.DB.prepare("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ? AND deleted_at IS NULL AND inactive_at IS NULL")
75
+ .bind(workspaceId).first<{ count: number }>(),
76
+ ]);
77
+ return {
78
+ projects: projects?.count ?? 0,
79
+ cloudMinutes: summary.lines.browser_minute.used,
80
+ aiCredits: summary.lines.ai_credit.used,
81
+ };
82
+ }
83
+
84
+ export async function workspaceFeatures(env: Env, workspaceId: string) {
85
+ const [evaluated, used, mode] = await Promise.all([
86
+ effectiveFeatures(env, workspaceId),
87
+ limitUsage(env, workspaceId),
88
+ entitlementMode(env, workspaceId),
89
+ ]);
90
+ return featuresBody(workspaceId, evaluated, used, mode);
91
+ }
92
+
93
+ async function getFeatures({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
94
+ if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
95
+ return response(await workspaceFeatures(env, workspaceId));
96
+ }
97
+
98
+ registerRoutes(async (context) => {
99
+ const params = matchPath(context.url.pathname, "/api/workspaces/:workspaceId/features");
100
+ if (params && context.request.method === "GET") return getFeatures(context, params.workspaceId);
101
+ return null;
102
+ });
@@ -5,6 +5,7 @@ import "./billing/routes.js";
5
5
  import "./jobs/retention.js";
6
6
  import "./ai/routes.js";
7
7
  import "./runner/routes.js";
8
+ import "./proposals/routes.js";
8
9
  import "./signup/routes.js";
9
10
  import "./signup/attribution.js";
10
11
  import "./billing/checkout-routes.js";
@@ -14,9 +15,17 @@ import "./notifications/routes.js";
14
15
  import "./github/checks.js";
15
16
  import "./gitlab/checks.js";
16
17
  import "./personas/routes.js";
18
+ import "./roles/routes.js";
19
+ import "./secrets/routes.js";
20
+ import "./identity/oidc.js";
17
21
  import "./feedback/routes.js";
18
22
  import "./feedback/website.js";
19
23
  import "./devices/routes.js";
20
24
  import "./domains/routes.js";
21
25
  import "./sso/routes.js";
22
26
  import "./mcp/routes.js";
27
+ import "./jira/routes.js";
28
+ import "./feature-flags/routes.js";
29
+ import "./feature-flags/admin.js";
30
+ import "./projects/identity.js";
31
+ import "./projects/inactivity.js";
@@ -6,7 +6,7 @@ import { githubAppConfigured, githubRequest, GithubAppError, type GithubAppEnv }
6
6
  import type { Env, Principal } from "../core.js";
7
7
  import { constantTimeEqual, newId, requestJson, response, sha256 } from "../core.js";
8
8
  import { getWorkspacePlan } from "../billing/entitlements.js";
9
- import { flagOn } from "../flags.js";
9
+ import { featureEnabled } from "../feature-access.js";
10
10
  import { recordAudit } from "../org/audit.js";
11
11
  import { drainSoon, enqueue, PermanentDeliveryError, registerOutboxHandler } from "../notifications/outbox.js";
12
12
  import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
@@ -174,7 +174,7 @@ async function submit({ env, principal, request, ctx }: AuthedRouteContext): Pro
174
174
  await recordAudit(env, { type: "user", id: principal.aegisId }, { workspaceId: workspace.id, projectId: project?.id ?? null, action: "feedback.submitted", targetType: "feedback", targetId: id, data: { kind } }, request);
175
175
  }
176
176
 
177
- if (await flagOn(env, workspace?.id ?? null, "feedback")) {
177
+ if (await featureEnabled(env, workspace?.id ?? null, "feedback")) {
178
178
  const since = new Date(Date.now() - DUPLICATE_WINDOW_MS).toISOString().replace("T", " ").slice(0, 19);
179
179
  const original = await env.DB.prepare(`
180
180
  SELECT id, github_issue_number AS issue FROM feedback_reports
@@ -1,8 +1,15 @@
1
- // Feature flags for the gap-closing rollout. A global var turns a feature on
2
- // for everyone ("on"), or leaves it to per-workspace opt-in ("beta", the
3
- // default); "off" disables it even for opted-in workspaces.
1
+ // The feature catalog and its global rollout switches. A FEATURE_<NAME> var
2
+ // turns a feature on for everyone ("on"), leaves it to per-workspace opt-in
3
+ // ("beta"), or kills it everywhere ("off"; "shadow" also counts as off for
4
+ // access, and only changes how the entitlements feature enforces limits).
5
+ //
6
+ // Whether a feature is usable by a workspace is decided in one place,
7
+ // feature-access.ts: kill switch → beta opt-in → workspace override → plan.
8
+ // See docs/feature-flags.md.
9
+ import type { PlanFeatures } from "../billing/plan-catalog.js";
4
10
 
5
11
  export const FEATURES = [
12
+ // Rollout flags from the gap-closing launch. Unset, they default to "beta".
6
13
  "entitlements",
7
14
  "cloud_runners",
8
15
  "billing",
@@ -13,27 +20,82 @@ export const FEATURES = [
13
20
  "sso",
14
21
  "feedback",
15
22
  "mcp",
23
+ // Plan features from the pricing matrix. Unset, they default to "on": the
24
+ // plan decides, and the var exists only as a kill switch.
25
+ "ai_fixes",
26
+ "scim",
27
+ "audit_export",
28
+ "priority_support",
29
+ "self_hosted_workers",
30
+ // Included in every plan (the pricing page's all-plans rows). Always on
31
+ // unless killed; no gate reads them yet beyond the evaluator.
32
+ "unlimited_users",
33
+ "unlimited_local_runs",
34
+ "desktop_browsers",
35
+ "emulated_mobile",
36
+ "ai_failure_analysis",
16
37
  ] as const;
17
38
  export type Feature = (typeof FEATURES)[number];
18
39
 
19
- export type FlagEnv = {
20
- DB: D1Database;
21
- [key: `FEATURE_${string}`]: string | undefined;
40
+ export type FlagMode = "on" | "beta" | "off" | "shadow";
41
+
42
+ export type FeatureDef = {
43
+ label: string;
44
+ /** Mode when FEATURE_<NAME> is unset. */
45
+ defaultMode: FlagMode;
46
+ /** The plan-catalog feature that gates it; features without one are on every plan. */
47
+ planFeature?: keyof PlanFeatures;
22
48
  };
23
49
 
24
- export type FlagMode = "on" | "beta" | "off" | "shadow";
50
+ export const FEATURE_DEFS: Record<Feature, FeatureDef> = {
51
+ entitlements: { label: "Plan limits", defaultMode: "beta" },
52
+ cloud_runners: { label: "Bugmole Cloud runs", defaultMode: "beta" },
53
+ billing: { label: "Self-serve billing", defaultMode: "beta" },
54
+ pr_checks: { label: "Pull and merge request checks", defaultMode: "beta", planFeature: "prChecks" },
55
+ slack: { label: "Slack alerts", defaultMode: "beta", planFeature: "slack" },
56
+ teams: { label: "Microsoft Teams alerts", defaultMode: "beta", planFeature: "teams" },
57
+ device_clouds: { label: "Real devices and device clouds", defaultMode: "beta", planFeature: "deviceClouds" },
58
+ sso: { label: "Single sign-on", defaultMode: "beta", planFeature: "sso" },
59
+ feedback: { label: "Feedback to GitHub issues", defaultMode: "beta" },
60
+ mcp: { label: "Hosted MCP", defaultMode: "beta" },
61
+ ai_fixes: { label: "AI test repair", defaultMode: "on", planFeature: "aiFixes" },
62
+ scim: { label: "SCIM provisioning", defaultMode: "on", planFeature: "scim" },
63
+ audit_export: { label: "Audit log export and full history", defaultMode: "on", planFeature: "auditLogExport" },
64
+ priority_support: { label: "Priority support", defaultMode: "on", planFeature: "prioritySupport" },
65
+ self_hosted_workers: { label: "Self-hosted cloud workers", defaultMode: "on", planFeature: "selfHostedWorkers" },
66
+ unlimited_users: { label: "Unlimited users", defaultMode: "on", planFeature: "unlimitedUsers" },
67
+ unlimited_local_runs: { label: "Unlimited local & CI runs", defaultMode: "on", planFeature: "unlimitedLocalRuns" },
68
+ desktop_browsers: { label: "Chrome, Firefox, Safari & Edge", defaultMode: "on", planFeature: "desktopBrowsers" },
69
+ emulated_mobile: { label: "Emulated iPhone & Android", defaultMode: "on", planFeature: "emulatedMobile" },
70
+ ai_failure_analysis: { label: "AI failure analysis", defaultMode: "on", planFeature: "aiFailureAnalysis" },
71
+ };
72
+
73
+ /**
74
+ * Operational flags: rollout and platform switches with no pricing-table row.
75
+ * The catalog's `extras.*` (Enterprise contract terms) are descriptive and
76
+ * never enforced, so they have no flags.
77
+ * Every other feature maps 1:1 to a plan-catalog `features.*` key through
78
+ * FEATURE_DEFS[feature].planFeature — this table is the one mapping between
79
+ * flag names and catalog keys (flags.test.ts fails if either side drifts).
80
+ */
81
+ export const OPERATIONAL_FEATURES = ["entitlements", "cloud_runners", "billing", "feedback", "mcp"] as const satisfies readonly Feature[];
25
82
 
26
- export function flagMode(env: FlagEnv, feature: Feature): FlagMode {
27
- const raw = (env[`FEATURE_${feature.toUpperCase()}`] ?? "beta").trim().toLowerCase();
28
- return raw === "on" || raw === "off" || raw === "shadow" ? raw : "beta";
83
+ export function isFeature(value: unknown): value is Feature {
84
+ return typeof value === "string" && (FEATURES as readonly string[]).includes(value);
29
85
  }
30
86
 
31
- export async function flagOn(env: FlagEnv, workspaceId: string | null | undefined, feature: Feature): Promise<boolean> {
32
- const mode = flagMode(env, feature);
33
- if (mode === "on") return true;
34
- if (mode === "off" || mode === "shadow" || !workspaceId) return false;
35
- const row = await env.DB.prepare(
36
- "SELECT enabled FROM workspace_flags WHERE workspace_id = ? AND flag = ?",
37
- ).bind(workspaceId, feature).first<{ enabled: number }>();
38
- return row?.enabled === 1;
87
+ /** The feature a plan-catalog key belongs to (e.g. "auditLogExport" → "audit_export"). */
88
+ export function featureForPlanKey(key: string): Feature | null {
89
+ return FEATURES.find((feature) => FEATURE_DEFS[feature].planFeature === key) ?? null;
90
+ }
91
+
92
+ export type FlagEnv = {
93
+ DB: D1Database;
94
+ [key: `FEATURE_${string}`]: string | undefined;
95
+ };
96
+
97
+ export function flagMode(env: Omit<FlagEnv, "DB">, feature: Feature): FlagMode {
98
+ const raw = env[`FEATURE_${feature.toUpperCase()}`]?.trim().toLowerCase();
99
+ if (raw === "on" || raw === "off" || raw === "shadow" || raw === "beta") return raw;
100
+ return FEATURE_DEFS[feature].defaultMode;
39
101
  }
@@ -7,8 +7,8 @@
7
7
  import { createCheckRun, githubAppConfigured, GithubAppError, updateCheckRun, type CheckRunUpdate, type GithubAppEnv } from "../../integrations/github-app.js";
8
8
  import type { Env, Principal } from "../core.js";
9
9
  import { memberOfProject, newId, requestJson, response } from "../core.js";
10
- import { getEntitlements, workspaceOfProject } from "../billing/entitlements.js";
11
- import { flagOn } from "../flags.js";
10
+ import { workspaceOfProject } from "../billing/entitlements.js";
11
+ import { featureEnabled } from "../feature-access.js";
12
12
  import { runChangedHooks, runService, type InternalRunFields } from "../hooks.js";
13
13
  import { auditProject } from "../org/audit.js";
14
14
  import { enqueue, PermanentDeliveryError, registerOutboxHandler } from "../notifications/outbox.js";
@@ -52,8 +52,8 @@ export { previewUrl };
52
52
  /** GitLab's merge request checks share this gate — one PR/MR checks feature regardless of provider. */
53
53
  export async function prChecksOn(env: Env, projectId: string): Promise<boolean> {
54
54
  const workspaceId = await workspaceOfProject(env, projectId);
55
- if (!workspaceId || !(await flagOn(env, workspaceId, "pr_checks"))) return false;
56
- return (await getEntitlements(env, workspaceId)).features.prChecks;
55
+ if (!workspaceId) return false;
56
+ return featureEnabled(env, workspaceId, "pr_checks");
57
57
  }
58
58
 
59
59
  /** The binding's installation must still be connected to its project and cover the repo. */
@@ -45,7 +45,16 @@ export type InternalRunFields = {
45
45
  prNumber?: number;
46
46
  githubInstallationId?: number;
47
47
  gitlabBindingId?: string;
48
+ /** A retest limited to some cases of one case set (a Jira ticket's case). */
49
+ caseScope?: RunCaseScope;
48
50
  };
51
+
52
+ /** Which cases a run judges; absent for a run of the whole flow. */
53
+ export type RunCaseScope = { caseSetId: string; caseIds: string[]; jiraIssueKey?: string };
54
+
55
+ /** A test case proposal was accepted, rejected, or applied to storage. */
56
+ export type ProposalDecided = { projectId: string; proposalId: string; status: string; applied: boolean };
57
+ export const proposalDecidedHooks: Array<(env: Env, event: ProposalDecided) => Promise<void>> = [];
49
58
  export type RunCreator = (request: Request, env: Env, principal: import("./core.js").Principal, projectId: string, ctx: ExecutionContext, internal?: InternalRunFields) => Promise<Response>;
50
59
  /** Set by index.ts, so integrations can create runs through the same checks as the API. */
51
60
  export const runService: { create?: RunCreator } = {};
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { createPublicKey, createVerify, generateKeyPairSync } from "node:crypto";
3
+ import test from "node:test";
4
+ import { call, createTestEnv, seedPlan, seedProject, seedVerifiedDomain } from "../testing/harness.js";
5
+ import { runnerLauncher, type LaunchRequest } from "../runner/dispatch.js";
6
+ import { runnerMayCall } from "../runner/tokens.js";
7
+ import { validAudience } from "./oidc.js";
8
+
9
+ const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
10
+ const PEM = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
11
+ const BASE = { RUNNER_TOKEN_SECRET: "runner-test-secret", FEATURE_CLOUD_RUNNERS: "on", REGISTRY_PUBLIC_URL: "https://registry.test" };
12
+ const PROVIDER = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/bugmole/providers/bugmole";
13
+
14
+ async function exploringProject(overrides: Record<string, string>) {
15
+ const launches: LaunchRequest[] = [];
16
+ const original = runnerLauncher.start;
17
+ runnerLauncher.start = async (_env, launch) => { launches.push(launch); };
18
+ const setup = createTestEnv({ ...BASE, ...overrides });
19
+ const project = await seedProject(setup.env, { storageProvider: "bugmole" });
20
+ await seedPlan(setup.env, project.projectId, project.owner);
21
+ seedVerifiedDomain(setup.db, project.workspaceId, "shop.test");
22
+ const created = await call(setup.env, "POST", `/api/projects/${project.projectId}/tasks`, {
23
+ as: project.owner,
24
+ body: { title: "Explore https://shop.test", description: "Explore https://shop.test", taskType: "discovery", idempotencyKey: "d1", executionMode: "cloud" },
25
+ });
26
+ assert.equal(created.status, 201);
27
+ return { ...setup, ...project, taskId: created.json.task.id as string, token: launches[0].token, restore: () => { runnerLauncher.start = original; } };
28
+ }
29
+
30
+ const decode = (part: string) => JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
31
+
32
+ test("Bugmole publishes OIDC discovery and its signing key, and nothing when no key is set", async () => {
33
+ const { env } = createTestEnv({ ...BASE, OIDC_SIGNING_KEY: PEM });
34
+ const discovery = await call(env, "GET", "/.well-known/openid-configuration");
35
+ assert.equal(discovery.status, 200);
36
+ assert.equal(discovery.json.issuer, "https://api.bugmole.com");
37
+ assert.equal(discovery.json.jwks_uri, "https://api.bugmole.com/.well-known/jwks.json");
38
+ assert.deepEqual(discovery.json.id_token_signing_alg_values_supported, ["RS256"]);
39
+ const jwks = await call(env, "GET", "/.well-known/jwks.json");
40
+ assert.equal(jwks.json.keys.length, 1);
41
+ const [key] = jwks.json.keys;
42
+ assert.deepEqual(Object.keys(key).sort(), ["alg", "e", "kid", "kty", "n", "use"], "the public half only");
43
+ assert.equal(key.alg, "RS256");
44
+
45
+ const bare = createTestEnv(BASE).env;
46
+ assert.notEqual((await call(bare, "GET", "/.well-known/jwks.json")).status, 200);
47
+ });
48
+
49
+ test("a cloud runner gets a token its project's cloud can verify, for its own job only", async () => {
50
+ const { env, projectId, workspaceId, taskId, token, restore } = await exploringProject({ OIDC_SIGNING_KEY: PEM });
51
+ try {
52
+ const issued = await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=${encodeURIComponent(PROVIDER)}`, { token });
53
+ assert.equal(issued.status, 200, JSON.stringify(issued.json));
54
+ const [header, payload, signature] = issued.json.token.split(".");
55
+ const claims = decode(payload);
56
+ assert.equal(claims.iss, "https://api.bugmole.com");
57
+ assert.equal(claims.sub, `project:${projectId}`);
58
+ assert.equal(claims.aud, PROVIDER);
59
+ assert.equal(claims.project_id, projectId);
60
+ assert.equal(claims.workspace_id, workspaceId);
61
+ assert.equal(claims.job_kind, "task");
62
+ assert.equal(claims.job_id, taskId);
63
+ assert.equal(claims.exp - claims.iat, 600, "ten minutes");
64
+
65
+ // Verified the way Google and AWS do: with the published JWKS, by kid.
66
+ const jwks = (await call(env, "GET", "/.well-known/jwks.json")).json.keys;
67
+ const jwk = jwks.find((key: { kid: string }) => key.kid === decode(header).kid);
68
+ assert.ok(jwk, "the token's kid is published");
69
+ const verifier = createVerify("RSA-SHA256").update(`${header}.${payload}`);
70
+ assert.equal(verifier.verify(createPublicKey({ key: jwk, format: "jwk" }), Buffer.from(signature, "base64url")), true);
71
+
72
+ assert.equal((await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=sts.amazonaws.com`, { token })).status, 200);
73
+ assert.equal((await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=ftp://nope`, { token })).status, 422);
74
+ assert.equal((await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token`, { token })).status, 422);
75
+ assert.equal((await call(env, "GET", `/api/runner/tasks/other/identity-token?audience=sts.amazonaws.com`, { token })).status, 403);
76
+ } finally {
77
+ restore();
78
+ }
79
+ });
80
+
81
+ test("no signing key: the token route says so instead of failing", async () => {
82
+ const { env, taskId, token, restore } = await exploringProject({});
83
+ try {
84
+ const reply = await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=sts.amazonaws.com`, { token });
85
+ assert.equal(reply.status, 503);
86
+ assert.equal(reply.json.code, "oidc_unconfigured");
87
+ } finally {
88
+ restore();
89
+ }
90
+ });
91
+
92
+ test("only the runner's own job may ask, and only the audiences a cloud uses", () => {
93
+ const run = { kind: "run" as const, id: "run_1", projectId: "shop", workspaceId: "acme" };
94
+ assert.equal(runnerMayCall(run, "GET", new URL("https://r/api/runner/runs/run_1/identity-token?audience=x")), true);
95
+ assert.equal(runnerMayCall(run, "GET", new URL("https://r/api/runner/runs/run_2/identity-token?audience=x")), false);
96
+ assert.equal(runnerMayCall(run, "POST", new URL("https://r/api/runner/runs/run_1/identity-token")), false);
97
+ assert.equal(validAudience(PROVIDER), true);
98
+ assert.equal(validAudience("sts.amazonaws.com"), true);
99
+ assert.equal(validAudience("https://vault.example.com"), true);
100
+ assert.equal(validAudience("//iam.googleapis.com/projects/abc/locations/global/workloadIdentityPools/x/providers/y"), false);
101
+ assert.equal(validAudience("http://insecure.example.com"), false);
102
+ assert.equal(validAudience(""), false);
103
+ });
@@ -0,0 +1,176 @@
1
+ // Bugmole Cloud as an OpenID Connect identity provider, so a cloud runner can
2
+ // read a customer's own vault without anyone storing a key for it.
3
+ //
4
+ // The registry signs a short-lived token (RS256, ten minutes) for the one run
5
+ // or exploration a runner holds: subject `project:<projectId>`, with the
6
+ // audience the customer's cloud expects. The customer trusts this issuer once
7
+ // (a Google Workload Identity pool, or an AWS IAM OIDC provider and role) and
8
+ // the runner trades the token for credentials scoped to their secrets:
9
+ //
10
+ // GET /.well-known/openid-configuration discovery (public)
11
+ // GET /.well-known/jwks.json the signing key's public half (public)
12
+ // GET /api/runner/{runs|tasks}/:id/identity-token?audience=… the runner's token
13
+ //
14
+ // The private key is OIDC_SIGNING_KEY (an RSA PKCS#8 PEM, a Worker secret).
15
+ // The key id is its RFC 7638 thumbprint, so rotating the key needs nothing
16
+ // else: clouds fetch the JWKS again when they meet an unknown kid.
17
+ import type { Env } from "../core.js";
18
+ import { response } from "../core.js";
19
+ import { auditProject } from "../org/audit.js";
20
+ import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
21
+ import { cloudWorkerId } from "../runner/jobs.js";
22
+
23
+ export const IDENTITY_TOKEN_TTL_SECONDS = 600;
24
+ const DEFAULT_ISSUER = "https://api.bugmole.com";
25
+
26
+ type OidcEnv = Env;
27
+
28
+ export function oidcIssuer(env: OidcEnv): string {
29
+ return (env.OIDC_ISSUER || DEFAULT_ISSUER).replace(/\/+$/, "");
30
+ }
31
+
32
+ const encoder = new TextEncoder();
33
+
34
+ function base64Url(bytes: Uint8Array | ArrayBuffer): string {
35
+ let binary = "";
36
+ for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
37
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
38
+ }
39
+
40
+ function pemBody(pem: string): Uint8Array {
41
+ const body = pem.replace(/-----(BEGIN|END) [A-Z ]+-----/g, "").replace(/\s+/g, "");
42
+ return Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
43
+ }
44
+
45
+ type SigningKey = { privateKey: CryptoKey; publicJwk: JsonWebKey & { kid: string; use: string; alg: string } };
46
+
47
+ let cached: { pem: string; key: Promise<SigningKey> } | null = null;
48
+
49
+ /** The signing key and its public JWK, from OIDC_SIGNING_KEY; null when it isn't configured. */
50
+ export function signingKey(env: OidcEnv): Promise<SigningKey> | null {
51
+ const pem = env.OIDC_SIGNING_KEY;
52
+ if (!pem) return null;
53
+ if (cached?.pem === pem) return cached.key;
54
+ const key = (async () => {
55
+ const algorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
56
+ // Imported once extractable, only to publish its public half.
57
+ const extractable = await crypto.subtle.importKey("pkcs8", pemBody(pem), algorithm, true, ["sign"]);
58
+ const jwk = await crypto.subtle.exportKey("jwk", extractable) as JsonWebKey;
59
+ const { n, e } = jwk;
60
+ if (!n || !e) throw new Error("OIDC_SIGNING_KEY isn't an RSA key");
61
+ // RFC 7638 thumbprint: SHA-256 of the required members in lexical order.
62
+ const thumbprint = await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify({ e, kty: "RSA", n })));
63
+ const privateKey = await crypto.subtle.importKey("pkcs8", pemBody(pem), algorithm, false, ["sign"]);
64
+ return { privateKey, publicJwk: { kty: "RSA", n, e, kid: base64Url(thumbprint), use: "sig", alg: "RS256" } };
65
+ })();
66
+ cached = { pem, key };
67
+ return key;
68
+ }
69
+
70
+ export type IdentityClaims = {
71
+ sub: string;
72
+ aud: string;
73
+ project_id: string;
74
+ workspace_id: string | null;
75
+ job_kind: "run" | "task";
76
+ job_id: string;
77
+ };
78
+
79
+ /** Signs an identity token for a runner's job. */
80
+ export async function signIdentityToken(env: OidcEnv, claims: IdentityClaims, now = Date.now()): Promise<{ token: string; expiresAt: string }> {
81
+ const key = signingKey(env);
82
+ if (!key) throw new Error("OIDC_SIGNING_KEY is not configured");
83
+ const { privateKey, publicJwk } = await key;
84
+ const iat = Math.floor(now / 1000);
85
+ const header = { alg: "RS256", typ: "JWT", kid: publicJwk.kid };
86
+ const payload = { iss: oidcIssuer(env), iat, nbf: iat - 30, exp: iat + IDENTITY_TOKEN_TTL_SECONDS, jti: crypto.randomUUID(), ...claims };
87
+ const unsigned = `${base64Url(encoder.encode(JSON.stringify(header)))}.${base64Url(encoder.encode(JSON.stringify(payload)))}`;
88
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, encoder.encode(unsigned));
89
+ return { token: `${unsigned}.${base64Url(signature)}`, expiresAt: new Date((iat + IDENTITY_TOKEN_TTL_SECONDS) * 1000).toISOString() };
90
+ }
91
+
92
+ /**
93
+ * Audiences a cloud accepts: Google's Workload Identity provider
94
+ * (//iam.googleapis.com/projects/…/providers/…), AWS STS
95
+ * (sts.amazonaws.com), or an https URL of the customer's choosing.
96
+ */
97
+ export function validAudience(audience: string): boolean {
98
+ if (!audience || audience.length > 512 || /\s/.test(audience)) return false;
99
+ if (audience === "sts.amazonaws.com") return true;
100
+ if (/^\/\/iam\.googleapis\.com\/projects\/\d+\/locations\/global\/workloadIdentityPools\/[a-z0-9-]+\/providers\/[a-z0-9-]+$/.test(audience)) return true;
101
+ try {
102
+ return new URL(audience).protocol === "https:";
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ const json = (body: unknown, cacheSeconds: number) =>
109
+ new Response(JSON.stringify(body), {
110
+ headers: { "content-type": "application/json", "cache-control": `public, max-age=${cacheSeconds}`, "access-control-allow-origin": "*" },
111
+ });
112
+
113
+ registerPublicRoutes(async ({ request, env, url }) => {
114
+ if (request.method !== "GET") return null;
115
+ const issuer = oidcIssuer(env as OidcEnv);
116
+ if (url.pathname === "/.well-known/openid-configuration") {
117
+ if (!signingKey(env as OidcEnv)) return null;
118
+ return json({
119
+ issuer,
120
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
121
+ response_types_supported: ["id_token"],
122
+ subject_types_supported: ["public"],
123
+ id_token_signing_alg_values_supported: ["RS256"],
124
+ claims_supported: ["iss", "sub", "aud", "iat", "nbf", "exp", "jti", "project_id", "workspace_id", "job_kind", "job_id"],
125
+ }, 3600);
126
+ }
127
+ if (url.pathname === "/.well-known/jwks.json") {
128
+ const key = signingKey(env as OidcEnv);
129
+ if (!key) return null;
130
+ return json({ keys: [(await key).publicJwk] }, 300);
131
+ }
132
+ return null;
133
+ });
134
+
135
+ /** A token for the one job a cloud runner holds, while it holds it. */
136
+ async function issueToRunner({ env, principal, request, url }: AuthedRouteContext, kind: "run" | "task", jobId: string): Promise<Response> {
137
+ const runner = principal.runner;
138
+ if (!runner || runner.kind !== kind || runner.id !== jobId) return response({ error: "Not found" }, 404);
139
+ const audience = url.searchParams.get("audience") ?? "";
140
+ if (!validAudience(audience)) {
141
+ return response({ error: "audience must be sts.amazonaws.com, a Google Workload Identity provider (//iam.googleapis.com/projects/…/providers/…), or an https URL" }, 422);
142
+ }
143
+ const table = kind === "run" ? "runs" : "agent_tasks";
144
+ const job = await env.DB.prepare(`SELECT project_id AS projectId, status, worker_id AS workerId FROM ${table} WHERE id = ?`)
145
+ .bind(jobId).first<{ projectId: string; status: string; workerId: string | null }>();
146
+ if (!job || job.projectId !== runner.projectId) return response({ error: "Not found" }, 404);
147
+ if (job.status !== "running" || job.workerId !== cloudWorkerId({ kind, id: jobId })) {
148
+ return response({ error: `This ${kind} isn't held by a cloud runner`, status: job.status }, 409);
149
+ }
150
+ if (!signingKey(env as OidcEnv)) return response({ error: "Identity tokens aren't configured on this registry", code: "oidc_unconfigured" }, 503);
151
+ const issued = await signIdentityToken(env as OidcEnv, {
152
+ sub: `project:${runner.projectId}`,
153
+ aud: audience,
154
+ project_id: runner.projectId,
155
+ workspace_id: runner.workspaceId,
156
+ job_kind: kind,
157
+ job_id: jobId,
158
+ });
159
+ await auditProject(env, principal, runner.projectId, {
160
+ action: "identity_token.issued",
161
+ targetType: kind,
162
+ targetId: jobId,
163
+ data: { audience },
164
+ }, request);
165
+ return response(issued);
166
+ }
167
+
168
+ registerRoutes(async (context) => {
169
+ const { request, url } = context;
170
+ if (request.method !== "GET") return null;
171
+ let params = matchPath(url.pathname, "/api/runner/runs/:runId/identity-token");
172
+ if (params) return issueToRunner(context, "run", params.runId);
173
+ params = matchPath(url.pathname, "/api/runner/tasks/:taskId/identity-token");
174
+ if (params) return issueToRunner(context, "task", params.taskId);
175
+ return null;
176
+ });