@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,283 @@
1
+ // One app is one project (identity dedupe, Free-plan identity_in_use) and
2
+ // projects paused after the plan's inactiveProjectDays.
3
+ import assert from "node:assert/strict";
4
+ import test from "node:test";
5
+ import type { Env } from "../core.js";
6
+ import { DAILY_CRON } from "../jobs/index.js";
7
+ import { call, createTestEnv, runScheduled, seedPlan, seedProject } from "../testing/harness.js";
8
+ import { lastActivityOf, parseRegistryTime, pauseInactiveProjects } from "./inactivity.js";
9
+
10
+ const DAY = 86_400_000;
11
+ const web = { kind: "repo", remote: "https://github.com/acme/shop.git", appPath: "apps/web" };
12
+ const webFromTeammate = { kind: "repo", remote: "git@github.com:Acme/Shop.git", appPath: "./apps/web" };
13
+ const mobile = { kind: "repo", remote: "https://github.com/acme/shop.git", appPath: "apps/mobile" };
14
+
15
+ type Db = ReturnType<typeof createTestEnv>["db"];
16
+
17
+ function register(env: Env, as: string, workspaceId: string, id: string, extra: Record<string, unknown> = {}) {
18
+ return call(env, "POST", "/api/projects/register", {
19
+ as, body: { id, name: id, workspaceId, environment: "staging", storageProvider: "r2", ...extra },
20
+ });
21
+ }
22
+
23
+ async function workspace(env: Env, owner: string, id: string) {
24
+ const created = await call(env, "POST", "/api/workspaces", { as: owner, body: { id, name: id } });
25
+ assert.equal(created.status, 201, JSON.stringify(created.json));
26
+ }
27
+
28
+ function addMember(db: Db, workspaceId: string, aegisId: string, role = "member") {
29
+ db.db.prepare("INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING").run(aegisId);
30
+ db.db.prepare("INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status) VALUES (?, ?, ?, 'active')").run(workspaceId, aegisId, role);
31
+ }
32
+
33
+ const projectCount = (db: Db, workspaceId: string) =>
34
+ Number((db.db.prepare("SELECT COUNT(*) AS c FROM projects WHERE workspace_id = ? AND deleted_at IS NULL").get(workspaceId) as { c: number }).c);
35
+
36
+ test("in one workspace the same app resolves to the existing project; each monorepo app is its own", async () => {
37
+ const { env, db } = createTestEnv();
38
+ await workspace(env, "user_ada", "acme");
39
+ const first = await register(env, "user_ada", "acme", "shop-web", { identity: web });
40
+ assert.equal(first.status, 201, JSON.stringify(first.json));
41
+ assert.deepEqual(first.json.identity, { kind: "repo", key: "repo:github.com/acme/shop#apps/web", label: "github.com/acme/shop (apps/web)" });
42
+
43
+ // A teammate's clone of the same repository, with a different remote form and project id.
44
+ addMember(db, "acme", "user_bob");
45
+ const again = await register(env, "user_bob", "acme", "bobs-shop", { identity: webFromTeammate });
46
+ assert.equal(again.status, 200, JSON.stringify(again.json));
47
+ assert.equal(again.json.existing, true);
48
+ assert.equal(again.json.project.id, "shop-web");
49
+
50
+ // The monorepo's other app is a second project.
51
+ const other = await register(env, "user_bob", "acme", "shop-mobile", { identity: mobile });
52
+ assert.equal(other.status, 201);
53
+ assert.equal(projectCount(db, "acme"), 2);
54
+
55
+ // Never stored: the remote's credentials.
56
+ const withToken = await register(env, "user_ada", "acme", "admin", { identity: { remote: "https://ada:ghp_secret@github.com/acme/admin.git" } });
57
+ assert.equal(withToken.status, 201);
58
+ const stored = db.db.prepare("SELECT identity_key AS k, identity_hash AS h FROM projects WHERE id = 'admin'").get() as { k: string; h: string };
59
+ assert.equal(stored.k, "repo:github.com/acme/admin#.");
60
+ assert.match(stored.h, /^[0-9a-f]{64}$/);
61
+ assert.doesNotMatch(JSON.stringify(db.db.prepare("SELECT * FROM projects").all()), /ghp_secret/);
62
+
63
+ assert.equal((await register(env, "user_ada", "acme", "bad", { identity: { remote: "/srv/repo" } })).json.code, "invalid_identity");
64
+ const listed = await call(env, "GET", "/api/projects", { as: "user_ada" });
65
+ assert.equal(listed.json.projects.find((p: { id: string }) => p.id === "shop-web").identity.label, "github.com/acme/shop (apps/web)");
66
+ });
67
+
68
+ test("on Free an app in someone else's Free workspace is refused with identity_in_use; paid workspaces aren't", async () => {
69
+ const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on", DASHBOARD_URL: "https://dash.test" });
70
+ await workspace(env, "user_ada", "ada");
71
+ assert.equal((await register(env, "user_ada", "ada", "shop", { identity: web })).status, 201);
72
+
73
+ await workspace(env, "user_bob", "bob");
74
+ const refused = await register(env, "user_bob", "bob", "shop-copy", { identity: webFromTeammate });
75
+ assert.equal(refused.status, 409, JSON.stringify(refused.json));
76
+ assert.equal(refused.json.code, "identity_in_use");
77
+ assert.match(refused.json.error, /github\.com\/acme\/shop \(apps\/web\) is already a project in another Free workspace\. Ask that project's owner to invite you/);
78
+ assert.equal(refused.json.billingUrl, "https://dash.test/billing?workspace=bob&plan=team");
79
+ assert.doesNotMatch(JSON.stringify(refused.json), /"ada"/, "doesn't reveal whose workspace it is");
80
+ assert.equal(projectCount(db, "bob"), 0);
81
+
82
+ // A different app of the same repo is fine.
83
+ assert.equal((await register(env, "user_bob", "bob", "mobile", { identity: mobile })).status, 201);
84
+
85
+ // Paid workspaces aren't restricted.
86
+ db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES ('bob', 'team')").run();
87
+ assert.equal((await register(env, "user_bob", "bob", "shop-copy", { identity: webFromTeammate })).status, 201);
88
+ });
89
+
90
+ test("identity_in_use follows the entitlements rollout, and a paused project doesn't hold its app", async () => {
91
+ const shadow = createTestEnv({ FEATURE_ENTITLEMENTS: "shadow" });
92
+ await workspace(shadow.env, "user_ada", "ada");
93
+ await register(shadow.env, "user_ada", "ada", "shop", { identity: web });
94
+ await workspace(shadow.env, "user_bob", "bob");
95
+ assert.equal((await register(shadow.env, "user_bob", "bob", "shop-copy", { identity: web })).status, 201, "shadow only logs");
96
+
97
+ const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
98
+ await workspace(env, "user_ada", "ada");
99
+ await register(env, "user_ada", "ada", "shop", { identity: web });
100
+ db.db.prepare("UPDATE projects SET inactive_at = CURRENT_TIMESTAMP WHERE id = 'shop'").run();
101
+ await workspace(env, "user_bob", "bob");
102
+ assert.equal((await register(env, "user_bob", "bob", "shop-copy", { identity: web })).status, 201);
103
+ });
104
+
105
+ test("sign-up with a site that's already someone else's Free project is refused", async () => {
106
+ const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
107
+ await workspace(env, "user_ada", "ada");
108
+ const cloud = await register(env, "user_ada", "ada", "store", { appUrl: "https://shop.example.com/" });
109
+ assert.equal(cloud.json.identity.key, "domain:shop.example.com");
110
+
111
+ const headers = { "x-aegis-email": "bob@elsewhere.example", "x-aegis-email-verified": "true" };
112
+ const body = { workspaceName: "Bob", projectName: "Store", siteUrl: "https://www.shop.example.com/cart" };
113
+ const refused = await call(env, "POST", "/api/onboarding", { as: "user_bob", body, headers });
114
+ assert.equal(refused.status, 409, JSON.stringify(refused.json));
115
+ assert.equal(refused.json.code, "identity_in_use");
116
+ const other = await call(env, "POST", "/api/onboarding", { as: "user_bob", body: { ...body, siteUrl: "https://bob.example.com" }, headers });
117
+ assert.equal(other.status, 201, JSON.stringify(other.json));
118
+
119
+ // Adding the site later, as an environment, is the same claim.
120
+ const env2 = await call(env, "POST", `/api/projects/${other.json.project.id}/environments`, {
121
+ as: "user_bob", body: { id: "prod2", name: "Shop", baseUrl: "https://shop.example.com" },
122
+ });
123
+ assert.equal(env2.status, 201, "the project already has an identity (bob.example.com)");
124
+ db.db.prepare("UPDATE projects SET identity_kind = NULL, identity_key = NULL, identity_hash = NULL WHERE id = ?").run(other.json.project.id);
125
+ const env3 = await call(env, "POST", `/api/projects/${other.json.project.id}/environments`, {
126
+ as: "user_bob", body: { id: "prod3", name: "Shop again", baseUrl: "https://shop.example.com/login" },
127
+ });
128
+ assert.equal(env3.status, 409);
129
+ assert.equal(env3.json.code, "identity_in_use");
130
+ });
131
+
132
+ async function pair(env: Env, who: string, projectId: string, identity: unknown) {
133
+ const start = await call(env, "POST", "/api/device-authorizations", {
134
+ body: { projectId, projectName: projectId, environment: "local", storageProvider: "local", identity },
135
+ });
136
+ assert.equal(start.status, 201, JSON.stringify(start.json));
137
+ const claimed = await call(env, "POST", "/api/device-authorizations/claim", {
138
+ as: who,
139
+ headers: { "x-aegis-email": `${who}@example.com`, "x-aegis-email-verified": "true" },
140
+ body: { attemptId: start.json.attemptId, code: start.json.code, deviceId: projectId },
141
+ });
142
+ const poll = await call(env, "POST", "/api/device-authorizations/poll", { body: { attemptId: start.json.attemptId, pollSecret: start.json.pollSecret } });
143
+ return { claimed, poll };
144
+ }
145
+
146
+ test("CLI pairing joins a teammate's project for the same app, and refuses one from someone else's Free workspace", async () => {
147
+ const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
148
+ await workspace(env, "user_ada", "acme");
149
+ await register(env, "user_ada", "acme", "shop", { identity: web });
150
+ addMember(db, "acme", "user_bob");
151
+
152
+ const bob = await pair(env, "user_bob", "bobs-clone", webFromTeammate);
153
+ assert.equal(bob.claimed.status, 200, JSON.stringify(bob.claimed.json));
154
+ assert.equal(bob.claimed.json.joined, true);
155
+ assert.equal(bob.poll.json.status, "completed");
156
+ assert.equal(bob.poll.json.projectId, "shop", "the CLI learns which project it joined");
157
+ assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM projects WHERE id = 'bobs-clone'").get()?.c, 0);
158
+ const key = db.db.prepare("SELECT project_ids_json AS ids FROM api_keys WHERE aegis_id = 'user_bob'").get() as { ids: string };
159
+ assert.deepEqual(JSON.parse(key.ids), ["shop"]);
160
+ assert.equal((db.db.prepare("SELECT role FROM workspace_memberships WHERE workspace_id = 'acme' AND aegis_id = 'user_bob'").get() as { role: string }).role, "member", "joining doesn't make them an owner");
161
+
162
+ const carol = await pair(env, "user_carol", "carols-clone", web);
163
+ assert.equal(carol.claimed.status, 409, JSON.stringify(carol.claimed.json));
164
+ assert.equal(carol.claimed.json.code, "identity_in_use");
165
+ assert.equal(carol.poll.json.status, "refused");
166
+ assert.equal(carol.poll.json.code, "identity_in_use");
167
+ assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM projects WHERE id = 'carols-clone'").get()?.c, 0);
168
+
169
+ // A new app pairs as a new project carrying its identity.
170
+ const fresh = await pair(env, "user_dan", "dans-app", { remote: "github.com/dan/app", appPath: "." });
171
+ assert.equal(fresh.claimed.status, 200);
172
+ assert.equal((db.db.prepare("SELECT identity_key AS k FROM projects WHERE id = 'dans-app'").get() as { k: string }).k, "repo:github.com/dan/app#.");
173
+ });
174
+
175
+ test("the CLI reports a project's identity once; another app can't take it over", async () => {
176
+ const { env } = createTestEnv({ DASHBOARD_URL: "https://dash.test" });
177
+ const { projectId, owner } = await seedProject(env);
178
+ const set = (identity: unknown) => call(env, "POST", `/api/projects/${projectId}/identity`, { as: owner, body: { identity } });
179
+ const first = await set(web);
180
+ assert.equal(first.status, 200, JSON.stringify(first.json));
181
+ assert.equal(first.json.identity.key, "repo:github.com/acme/shop#apps/web");
182
+ assert.equal((await set(webFromTeammate)).status, 200, "the same app again is fine");
183
+ const mismatch = await set(mobile);
184
+ assert.equal(mismatch.status, 409);
185
+ assert.equal(mismatch.json.code, "identity_mismatch");
186
+ assert.match(mismatch.json.error, /Each app is its own project/);
187
+ assert.equal((await set(null)).status, 422);
188
+ });
189
+
190
+ async function idle(db: Db, projectId: string, days: number, now = Date.now()) {
191
+ const at = new Date(now - days * DAY).toISOString();
192
+ db.db.prepare("UPDATE projects SET created_at = ? WHERE id = ?").run(at, projectId);
193
+ db.db.prepare("UPDATE audit_log SET created_at = ? WHERE project_id = ?").run(at, projectId);
194
+ db.db.prepare("UPDATE plan_drafts SET updated_at = ? WHERE project_id = ?").run(at, projectId);
195
+ db.db.prepare("UPDATE runs SET requested_at = ?, completed_at = NULL WHERE project_id = ?").run(at, projectId);
196
+ }
197
+
198
+ test("activity is the latest of every timestamp a project has, in either D1 format", () => {
199
+ assert.equal(parseRegistryTime("2026-09-01 10:00:00"), Date.parse("2026-09-01T10:00:00Z"));
200
+ assert.equal(lastActivityOf({
201
+ createdAt: "2026-01-01 00:00:00", workerSeenAt: null, activatedAt: null, runAt: "2026-03-01T00:00:00.000Z",
202
+ taskAt: null, planAt: "2026-02-01 00:00:00", journeyAt: null, auditAt: null,
203
+ }), Date.parse("2026-03-01T00:00:00Z"));
204
+ });
205
+
206
+ test("Free projects idle past inactiveProjectDays are paused: no runs, tasks or claims, not counted, until activated", async () => {
207
+ const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on", DASHBOARD_URL: "https://dash.test" });
208
+ const { projectId, owner, workspaceId } = await seedProject(env);
209
+ await seedPlan(env, projectId, owner);
210
+ const queued = await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "before-pause" } });
211
+ assert.equal(queued.status, 201, JSON.stringify(queued.json));
212
+
213
+ const now = new Date();
214
+ await idle(db, projectId, 29, now.getTime());
215
+ assert.deepEqual(await pauseInactiveProjects(env, now), { paused: 0, wouldPause: 0 }, "29 days isn't long enough");
216
+ await idle(db, projectId, 31, now.getTime());
217
+ await runScheduled(env, DAILY_CRON, now.getTime());
218
+ const row = db.db.prepare("SELECT inactive_at AS at FROM projects WHERE id = ?").get(projectId) as { at: string | null };
219
+ assert.ok(row.at, "the daily job paused it");
220
+
221
+ const listed = await call(env, "GET", "/api/projects", { as: owner });
222
+ assert.ok(listed.json.projects[0].inactiveAt);
223
+ const audit = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: owner });
224
+ assert.ok(audit.json.entries.some((entry: { action: string }) => entry.action === "project.paused"));
225
+
226
+ const run = await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "after-pause" } });
227
+ assert.equal(run.status, 409);
228
+ assert.equal(run.json.code, "project_inactive");
229
+ assert.equal(run.json.activateUrl, `https://dash.test/?project=${projectId}`);
230
+ assert.match(run.json.error, /paused after 30 days without activity/);
231
+ const task = await call(env, "POST", `/api/projects/${projectId}/tasks`, { as: owner, body: { title: "t", description: "d", idempotencyKey: "t1" } });
232
+ assert.equal(task.json.code, "project_inactive");
233
+ const claim = await call(env, "POST", `/api/runs/${queued.json.run.id}/claim`, { as: owner, body: { workerId: "w1" } });
234
+ assert.equal(claim.status, 409);
235
+ assert.equal(claim.json.code, "project_inactive");
236
+
237
+ // Paused, it doesn't count: the Free workspace has room for another project...
238
+ const features = await call(env, "GET", `/api/workspaces/${workspaceId}/features`, { as: owner });
239
+ assert.deepEqual(features.json.limits.inactiveProjectDays, { limit: 30, unlimited: false });
240
+ assert.equal(features.json.limits.projects.used, 0);
241
+ assert.equal((await register(env, owner, workspaceId, "second")).status, 201);
242
+ // ...and activating it again needs room.
243
+ const full = await call(env, "POST", `/api/projects/${projectId}/activate`, { as: owner });
244
+ assert.equal(full.status, 402);
245
+ assert.equal(full.json.code, "plan_limit");
246
+ assert.equal((await call(env, "POST", `/api/projects/${projectId}/activate`, { as: "user_stranger" })).status, 403);
247
+
248
+ db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, 'team')").run(workspaceId);
249
+ const activated = await call(env, "POST", `/api/projects/${projectId}/activate`, { as: owner });
250
+ assert.equal(activated.status, 200, JSON.stringify(activated.json));
251
+ assert.equal(activated.json.activated, true);
252
+ assert.equal((await call(env, "POST", `/api/projects/${projectId}/activate`, { as: owner })).json.alreadyActive, true);
253
+ const after = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: owner });
254
+ assert.ok(after.json.entries.some((entry: { action: string }) => entry.action === "project.activated"));
255
+ assert.equal((await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "after-activate" } })).status, 201);
256
+
257
+ // Paid plans never pause, however long a project sits idle.
258
+ await idle(db, projectId, 400, now.getTime());
259
+ db.db.prepare("UPDATE projects SET activated_at = NULL").run();
260
+ assert.equal((await pauseInactiveProjects(env, now)).paused, 0);
261
+ const teamFeatures = await call(env, "GET", `/api/workspaces/${workspaceId}/features`, { as: owner });
262
+ assert.deepEqual(teamFeatures.json.limits.inactiveProjectDays, { limit: null, unlimited: true });
263
+ });
264
+
265
+ test("recent activity keeps a project active, and shadow mode only logs", async () => {
266
+ const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
267
+ const { projectId } = await seedProject(env);
268
+ const now = new Date();
269
+ await idle(db, projectId, 60, now.getTime());
270
+ db.db.prepare("UPDATE projects SET local_worker_seen_at = ? WHERE id = ?").run(new Date(now.getTime() - 2 * DAY).toISOString(), projectId);
271
+ assert.equal((await pauseInactiveProjects(env, now)).paused, 0, "a machine checked in two days ago");
272
+
273
+ const shadow = createTestEnv({ FEATURE_ENTITLEMENTS: "shadow" });
274
+ const seeded = await seedProject(shadow.env);
275
+ await idle(shadow.db, seeded.projectId, 60, now.getTime());
276
+ assert.deepEqual(await pauseInactiveProjects(shadow.env, now), { paused: 0, wouldPause: 1 });
277
+ assert.equal((shadow.db.db.prepare("SELECT inactive_at AS at FROM projects").get() as { at: string | null }).at, null);
278
+
279
+ const off = createTestEnv();
280
+ const offSeed = await seedProject(off.env);
281
+ await idle(off.db, offSeed.projectId, 60, now.getTime());
282
+ assert.deepEqual(await pauseInactiveProjects(off.env, now), { paused: 0, wouldPause: 0 });
283
+ });
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { call, createTestEnv, seedProject } from "../testing/harness.js";
4
+
5
+ async function inviteAndJoin(env: Parameters<typeof call>[0], inviter: string, workspaceId: string, who: string, role: string) {
6
+ const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: inviter, body: { email: `${who}@example.com`, role } });
7
+ const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
8
+ await call(env, "POST", `/api/invitations/${token}/accept`, { as: who });
9
+ }
10
+
11
+ const loginCase = (title: string) => ({
12
+ title, nodeId: "screen:/login", intent: "Prove a person can sign in.",
13
+ steps: ["Open /login", "Sign in"], expected: ["The dashboard opens"], confidence: "high", basis: ["spec"],
14
+ });
15
+
16
+ async function setup(storageProvider = "bugmole") {
17
+ const setupEnv = createTestEnv();
18
+ const project = await seedProject(setupEnv.env, { storageProvider });
19
+ const key = `projects/${project.projectId}/test-cases/login.json`;
20
+ await setupEnv.artifacts.put(key, JSON.stringify({ schemaVersion: 1, caseSetId: "login", journeyId: "j", journeyRevision: 1, cases: [{ caseId: "TC-login", ...loginCase("Sign in works") }] }));
21
+ const propose = (body: Record<string, unknown>, as = project.owner) =>
22
+ call(setupEnv.env, "POST", `/api/projects/${project.projectId}/proposals`, { as, body: { caseSetId: "login", caseId: "TC-login", source: "mcp", summary: "Name the password path", ...body } });
23
+ const readSet = async () => JSON.parse(await (await setupEnv.artifacts.get(key))!.text());
24
+ return { ...setupEnv, ...project, propose, readSet };
25
+ }
26
+
27
+ test("a proposal waits for a manager, and accepting it on Bugmole storage writes revision 2 and obsoletes revision 1", async () => {
28
+ const { env, owner, workspaceId, propose, readSet } = await setup();
29
+ await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
30
+ const proposed = await propose({ action: "change", baseRevision: 1, case: loginCase("Sign in with a password works") }, "user_member");
31
+ assert.equal(proposed.status, 201, JSON.stringify(proposed.json));
32
+ assert.equal(proposed.json.proposal.status, "pending", "auto mode reviews spec changes like code");
33
+ assert.equal(proposed.json.proposal.proposedBy, "user_member");
34
+ const id = proposed.json.proposal.id;
35
+
36
+ assert.equal((await call(env, "POST", `/api/proposals/${id}/accept`, { as: "user_member" })).status, 403, "members propose, managers decide");
37
+ const accepted = await call(env, "POST", `/api/proposals/${id}/accept`, { as: owner });
38
+ assert.equal(accepted.status, 200, JSON.stringify(accepted.json));
39
+ assert.deepEqual([accepted.json.proposal.status, accepted.json.proposal.appliedRevision], ["accepted", 2]);
40
+ const set = await readSet();
41
+ assert.deepEqual(set.cases.map((testCase: { revision?: number; status?: string }) => [testCase.revision ?? 1, testCase.status ?? "active"]), [[1, "obsolete"], [2, "active"]]);
42
+ assert.equal(set.cases[1].proposalId, id);
43
+ assert.equal((await call(env, "POST", `/api/proposals/${id}/accept`, { as: owner })).status, 409, "deciding twice");
44
+ });
45
+
46
+ test("a proposal against a revision that moved on is marked outdated, and a rejected one changes nothing", async () => {
47
+ const { env, owner, propose, readSet } = await setup();
48
+ const first = (await propose({ action: "change", baseRevision: 1, case: loginCase("B") })).json.proposal.id;
49
+ const second = (await propose({ action: "change", baseRevision: 1, case: loginCase("C") })).json.proposal.id;
50
+ await call(env, "POST", `/api/proposals/${first}/accept`, { as: owner });
51
+ const stale = await call(env, "POST", `/api/proposals/${second}/accept`, { as: owner });
52
+ assert.equal(stale.json.proposal.status, "outdated");
53
+ assert.match(stale.json.proposal.decisionNote, /revision 2, not 1/);
54
+
55
+ const third = (await propose({ action: "retire", baseRevision: 2 })).json.proposal.id;
56
+ const rejected = await call(env, "POST", `/api/proposals/${third}/reject`, { as: owner, body: { note: "Still needed" } });
57
+ assert.deepEqual([rejected.json.proposal.status, rejected.json.proposal.decisionNote], ["rejected", "Still needed"]);
58
+ assert.equal((await readSet()).cases.at(-1).status, "active");
59
+ });
60
+
61
+ test("extreme mode accepts on arrival; a project's own storage leaves applying to its worker", async () => {
62
+ const { env, owner, projectId, propose } = await setup("local");
63
+ await call(env, "PATCH", `/api/projects/${projectId}/settings`, { as: owner, body: { taskApprovalMode: "extreme" } });
64
+ const proposal = (await propose({ action: "add", caseId: "TC-new", case: loginCase("New") })).json.proposal;
65
+ assert.deepEqual([proposal.status, proposal.decidedBy, proposal.appliedAt], ["accepted", "auto:extreme", null]);
66
+
67
+ const waiting = await call(env, "GET", `/api/projects/${projectId}/proposals?unapplied=1`, { as: owner });
68
+ assert.deepEqual(waiting.json.proposals.map((item: { id: string }) => item.id), [proposal.id]);
69
+ const done = await call(env, "POST", `/api/proposals/${proposal.id}/applied`, { as: owner, body: { outcome: "applied", revision: 1 } });
70
+ assert.equal(done.json.proposal.appliedRevision, 1);
71
+ assert.equal((await call(env, "GET", `/api/projects/${projectId}/proposals?unapplied=1`, { as: owner })).json.proposals.length, 0);
72
+ });
73
+
74
+ test("a proposal must say why, name its base revision, and carry a valid case", async () => {
75
+ const { propose } = await setup();
76
+ assert.equal((await propose({ action: "change", baseRevision: 1, case: loginCase("x"), summary: "" })).status, 422);
77
+ assert.equal((await propose({ action: "change", case: loginCase("x") })).status, 422);
78
+ assert.equal((await propose({ action: "change", baseRevision: 1, case: { ...loginCase("x"), steps: [] } })).status, 422);
79
+ assert.equal((await propose({ action: "rename", baseRevision: 1 })).status, 422);
80
+ });
@@ -0,0 +1,183 @@
1
+ // Proposals: a developer, an agent or a person in the dashboard suggests a
2
+ // change to a test case, and it becomes a new revision once accepted. The
3
+ // project's approval mode decides whether a manager must accept it (manual,
4
+ // auto) or it's accepted on arrival (extreme). On Bugmole storage the
5
+ // registry applies an accepted proposal itself; on a project's own storage,
6
+ // `bugmole serve` or the proposing CLI applies it and reports back.
7
+ import type { Env } from "../core.js";
8
+ import { can, memberOfProject, randomToken, requestJson, response } from "../core.js";
9
+ import { proposalDecidedHooks } from "../hooks.js";
10
+ import { auditProject } from "../org/audit.js";
11
+ import { matchPath, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
12
+ import { DEFAULT_APPROVAL_MODE, approvalModeOf, autoApprover, needsApproval } from "../../registry/task-scheduling.js";
13
+ import { applyTestCaseChange, caseProblem, type CaseSetDocument, type RevisionedCase, type TestCaseChange } from "../../registry/test-case-revisions.js";
14
+
15
+ const ACTIONS = ["add", "change", "retire"] as const;
16
+ const SOURCES = ["mcp", "cli", "dashboard"] as const;
17
+ const CASE_SET_ID = /^[a-zA-Z0-9._-]+$/;
18
+
19
+ const proposalSelect = `
20
+ SELECT id, project_id AS projectId, kind, case_set_id AS caseSetId, case_id AS caseId, action,
21
+ base_revision AS baseRevision, proposed_json AS proposedJson, summary, source,
22
+ proposed_by AS proposedBy, status, decided_by AS decidedBy, decided_at AS decidedAt,
23
+ decision_note AS decisionNote, applied_at AS appliedAt, applied_revision AS appliedRevision,
24
+ created_at AS createdAt
25
+ FROM spec_proposals`;
26
+
27
+ type ProposalRow = {
28
+ id: string; projectId: string; kind: string; caseSetId: string; caseId: string; action: TestCaseChange["action"];
29
+ baseRevision: number | null; proposedJson: string | null; summary: string; source: string; proposedBy: string;
30
+ status: string; decidedBy: string | null; decidedAt: string | null; decisionNote: string | null;
31
+ appliedAt: string | null; appliedRevision: number | null; createdAt: string;
32
+ };
33
+
34
+ function view(row: ProposalRow) {
35
+ const { proposedJson, ...rest } = row;
36
+ let proposed: unknown = null;
37
+ try {
38
+ proposed = proposedJson ? JSON.parse(proposedJson) : null;
39
+ } catch {
40
+ proposed = null;
41
+ }
42
+ return { ...rest, proposed };
43
+ }
44
+
45
+ /** Tells integrations (Jira) a proposal was decided or applied. A failing hook never fails the request. */
46
+ async function announce(env: Env, row: ProposalRow): Promise<void> {
47
+ for (const hook of proposalDecidedHooks) {
48
+ await hook(env, { projectId: row.projectId, proposalId: row.id, status: row.status, applied: Boolean(row.appliedAt) })
49
+ .catch((error) => console.error("[proposals] decision hook failed", error));
50
+ }
51
+ }
52
+
53
+ function changeOf(row: ProposalRow): TestCaseChange {
54
+ const proposed = (row.proposedJson ? JSON.parse(row.proposedJson) : {}) as RevisionedCase;
55
+ if (row.action === "add") return { action: "add", case: { ...proposed, caseId: row.caseId } };
56
+ if (row.action === "retire") return { action: "retire", caseId: row.caseId, baseRevision: row.baseRevision ?? 0 };
57
+ return { action: "change", caseId: row.caseId, baseRevision: row.baseRevision ?? 0, case: { ...proposed, caseId: row.caseId } };
58
+ }
59
+
60
+ async function load(env: Env, id: string): Promise<ProposalRow | null> {
61
+ return env.DB.prepare(`${proposalSelect} WHERE id = ?`).bind(id).first<ProposalRow>();
62
+ }
63
+
64
+ async function managed(env: Env, projectId: string): Promise<boolean> {
65
+ const row = await env.DB.prepare("SELECT storage_mode AS storageMode FROM projects WHERE id = ?").bind(projectId).first<{ storageMode: string }>();
66
+ return row?.storageMode === "managed" && Boolean(env.ARTIFACTS);
67
+ }
68
+
69
+ /**
70
+ * Applies an accepted proposal to Bugmole storage. Returns the updated row;
71
+ * a proposal the case moved past becomes "outdated" instead of overwriting.
72
+ */
73
+ async function applyManaged(env: Env, row: ProposalRow): Promise<ProposalRow> {
74
+ const key = `projects/${row.projectId}/test-cases/${row.caseSetId}.json`;
75
+ const object = await env.ARTIFACTS!.get(key);
76
+ const outcome = object
77
+ ? applyTestCaseChange(JSON.parse(await object.text()) as CaseSetDocument, changeOf(row), { proposalId: row.id })
78
+ : { ok: false as const, reason: "missing" as const, message: `There's no case set ${row.caseSetId}` };
79
+ if (!outcome.ok) {
80
+ await env.DB.prepare("UPDATE spec_proposals SET status = 'outdated', decision_note = ? WHERE id = ?").bind(outcome.message, row.id).run();
81
+ } else {
82
+ await env.ARTIFACTS!.put(key, `${JSON.stringify(outcome.caseSet, null, 2)}\n`, { httpMetadata: { contentType: "application/json" } });
83
+ await env.DB.prepare("UPDATE spec_proposals SET applied_at = CURRENT_TIMESTAMP, applied_revision = ? WHERE id = ?").bind(outcome.revision, row.id).run();
84
+ }
85
+ return (await load(env, row.id))!;
86
+ }
87
+
88
+ async function propose({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
89
+ if (!can(principal, "tasks:write") && !can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Proposing changes is not allowed" }, 403);
90
+ if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
91
+ const body = await requestJson(request);
92
+ const action = ACTIONS.find((item) => item === body?.action);
93
+ const caseSetId = typeof body?.caseSetId === "string" ? body.caseSetId.trim() : "";
94
+ const proposed = (body?.case && typeof body.case === "object" ? body.case : null) as RevisionedCase | null;
95
+ const caseId = typeof body?.caseId === "string" ? body.caseId.trim() : proposed?.caseId ?? "";
96
+ const summary = typeof body?.summary === "string" ? body.summary.trim() : "";
97
+ const source = SOURCES.find((item) => item === body?.source) ?? "dashboard";
98
+ const baseRevision = Number.isInteger(body?.baseRevision) ? body!.baseRevision as number : null;
99
+ if (!action) return response({ error: "action must be add, change or retire" }, 422);
100
+ if (!CASE_SET_ID.test(caseSetId) || !CASE_SET_ID.test(caseId)) return response({ error: "caseSetId and caseId may contain only letters, numbers, ., _ and -" }, 422);
101
+ if (!summary) return response({ error: "Say why in summary" }, 422);
102
+ if (action !== "add" && !baseRevision) return response({ error: "baseRevision is the revision you're changing" }, 422);
103
+ if (action !== "retire") {
104
+ const problem = caseProblem(proposed ? { ...proposed, caseId } : undefined);
105
+ if (problem) return response({ error: problem }, 422);
106
+ }
107
+ const mode = approvalModeOf((await env.DB.prepare("SELECT task_approval_mode AS mode FROM projects WHERE id = ?").bind(projectId).first<{ mode: string }>())?.mode) ?? DEFAULT_APPROVAL_MODE;
108
+ // A spec change is reviewed like a code change: it waits in manual and auto.
109
+ const autoAccepted = !needsApproval(mode, "code");
110
+ const id = `prop_${randomToken(10)}`;
111
+ await env.DB.prepare(`
112
+ INSERT INTO spec_proposals (id, project_id, kind, case_set_id, case_id, action, base_revision, proposed_json, summary, source, proposed_by, status, decided_by, decided_at)
113
+ VALUES (?, ?, 'test_case', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END)
114
+ `).bind(
115
+ id, projectId, caseSetId, caseId, action, baseRevision, action === "retire" ? null : JSON.stringify(proposed),
116
+ summary, source, principal.email || principal.aegisId, autoAccepted ? "accepted" : "pending", autoAccepted ? autoApprover(mode) : null, autoAccepted ? 1 : 0,
117
+ ).run();
118
+ let row = (await load(env, id))!;
119
+ if (autoAccepted && (await managed(env, projectId))) row = await applyManaged(env, row);
120
+ if (autoAccepted) await announce(env, row);
121
+ return response({ proposal: view(row) }, 201);
122
+ }
123
+
124
+ async function list({ env, principal, url }: AuthedRouteContext, projectId: string): Promise<Response> {
125
+ if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
126
+ const status = url.searchParams.get("status");
127
+ const unapplied = url.searchParams.get("unapplied") === "1";
128
+ const rows = await env.DB.prepare(`
129
+ ${proposalSelect}
130
+ WHERE project_id = ? AND (? IS NULL OR status = ?) AND (? = 0 OR (status = 'accepted' AND applied_at IS NULL))
131
+ ORDER BY created_at DESC LIMIT 200
132
+ `).bind(projectId, status, status, unapplied ? 1 : 0).all<ProposalRow>();
133
+ return response({ proposals: rows.results.map(view) });
134
+ }
135
+
136
+ async function decide({ env, principal, request }: AuthedRouteContext, id: string, decision: "accept" | "reject"): Promise<Response> {
137
+ const row = await load(env, id);
138
+ if (!row || !(await memberOfProject(env, principal, row.projectId))) return response({ error: "Proposal not found" }, 404);
139
+ if (!(await memberOfProject(env, principal, row.projectId, "project.manage"))) return response({ error: "Only project managers can accept or reject proposals" }, 403);
140
+ if (row.status !== "pending") return response({ error: `This proposal is already ${row.status}` }, 409);
141
+ const body = await requestJson(request);
142
+ const note = typeof body?.note === "string" && body.note.trim() ? body.note.trim().slice(0, 500) : null;
143
+ await env.DB.prepare("UPDATE spec_proposals SET status = ?, decided_by = ?, decided_at = CURRENT_TIMESTAMP, decision_note = ? WHERE id = ? AND status = 'pending'")
144
+ .bind(decision === "accept" ? "accepted" : "rejected", principal.email || principal.aegisId, note, id).run();
145
+ await auditProject(env, principal, row.projectId, { action: `proposal.${decision === "accept" ? "accepted" : "rejected"}`, targetType: "proposal", targetId: id }, request);
146
+ let updated = (await load(env, id))!;
147
+ if (decision === "accept" && (await managed(env, row.projectId))) updated = await applyManaged(env, updated);
148
+ await announce(env, updated);
149
+ return response({ proposal: view(updated) });
150
+ }
151
+
152
+ /** A worker or CLI that applied an accepted proposal to the project's own storage reports the outcome. */
153
+ async function applied({ env, principal, request }: AuthedRouteContext, id: string): Promise<Response> {
154
+ const row = await load(env, id);
155
+ if (!row || !(await memberOfProject(env, principal, row.projectId, "project.write"))) return response({ error: "Proposal not found" }, 404);
156
+ if (row.status !== "accepted" || row.appliedAt) return response({ error: "Only an accepted, unapplied proposal can be applied" }, 409);
157
+ const body = await requestJson(request);
158
+ if (body?.outcome === "outdated") {
159
+ const message = typeof body.message === "string" ? body.message.slice(0, 500) : "The case changed since this was proposed";
160
+ await env.DB.prepare("UPDATE spec_proposals SET status = 'outdated', decision_note = ? WHERE id = ?").bind(message, id).run();
161
+ } else {
162
+ const revision = Number.isInteger(body?.revision) ? body!.revision as number : null;
163
+ await env.DB.prepare("UPDATE spec_proposals SET applied_at = CURRENT_TIMESTAMP, applied_revision = ? WHERE id = ?").bind(revision, id).run();
164
+ }
165
+ const updated = (await load(env, id))!;
166
+ await announce(env, updated);
167
+ return response({ proposal: view(updated) });
168
+ }
169
+
170
+ registerRoutes(async (context) => {
171
+ const { request, url } = context;
172
+ const projectMatch = matchPath(url.pathname, "/api/projects/:projectId/proposals");
173
+ if (projectMatch && request.method === "POST") return propose(context, projectMatch.projectId);
174
+ if (projectMatch && request.method === "GET") return list(context, projectMatch.projectId);
175
+ const acceptMatch = matchPath(url.pathname, "/api/proposals/:proposalId/accept");
176
+ if (acceptMatch && request.method === "POST") return decide(context, acceptMatch.proposalId, "accept");
177
+ const rejectMatch = matchPath(url.pathname, "/api/proposals/:proposalId/reject");
178
+ if (rejectMatch && request.method === "POST") return decide(context, rejectMatch.proposalId, "reject");
179
+ const appliedMatch = matchPath(url.pathname, "/api/proposals/:proposalId/applied");
180
+ if (appliedMatch && request.method === "POST") return applied(context, appliedMatch.proposalId);
181
+ return null;
182
+ });
183
+
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { call, createTestEnv, seedProject } from "../testing/harness.js";
4
+
5
+ async function setup() {
6
+ const { env, db } = createTestEnv();
7
+ const { owner, workspaceId, projectId } = await seedProject(env);
8
+ const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "agent", projectIds: [projectId] } });
9
+ assert.equal(key.status, 201, JSON.stringify(key.json));
10
+ const token: string = key.json.token;
11
+ const roles = `/api/projects/${projectId}/roles`;
12
+ return { env, db, owner, workspaceId, projectId, token, roles };
13
+ }
14
+
15
+ test("an agent's role is only a suggestion until a person confirms it", async () => {
16
+ const { env, owner, token, roles, db } = await setup();
17
+ const suggested = await call(env, "POST", roles, {
18
+ token,
19
+ body: { name: "Signed-in Member", description: "Signed-in shopper.", source: "sign_in_wall", evidence: "sign-in screen at /login" },
20
+ });
21
+ assert.equal(suggested.status, 201, JSON.stringify(suggested.json));
22
+ assert.equal(suggested.json.role.name, "signed_in_member", "names become actor ids");
23
+ assert.equal(suggested.json.role.status, "suggested");
24
+ assert.equal(suggested.json.role.source, "sign_in_wall");
25
+
26
+ const pretendPerson = await call(env, "POST", roles, { token, body: { name: "admin", description: "x", source: "person" } });
27
+ assert.equal(pretendPerson.status, 422, "an API key can't add a confirmed role, and a suggestion needs evidence");
28
+ const noEvidence = await call(env, "POST", roles, { token, body: { name: "admin", description: "Admin" } });
29
+ assert.equal(noEvidence.status, 422);
30
+
31
+ const byKey = await call(env, "PATCH", `${roles}/${suggested.json.role.id}`, { token, body: { action: "confirm" } });
32
+ assert.equal(byKey.status, 403, "an agent can't confirm its own suggestion");
33
+
34
+ const confirmed = await call(env, "PATCH", `${roles}/${suggested.json.role.id}`, { as: owner, body: { action: "confirm", name: "member" } });
35
+ assert.equal(confirmed.status, 200, JSON.stringify(confirmed.json));
36
+ assert.equal(confirmed.json.role.status, "confirmed");
37
+ assert.equal(confirmed.json.role.name, "member", "confirming can rename");
38
+ assert.equal(confirmed.json.role.decidedBy, owner);
39
+
40
+ const listed = await call(env, "GET", `${roles}?status=confirmed`, { token });
41
+ assert.deepEqual(listed.json.roles.map((role: { name: string }) => role.name), ["member"]);
42
+
43
+ const audit = db.db.prepare("SELECT action, data_json AS data FROM audit_log WHERE target_type = 'role' ORDER BY rowid").all() as Array<{ action: string; data: string }>;
44
+ assert.deepEqual(audit.map((row) => row.action), ["role.suggested", "role.confirmed"]);
45
+ assert.match(audit[1].data, /renamedFrom/);
46
+ });
47
+
48
+ test("a rejected role stays rejected however often it is suggested again", async () => {
49
+ const { env, owner, token, roles } = await setup();
50
+ const body = { name: "admin", description: "Admin area user.", source: "role_gated_route", evidence: "route /admin" };
51
+ const first = await call(env, "POST", roles, { token, body });
52
+ const rejected = await call(env, "PATCH", `${roles}/${first.json.role.id}`, { as: owner, body: { action: "reject" } });
53
+ assert.equal(rejected.json.role.status, "rejected");
54
+
55
+ const again = await call(env, "POST", roles, { token, body: { ...body, evidence: "menu Admin" } });
56
+ assert.equal(again.status, 200);
57
+ assert.equal(again.json.role.status, "rejected");
58
+ assert.equal(again.json.alreadyDecided, true);
59
+
60
+ // A person can still change their mind.
61
+ const confirmed = await call(env, "PATCH", `${roles}/${first.json.role.id}`, { as: owner, body: { action: "confirm" } });
62
+ assert.equal(confirmed.json.role.status, "confirmed");
63
+ });
64
+
65
+ test("a manager adding a role picks it; members and strangers can't decide", async () => {
66
+ const { env, owner, workspaceId, roles } = await setup();
67
+ const added = await call(env, "POST", roles, { as: owner, body: { name: "guest", description: "Not signed in." } });
68
+ assert.equal(added.status, 201);
69
+ assert.equal(added.json.role.status, "confirmed");
70
+ assert.equal(added.json.role.source, "person");
71
+
72
+ const invited = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, { as: owner, body: { email: "m@example.com", role: "member" } });
73
+ const inviteToken = invited.json.invitation.acceptUrl.split("/invite/")[1];
74
+ await call(env, "POST", `/api/invitations/${inviteToken}/accept`, { as: "user_member" });
75
+ const byMember = await call(env, "POST", roles, { as: "user_member", body: { name: "admin", description: "Admin.", evidence: "I saw an admin link" } });
76
+ assert.equal(byMember.status, 201);
77
+ assert.equal(byMember.json.role.status, "suggested", "a member's role is a suggestion");
78
+ assert.equal((await call(env, "PATCH", `${roles}/${byMember.json.role.id}`, { as: "user_member", body: { action: "confirm" } })).status, 403);
79
+ assert.equal((await call(env, "GET", roles, { as: "user_stranger" })).status, 403);
80
+
81
+ const clash = await call(env, "PATCH", `${roles}/${byMember.json.role.id}`, { as: owner, body: { action: "confirm", name: "guest" } });
82
+ assert.equal(clash.status, 409);
83
+ assert.equal((await call(env, "DELETE", `${roles}/${byMember.json.role.id}`, { as: owner })).status, 200);
84
+ });