@bugmole/cli 0.4.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 (308) hide show
  1. package/.bugmole.env.example +20 -0
  2. package/LICENSE +7 -0
  3. package/README.md +293 -0
  4. package/TESTING.md +117 -0
  5. package/bugmole.config.yaml +39 -0
  6. package/package.json +85 -0
  7. package/scripts/billing/paypal-setup.mjs +121 -0
  8. package/scripts/bugmole-continue.ts +318 -0
  9. package/scripts/bugmole-init.ts +188 -0
  10. package/scripts/bugmole.cjs +17 -0
  11. package/scripts/bugmole.test.ts +344 -0
  12. package/scripts/bugmole.ts +657 -0
  13. package/scripts/ensure-maestro.cjs +79 -0
  14. package/scripts/ios-tunnel-keeper.sh +45 -0
  15. package/scripts/ios-wda-keeper.sh +66 -0
  16. package/scripts/sync-plan-catalog.d.mts +3 -0
  17. package/scripts/sync-plan-catalog.mjs +16 -0
  18. package/scripts/ui-parity-diff.py +65 -0
  19. package/scripts/ui-parity-requirements.txt +1 -0
  20. package/scripts/verify-manage-to-plans.mts +194 -0
  21. package/spec/app-ui-audit.schema.json +176 -0
  22. package/spec/blockers.yaml +79 -0
  23. package/spec/bugs.index.json +42 -0
  24. package/spec/design-dna.schema.json +38 -0
  25. package/spec/domain_rules.yaml +24 -0
  26. package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
  27. package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
  28. package/spec/journey_graph.yaml +126 -0
  29. package/spec/journeys.graph.json +2618 -0
  30. package/spec/plans/dashboard-smoke.flow.yaml +20 -0
  31. package/spec/plans/example.flow.yaml +99 -0
  32. package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
  33. package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
  34. package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
  35. package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
  36. package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
  37. package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
  38. package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
  39. package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
  40. package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
  41. package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
  42. package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
  43. package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
  44. package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
  45. package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
  46. package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
  47. package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
  48. package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
  49. package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
  50. package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
  51. package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
  52. package/spec/plans/login.flow.yaml +22 -0
  53. package/spec/plans/owner-operations.flow.yaml +20 -0
  54. package/spec/project_config.yaml +55 -0
  55. package/spec/roles.yaml +30 -0
  56. package/spec/schema.md +394 -0
  57. package/spec/test-case-results.schema.json +62 -0
  58. package/spec/test-cases.schema.json +85 -0
  59. package/spec/ui-parity-audit.schema.json +194 -0
  60. package/spec/ui-reverse-engineering.schema.json +94 -0
  61. package/src/billing/plan-catalog.test.ts +46 -0
  62. package/src/billing/plan-catalog.ts +199 -0
  63. package/src/integrations/aws-sigv4.test.ts +42 -0
  64. package/src/integrations/aws-sigv4.ts +72 -0
  65. package/src/integrations/device-farm.ts +155 -0
  66. package/src/integrations/github-app.test.ts +57 -0
  67. package/src/integrations/github-app.ts +143 -0
  68. package/src/integrations/gitlab.ts +81 -0
  69. package/src/integrations/temp-email.test.ts +123 -0
  70. package/src/integrations/temp-email.ts +175 -0
  71. package/src/integrations/testflight-feedback.test.ts +51 -0
  72. package/src/integrations/testflight-feedback.ts +173 -0
  73. package/src/integrations/webdriver-client.ts +131 -0
  74. package/src/mcp/server.test.ts +1220 -0
  75. package/src/mcp/server.ts +3064 -0
  76. package/src/mcp/write-test-cases.test.ts +287 -0
  77. package/src/registry/api-key-client.ts +39 -0
  78. package/src/registry/control-plane-client.ts +212 -0
  79. package/src/registry/migrations/0001_registry.sql +47 -0
  80. package/src/registry/migrations/0002_device_authorizations.sql +23 -0
  81. package/src/registry/migrations/0003_project_environments.sql +25 -0
  82. package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
  83. package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
  84. package/src/registry/migrations/0006_workspaces.sql +36 -0
  85. package/src/registry/migrations/0007_project_apps.sql +26 -0
  86. package/src/registry/migrations/0008_agent_tasks.sql +30 -0
  87. package/src/registry/migrations/0009_journey_revisions.sql +17 -0
  88. package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
  89. package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
  90. package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
  91. package/src/registry/migrations/0013_device_sessions.sql +22 -0
  92. package/src/registry/migrations/0014_agent_task_step.sql +1 -0
  93. package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
  94. package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
  95. package/src/registry/migrations/0017_run_targets.sql +7 -0
  96. package/src/registry/migrations/0018_run_fix_from.sql +4 -0
  97. package/src/registry/migrations/0019_workspace_flags.sql +9 -0
  98. package/src/registry/migrations/0020_orgs.sql +40 -0
  99. package/src/registry/migrations/0021_billing_core.sql +58 -0
  100. package/src/registry/migrations/0022_cloud_runners.sql +19 -0
  101. package/src/registry/migrations/0023_signup.sql +4 -0
  102. package/src/registry/migrations/0024_billing.sql +67 -0
  103. package/src/registry/migrations/0025_notifications.sql +47 -0
  104. package/src/registry/migrations/0026_repo_bindings.sql +28 -0
  105. package/src/registry/migrations/0027_feedback.sql +29 -0
  106. package/src/registry/migrations/0028_devices.sql +48 -0
  107. package/src/registry/migrations/0029_sso.sql +31 -0
  108. package/src/registry/migrations/0030_workspace_domains.sql +18 -0
  109. package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
  110. package/src/registry/migrations/0032_personas.sql +15 -0
  111. package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
  112. package/src/registry/task-scheduling.test.ts +100 -0
  113. package/src/registry/task-scheduling.ts +80 -0
  114. package/src/registry-worker/ai/platform-model.ts +77 -0
  115. package/src/registry-worker/ai/routes.test.ts +88 -0
  116. package/src/registry-worker/ai/routes.ts +90 -0
  117. package/src/registry-worker/artifacts.test.ts +98 -0
  118. package/src/registry-worker/artifacts.ts +85 -0
  119. package/src/registry-worker/billing/billing-core.test.ts +175 -0
  120. package/src/registry-worker/billing/checkout-routes.ts +209 -0
  121. package/src/registry-worker/billing/enforcement.ts +69 -0
  122. package/src/registry-worker/billing/entitlements.ts +108 -0
  123. package/src/registry-worker/billing/ledger.ts +186 -0
  124. package/src/registry-worker/billing/paypal/api.ts +259 -0
  125. package/src/registry-worker/billing/paypal/client.ts +91 -0
  126. package/src/registry-worker/billing/paypal/provider.ts +143 -0
  127. package/src/registry-worker/billing/paypal.test.ts +466 -0
  128. package/src/registry-worker/billing/provider.ts +114 -0
  129. package/src/registry-worker/billing/routes.ts +66 -0
  130. package/src/registry-worker/billing/subscriptions.ts +780 -0
  131. package/src/registry-worker/billing/thresholds.ts +107 -0
  132. package/src/registry-worker/core.ts +308 -0
  133. package/src/registry-worker/devices/devices.test.ts +185 -0
  134. package/src/registry-worker/devices/policy.ts +71 -0
  135. package/src/registry-worker/devices/routes.ts +453 -0
  136. package/src/registry-worker/domains/domains.test.ts +210 -0
  137. package/src/registry-worker/domains/routes.ts +139 -0
  138. package/src/registry-worker/domains.ts +88 -0
  139. package/src/registry-worker/email/sender.ts +75 -0
  140. package/src/registry-worker/env.d.ts +14716 -0
  141. package/src/registry-worker/features.ts +20 -0
  142. package/src/registry-worker/feedback/feedback.test.ts +230 -0
  143. package/src/registry-worker/feedback/format.ts +148 -0
  144. package/src/registry-worker/feedback/routes.ts +386 -0
  145. package/src/registry-worker/flags.ts +39 -0
  146. package/src/registry-worker/github/checks.test.ts +177 -0
  147. package/src/registry-worker/github/checks.ts +374 -0
  148. package/src/registry-worker/gitlab/checks.test.ts +141 -0
  149. package/src/registry-worker/gitlab/checks.ts +349 -0
  150. package/src/registry-worker/hooks.ts +54 -0
  151. package/src/registry-worker/index.ts +2077 -0
  152. package/src/registry-worker/jobs/index.ts +29 -0
  153. package/src/registry-worker/jobs/retention.ts +68 -0
  154. package/src/registry-worker/mcp/mcp.test.ts +355 -0
  155. package/src/registry-worker/mcp/routes.ts +215 -0
  156. package/src/registry-worker/mcp/token.ts +126 -0
  157. package/src/registry-worker/mcp/tools.ts +563 -0
  158. package/src/registry-worker/notifications/alerts.ts +212 -0
  159. package/src/registry-worker/notifications/notifications.test.ts +298 -0
  160. package/src/registry-worker/notifications/outbox.ts +83 -0
  161. package/src/registry-worker/notifications/routes.ts +280 -0
  162. package/src/registry-worker/notifications/secrets.ts +49 -0
  163. package/src/registry-worker/notifications/slack.ts +96 -0
  164. package/src/registry-worker/notifications/teams.ts +46 -0
  165. package/src/registry-worker/org/audit.ts +116 -0
  166. package/src/registry-worker/org/routes.test.ts +163 -0
  167. package/src/registry-worker/org/routes.ts +302 -0
  168. package/src/registry-worker/personas/personas.test.ts +78 -0
  169. package/src/registry-worker/personas/routes.ts +100 -0
  170. package/src/registry-worker/repo-triggers.ts +20 -0
  171. package/src/registry-worker/routes/index.ts +74 -0
  172. package/src/registry-worker/run-events.ts +24 -0
  173. package/src/registry-worker/runner/dispatch.ts +219 -0
  174. package/src/registry-worker/runner/jobs.ts +43 -0
  175. package/src/registry-worker/runner/metering.ts +82 -0
  176. package/src/registry-worker/runner/policy.ts +59 -0
  177. package/src/registry-worker/runner/routes.ts +171 -0
  178. package/src/registry-worker/runner/runner.test.ts +358 -0
  179. package/src/registry-worker/runner/tokens.ts +93 -0
  180. package/src/registry-worker/runs.test.ts +60 -0
  181. package/src/registry-worker/signup/policy.ts +57 -0
  182. package/src/registry-worker/signup/routes.ts +106 -0
  183. package/src/registry-worker/signup/signup.test.ts +81 -0
  184. package/src/registry-worker/sso/aegis.ts +141 -0
  185. package/src/registry-worker/sso/membership.ts +157 -0
  186. package/src/registry-worker/sso/routes.ts +458 -0
  187. package/src/registry-worker/sso/sso.test.ts +344 -0
  188. package/src/registry-worker/testing/d1-shim.ts +180 -0
  189. package/src/registry-worker/testing/harness.ts +137 -0
  190. package/src/runner-worker/index.ts +108 -0
  191. package/src/runtime/ai-analysis.ts +97 -0
  192. package/src/runtime/ai-exploration.test.ts +32 -0
  193. package/src/runtime/ai-exploration.ts +69 -0
  194. package/src/runtime/ai-repair.ts +74 -0
  195. package/src/runtime/ai-work.test.ts +99 -0
  196. package/src/runtime/android-screen-record.test.ts +75 -0
  197. package/src/runtime/android-screen-record.ts +192 -0
  198. package/src/runtime/app-understanding.test.ts +123 -0
  199. package/src/runtime/app-understanding.ts +201 -0
  200. package/src/runtime/appium-driver.test.ts +179 -0
  201. package/src/runtime/appium-driver.ts +295 -0
  202. package/src/runtime/blocker-resolution.test.ts +113 -0
  203. package/src/runtime/blocker-resolution.ts +111 -0
  204. package/src/runtime/browser-matrix.integration.test.ts +212 -0
  205. package/src/runtime/browser-matrix.test.ts +143 -0
  206. package/src/runtime/browser-matrix.ts +200 -0
  207. package/src/runtime/canonical-flow.test.ts +52 -0
  208. package/src/runtime/config-validate.ts +185 -0
  209. package/src/runtime/continuous-execution.ts +291 -0
  210. package/src/runtime/cursor-applescript.ts +573 -0
  211. package/src/runtime/cursor-cli-driver.test.ts +78 -0
  212. package/src/runtime/cursor-cli-driver.ts +156 -0
  213. package/src/runtime/cursor-driver-example.ts +117 -0
  214. package/src/runtime/cursor-driver-index.ts +65 -0
  215. package/src/runtime/cursor-driver-init.ts +277 -0
  216. package/src/runtime/cursor-driver-run.test.ts +15 -0
  217. package/src/runtime/cursor-driver-run.ts +323 -0
  218. package/src/runtime/cursor-driver.ts +332 -0
  219. package/src/runtime/cursor-llm-example.ts +90 -0
  220. package/src/runtime/cursor-llm.ts +206 -0
  221. package/src/runtime/cursor-mcp-monitor.ts +386 -0
  222. package/src/runtime/device-clouds/browserstack.ts +73 -0
  223. package/src/runtime/device-clouds/device-farm.ts +52 -0
  224. package/src/runtime/device-clouds/index.ts +92 -0
  225. package/src/runtime/device-clouds/kobiton.ts +70 -0
  226. package/src/runtime/device-clouds/targets.ts +44 -0
  227. package/src/runtime/device-clouds/types.ts +62 -0
  228. package/src/runtime/diff-proposal.ts +84 -0
  229. package/src/runtime/discovery-task.test.ts +29 -0
  230. package/src/runtime/discovery-task.ts +284 -0
  231. package/src/runtime/driver-recovery.ts +69 -0
  232. package/src/runtime/driver.ts +79 -0
  233. package/src/runtime/environment.test.ts +104 -0
  234. package/src/runtime/environment.ts +137 -0
  235. package/src/runtime/executor.test.ts +509 -0
  236. package/src/runtime/executor.ts +921 -0
  237. package/src/runtime/explorer.test.ts +101 -0
  238. package/src/runtime/explorer.ts +1013 -0
  239. package/src/runtime/failure-analysis.test.ts +111 -0
  240. package/src/runtime/failure-analysis.ts +272 -0
  241. package/src/runtime/fixtures/fake-maestro.sh +36 -0
  242. package/src/runtime/flow-language.test.ts +268 -0
  243. package/src/runtime/flow-language.ts +414 -0
  244. package/src/runtime/init-wizard.ts +354 -0
  245. package/src/runtime/ios-screen-record.test.ts +68 -0
  246. package/src/runtime/ios-screen-record.ts +155 -0
  247. package/src/runtime/journey-editor.ts +452 -0
  248. package/src/runtime/journey-evidence.test.ts +161 -0
  249. package/src/runtime/journey-evidence.ts +180 -0
  250. package/src/runtime/journey-graph.test.ts +257 -0
  251. package/src/runtime/journey-graph.ts +170 -0
  252. package/src/runtime/legacy-names.ts +32 -0
  253. package/src/runtime/llm-example.ts +105 -0
  254. package/src/runtime/llm.ts +527 -0
  255. package/src/runtime/local-browser.test.ts +45 -0
  256. package/src/runtime/local-browser.ts +48 -0
  257. package/src/runtime/local-registry-stub.test.ts +325 -0
  258. package/src/runtime/local-registry-stub.ts +803 -0
  259. package/src/runtime/maestro-driver.test.ts +84 -0
  260. package/src/runtime/maestro-driver.ts +209 -0
  261. package/src/runtime/mole-voice.ts +21 -0
  262. package/src/runtime/nav-crawl.test.ts +100 -0
  263. package/src/runtime/nav-crawl.ts +153 -0
  264. package/src/runtime/pipeline.test.ts +405 -0
  265. package/src/runtime/pipeline.ts +833 -0
  266. package/src/runtime/planner.test.ts +37 -0
  267. package/src/runtime/planner.ts +274 -0
  268. package/src/runtime/platform-ai.ts +76 -0
  269. package/src/runtime/playwright-driver.test.ts +93 -0
  270. package/src/runtime/playwright-driver.ts +620 -0
  271. package/src/runtime/project-spec.ts +140 -0
  272. package/src/runtime/record-run-verdicts.ts +68 -0
  273. package/src/runtime/reporter.test.ts +56 -0
  274. package/src/runtime/reporter.ts +158 -0
  275. package/src/runtime/reset.test.ts +44 -0
  276. package/src/runtime/reset.ts +61 -0
  277. package/src/runtime/reviewer.test.ts +73 -0
  278. package/src/runtime/reviewer.ts +158 -0
  279. package/src/runtime/run-job.ts +136 -0
  280. package/src/runtime/run-once.test.ts +207 -0
  281. package/src/runtime/run-once.ts +168 -0
  282. package/src/runtime/run.ts +132 -0
  283. package/src/runtime/screen-recording.ts +34 -0
  284. package/src/runtime/serve-gateway.test.ts +74 -0
  285. package/src/runtime/serve-gateway.ts +164 -0
  286. package/src/runtime/serve-worker.test.ts +23 -0
  287. package/src/runtime/serve-worker.ts +278 -0
  288. package/src/runtime/site-discovery.test.ts +168 -0
  289. package/src/runtime/site-discovery.ts +308 -0
  290. package/src/runtime/target-runner.ts +144 -0
  291. package/src/runtime/test-case-verdicts.test.ts +94 -0
  292. package/src/runtime/test-case-verdicts.ts +120 -0
  293. package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
  294. package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
  295. package/src/runtime/ui-reverse-engineering/types.ts +197 -0
  296. package/src/runtime/web-suite.test.ts +97 -0
  297. package/src/runtime/web-suite.ts +176 -0
  298. package/src/storage/create-object-store.ts +144 -0
  299. package/src/storage/keys.ts +34 -0
  300. package/src/storage/local-artifact-server.test.ts +314 -0
  301. package/src/storage/local-artifact-server.ts +357 -0
  302. package/src/storage/object-store.test.ts +28 -0
  303. package/src/storage/object-store.ts +101 -0
  304. package/src/storage/registry-object-store.ts +88 -0
  305. package/src/storage/remote-object-store.ts +104 -0
  306. package/src/storage/storage-directory.test.ts +43 -0
  307. package/src/storage/storage-directory.ts +24 -0
  308. package/tsconfig.json +24 -0
@@ -0,0 +1,458 @@
1
+ // Single sign-on and SCIM for a workspace: link it to an Aegis organization,
2
+ // manage domains, identity providers and SCIM tokens there, map IdP groups to
3
+ // roles here, and act on Aegis's deprovisioning events.
4
+ import { type Env, memberOfWorkspace, requestJson, response } from "../core.js";
5
+ import { escapeHtml } from "../email/sender.js";
6
+ import { getEntitlements } from "../billing/entitlements.js";
7
+ import { flagOn } from "../flags.js";
8
+ import { planChangedHooks } from "../hooks.js";
9
+ import { registerJob } from "../jobs/index.js";
10
+ import { enqueue, registerOutboxHandler } from "../notifications/outbox.js";
11
+ import { actorOf, recordAudit } from "../org/audit.js";
12
+ import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
13
+ import {
14
+ type AegisOrgEnv,
15
+ AegisError,
16
+ aegisOrgConfigured,
17
+ aegisOrgRequest,
18
+ ensureOrganization,
19
+ getOrganization,
20
+ setOrganizationEntitlements,
21
+ verifyAegisEvent,
22
+ } from "./aegis.js";
23
+ import {
24
+ applyOrgMembership,
25
+ deprovisionMember,
26
+ groupRoleMappings,
27
+ isSsoRole,
28
+ linkedWorkspace,
29
+ reactivateMember,
30
+ } from "./membership.js";
31
+
32
+ type SsoEnv = Env & AegisOrgEnv;
33
+
34
+ /** How long a workspace keeps SSO after its plan stops including it. */
35
+ export const SSO_GRACE_DAYS = 14;
36
+ const MAX_GROUP_MAPPINGS = 100;
37
+
38
+ type WorkspaceSsoRow = {
39
+ id: string;
40
+ name: string;
41
+ aegisOrgId: string | null;
42
+ defaultRole: string;
43
+ entitlement: string | null;
44
+ graceUntil: string | null;
45
+ };
46
+
47
+ async function workspaceSso(env: Env, workspaceId: string): Promise<WorkspaceSsoRow | null> {
48
+ return env.DB.prepare(`
49
+ SELECT id, name, aegis_org_id AS aegisOrgId, sso_default_role AS defaultRole,
50
+ sso_entitlement AS entitlement, sso_grace_until AS graceUntil
51
+ FROM workspaces WHERE id = ?
52
+ `).bind(workspaceId).first<WorkspaceSsoRow>();
53
+ }
54
+
55
+ function registryUrl(env: Env): string | null {
56
+ return env.REGISTRY_PUBLIC_URL ? env.REGISTRY_PUBLIC_URL.replace(/\/+$/, "") : null;
57
+ }
58
+
59
+ type Allowed = { sso: boolean; scim: boolean };
60
+
61
+ /** Stored as "sso", "sso+scim" or "off": what Aegis was last told. */
62
+ export function encodeAllowed(allowed: Allowed): string {
63
+ return allowed.sso ? (allowed.scim ? "sso+scim" : "sso") : "off";
64
+ }
65
+
66
+ export function decodeAllowed(value: string | null): Allowed {
67
+ return { sso: value === "sso" || value === "sso+scim", scim: value === "sso+scim" };
68
+ }
69
+
70
+ export function planAllows(features: { sso: boolean; scim: boolean }): Allowed {
71
+ return { sso: features.sso, scim: features.sso && features.scim };
72
+ }
73
+
74
+ /** True when the plan takes away something Aegis currently allows. */
75
+ export function losesAccess(previous: Allowed, next: Allowed): boolean {
76
+ return (previous.sso && !next.sso) || (previous.scim && !next.scim);
77
+ }
78
+
79
+ async function availability(env: SsoEnv, workspaceId: string) {
80
+ const { features } = await getEntitlements(env, workspaceId);
81
+ if (!(await flagOn(env, workspaceId, "sso")) || !aegisOrgConfigured(env) || !registryUrl(env)) {
82
+ return { available: false, reason: "Single sign-on isn't available yet.", features };
83
+ }
84
+ if (!features.sso) return { available: false, reason: "Single sign-on is included in Business and Enterprise.", features };
85
+ return { available: true, features };
86
+ }
87
+
88
+ function aegisFailure(error: unknown): Response {
89
+ if (error instanceof AegisError) {
90
+ const status = error.status >= 500 ? 502 : error.status;
91
+ return response({ error: error.message, code: error.body.error ?? null }, status);
92
+ }
93
+ console.error("[sso] Aegis request failed", error);
94
+ return response({ error: "The identity service didn't respond. Try again." }, 502);
95
+ }
96
+
97
+ async function overview(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
98
+ const env = context.env as SsoEnv;
99
+ if (!(await memberOfWorkspace(env, context.principal, workspaceId, "workspace.manage"))) {
100
+ return response({ error: "Only owners and admins can manage single sign-on" }, 403);
101
+ }
102
+ const workspace = await workspaceSso(env, workspaceId);
103
+ if (!workspace) return response({ error: "Workspace not found" }, 404);
104
+ const { available, reason, features } = await availability(env, workspaceId);
105
+ const base = {
106
+ available,
107
+ reason: reason ?? null,
108
+ plan: { sso: features.sso, scim: features.scim },
109
+ linked: Boolean(workspace.aegisOrgId),
110
+ defaultRole: workspace.defaultRole,
111
+ graceUntil: workspace.graceUntil,
112
+ groupRoles: await groupRoleMappings(env, workspaceId),
113
+ eventsUrl: registryUrl(env) ? `${registryUrl(env)}/api/aegis/events` : null,
114
+ };
115
+ if (!workspace.aegisOrgId || !aegisOrgConfigured(env)) return response({ ...base, organization: null });
116
+ try {
117
+ const organization = await getOrganization(env, workspace.aegisOrgId);
118
+ return response({ ...base, organization });
119
+ } catch (error) {
120
+ return aegisFailure(error);
121
+ }
122
+ }
123
+
124
+ async function enable(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
125
+ const env = context.env as SsoEnv;
126
+ if (!(await memberOfWorkspace(env, context.principal, workspaceId, "workspace.manage"))) {
127
+ return response({ error: "Only owners and admins can manage single sign-on" }, 403);
128
+ }
129
+ const { available, reason, features } = await availability(env, workspaceId);
130
+ if (!available) return response({ error: reason }, features.sso ? 503 : 402);
131
+ const workspace = await workspaceSso(env, workspaceId);
132
+ if (!workspace) return response({ error: "Workspace not found" }, 404);
133
+ try {
134
+ const org = await ensureOrganization(env, {
135
+ name: workspace.name,
136
+ externalRef: workspace.id,
137
+ eventsUrl: `${registryUrl(env)}/api/aegis/events`,
138
+ });
139
+ const entitlements = planAllows(features);
140
+ await setOrganizationEntitlements(env, org.id, entitlements);
141
+ await env.DB.prepare(`
142
+ UPDATE workspaces SET aegis_org_id = ?, sso_entitlement = ?, sso_grace_until = NULL WHERE id = ?
143
+ `).bind(org.id, encodeAllowed(entitlements), workspace.id).run();
144
+ await recordAudit(env, actorOf(context.principal), {
145
+ workspaceId,
146
+ action: "sso.enabled",
147
+ targetType: "aegis_organization",
148
+ targetId: org.id,
149
+ }, context.request);
150
+ return response({ linked: true, organizationId: org.id }, workspace.aegisOrgId ? 200 : 201);
151
+ } catch (error) {
152
+ return aegisFailure(error);
153
+ }
154
+ }
155
+
156
+ async function updateSettings(context: AuthedRouteContext, workspace: WorkspaceSsoRow, body: Record<string, unknown>): Promise<Response> {
157
+ const env = context.env as SsoEnv;
158
+ const changes: string[] = [];
159
+ if ("defaultRole" in body) {
160
+ if (body.defaultRole !== "none" && !isSsoRole(body.defaultRole)) {
161
+ return response({ error: "defaultRole must be admin, member, billing, or none" }, 422);
162
+ }
163
+ await env.DB.prepare("UPDATE workspaces SET sso_default_role = ? WHERE id = ?").bind(body.defaultRole, workspace.id).run();
164
+ changes.push("defaultRole");
165
+ }
166
+ if ("ssoRequired" in body) {
167
+ if (typeof body.ssoRequired !== "boolean") return response({ error: "ssoRequired must be true or false" }, 422);
168
+ try {
169
+ await aegisOrgRequest(env, "PATCH", `/organizations/${workspace.aegisOrgId}`, { ssoRequired: body.ssoRequired });
170
+ } catch (error) {
171
+ return aegisFailure(error);
172
+ }
173
+ changes.push("ssoRequired");
174
+ }
175
+ await recordAudit(env, actorOf(context.principal), {
176
+ workspaceId: workspace.id,
177
+ action: "sso.settings.updated",
178
+ data: Object.fromEntries(changes.map((key) => [key, body[key]])),
179
+ }, context.request);
180
+ return response({ updated: changes });
181
+ }
182
+
183
+ async function replaceGroupRoles(context: AuthedRouteContext, workspace: WorkspaceSsoRow): Promise<Response> {
184
+ const env = context.env;
185
+ const body = await requestJson(context.request);
186
+ const list = Array.isArray(body?.mappings) ? body.mappings : null;
187
+ if (!list || list.length > MAX_GROUP_MAPPINGS) {
188
+ return response({ error: `mappings must be a list of at most ${MAX_GROUP_MAPPINGS} { group, role } entries` }, 422);
189
+ }
190
+ const mappings = new Map<string, string>();
191
+ for (const entry of list) {
192
+ const group = typeof entry?.group === "string" ? entry.group.trim() : "";
193
+ if (!group || group.length > 200) return response({ error: "Each mapping needs a group name (up to 200 characters)" }, 422);
194
+ if (!isSsoRole(entry.role)) return response({ error: `Role for ${group} must be admin, member, or billing` }, 422);
195
+ mappings.set(group, entry.role);
196
+ }
197
+ await env.DB.batch([
198
+ env.DB.prepare("DELETE FROM workspace_group_roles WHERE workspace_id = ?").bind(workspace.id),
199
+ ...[...mappings].map(([group, role]) => env.DB.prepare(
200
+ "INSERT INTO workspace_group_roles (workspace_id, group_name, role, created_by) VALUES (?, ?, ?, ?)",
201
+ ).bind(workspace.id, group, role, context.principal.aegisId)),
202
+ ]);
203
+ await recordAudit(env, actorOf(context.principal), {
204
+ workspaceId: workspace.id,
205
+ action: "sso.group_roles.updated",
206
+ data: { mappings: Object.fromEntries(mappings) },
207
+ }, context.request);
208
+ return response({ mappings: await groupRoleMappings(env, workspace.id) });
209
+ }
210
+
211
+ /** Aegis calls a workspace admin may make, with the fields each may send. */
212
+ const PASSTHROUGH: Array<{
213
+ method: string;
214
+ pattern: string;
215
+ path: (params: Record<string, string>) => string;
216
+ fields?: string[];
217
+ needs?: "scim";
218
+ action: string;
219
+ }> = [
220
+ { method: "POST", pattern: "/domains", path: () => "/domains", fields: ["domain"], action: "sso.domain.added" },
221
+ { method: "POST", pattern: "/domains/:domainId/verify", path: (p) => `/domains/${p.domainId}/verify`, action: "sso.domain.verified" },
222
+ { method: "DELETE", pattern: "/domains/:domainId", path: (p) => `/domains/${p.domainId}`, action: "sso.domain.removed" },
223
+ {
224
+ method: "POST",
225
+ pattern: "/connections",
226
+ path: () => "/connections",
227
+ fields: ["name", "protocol", "enabled", "oidcIssuer", "oidcClientId", "oidcClientSecret", "samlEntryPoint", "samlIdpIssuer", "samlIdpCert", "attributeMapping", "jitProvisioning", "defaultRole"],
228
+ action: "sso.connection.created",
229
+ },
230
+ {
231
+ method: "PATCH",
232
+ pattern: "/connections/:connectionId",
233
+ path: (p) => `/connections/${p.connectionId}`,
234
+ fields: ["name", "enabled", "oidcIssuer", "oidcClientId", "oidcClientSecret", "samlEntryPoint", "samlIdpIssuer", "samlIdpCert", "attributeMapping", "jitProvisioning", "defaultRole"],
235
+ action: "sso.connection.updated",
236
+ },
237
+ { method: "DELETE", pattern: "/connections/:connectionId", path: (p) => `/connections/${p.connectionId}`, action: "sso.connection.removed" },
238
+ { method: "POST", pattern: "/scim-tokens", path: () => "/scim-tokens", fields: ["name"], needs: "scim", action: "scim.token.created" },
239
+ { method: "DELETE", pattern: "/scim-tokens/:tokenId", path: (p) => `/scim-tokens/${p.tokenId}`, action: "scim.token.revoked" },
240
+ { method: "GET", pattern: "/groups", path: () => "/groups", action: "" },
241
+ ];
242
+
243
+ const SAFE_ID = /^[0-9a-f-]{36}$/i;
244
+
245
+ async function passthrough(context: AuthedRouteContext, workspace: WorkspaceSsoRow, subpath: string): Promise<Response | null> {
246
+ const env = context.env as SsoEnv;
247
+ const { request } = context;
248
+ for (const entry of PASSTHROUGH) {
249
+ if (entry.method !== request.method) continue;
250
+ const params = matchPath(subpath, entry.pattern);
251
+ if (!params) continue;
252
+ if (Object.values(params).some((value) => !SAFE_ID.test(value))) return response({ error: "Not found" }, 404);
253
+ if (entry.needs === "scim") {
254
+ const { features } = await getEntitlements(env, workspace.id);
255
+ if (!features.scim) return response({ error: "SCIM provisioning is included in Enterprise." }, 402);
256
+ }
257
+ let body: Record<string, unknown> | undefined;
258
+ if (entry.fields) {
259
+ const raw = await requestJson(request);
260
+ if (!raw || typeof raw !== "object") return response({ error: "Expected a JSON body" }, 422);
261
+ body = Object.fromEntries(entry.fields.filter((field) => field in raw).map((field) => [field, (raw as Record<string, unknown>)[field]]));
262
+ }
263
+ try {
264
+ const result = await aegisOrgRequest(env, entry.method, `/organizations/${workspace.aegisOrgId}${entry.path(params)}`, body);
265
+ if (entry.action) {
266
+ await recordAudit(env, actorOf(context.principal), {
267
+ workspaceId: workspace.id,
268
+ action: entry.action,
269
+ targetType: "aegis_organization",
270
+ targetId: workspace.aegisOrgId ?? undefined,
271
+ // Never record secrets or certificates; the field names are enough.
272
+ data: { ...params, ...(body ? { fields: Object.keys(body) } : {}) },
273
+ }, request);
274
+ }
275
+ if (result === undefined) return new Response(null, { status: 204 });
276
+ return response(result, request.method === "POST" && !subpath.endsWith("/verify") ? 201 : 200);
277
+ } catch (error) {
278
+ return aegisFailure(error);
279
+ }
280
+ }
281
+ return null;
282
+ }
283
+
284
+ async function manage(context: AuthedRouteContext, workspaceId: string, subpath: string): Promise<Response | null> {
285
+ const env = context.env as SsoEnv;
286
+ if (!(await memberOfWorkspace(env, context.principal, workspaceId, "workspace.manage"))) {
287
+ return response({ error: "Only owners and admins can manage single sign-on" }, 403);
288
+ }
289
+ const workspace = await workspaceSso(env, workspaceId);
290
+ if (!workspace) return response({ error: "Workspace not found" }, 404);
291
+ if (!workspace.aegisOrgId) return response({ error: "Turn on single sign-on first" }, 409);
292
+ const { request } = context;
293
+
294
+ // Mappings and the default role stay usable during a grace period or after a downgrade,
295
+ // so admins can tidy up; changes in Aegis need the plan.
296
+ if (subpath === "/group-roles" && request.method === "PUT") return replaceGroupRoles(context, workspace);
297
+ const { available, reason } = await availability(env, workspaceId);
298
+ const inGrace = Boolean(workspace.graceUntil && Date.parse(workspace.graceUntil) > Date.now());
299
+ if (subpath === "" && request.method === "PATCH") {
300
+ const body = await requestJson(request);
301
+ if (!body || typeof body !== "object") return response({ error: "Expected a JSON body" }, 422);
302
+ // Turning "SSO required" off is always allowed; turning it on needs the plan.
303
+ if (body.ssoRequired === true && !available) return response({ error: reason }, 402);
304
+ return updateSettings(context, workspace, body as Record<string, unknown>);
305
+ }
306
+ if (!available && !(inGrace && request.method === "DELETE")) return response({ error: reason }, 402);
307
+ return passthrough(context, workspace, subpath);
308
+ }
309
+
310
+ registerRoutes(async (context) => {
311
+ const { url, request } = context;
312
+ // The dashboard calls this once after an enterprise sign-in; index.ts has
313
+ // already applied the person's groups by the time it gets here.
314
+ if (url.pathname === "/api/sso/sign-in" && request.method === "POST") {
315
+ return new Response(null, { status: 204 });
316
+ }
317
+ const root = matchPath(url.pathname, "/api/workspaces/:workspaceId/sso");
318
+ if (root) {
319
+ if (request.method === "GET") return overview(context, root.workspaceId);
320
+ if (request.method === "PATCH") return manage(context, root.workspaceId, "");
321
+ return null;
322
+ }
323
+ const enableMatch = matchPath(url.pathname, "/api/workspaces/:workspaceId/sso/enable");
324
+ if (enableMatch && request.method === "POST") return enable(context, enableMatch.workspaceId);
325
+ const prefix = /^\/api\/workspaces\/([^/]+)\/sso(\/.+)$/.exec(url.pathname);
326
+ if (prefix) return manage(context, decodeURIComponent(prefix[1]), prefix[2]);
327
+ return null;
328
+ });
329
+
330
+ // ---- Aegis events ----
331
+
332
+ type AegisEvent = {
333
+ id?: unknown;
334
+ type?: unknown;
335
+ organizationId?: unknown;
336
+ externalRef?: unknown;
337
+ data?: { userId?: unknown; email?: unknown; groups?: unknown };
338
+ };
339
+
340
+ export async function handleAegisEvent(request: Request, env: SsoEnv): Promise<Response> {
341
+ if (!env.AEGIS_EVENTS_SECRET) return response({ error: "Events aren't configured" }, 503);
342
+ const body = await request.text();
343
+ if (body.length > 64_000) return response({ error: "Event too large" }, 413);
344
+ if (!(await verifyAegisEvent(env.AEGIS_EVENTS_SECRET, request.headers.get("x-aegis-signature"), body))) {
345
+ return response({ error: "Invalid signature" }, 401);
346
+ }
347
+ let event: AegisEvent;
348
+ try {
349
+ event = JSON.parse(body) as AegisEvent;
350
+ } catch {
351
+ return response({ error: "Invalid JSON" }, 400);
352
+ }
353
+ if (typeof event.id !== "string" || typeof event.type !== "string" || typeof event.organizationId !== "string") {
354
+ return response({ error: "Malformed event" }, 400);
355
+ }
356
+ const fresh = await env.DB.prepare("INSERT OR IGNORE INTO aegis_events (id, type) VALUES (?, ?)").bind(event.id, event.type).run();
357
+ if (!fresh.meta.changes) return response({ duplicate: true }, 200);
358
+
359
+ const workspace = await linkedWorkspace(env, event.organizationId, event.externalRef);
360
+ const userId = typeof event.data?.userId === "string" ? event.data.userId : null;
361
+ if (!workspace || !userId) return response({ ignored: true }, 202);
362
+ const email = typeof event.data?.email === "string" ? event.data.email.toLowerCase() : undefined;
363
+
364
+ if (event.type === "user.deprovisioned") {
365
+ await deprovisionMember(env, workspace.id, userId, email);
366
+ } else if (event.type === "user.reactivated") {
367
+ await reactivateMember(env, workspace.id, userId);
368
+ } else if (event.type === "user.groups_changed") {
369
+ const groups = Array.isArray(event.data?.groups)
370
+ ? event.data.groups.filter((g): g is string => typeof g === "string").slice(0, 500)
371
+ : [];
372
+ await applyOrgMembership(env, { aegisId: userId, email }, { id: event.organizationId, groups }, { type: "scim", id: "aegis" });
373
+ } else {
374
+ return response({ ignored: true }, 202);
375
+ }
376
+ return response({ handled: true }, 202);
377
+ }
378
+
379
+ registerPublicRoutes(async ({ request, env, url }) => {
380
+ if (url.pathname === "/api/aegis/events" && request.method === "POST") return handleAegisEvent(request, env as SsoEnv);
381
+ return null;
382
+ });
383
+
384
+ // ---- Plan changes ----
385
+
386
+ async function ownerEmails(env: Env, workspaceId: string): Promise<string[]> {
387
+ const rows = await env.DB.prepare(`
388
+ SELECT DISTINCT u.email FROM workspace_memberships m JOIN aegis_users u ON u.id = m.aegis_id
389
+ WHERE m.workspace_id = ? AND m.role = 'owner' AND m.status = 'active' AND u.email IS NOT NULL
390
+ `).bind(workspaceId).all<{ email: string }>();
391
+ return rows.results.map((row) => row.email);
392
+ }
393
+
394
+ /** Tells Aegis what the workspace's plan now allows, starting a grace period when SSO is lost. */
395
+ export async function syncSsoEntitlements(env: SsoEnv, workspaceId: string, now = new Date()): Promise<void> {
396
+ const workspace = await workspaceSso(env, workspaceId);
397
+ if (!workspace?.aegisOrgId) return;
398
+ const { features } = await getEntitlements(env, workspaceId);
399
+ const previous = decodeAllowed(workspace.entitlement);
400
+ const allowed = planAllows(features);
401
+ let graceUntil = workspace.graceUntil;
402
+ let desired = allowed;
403
+ if (!losesAccess(previous, allowed)) {
404
+ graceUntil = null;
405
+ } else if (graceUntil && Date.parse(graceUntil) > now.getTime()) {
406
+ desired = previous;
407
+ } else if (graceUntil) {
408
+ // The notice period is over.
409
+ graceUntil = null;
410
+ } else {
411
+ desired = previous;
412
+ graceUntil = new Date(now.getTime() + SSO_GRACE_DAYS * 86_400_000).toISOString();
413
+ const dashboard = (env.DASHBOARD_URL ?? "https://app.bugmole.com").replace(/\/+$/, "");
414
+ const until = graceUntil.slice(0, 10);
415
+ const recipients = await ownerEmails(env, workspaceId);
416
+ if (recipients.length) {
417
+ await enqueue(env, "email.message", {
418
+ to: recipients.map((email) => ({ email })),
419
+ subject: allowed.sso
420
+ ? `SCIM provisioning for ${workspace.name} ends on ${until}`
421
+ : `Single sign-on for ${workspace.name} ends on ${until}`,
422
+ text: allowed.sso
423
+ ? `Your plan no longer includes SCIM provisioning. Your identity provider can keep managing ${workspace.name}'s members until ${until}; after that, add and remove people in Bugmole. Upgrade to keep SCIM: ${dashboard}/billing`
424
+ : `Your plan no longer includes single sign-on. Members of ${workspace.name} can keep signing in through your identity provider until ${until}. After that, they'll sign in with an email link instead, and SCIM provisioning stops. Upgrade to keep SSO: ${dashboard}/billing`,
425
+ html: allowed.sso
426
+ ? `<p>Your plan no longer includes SCIM provisioning.</p><p>Your identity provider can keep managing <strong>${escapeHtml(workspace.name)}</strong>'s members until <strong>${until}</strong>. After that, add and remove people in Bugmole.</p><p><a href="${dashboard}/billing">Upgrade to keep SCIM</a></p>`
427
+ : `<p>Your plan no longer includes single sign-on.</p><p>Members of <strong>${escapeHtml(workspace.name)}</strong> can keep signing in through your identity provider until <strong>${until}</strong>. After that, they'll sign in with an email link instead, and SCIM provisioning stops.</p><p><a href="${dashboard}/billing">Upgrade to keep SSO</a></p>`,
428
+ tag: "sso-grace",
429
+ }, { dedupeKey: `sso-grace:${workspaceId}:${until}` });
430
+ }
431
+ }
432
+ await setOrganizationEntitlements(env, workspace.aegisOrgId, desired);
433
+ await env.DB.prepare("UPDATE workspaces SET sso_entitlement = ?, sso_grace_until = ? WHERE id = ?")
434
+ .bind(encodeAllowed(desired), graceUntil, workspaceId).run();
435
+ }
436
+
437
+ registerOutboxHandler("aegis.entitlements", async (env, payload: { workspaceId: string }) => {
438
+ await syncSsoEntitlements(env as SsoEnv, payload.workspaceId);
439
+ });
440
+
441
+ planChangedHooks.push(async (env, event) => {
442
+ const linked = await env.DB.prepare("SELECT 1 AS yes FROM workspaces WHERE id = ? AND aegis_org_id IS NOT NULL")
443
+ .bind(event.workspaceId).first();
444
+ if (linked) await enqueue(env, "aegis.entitlements", { workspaceId: event.workspaceId });
445
+ });
446
+
447
+ // Grace periods that have run out, and a daily re-sync in case an update was lost.
448
+ registerJob("daily", "sso-entitlements", async (env, _ctx, now) => {
449
+ const rows = await env.DB.prepare(`
450
+ SELECT id FROM workspaces
451
+ WHERE aegis_org_id IS NOT NULL
452
+ AND (sso_grace_until IS NOT NULL OR sso_entitlement IS NULL OR sso_entitlement <> 'off')
453
+ `).all<{ id: string }>();
454
+ for (const row of rows.results) {
455
+ await enqueue(env, "aegis.entitlements", { workspaceId: row.id }, { dedupeKey: `aegis-entitlements:${row.id}:${now.toISOString().slice(0, 10)}` });
456
+ }
457
+ });
458
+