@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,296 @@
1
+ // Sign-in for cloud runs: a project's secrets (write-only) and its sign-in flow.
2
+ //
3
+ // Who can do what:
4
+ // - Project managers, through the dashboard, set, replace and delete secrets.
5
+ // No route ever returns a value to a browser or to an API key; the list
6
+ // carries names and who changed them when.
7
+ // - Machine API keys (self-hosted `bugmole serve`, CI) can neither write nor
8
+ // read values: those workers keep using BUGMOLE_FLOW_SECRET_* from their
9
+ // own environment.
10
+ // - A Bugmole Cloud runner reads the values for the one run or exploration it
11
+ // holds, with its run-scoped token, and nothing else.
12
+ import type { Env, Principal } from "../core.js";
13
+ import { memberOfProject, newId, requestJson, response } from "../core.js";
14
+ import { auditProject } from "../org/audit.js";
15
+ import { matchPath, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
16
+ import { cloudWorkerId } from "../runner/jobs.js";
17
+ import { parseMaestroStyleFlow, referencedSecretNames } from "../../runtime/flow-language.js";
18
+ import { currentKeyVersion, openSecret, PROJECT_SECRETS_UNCONFIGURED, sealSecret, secretsConfigured, type SecretScope } from "./crypto.js";
19
+
20
+ export const SECRET_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
21
+ export const SECRET_VALUE_MAX = 4096;
22
+ export const SIGN_IN_FLOW_MAX = 20_000;
23
+
24
+ const unconfigured = () => response({ error: PROJECT_SECRETS_UNCONFIGURED, code: "project_secrets_unconfigured" }, 503);
25
+ const forbidden = () => response({ error: "Project scope is not allowed" }, 403);
26
+
27
+ type SecretRow = {
28
+ id: string;
29
+ environmentId: string | null;
30
+ name: string;
31
+ ciphertext: string;
32
+ iv: string;
33
+ keyVersion: number;
34
+ createdBy: string;
35
+ updatedBy: string;
36
+ createdAt: string;
37
+ updatedAt: string;
38
+ lastUsedAt: string | null;
39
+ };
40
+
41
+ /** What anyone but a runner may see of a secret: never its value. */
42
+ function metadata(row: Pick<SecretRow, "name" | "environmentId" | "createdBy" | "updatedBy" | "createdAt" | "updatedAt" | "lastUsedAt">) {
43
+ return {
44
+ name: row.name,
45
+ environmentId: row.environmentId,
46
+ createdBy: row.createdBy,
47
+ updatedBy: row.updatedBy,
48
+ createdAt: row.createdAt,
49
+ updatedAt: row.updatedAt,
50
+ lastUsedAt: row.lastUsedAt,
51
+ };
52
+ }
53
+
54
+ async function secretRows(env: Env, projectId: string): Promise<SecretRow[]> {
55
+ const rows = await env.DB.prepare(`
56
+ SELECT id, environment_id AS environmentId, name, ciphertext, iv, key_version AS keyVersion,
57
+ created_by AS createdBy, updated_by AS updatedBy, created_at AS createdAt,
58
+ updated_at AS updatedAt, last_used_at AS lastUsedAt
59
+ FROM project_secrets WHERE project_id = ?
60
+ ORDER BY name ASC, environment_id ASC
61
+ `).bind(projectId).all<SecretRow>();
62
+ return rows.results;
63
+ }
64
+
65
+ /** Values are written by people in the dashboard, never by keys or runners. */
66
+ function mayWriteSecrets(principal: Principal): boolean {
67
+ return !principal.runner && !principal.apiKeyId;
68
+ }
69
+
70
+ async function environmentExists(env: Env, projectId: string, environmentId: string): Promise<boolean> {
71
+ const row = await env.DB.prepare("SELECT id FROM project_environments WHERE project_id = ? AND id = ?")
72
+ .bind(projectId, environmentId).first();
73
+ return Boolean(row);
74
+ }
75
+
76
+ async function listSecrets({ env, principal }: AuthedRouteContext, projectId: string): Promise<Response> {
77
+ if (principal.runner || !(await memberOfProject(env, principal, projectId))) return forbidden();
78
+ if (!secretsConfigured(env)) return unconfigured();
79
+ const environments = await env.DB.prepare(`
80
+ SELECT id, name FROM project_environments WHERE project_id = ? AND status = 'active' ORDER BY is_default DESC, name ASC
81
+ `).bind(projectId).all<{ id: string; name: string }>();
82
+ return response({
83
+ secrets: (await secretRows(env, projectId)).map(metadata),
84
+ environments: environments.results,
85
+ });
86
+ }
87
+
88
+ async function setSecret({ env, principal, request, url }: AuthedRouteContext, projectId: string, name: string): Promise<Response> {
89
+ if (!mayWriteSecrets(principal)) return response({ error: "Secrets are set by a project manager in the dashboard" }, 403);
90
+ if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return forbidden();
91
+ if (!secretsConfigured(env)) return unconfigured();
92
+ if (!SECRET_NAME.test(name)) return response({ error: "name must be capital letters, digits and _ (starting with a letter), up to 64 characters" }, 422);
93
+ const environmentId = url.searchParams.get("environment") || null;
94
+ if (environmentId && !(await environmentExists(env, projectId, environmentId))) return response({ error: "Unknown environment for this project" }, 422);
95
+ const body = await requestJson(request);
96
+ const value = body?.value;
97
+ if (typeof value !== "string" || value.length === 0) return response({ error: "value is required" }, 422);
98
+ if (value.length > SECRET_VALUE_MAX) return response({ error: `value can be up to ${SECRET_VALUE_MAX} characters` }, 422);
99
+
100
+ const scope: SecretScope = { projectId, environmentId, name };
101
+ const sealed = await sealSecret(env, scope, value);
102
+ const existing = await env.DB.prepare(`
103
+ SELECT id FROM project_secrets WHERE project_id = ? AND COALESCE(environment_id, '') = ? AND name = ?
104
+ `).bind(projectId, environmentId ?? "", name).first<{ id: string }>();
105
+ if (existing) {
106
+ await env.DB.prepare(`
107
+ UPDATE project_secrets
108
+ SET ciphertext = ?, iv = ?, key_version = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP
109
+ WHERE id = ?
110
+ `).bind(sealed.ciphertext, sealed.iv, sealed.keyVersion, principal.aegisId, existing.id).run();
111
+ } else {
112
+ try {
113
+ await env.DB.prepare(`
114
+ INSERT INTO project_secrets (id, project_id, environment_id, name, ciphertext, iv, key_version, created_by, updated_by)
115
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
116
+ `).bind(newId("psec"), projectId, environmentId, name, sealed.ciphertext, sealed.iv, sealed.keyVersion, principal.aegisId, principal.aegisId).run();
117
+ } catch {
118
+ return response({ error: "That secret was just set by someone else; try again" }, 409);
119
+ }
120
+ }
121
+ await auditProject(env, principal, projectId, {
122
+ action: existing ? "project_secret.replaced" : "project_secret.set",
123
+ targetType: "project_secret",
124
+ targetId: name,
125
+ data: { name, environmentId },
126
+ }, request);
127
+ const row = (await secretRows(env, projectId)).find((candidate) => candidate.name === name && candidate.environmentId === environmentId);
128
+ return response({ secret: row ? metadata(row) : { name, environmentId } }, existing ? 200 : 201);
129
+ }
130
+
131
+ async function deleteSecret({ env, principal, request, url }: AuthedRouteContext, projectId: string, name: string): Promise<Response> {
132
+ if (!mayWriteSecrets(principal)) return response({ error: "Secrets are removed by a project manager in the dashboard" }, 403);
133
+ if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return forbidden();
134
+ if (!secretsConfigured(env)) return unconfigured();
135
+ const environmentId = url.searchParams.get("environment") || null;
136
+ const deleted = await env.DB.prepare(`
137
+ DELETE FROM project_secrets WHERE project_id = ? AND COALESCE(environment_id, '') = ? AND name = ?
138
+ `).bind(projectId, environmentId ?? "", name).run();
139
+ if ((deleted.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
140
+ await auditProject(env, principal, projectId, {
141
+ action: "project_secret.deleted",
142
+ targetType: "project_secret",
143
+ targetId: name,
144
+ data: { name, environmentId },
145
+ }, request);
146
+ return response({ deleted: true });
147
+ }
148
+
149
+ type SignInFlowRow = { content: string; updatedBy: string; updatedAt: string };
150
+
151
+ export async function signInFlowOf(env: Env, projectId: string): Promise<SignInFlowRow | null> {
152
+ return env.DB.prepare(`
153
+ SELECT content, updated_by AS updatedBy, updated_at AS updatedAt FROM project_sign_in_flows WHERE project_id = ?
154
+ `).bind(projectId).first<SignInFlowRow>();
155
+ }
156
+
157
+ async function describeSignInFlow(env: Env, projectId: string, row: SignInFlowRow) {
158
+ const references = referencedSecretNames(row.content);
159
+ const names = await env.DB.prepare("SELECT DISTINCT name FROM project_secrets WHERE project_id = ?").bind(projectId).all<{ name: string }>();
160
+ const set = new Set(names.results.map((entry) => entry.name));
161
+ return { ...row, references, missing: references.filter((name) => !set.has(name)) };
162
+ }
163
+
164
+ async function getSignInFlow({ env, principal }: AuthedRouteContext, projectId: string): Promise<Response> {
165
+ if (principal.runner || !(await memberOfProject(env, principal, projectId))) return forbidden();
166
+ const row = await signInFlowOf(env, projectId);
167
+ return response({ signInFlow: row ? await describeSignInFlow(env, projectId, row) : null });
168
+ }
169
+
170
+ /** Checks a sign-in flow is runnable and holds no literal value of a stored secret. */
171
+ async function checkSignInFlow(env: Env, projectId: string, content: string): Promise<string | null> {
172
+ let steps: number;
173
+ try {
174
+ steps = parseMaestroStyleFlow(content, "https://example.invalid").steps.length;
175
+ } catch (error) {
176
+ return `The sign-in flow doesn't parse: ${error instanceof Error ? error.message : String(error)}`;
177
+ }
178
+ if (steps === 0) return "The sign-in flow has no steps";
179
+ for (const name of referencedSecretNames(content)) {
180
+ if (!SECRET_NAME.test(name)) return `{{secret.${name}}} isn't a valid secret name: use capital letters, digits and _`;
181
+ }
182
+ if (!secretsConfigured(env)) return null;
183
+ for (const row of await secretRows(env, projectId)) {
184
+ const value = await openSecret(env, { projectId, environmentId: row.environmentId, name: row.name }, row).catch(() => null);
185
+ if (value && value.length >= 4 && content.includes(value)) {
186
+ return `The flow contains the value of the secret ${row.name}. Type it as {{secret.${row.name}}} instead; nothing was saved.`;
187
+ }
188
+ }
189
+ return null;
190
+ }
191
+
192
+ async function putSignInFlow({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
193
+ if (principal.runner || !(await memberOfProject(env, principal, projectId, "project.manage"))) return forbidden();
194
+ const body = await requestJson(request);
195
+ const content = typeof body?.content === "string" ? body.content.replace(/\r\n/g, "\n") : "";
196
+ if (!content.trim()) return response({ error: "content is required" }, 422);
197
+ if (content.length > SIGN_IN_FLOW_MAX) return response({ error: `The sign-in flow can be up to ${SIGN_IN_FLOW_MAX} characters` }, 422);
198
+ const problem = await checkSignInFlow(env, projectId, content);
199
+ if (problem) return response({ error: problem }, 422);
200
+ await env.DB.prepare(`
201
+ INSERT INTO project_sign_in_flows (project_id, content, updated_by, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)
202
+ ON CONFLICT(project_id) DO UPDATE SET content = excluded.content, updated_by = excluded.updated_by, updated_at = CURRENT_TIMESTAMP
203
+ `).bind(projectId, content, principal.aegisId).run();
204
+ await auditProject(env, principal, projectId, {
205
+ action: "sign_in_flow.updated",
206
+ targetType: "project",
207
+ targetId: projectId,
208
+ data: { references: referencedSecretNames(content) },
209
+ }, request);
210
+ const row = await signInFlowOf(env, projectId);
211
+ return response({ signInFlow: row ? await describeSignInFlow(env, projectId, row) : null });
212
+ }
213
+
214
+ async function deleteSignInFlow({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
215
+ if (principal.runner || !(await memberOfProject(env, principal, projectId, "project.manage"))) return forbidden();
216
+ const deleted = await env.DB.prepare("DELETE FROM project_sign_in_flows WHERE project_id = ?").bind(projectId).run();
217
+ if ((deleted.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
218
+ await auditProject(env, principal, projectId, { action: "sign_in_flow.deleted", targetType: "project", targetId: projectId }, request);
219
+ return response({ deleted: true });
220
+ }
221
+
222
+ /**
223
+ * The decrypted values for a cloud runner's own run or exploration: project-
224
+ * wide values, with the run environment's own values taking their place.
225
+ */
226
+ async function releaseToRunner({ env, principal, request }: AuthedRouteContext, kind: "run" | "task", jobId: string): Promise<Response> {
227
+ const runner = principal.runner;
228
+ if (!runner || runner.kind !== kind || runner.id !== jobId) return response({ error: "Not found" }, 404);
229
+ const table = kind === "run" ? "runs" : "agent_tasks";
230
+ const environmentColumn = kind === "run" ? "environment_id" : "NULL";
231
+ const job = await env.DB.prepare(`
232
+ SELECT project_id AS projectId, status, worker_id AS workerId, ${environmentColumn} AS environmentId FROM ${table} WHERE id = ?
233
+ `).bind(jobId).first<{ projectId: string; status: string; workerId: string | null; environmentId: string | null }>();
234
+ if (!job || job.projectId !== runner.projectId) return response({ error: "Not found" }, 404);
235
+ // Only while the runner holds the job: never after it ended or moved on.
236
+ if (job.status !== "running" || job.workerId !== cloudWorkerId({ kind, id: jobId })) {
237
+ return response({ error: `This ${kind} isn't held by a cloud runner`, status: job.status }, 409);
238
+ }
239
+ if (!secretsConfigured(env)) return unconfigured();
240
+ const projectId = runner.projectId;
241
+ const environmentId = job.environmentId || null;
242
+ const rows = (await secretRows(env, projectId)).filter((row) => row.environmentId === null || row.environmentId === environmentId);
243
+ const secrets: Record<string, string> = {};
244
+ const unreadable: string[] = [];
245
+ const used: SecretRow[] = [];
246
+ // Project-wide first, so an environment's own value replaces it.
247
+ for (const row of [...rows].sort((a, b) => Number(a.environmentId !== null) - Number(b.environmentId !== null))) {
248
+ try {
249
+ secrets[row.name] = await openSecret(env, { projectId, environmentId: row.environmentId, name: row.name }, row);
250
+ used.push(row);
251
+ } catch {
252
+ unreadable.push(row.name);
253
+ }
254
+ }
255
+ const current = currentKeyVersion(env);
256
+ for (const row of used) {
257
+ // Re-seal a value still under a previous key, so the old key can retire.
258
+ if (current !== null && row.keyVersion !== current) {
259
+ const sealed = await sealSecret(env, { projectId, environmentId: row.environmentId, name: row.name }, secrets[row.name]).catch(() => null);
260
+ if (sealed) {
261
+ await env.DB.prepare("UPDATE project_secrets SET ciphertext = ?, iv = ?, key_version = ? WHERE id = ? AND key_version = ?")
262
+ .bind(sealed.ciphertext, sealed.iv, sealed.keyVersion, row.id, row.keyVersion).run();
263
+ }
264
+ }
265
+ }
266
+ if (used.length) {
267
+ const placeholders = used.map(() => "?").join(", ");
268
+ await env.DB.prepare(`UPDATE project_secrets SET last_used_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`)
269
+ .bind(...used.map((row) => row.id)).run();
270
+ await auditProject(env, principal, projectId, {
271
+ action: "project_secret.released",
272
+ targetType: kind,
273
+ targetId: jobId,
274
+ data: { names: [...new Set(used.map((row) => row.name))].sort(), environmentId },
275
+ }, request);
276
+ }
277
+ return response({ environmentId, secrets, ...(unreadable.length ? { unreadable: [...new Set(unreadable)] } : {}) });
278
+ }
279
+
280
+ registerRoutes(async (context) => {
281
+ const { url, request } = context;
282
+ let params = matchPath(url.pathname, "/api/projects/:projectId/secrets");
283
+ if (params && request.method === "GET") return listSecrets(context, params.projectId);
284
+ params = matchPath(url.pathname, "/api/projects/:projectId/secrets/:name");
285
+ if (params && request.method === "PUT") return setSecret(context, params.projectId, params.name);
286
+ if (params && request.method === "DELETE") return deleteSecret(context, params.projectId, params.name);
287
+ params = matchPath(url.pathname, "/api/projects/:projectId/sign-in-flow");
288
+ if (params && request.method === "GET") return getSignInFlow(context, params.projectId);
289
+ if (params && request.method === "PUT") return putSignInFlow(context, params.projectId);
290
+ if (params && request.method === "DELETE") return deleteSignInFlow(context, params.projectId);
291
+ params = matchPath(url.pathname, "/api/runner/runs/:runId/secrets");
292
+ if (params && request.method === "GET") return releaseToRunner(context, "run", params.runId);
293
+ params = matchPath(url.pathname, "/api/runner/tasks/:taskId/secrets");
294
+ if (params && request.method === "GET") return releaseToRunner(context, "task", params.taskId);
295
+ return null;
296
+ });
@@ -0,0 +1,237 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { call, createTestEnv, seedPlan, seedProject, seedVerifiedDomain } from "../testing/harness.js";
4
+ import { runnerLauncher, type LaunchRequest } from "../runner/dispatch.js";
5
+ import { runnerMayCall, signRunnerToken } from "../runner/tokens.js";
6
+ import { openSecret, rootKeys, sealSecret, secretsConfigured } from "./crypto.js";
7
+
8
+ const KEY = Buffer.alloc(32, 7).toString("base64");
9
+ const OTHER_KEY = Buffer.alloc(32, 9).toString("base64");
10
+ const ENV = { PROJECT_SECRETS_KEY: KEY, RUNNER_TOKEN_SECRET: "runner-test-secret", FEATURE_CLOUD_RUNNERS: "on", REGISTRY_PUBLIC_URL: "https://registry.test" };
11
+ const SIGN_IN = [
12
+ "url: https://shop.test/login",
13
+ "name: Sign in",
14
+ "---",
15
+ "- launchApp",
16
+ "- tapOn: Email",
17
+ '- inputText: "{{secret.APP_EMAIL}}"',
18
+ "- tapOn: Password",
19
+ '- inputText: "{{secret.APP_PASSWORD}}"',
20
+ "- tapOn: Sign in",
21
+ "",
22
+ ].join("\n");
23
+
24
+ async function inviteAndJoin(env: Parameters<typeof call>[0], inviter: string, workspaceId: string, who: string, role: string) {
25
+ const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: inviter, body: { email: `${who}@example.com`, role } });
26
+ assert.equal(invited.status, 201, JSON.stringify(invited.json));
27
+ const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
28
+ assert.equal((await call(env, "POST", `/api/invitations/${token}/accept`, { as: who })).status, 200);
29
+ }
30
+
31
+ /** A project on Bugmole storage, with one cloud run and one cloud exploration held by runners. */
32
+ async function cloudProject(overrides: Record<string, string> = {}) {
33
+ const setup = createTestEnv({ ...ENV, ...overrides });
34
+ const project = await seedProject(setup.env, { storageProvider: "bugmole" });
35
+ await seedPlan(setup.env, project.projectId, project.owner);
36
+ seedVerifiedDomain(setup.db, project.workspaceId, "shop.test");
37
+ setup.db.db.prepare("INSERT INTO project_environments (id, project_id, name, base_url, status) VALUES ('staging', ?, 'Staging', 'https://shop.test', 'active')")
38
+ .run(project.projectId);
39
+ const launches: LaunchRequest[] = [];
40
+ const original = runnerLauncher.start;
41
+ runnerLauncher.start = async (_env, launch) => {
42
+ launches.push(launch);
43
+ };
44
+ try {
45
+ const run = await call(setup.env, "POST", `/api/projects/${project.projectId}/runs`, {
46
+ as: project.owner,
47
+ headers: { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" },
48
+ body: { planId: "checkout", idempotencyKey: "r1", executionMode: "cloud" },
49
+ });
50
+ assert.equal(run.status, 201, JSON.stringify(run.json));
51
+ const task = await call(setup.env, "POST", `/api/projects/${project.projectId}/tasks`, {
52
+ as: project.owner,
53
+ body: { title: "Explore https://shop.test", description: "Explore https://shop.test/", taskType: "discovery", idempotencyKey: "d1", executionMode: "cloud" },
54
+ });
55
+ assert.equal(task.status, 201, JSON.stringify(task.json));
56
+ } finally {
57
+ runnerLauncher.start = original;
58
+ }
59
+ const runLaunch = launches.find((launch) => launch.kind === "run")!;
60
+ const taskLaunch = launches.find((launch) => launch.kind === "task")!;
61
+ assert.ok(runLaunch && taskLaunch, "both jobs went to runners");
62
+ const put = (name: string, value: string, as = project.owner, environment?: string) =>
63
+ call(setup.env, "PUT", `/api/projects/${project.projectId}/secrets/${name}${environment ? `?environment=${environment}` : ""}`, { as, body: { value } });
64
+ return { ...setup, ...project, runLaunch, taskLaunch, put };
65
+ }
66
+
67
+ test("a sealed secret opens only for its own project, environment and name", async () => {
68
+ const env = { PROJECT_SECRETS_KEY: KEY };
69
+ const scope = { projectId: "shop", environmentId: null, name: "APP_PASSWORD" };
70
+ const sealed = await sealSecret(env, scope, "hunter2-long");
71
+ assert.equal(sealed.keyVersion, 1);
72
+ assert.ok(!sealed.ciphertext.includes("hunter2"));
73
+ assert.equal(await openSecret(env, scope, sealed), "hunter2-long");
74
+ const again = await sealSecret(env, scope, "hunter2-long");
75
+ assert.notEqual(again.ciphertext, sealed.ciphertext, "a fresh IV every time");
76
+
77
+ await assert.rejects(openSecret(env, { ...scope, projectId: "other" }, sealed), "another project's key can't open it");
78
+ await assert.rejects(openSecret(env, { ...scope, name: "APP_EMAIL" }, sealed), "moving it to another name fails");
79
+ await assert.rejects(openSecret(env, { ...scope, environmentId: "prod" }, sealed), "moving it to one environment fails");
80
+ const tampered = { ...sealed, ciphertext: `${sealed.ciphertext.slice(0, -4)}AAAA` };
81
+ await assert.rejects(openSecret(env, scope, tampered), "tampering is detected");
82
+ await assert.rejects(openSecret({ PROJECT_SECRETS_KEY: OTHER_KEY }, scope, sealed), "a different root key can't open it");
83
+ });
84
+
85
+ test("the key must be 32 bytes, and rotation keeps the previous key readable", async () => {
86
+ assert.equal(secretsConfigured({}), false);
87
+ assert.equal(secretsConfigured({ PROJECT_SECRETS_KEY: Buffer.alloc(16).toString("base64") }), false, "too short a key turns the feature off");
88
+ await assert.rejects(sealSecret({}, { projectId: "p", environmentId: null, name: "A" }, "value"), /PROJECT_SECRETS_KEY/);
89
+
90
+ const scope = { projectId: "shop", environmentId: "staging", name: "APP_PASSWORD" };
91
+ const old = await sealSecret({ PROJECT_SECRETS_KEY: KEY }, scope, "old-value");
92
+ const rotated = { PROJECT_SECRETS_KEY: OTHER_KEY, PROJECT_SECRETS_KEY_VERSION: "2", PROJECT_SECRETS_KEY_PREVIOUS: KEY, PROJECT_SECRETS_KEY_PREVIOUS_VERSION: "1" };
93
+ assert.deepEqual(rootKeys(rotated).map((key) => key.version), [2, 1]);
94
+ assert.equal(await openSecret(rotated, scope, old), "old-value");
95
+ assert.equal((await sealSecret(rotated, scope, "new")).keyVersion, 2);
96
+ await assert.rejects(openSecret({ PROJECT_SECRETS_KEY: OTHER_KEY, PROJECT_SECRETS_KEY_VERSION: "2" }, scope, old), /key version 1/);
97
+ });
98
+
99
+ test("secrets are write-only: managers set them, and no list or API ever returns a value", async () => {
100
+ const { env, db, owner, projectId, workspaceId, put } = await cloudProject();
101
+ assert.equal((await put("APP_PASSWORD", "correct-horse-battery")).status, 201);
102
+ assert.equal((await put("APP_PASSWORD", "correct-horse-battery-2")).status, 200, "replacing");
103
+ assert.equal((await put("APP_PASSWORD", "staging-only-value", owner, "staging")).status, 201, "one environment's own value");
104
+ assert.equal((await put("app_password", "x")).status, 422, "names are upper case");
105
+ assert.equal((await put("APP_PASSWORD", "x", owner, "nowhere")).status, 422, "unknown environment");
106
+ assert.equal((await call(env, "PUT", `/api/projects/${projectId}/secrets/EMPTY`, { as: owner, body: { value: "" } })).status, 422);
107
+
108
+ const stored = db.db.prepare("SELECT ciphertext, iv, key_version AS keyVersion, updated_by AS updatedBy FROM project_secrets WHERE project_id = ?").all(projectId) as any[];
109
+ assert.equal(stored.length, 2, "one project-wide row and one environment row");
110
+ assert.ok(stored.every((row) => !String(row.ciphertext).includes("correct-horse") && row.keyVersion === 1 && row.updatedBy === owner));
111
+
112
+ const list = await call(env, "GET", `/api/projects/${projectId}/secrets`, { as: owner });
113
+ assert.equal(list.status, 200);
114
+ assert.deepEqual(list.json.secrets.map((secret: { name: string; environmentId: string | null }) => [secret.name, secret.environmentId]), [["APP_PASSWORD", null], ["APP_PASSWORD", "staging"]]);
115
+ const text = JSON.stringify(list.json);
116
+ assert.ok(!/correct-horse|staging-only-value/.test(text), "no value in the list");
117
+ assert.ok(!/ciphertext|"iv"/.test(text), "not even the ciphertext");
118
+ assert.ok(list.json.environments.some((environment: { id: string }) => environment.id === "staging"));
119
+
120
+ // A member reads names but can't change anything; a stranger sees nothing.
121
+ await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
122
+ assert.equal((await call(env, "GET", `/api/projects/${projectId}/secrets`, { as: "user_member" })).status, 200);
123
+ assert.equal((await put("APP_PASSWORD", "hijack-value", "user_member")).status, 403);
124
+ assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/secrets/APP_PASSWORD`, { as: "user_member" })).status, 403);
125
+ assert.equal((await call(env, "GET", `/api/projects/${projectId}/secrets`, { as: "user_stranger" })).status, 403);
126
+
127
+ // API keys (self-hosted workers, CI) neither write nor read values.
128
+ const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "ci", projectIds: [projectId] } });
129
+ const token = key.json.token as string;
130
+ assert.equal((await call(env, "PUT", `/api/projects/${projectId}/secrets/APP_PASSWORD`, { token, body: { value: "from-a-key" } })).status, 403);
131
+ assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/secrets/APP_PASSWORD`, { token })).status, 403);
132
+ const listedByKey = await call(env, "GET", `/api/projects/${projectId}/secrets`, { token });
133
+ assert.ok(!JSON.stringify(listedByKey.json).includes("correct-horse"));
134
+ const runId = (db.db.prepare("SELECT id FROM runs WHERE project_id = ?").get(projectId) as { id: string }).id;
135
+ assert.equal((await call(env, "GET", `/api/runner/runs/${runId}/secrets`, { token })).status, 404, "values are for runners only");
136
+ assert.equal((await call(env, "GET", `/api/runner/runs/${runId}/secrets`, { as: owner })).status, 404, "not even the owner's browser");
137
+
138
+ // Audited without values.
139
+ const audit = db.db.prepare("SELECT action, data_json AS data FROM audit_log WHERE project_id = ? AND action LIKE 'project_secret.%' ORDER BY rowid").all(projectId) as any[];
140
+ assert.deepEqual(audit.map((row) => row.action), ["project_secret.set", "project_secret.replaced", "project_secret.set"]);
141
+ assert.ok(audit.every((row) => !String(row.data).includes("correct-horse")));
142
+
143
+ assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/secrets/APP_PASSWORD?environment=staging`, { as: owner })).status, 200);
144
+ assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/secrets/APP_PASSWORD?environment=staging`, { as: owner })).status, 404);
145
+ const after = await call(env, "GET", `/api/projects/${projectId}/secrets`, { as: owner });
146
+ assert.deepEqual(after.json.secrets.map((secret: { environmentId: string | null }) => secret.environmentId), [null], "the project-wide value stays");
147
+ });
148
+
149
+ test("without PROJECT_SECRETS_KEY the feature is off and nothing is stored", async () => {
150
+ const { env, db, owner, projectId, runLaunch } = await cloudProject({ PROJECT_SECRETS_KEY: "" });
151
+ const set = await call(env, "PUT", `/api/projects/${projectId}/secrets/APP_PASSWORD`, { as: owner, body: { value: "plain-text-value" } });
152
+ assert.equal(set.status, 503);
153
+ assert.match(set.json.error, /PROJECT_SECRETS_KEY/);
154
+ assert.equal((await call(env, "GET", `/api/projects/${projectId}/secrets`, { as: owner })).status, 503);
155
+ assert.equal((await call(env, "GET", `/api/runner/runs/${runLaunch.id}/secrets`, { token: runLaunch.token })).status, 503);
156
+ assert.equal((db.db.prepare("SELECT COUNT(*) AS c FROM project_secrets").get() as { c: number }).c, 0);
157
+ });
158
+
159
+ test("a cloud runner reads its own job's secrets, with the run's environment overriding", async () => {
160
+ const { env, db, owner, projectId, runLaunch, taskLaunch, put } = await cloudProject();
161
+ await put("APP_EMAIL", "qa@shop.test");
162
+ await put("APP_PASSWORD", "project-wide-pass");
163
+ await put("APP_PASSWORD", "staging-pass", owner, "staging");
164
+ db.db.prepare("UPDATE runs SET environment_id = 'staging' WHERE id = ?").run(runLaunch.id);
165
+
166
+ const run = await call(env, "GET", `/api/runner/runs/${runLaunch.id}/secrets`, { token: runLaunch.token });
167
+ assert.equal(run.status, 200, JSON.stringify(run.json));
168
+ assert.deepEqual(run.json.secrets, { APP_EMAIL: "qa@shop.test", APP_PASSWORD: "staging-pass" });
169
+ assert.equal(run.json.environmentId, "staging");
170
+
171
+ const task = await call(env, "GET", `/api/runner/tasks/${taskLaunch.id}/secrets`, { token: taskLaunch.token });
172
+ assert.deepEqual(task.json.secrets, { APP_EMAIL: "qa@shop.test", APP_PASSWORD: "project-wide-pass" }, "an exploration has no environment");
173
+
174
+ const used = db.db.prepare("SELECT COUNT(*) AS c FROM project_secrets WHERE last_used_at IS NOT NULL").get() as { c: number };
175
+ assert.equal(used.c, 3);
176
+ const released = db.db.prepare("SELECT actor_type AS actor, data_json AS data FROM audit_log WHERE action = 'project_secret.released' ORDER BY rowid").all() as any[];
177
+ assert.equal(released.length, 2);
178
+ assert.equal(released[0].actor, "runner");
179
+ assert.deepEqual(JSON.parse(released[0].data).names, ["APP_EMAIL", "APP_PASSWORD"]);
180
+ assert.ok(released.every((row) => !String(row.data).includes("pass")), "names, never values");
181
+
182
+ // A runner token only reaches its own job's secrets.
183
+ assert.equal((await call(env, "GET", `/api/runner/tasks/${taskLaunch.id}/secrets`, { token: runLaunch.token })).status, 403);
184
+ assert.equal((await call(env, "GET", `/api/projects/${projectId}/secrets`, { token: runLaunch.token })).status, 403);
185
+ const forged = await signRunnerToken(env, { kind: "run", id: runLaunch.id, projectId: "someone-else", workspaceId: null });
186
+ assert.equal((await call(env, "GET", `/api/runner/runs/${runLaunch.id}/secrets`, { token: forged })).status, 404, "a token for another project gets nothing");
187
+ const allowed = (path: string) => runnerMayCall({ kind: "run", id: "run_1", projectId: "shop", workspaceId: null }, "GET", new URL(`https://r${path}`));
188
+ assert.equal(allowed("/api/runner/runs/run_1/secrets"), true);
189
+ assert.equal(allowed("/api/runner/runs/run_2/secrets"), false);
190
+ assert.equal(allowed("/api/runner/tasks/run_1/secrets"), false);
191
+
192
+ // Once the run is over, its token reads nothing more.
193
+ db.db.prepare("UPDATE runs SET status = 'success' WHERE id = ?").run(runLaunch.id);
194
+ assert.equal((await call(env, "GET", `/api/runner/runs/${runLaunch.id}/secrets`, { token: runLaunch.token })).status, 409);
195
+ });
196
+
197
+ test("rotating the key re-seals a value the next time a runner reads it", async () => {
198
+ const { env, db, projectId, runLaunch, put } = await cloudProject();
199
+ await put("APP_PASSWORD", "rotate-me-please");
200
+ Object.assign(env, { PROJECT_SECRETS_KEY: OTHER_KEY, PROJECT_SECRETS_KEY_VERSION: "2", PROJECT_SECRETS_KEY_PREVIOUS: KEY, PROJECT_SECRETS_KEY_PREVIOUS_VERSION: "1" });
201
+ const read = await call(env, "GET", `/api/runner/runs/${runLaunch.id}/secrets`, { token: runLaunch.token });
202
+ assert.deepEqual(read.json.secrets, { APP_PASSWORD: "rotate-me-please" });
203
+ const row = db.db.prepare("SELECT key_version AS v FROM project_secrets WHERE project_id = ?").get(projectId) as { v: number };
204
+ assert.equal(row.v, 2);
205
+ Object.assign(env, { PROJECT_SECRETS_KEY_PREVIOUS: "" });
206
+ assert.deepEqual((await call(env, "GET", `/api/runner/runs/${runLaunch.id}/secrets`, { token: runLaunch.token })).json.secrets, { APP_PASSWORD: "rotate-me-please" });
207
+ });
208
+
209
+ test("a project's sign-in flow names its secrets, never holds their values, and reaches the exploration runner", async () => {
210
+ const { env, owner, projectId, workspaceId, taskLaunch, put } = await cloudProject();
211
+ const save = (content: string, as = owner) => call(env, "PUT", `/api/projects/${projectId}/sign-in-flow`, { as, body: { content } });
212
+ assert.equal((await call(env, "GET", `/api/projects/${projectId}/sign-in-flow`, { as: owner })).json.signInFlow, null);
213
+
214
+ const saved = await save(SIGN_IN);
215
+ assert.equal(saved.status, 200, JSON.stringify(saved.json));
216
+ assert.deepEqual(saved.json.signInFlow.references, ["APP_EMAIL", "APP_PASSWORD"]);
217
+ assert.deepEqual(saved.json.signInFlow.missing, ["APP_EMAIL", "APP_PASSWORD"]);
218
+ await put("APP_PASSWORD", "s3cret-pass");
219
+ const read = await call(env, "GET", `/api/projects/${projectId}/sign-in-flow`, { as: owner });
220
+ assert.deepEqual(read.json.signInFlow.missing, ["APP_EMAIL"]);
221
+
222
+ const leaked = await save(SIGN_IN.replace("{{secret.APP_PASSWORD}}", "s3cret-pass"));
223
+ assert.equal(leaked.status, 422);
224
+ assert.match(leaked.json.error, /APP_PASSWORD/);
225
+ assert.ok(!leaked.json.error.includes("s3cret-pass"));
226
+ assert.equal((await save("not a flow")).status, 422);
227
+
228
+ await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
229
+ assert.equal((await save(SIGN_IN, "user_member")).status, 403);
230
+
231
+ const bundle = await call(env, "GET", `/api/runner/tasks/${taskLaunch.id}/bundle`, { token: taskLaunch.token });
232
+ assert.equal(bundle.status, 200);
233
+ assert.equal(bundle.json.signInFlow, SIGN_IN);
234
+
235
+ assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/sign-in-flow`, { as: owner })).status, 200);
236
+ assert.equal((await call(env, "GET", `/api/runner/tasks/${taskLaunch.id}/bundle`, { token: taskLaunch.token })).json.signInFlow, null);
237
+ });
@@ -3,7 +3,7 @@
3
3
  // whose email Aegis confirmed.
4
4
  import type { Env, Principal } from "../core.js";
5
5
  import { response } from "../core.js";
6
- import { entitlementMode } from "../billing/enforcement.js";
6
+ import { billingLinks, entitlementMode } from "../billing/enforcement.js";
7
7
  import { getWorkspacePlan } from "../billing/entitlements.js";
8
8
  import { flagMode } from "../flags.js";
9
9
 
@@ -30,7 +30,7 @@ export async function freeWorkspaceLimit(env: Env, principal: Principal): Promis
30
30
  error: "You already have a Free workspace. Upgrade it, or ask a teammate to invite you to theirs.",
31
31
  code: "free_workspace_exists",
32
32
  workspaceId: owned[0],
33
- upgradeUrl: "/billing",
33
+ ...billingLinks(env, owned[0], "team"),
34
34
  }, 402);
35
35
  }
36
36
 
@@ -6,6 +6,7 @@ import { newId, response, requestJson } from "../core.js";
6
6
  import { recordAudit, actorOf } from "../org/audit.js";
7
7
  import { matchPath, registerRoutes } from "../routes/index.js";
8
8
  import { ownedFreeWorkspaces } from "./policy.js";
9
+ import { domainIdentity, identityInUse, identityValues } from "../projects/identity.js";
9
10
 
10
11
  const MAX_NAME = 80;
11
12
 
@@ -54,6 +55,13 @@ async function onboard(env: Env, principal: Principal, request: Request): Promis
54
55
  const workspaceId = `${slugify(workspaceName, "workspace")}-${suffix()}`;
55
56
  const projectId = `${slugify(projectName, "project")}-${suffix()}`;
56
57
  const environment = siteUrl ? { id: "production", name: "Production", baseUrl: siteUrl } : null;
58
+ // The site's domain is the project's identity: one that's already someone
59
+ // else's Free project can't start a second Free workspace.
60
+ const identity = await domainIdentity(siteUrl);
61
+ if (identity) {
62
+ const inUse = await identityInUse(env, principal, workspaceId, identity);
63
+ if (inUse) return inUse;
64
+ }
57
65
  const statements = [
58
66
  env.DB.prepare(`
59
67
  INSERT INTO aegis_users (id, email, email_verified) VALUES (?, ?, 1)
@@ -63,9 +71,10 @@ async function onboard(env: Env, principal: Principal, request: Request): Promis
63
71
  env.DB.prepare("INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
64
72
  .bind(workspaceId, principal.aegisId),
65
73
  env.DB.prepare(`
66
- INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode, default_execution_mode, created_at)
67
- VALUES (?, ?, ?, ?, ?, 'bugmole', 'managed', 'cloud', CURRENT_TIMESTAMP)
68
- `).bind(projectId, workspaceId, workspaceId, projectName, environment?.id ?? "production"),
74
+ INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode, default_execution_mode,
75
+ identity_kind, identity_key, identity_hash, identity_origin, created_at)
76
+ VALUES (?, ?, ?, ?, ?, 'bugmole', 'managed', 'cloud', ?, ?, ?, ?, CURRENT_TIMESTAMP)
77
+ `).bind(projectId, workspaceId, workspaceId, projectName, environment?.id ?? "production", ...identityValues(identity)),
69
78
  env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
70
79
  .bind(projectId, projectName),
71
80
  ];
@@ -1,7 +1,7 @@
1
1
  // Workspace membership for people who sign in through a workspace's identity
2
2
  // provider: roles follow their IdP groups, and the IdP can remove them.
3
3
  import { type Env, type WorkspaceRole, isWorkspaceRole } from "../core.js";
4
- import { flagOn } from "../flags.js";
4
+ import { blockedBy, featureState } from "../feature-access.js";
5
5
  import { type AuditActor, recordAudit } from "../org/audit.js";
6
6
 
7
7
  /** Roles an IdP group may grant. Ownership is only ever given by a person. */
@@ -65,7 +65,9 @@ export async function applyOrgMembership(
65
65
  actor: AuditActor = { type: "user", id: identity.aegisId },
66
66
  ): Promise<void> {
67
67
  const workspace = await workspaceForOrg(env, org.id);
68
- if (!workspace || !(await flagOn(env, workspace.id, "sso"))) return;
68
+ // Only the rollout gates this: a workspace that lost SSO on its plan keeps
69
+ // membership sync through the grace period syncSsoEntitlements manages.
70
+ if (!workspace || blockedBy(await featureState(env, workspace.id, "sso")) === "rollout") return;
69
71
  const role = pickRole(await groupRoleMappings(env, workspace.id), org.groups, workspace.defaultRole);
70
72
  const existing = await env.DB.prepare(
71
73
  "SELECT role, status, source FROM workspace_memberships WHERE workspace_id = ? AND aegis_id = ?",