@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,206 @@
1
+ /**
2
+ * What makes two projects "the same app", so one app is one project.
3
+ *
4
+ * - A cloud project (its app is reachable on the public internet) is its
5
+ * app's domain: the normalized host, e.g. `domain:shop.example.com`.
6
+ * - A local project is its repository plus the app's folder inside it:
7
+ * `repo:github.com/acme/shop#apps/web`. Two teammates who clone the same
8
+ * repository compute the same key, and each app in a monorepo gets its own.
9
+ *
10
+ * The CLI computes the identity and the registry normalizes it again (it
11
+ * never trusts the client's form of it), then stores the key and its SHA-256
12
+ * for indexed lookups. Credentials in a git remote (`https://user:token@...`)
13
+ * are dropped here, before anything is sent or stored.
14
+ *
15
+ * Dependency-free on purpose: the Cloudflare Worker registry and the Node CLI
16
+ * both import it.
17
+ */
18
+
19
+ export type ProjectIdentityKind = "domain" | "repo";
20
+
21
+ export type ProjectIdentity = {
22
+ kind: ProjectIdentityKind;
23
+ /** The normalized, credential-free key: `domain:<host>` or `repo:<remote>#<appPath>`. */
24
+ key: string;
25
+ /** The app's origin (scheme, host, port), when one is known; informational, not part of the key. */
26
+ origin?: string | null;
27
+ };
28
+
29
+ /** What the CLI or dashboard sends; the registry normalizes it with parseProjectIdentity. */
30
+ export type ProjectIdentityInput = {
31
+ kind?: unknown;
32
+ /** A git remote URL in any form git accepts (https, ssh, scp-like). */
33
+ remote?: unknown;
34
+ /** The app's folder relative to the repository root; "." for the root. */
35
+ appPath?: unknown;
36
+ /** The app's URL or origin. */
37
+ appUrl?: unknown;
38
+ /** A domain name, for kind "domain". */
39
+ domain?: unknown;
40
+ };
41
+
42
+ /** Hosts whose repository paths are case-insensitive, so `Acme/Shop` and `acme/shop` are one repo. */
43
+ const CASE_INSENSITIVE_GIT_HOSTS = new Set(["github.com", "gitlab.com", "bitbucket.org", "dev.azure.com", "ssh.dev.azure.com", "codeberg.org"]);
44
+
45
+ const PRIVATE_HOST = /^(localhost|.*\.localhost|.*\.local|.*\.test|.*\.internal|127\.|10\.|192\.168\.|169\.254\.|0\.0\.0\.0|\[?::1\]?$)/;
46
+
47
+ /** Whether a host can only be reached from the machine or network it runs on. */
48
+ export function isPrivateHost(rawHost: string): boolean {
49
+ const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
50
+ if (PRIVATE_HOST.test(host) || host === "::1") return true;
51
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
52
+ const octets = /^172\.(\d{1,3})\./.exec(host);
53
+ if (octets && Number(octets[1]) >= 16 && Number(octets[1]) <= 31) return true;
54
+ }
55
+ return !host.includes(".");
56
+ }
57
+
58
+ /**
59
+ * A git remote as `host/path`: no scheme, user, password, token, port or
60
+ * `.git`; lowercase host. Null when it isn't a remote this can identify
61
+ * (a local path, an empty string).
62
+ *
63
+ * https://user:tok@GitHub.com/Acme/Shop.git -> github.com/acme/shop
64
+ * git@github.com:acme/shop.git -> github.com/acme/shop
65
+ * ssh://git@gitlab.example.com:2222/a/b.git -> gitlab.example.com/a/b
66
+ */
67
+ export function normalizeGitRemote(value: unknown): string | null {
68
+ if (typeof value !== "string") return null;
69
+ const raw = value.trim();
70
+ if (!raw) return null;
71
+ let host: string;
72
+ let repoPath: string;
73
+ const scpLike = /^(?:[^@/\s]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(raw);
74
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
75
+ let url: URL;
76
+ try {
77
+ // git+ssh://, ssh://, git://, http(s)://: URL parses them all once the scheme is plain.
78
+ url = new URL(raw.replace(/^[a-z0-9+.-]+:\/\//i, "https://"));
79
+ } catch {
80
+ return null;
81
+ }
82
+ if (/^file:/i.test(raw)) return null;
83
+ host = url.hostname;
84
+ repoPath = decodeURIComponent(url.pathname);
85
+ } else if (scpLike && !/^[a-z]:\\/i.test(raw)) {
86
+ host = scpLike[1];
87
+ repoPath = scpLike[2];
88
+ } else if (/^[a-z0-9-]+(\.[a-z0-9-]+)+\/[^\s]+$/i.test(raw)) {
89
+ // Already normalized (host/path), as the CLI sends it.
90
+ [host, repoPath] = [raw.slice(0, raw.indexOf("/")), raw.slice(raw.indexOf("/") + 1)];
91
+ } else {
92
+ return null;
93
+ }
94
+ host = host.toLowerCase().replace(/\.$/, "");
95
+ if (!host) return null;
96
+ repoPath = repoPath
97
+ .split(/[?#]/, 1)[0]
98
+ .replace(/\\/g, "/")
99
+ .replace(/\/+/g, "/")
100
+ .replace(/^\/+|\/+$/g, "")
101
+ .replace(/\.git$/i, "")
102
+ .replace(/\/+$/, "");
103
+ // Azure DevOps ssh remotes carry a "v3/" prefix the https form doesn't.
104
+ if (host === "ssh.dev.azure.com") {
105
+ host = "dev.azure.com";
106
+ repoPath = repoPath.replace(/^v3\//i, "");
107
+ }
108
+ if (!repoPath || repoPath.split("/").some((segment) => segment === "." || segment === "..")) return null;
109
+ if (CASE_INSENSITIVE_GIT_HOSTS.has(host)) repoPath = repoPath.toLowerCase();
110
+ return `${host}/${repoPath}`;
111
+ }
112
+
113
+ /** The app's folder inside the repository, as a clean relative POSIX path; "." for the root. */
114
+ export function normalizeAppPath(value: unknown): string | null {
115
+ if (value === undefined || value === null) return ".";
116
+ if (typeof value !== "string") return null;
117
+ const cleaned = value.trim().replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^(\.\/)+/, "").replace(/^\/+|\/+$/g, "");
118
+ if (!cleaned || cleaned === ".") return ".";
119
+ if (cleaned.split("/").some((segment) => segment === ".." || segment === ".")) return null;
120
+ return cleaned;
121
+ }
122
+
123
+ /** A plain hostname for identity: lowercase, no port, trailing dot or leading "www.". Null when it isn't one. */
124
+ export function normalizeIdentityHost(value: unknown): string | null {
125
+ if (typeof value !== "string") return null;
126
+ const host = value
127
+ .trim()
128
+ .toLowerCase()
129
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//, "")
130
+ .replace(/^[^@/]*@/, "")
131
+ .replace(/[/?#].*$/, "")
132
+ .replace(/:\d+$/, "")
133
+ .replace(/\.$/, "")
134
+ .replace(/^www\./, "");
135
+ if (!/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(host)) return null;
136
+ return host;
137
+ }
138
+
139
+ /** The origin of an app URL with any credentials dropped; null when it isn't an http(s) URL. */
140
+ export function normalizeAppOrigin(value: unknown): string | null {
141
+ if (typeof value !== "string" || !value.trim()) return null;
142
+ try {
143
+ const url = new URL(value.trim());
144
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
145
+ return url.origin.toLowerCase();
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ /** A cloud project's identity from its app URL, or null for a private/local URL. */
152
+ export function domainIdentityFromUrl(appUrl: unknown): ProjectIdentity | null {
153
+ const origin = normalizeAppOrigin(appUrl);
154
+ if (!origin) return null;
155
+ const hostname = new URL(origin).hostname;
156
+ if (isPrivateHost(hostname)) return null;
157
+ const host = normalizeIdentityHost(hostname);
158
+ return host ? { kind: "domain", key: `domain:${host}`, origin } : null;
159
+ }
160
+
161
+ /** A local project's identity from its git remote and the app's folder. */
162
+ export function repoIdentity(remote: unknown, appPath: unknown, appUrl?: unknown): ProjectIdentity | null {
163
+ const normalizedRemote = normalizeGitRemote(remote);
164
+ const normalizedPath = normalizeAppPath(appPath);
165
+ if (!normalizedRemote || !normalizedPath) return null;
166
+ return { kind: "repo", key: `repo:${normalizedRemote}#${normalizedPath}`, origin: normalizeAppOrigin(appUrl) };
167
+ }
168
+
169
+ /**
170
+ * The registry's reading of what a client sent: a repo identity when there
171
+ * is a remote, otherwise a domain identity from the domain or public app URL.
172
+ * Null when nothing identifies the app (a new repo with no remote on
173
+ * localhost, say); "invalid" when the client sent something unusable.
174
+ */
175
+ export function parseProjectIdentity(input: unknown): ProjectIdentity | null | "invalid" {
176
+ if (input === undefined || input === null) return null;
177
+ if (typeof input !== "object" || Array.isArray(input)) return "invalid";
178
+ const value = input as ProjectIdentityInput;
179
+ if (value.kind !== undefined && value.kind !== "repo" && value.kind !== "domain") return "invalid";
180
+ if (value.kind === "repo" || (value.kind === undefined && value.remote !== undefined)) {
181
+ return repoIdentity(value.remote, value.appPath, value.appUrl) ?? "invalid";
182
+ }
183
+ if (value.domain !== undefined) {
184
+ const host = normalizeIdentityHost(value.domain);
185
+ if (!host || isPrivateHost(host)) return "invalid";
186
+ return { kind: "domain", key: `domain:${host}`, origin: normalizeAppOrigin(value.appUrl) };
187
+ }
188
+ if (value.appUrl !== undefined) {
189
+ if (!normalizeAppOrigin(value.appUrl)) return "invalid";
190
+ return domainIdentityFromUrl(value.appUrl);
191
+ }
192
+ return value.kind === "domain" ? "invalid" : null;
193
+ }
194
+
195
+ /** The hex SHA-256 the registry indexes and compares identities by. */
196
+ export async function identityHash(key: string): Promise<string> {
197
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
198
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
199
+ }
200
+
201
+ /** How the dashboard and CLI name an identity to a person. */
202
+ export function describeIdentity(identity: Pick<ProjectIdentity, "kind" | "key">): string {
203
+ if (identity.kind === "domain") return identity.key.replace(/^domain:/, "");
204
+ const [remote, appPath] = identity.key.replace(/^repo:/, "").split("#");
205
+ return appPath && appPath !== "." ? `${remote} (${appPath})` : remote;
206
+ }
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { actorProblem, normalizeRoleName, specRoleStatus, suggestRoles, suggestedRoleNames, usableRoleNames } from "./roles.js";
4
+
5
+ test("role names become actor ids", () => {
6
+ assert.equal(normalizeRoleName("Signed-in Member"), "signed_in_member");
7
+ assert.equal(normalizeRoleName(" admin "), "admin");
8
+ assert.equal(normalizeRoleName("9lives"), null);
9
+ assert.equal(normalizeRoleName(""), null);
10
+ assert.equal(normalizeRoleName("x".repeat(61)), null);
11
+ });
12
+
13
+ test("roles.yaml entries without a status are confirmed; the registry's decision wins", () => {
14
+ assert.equal(specRoleStatus({}), "confirmed");
15
+ assert.equal(specRoleStatus({ status: "suggested" }), "suggested");
16
+ const spec = [
17
+ { name: "user", status: specRoleStatus({}) },
18
+ { name: "admin", status: specRoleStatus({ status: "suggested" }) },
19
+ { name: "owner", status: specRoleStatus({}) },
20
+ ];
21
+ assert.deepEqual(usableRoleNames(spec), ["user", "owner"]);
22
+ const registry = [
23
+ { name: "owner", status: "rejected" as const },
24
+ { name: "admin", status: "confirmed" as const },
25
+ { name: "guest", status: "suggested" as const },
26
+ ];
27
+ assert.deepEqual(usableRoleNames(spec, registry).sort(), ["admin", "user"]);
28
+ assert.deepEqual(suggestedRoleNames(spec, registry), ["guest"]);
29
+ });
30
+
31
+ test("an unusable actor says why and what to do", () => {
32
+ const spec = [{ name: "user", status: "confirmed" as const }, { name: "admin", status: "suggested" as const }];
33
+ assert.equal(actorProblem("user", spec), null);
34
+ assert.match(actorProblem("admin", spec)!, /only suggested.*confirm it/);
35
+ assert.match(actorProblem("admin", spec, [{ name: "admin", status: "rejected" }])!, /rejected/);
36
+ assert.match(actorProblem("ghost", spec)!, /bugmole_role_propose/);
37
+ assert.match(actorProblem("ghost", spec)!, /Confirmed roles: user/);
38
+ });
39
+
40
+ test("suggestions come from sign-in walls, gated routes and menus, suggested spec roles and journey actors", () => {
41
+ const suggestions = suggestRoles({
42
+ nodes: [
43
+ { id: "screen:/", route: "/" },
44
+ { id: "screen:/login", route: "/login" },
45
+ { id: "screen:/admin/users", route: "/admin/users" },
46
+ { id: "action:mod", action: "Open moderation queue" },
47
+ ],
48
+ journeys: [{ id: "flow:checkout", name: "Checkout", actor: "Shopper" }],
49
+ specRoles: [{ name: "support_agent", description: "Support desk.", status: "suggested" }, { name: "user" }],
50
+ });
51
+ const byName = Object.fromEntries(suggestions.map((item) => [item.name, item]));
52
+ assert.equal(byName.guest.source, "sign_in_wall");
53
+ assert.match(byName.guest.evidence, /\/login/);
54
+ assert.equal(byName.member.source, "sign_in_wall");
55
+ assert.equal(byName.admin.source, "role_gated_route");
56
+ assert.equal(byName.moderator.source, "role_gated_menu");
57
+ assert.equal(byName.support_agent.source, "spec");
58
+ assert.equal(byName.shopper.source, "persona");
59
+ assert.equal(byName.user, undefined, "a confirmed spec role needs no suggestion");
60
+ assert.deepEqual(suggestRoles({ nodes: [{ id: "screen:/", route: "/" }] }), [], "nothing concrete, nothing suggested");
61
+ });
62
+
63
+ test("an auth blocker or a probe blocked behind sign-in is a sign-in wall too", () => {
64
+ assert.equal(suggestRoles({ blockers: [{ name: "login_required", type: "auth" }] })[0].source, "sign_in_wall");
65
+ assert.equal(suggestRoles({ nodes: [{ id: "screen:/orders", route: "/orders", runtimeProbe: { status: "blocked" } }] })[0].name, "guest");
66
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Roles: who a test journey is run as (a guest, a signed-in member, an
3
+ * admin). Shared by the registry Worker, the local registry stub, the
4
+ * planner, the pipeline, the MCP server and the dashboard, so it stays
5
+ * dependency-free.
6
+ *
7
+ * Bugmole proposes roles by itself — from sign-in walls, role-gated routes
8
+ * and menus, the project's spec/roles.yaml and the actors discovered
9
+ * journeys were walked as — but a proposal is only a suggestion. A person
10
+ * confirms, renames or rejects it, and only confirmed roles are used to
11
+ * plan test cases and runs. See docs/public/roles.md for why.
12
+ */
13
+
14
+ export type RoleStatus = "suggested" | "confirmed" | "rejected";
15
+ export const ROLE_STATUSES: readonly RoleStatus[] = ["suggested", "confirmed", "rejected"];
16
+
17
+ /** Where a role came from. `person` means someone added it by hand. */
18
+ export type RoleSource = "sign_in_wall" | "role_gated_route" | "role_gated_menu" | "spec" | "persona" | "agent" | "person";
19
+ export const ROLE_SOURCES: readonly RoleSource[] = ["sign_in_wall", "role_gated_route", "role_gated_menu", "spec", "persona", "agent", "person"];
20
+
21
+ export const ROLE_NAME_MAX = 60;
22
+ export const ROLE_DESCRIPTION_MAX = 1000;
23
+ export const ROLE_EVIDENCE_MAX = 2000;
24
+ const ROLE_NAME = /^[a-z][a-z0-9_]*$/;
25
+
26
+ export type RoleRecord = {
27
+ name: string;
28
+ description?: string | null;
29
+ status: RoleStatus;
30
+ source?: RoleSource | string;
31
+ evidence?: string | null;
32
+ };
33
+
34
+ export type RoleSuggestion = {
35
+ name: string;
36
+ description: string;
37
+ source: RoleSource;
38
+ evidence: string;
39
+ };
40
+
41
+ export function roleStatusOf(value: unknown): RoleStatus | null {
42
+ return typeof value === "string" && (ROLE_STATUSES as readonly string[]).includes(value) ? value as RoleStatus : null;
43
+ }
44
+
45
+ export function roleSourceOf(value: unknown): RoleSource | null {
46
+ return typeof value === "string" && (ROLE_SOURCES as readonly string[]).includes(value) ? value as RoleSource : null;
47
+ }
48
+
49
+ /**
50
+ * A role name as an actor id: lower case, words joined by underscores
51
+ * ("Signed-in member" → "signed_in_member"). Returns null when nothing
52
+ * usable is left or it is too long.
53
+ */
54
+ export function normalizeRoleName(value: unknown): string | null {
55
+ if (typeof value !== "string") return null;
56
+ const name = value.trim().toLowerCase().replace(/[\s-]+/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
57
+ if (!name || name.length > ROLE_NAME_MAX || !ROLE_NAME.test(name)) return null;
58
+ return name;
59
+ }
60
+
61
+ /**
62
+ * A role from spec/roles.yaml. An entry without `status` is confirmed: the
63
+ * file predates suggestions, and whoever checked it in chose it. Entries an
64
+ * agent adds are written `status: suggested` (bugmole_write_spec enforces it).
65
+ */
66
+ export function specRoleStatus(entry: { status?: unknown } | null | undefined): RoleStatus {
67
+ return roleStatusOf(entry?.status) ?? "confirmed";
68
+ }
69
+
70
+ /**
71
+ * The roles a plan or a test case may be written for.
72
+ *
73
+ * A registry decision wins: a role a person rejected (or that still waits
74
+ * for them) in the dashboard is not usable just because roles.yaml lists
75
+ * it, and a role confirmed in the dashboard is usable even before anyone
76
+ * adds it to roles.yaml. Without a registry, roles.yaml decides.
77
+ */
78
+ export function usableRoleNames(specRoles: RoleRecord[], registryRoles: RoleRecord[] = []): string[] {
79
+ const decided = new Map<string, RoleStatus>();
80
+ for (const role of specRoles) {
81
+ const name = normalizeRoleName(role.name) ?? role.name;
82
+ decided.set(name, role.status);
83
+ }
84
+ for (const role of registryRoles) {
85
+ const name = normalizeRoleName(role.name) ?? role.name;
86
+ decided.set(name, role.status);
87
+ }
88
+ return [...decided].filter(([, status]) => status === "confirmed").map(([name]) => name);
89
+ }
90
+
91
+ /** Roles still waiting for a person to confirm or reject them. */
92
+ export function suggestedRoleNames(specRoles: RoleRecord[], registryRoles: RoleRecord[] = []): string[] {
93
+ const decided = new Map<string, RoleStatus>();
94
+ for (const role of [...specRoles, ...registryRoles]) decided.set(normalizeRoleName(role.name) ?? role.name, role.status);
95
+ return [...decided].filter(([, status]) => status === "suggested").map(([name]) => name);
96
+ }
97
+
98
+ /** Why an actor can't be used, or null when it can. */
99
+ export function actorProblem(actor: string, specRoles: RoleRecord[], registryRoles: RoleRecord[] = []): string | null {
100
+ const usable = usableRoleNames(specRoles, registryRoles);
101
+ if (usable.includes(actor)) return null;
102
+ const all = [...specRoles, ...registryRoles];
103
+ const known = [...all].reverse().find((role) => (normalizeRoleName(role.name) ?? role.name) === actor);
104
+ const choices = usable.length ? usable.join(", ") : "(none confirmed yet)";
105
+ if (known?.status === "suggested") {
106
+ return `Role "${actor}" is only suggested. A person has to confirm it on the dashboard's Roles page (Manage → Roles) before it is used for test cases or runs. Confirmed roles: ${choices}.`;
107
+ }
108
+ if (known?.status === "rejected") {
109
+ return `Role "${actor}" was rejected by a person, so it isn't used for test cases or runs. Confirmed roles: ${choices}.`;
110
+ }
111
+ return `Role "${actor}" is not a confirmed role. Propose it with bugmole_role_propose and ask a person to confirm it. Confirmed roles: ${choices}.`;
112
+ }
113
+
114
+ type EvidenceNode = { id?: string; title?: string; route?: string; kind?: string; action?: string; actor?: string; metadata?: Record<string, unknown>; runtimeProbe?: { status?: string } };
115
+ type EvidenceEdge = { label?: string; action?: string; to?: string };
116
+ type EvidenceJourney = { id?: string; name?: string; actor?: string };
117
+
118
+ export type RoleEvidence = {
119
+ nodes?: EvidenceNode[];
120
+ edges?: EvidenceEdge[];
121
+ journeys?: EvidenceJourney[];
122
+ /** Blockers from spec/blockers.yaml or recorded during exploration. */
123
+ blockers?: Array<{ name?: string; id?: string; type?: string; description?: string }>;
124
+ /** spec/roles.yaml entries. */
125
+ specRoles?: Array<{ name?: unknown; description?: unknown; status?: unknown }>;
126
+ };
127
+
128
+ const SIGN_IN_ROUTE = /(^|\/)(\(auth\)|login|log-in|signin|sign-in|signup|sign-up|register|auth|oauth)(\/|$)/i;
129
+ /** Route segments that name who may use them, and the role each implies. */
130
+ const GATED_SEGMENTS: Array<{ pattern: RegExp; role: string; description: string }> = [
131
+ { pattern: /(^|\/)(admin|administrator|superadmin)(\/|$)/i, role: "admin", description: "Administrator who can reach the admin area." },
132
+ { pattern: /(^|\/)(owner|owners)(\/|$)/i, role: "owner", description: "Owner who can reach owner-only screens." },
133
+ { pattern: /(^|\/)(staff|backoffice|back-office|ops|operations)(\/|$)/i, role: "staff", description: "Staff or operations user who can reach back-office screens." },
134
+ { pattern: /(^|\/)(moderator|moderation)(\/|$)/i, role: "moderator", description: "Moderator who can reach moderation screens." },
135
+ { pattern: /(^|\/)(vendor|seller|merchant)(\/|$)/i, role: "seller", description: "Seller or vendor who manages their own listings." },
136
+ ];
137
+ const GATED_LABELS: Array<{ pattern: RegExp; role: string; description: string }> = [
138
+ { pattern: /\badmin(istration|istrator)?\b/i, role: "admin", description: "Administrator who sees the admin menu entry." },
139
+ { pattern: /\bstaff\b|\bback ?office\b/i, role: "staff", description: "Staff user who sees the back-office menu entry." },
140
+ { pattern: /\bmoderat(e|ion|or)\b/i, role: "moderator", description: "Moderator who sees the moderation menu entry." },
141
+ ];
142
+
143
+ /**
144
+ * What roles the evidence implies, as suggestions for a person to decide.
145
+ * Deterministic and conservative: it only names a role something concrete
146
+ * points at, and says what that was in `evidence`.
147
+ */
148
+ export function suggestRoles(evidence: RoleEvidence): RoleSuggestion[] {
149
+ const suggestions = new Map<string, RoleSuggestion>();
150
+ const add = (suggestion: RoleSuggestion) => {
151
+ if (!suggestions.has(suggestion.name)) suggestions.set(suggestion.name, suggestion);
152
+ };
153
+ const nodes = evidence.nodes ?? [];
154
+
155
+ const signIn = nodes.find((node) => typeof node.route === "string" && SIGN_IN_ROUTE.test(node.route));
156
+ const authBlocked = nodes.find((node) => node.runtimeProbe?.status === "blocked"
157
+ || (node.metadata as { runtimeProbe?: { status?: string } } | undefined)?.runtimeProbe?.status === "blocked"
158
+ || (node.metadata as { requiresAuth?: unknown } | undefined)?.requiresAuth === true);
159
+ const authBlocker = (evidence.blockers ?? []).find((blocker) =>
160
+ /auth|login|sign.?in|session/i.test(`${blocker.type ?? ""} ${blocker.name ?? blocker.id ?? ""}`));
161
+ const wall = signIn
162
+ ? `sign-in screen at ${signIn.route}`
163
+ : authBlocked
164
+ ? `${authBlocked.route ?? authBlocked.id} needs a signed-in session`
165
+ : authBlocker
166
+ ? `blocker "${authBlocker.name ?? authBlocker.id}" (${authBlocker.type ?? "auth"})`
167
+ : null;
168
+ if (wall) {
169
+ add({ name: "guest", description: "Visitor who is not signed in and only sees public screens.", source: "sign_in_wall", evidence: wall });
170
+ add({ name: "member", description: "Signed-in user who gets past the sign-in wall.", source: "sign_in_wall", evidence: wall });
171
+ }
172
+
173
+ for (const node of nodes) {
174
+ const route = typeof node.route === "string" ? node.route : "";
175
+ for (const gated of GATED_SEGMENTS) {
176
+ if (route && gated.pattern.test(route)) add({ name: gated.role, description: gated.description, source: "role_gated_route", evidence: `route ${route}` });
177
+ }
178
+ }
179
+ const labels = [
180
+ ...nodes.map((node) => node.action ?? ""),
181
+ ...(evidence.edges ?? []).flatMap((edge) => [edge.label ?? "", edge.action ?? ""]),
182
+ ].filter(Boolean);
183
+ for (const label of labels) {
184
+ for (const gated of GATED_LABELS) {
185
+ if (gated.pattern.test(label)) add({ name: gated.role, description: gated.description, source: "role_gated_menu", evidence: `menu or action "${label.slice(0, 80)}"` });
186
+ }
187
+ }
188
+
189
+ for (const entry of evidence.specRoles ?? []) {
190
+ const name = normalizeRoleName(entry?.name);
191
+ if (!name || specRoleStatus(entry as { status?: unknown }) !== "suggested") continue;
192
+ add({ name, description: typeof entry.description === "string" ? entry.description : `Role ${name} from spec/roles.yaml.`, source: "spec", evidence: "spec/roles.yaml lists it as suggested" });
193
+ }
194
+
195
+ for (const journey of evidence.journeys ?? []) {
196
+ const name = normalizeRoleName(journey.actor);
197
+ if (!name) continue;
198
+ add({ name, description: `The role the discovered journey "${journey.name ?? journey.id}" was walked as.`, source: "persona", evidence: `journey ${journey.id ?? journey.name} actor` });
199
+ }
200
+ return [...suggestions.values()];
201
+ }
@@ -2,12 +2,50 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import {
4
4
  MAX_TASK_ATTEMPTS,
5
+ approvalModeOf,
6
+ needsApproval,
7
+ workerApprovalMode,
8
+ workerMayTake,
5
9
  backoffUntil,
6
10
  isClaimableTask,
7
11
  isResumableTask,
8
12
  resumedTaskFields,
13
+ resumedApproval,
14
+ localWorkerConnected,
15
+ resumePlacement,
16
+ strandedTaskMessage,
17
+ LOCAL_WORKER_FRESH_MS,
18
+ IN_REVIEW,
19
+ completionStatus,
20
+ needsCompletionReview,
21
+ sentBackTaskFields,
9
22
  } from "./task-scheduling.js";
10
23
 
24
+ test("completion review follows the same risk model as start approval", () => {
25
+ for (const type of ["code", "ui", "investigation", "discovery"]) {
26
+ assert.equal(completionStatus("manual", type), IN_REVIEW, `manual reviews ${type}`);
27
+ assert.equal(completionStatus("extreme", type), "completed", `extreme reviews nothing (${type})`);
28
+ assert.equal(needsCompletionReview("auto", type), needsApproval("auto", type));
29
+ }
30
+ assert.equal(completionStatus("auto", "code"), IN_REVIEW);
31
+ assert.equal(completionStatus("auto", "ui"), IN_REVIEW);
32
+ assert.equal(completionStatus("auto", "investigation"), "completed");
33
+ assert.equal(completionStatus("auto", "discovery"), "completed");
34
+ });
35
+
36
+ test("a task in review is neither claimable nor resumable, and sending it back requeues it with the note", () => {
37
+ const now = Date.UTC(2026, 0, 1, 12, 0, 0);
38
+ const inReview = { status: IN_REVIEW, attempts: 1, approvedAt: "2026-01-01T00:00:00Z", approvedBy: "ann@example.com" };
39
+ assert.equal(isClaimableTask(inReview, now), false);
40
+ assert.equal(isResumableTask(inReview, now), false);
41
+ const fields = sentBackTaskFields("Fix the overlap");
42
+ assert.equal(fields.status, "queued");
43
+ assert.equal(fields.attempts, 0);
44
+ assert.equal(fields.reviewNote, "Fix the overlap");
45
+ assert.match(fields.progressMessage, /Sent back from review: Fix the overlap/);
46
+ assert.equal(isClaimableTask({ ...inReview, ...fields }, now), true, "the kept approval lets a worker take it again");
47
+ });
48
+
11
49
  const NOW = Date.UTC(2026, 0, 1, 12, 0, 0);
12
50
 
13
51
  test("a queued task with no history is claimable", () => {
@@ -98,3 +136,59 @@ test("a resumed task is immediately claimable again — the whole point of resum
98
136
  assert.equal(isClaimableTask(parked, NOW), false);
99
137
  assert.equal(isClaimableTask({ ...parked, ...resumedTaskFields() }, NOW), true);
100
138
  });
139
+
140
+ test("approval modes: manual holds everything, auto holds code changes, extreme holds nothing", () => {
141
+ for (const type of ["code", "ui", "investigation", "discovery"]) assert.equal(needsApproval("manual", type), true, type);
142
+ assert.deepEqual(["code", "ui", "investigation", "discovery"].map((type) => needsApproval("auto", type)), [true, true, false, false]);
143
+ for (const type of ["code", "ui", "investigation", "discovery"]) assert.equal(needsApproval("extreme", type), false, type);
144
+ assert.equal(approvalModeOf("Manual"), null, "modes are exact");
145
+ assert.equal(workerApprovalMode({ BUGMOLE_APPROVAL: " manual " }), "manual");
146
+ assert.equal(workerApprovalMode({ BUGMOLE_APPROVAL: "yolo" }), undefined);
147
+ });
148
+
149
+ test("a task waiting for approval is never claimable, and a stricter worker waits for a person", () => {
150
+ const waiting = { status: "queued", taskType: "code", approvedAt: null, approvedBy: null };
151
+ assert.equal(isClaimableTask(waiting, NOW), false);
152
+ const autoApproved = { status: "queued", taskType: "code", approvedAt: "2026-01-01", approvedBy: "auto:extreme" };
153
+ assert.equal(isClaimableTask(autoApproved, NOW), true, "no worker mode: whatever the project allows");
154
+ assert.equal(isClaimableTask(autoApproved, NOW, "auto"), false, "an auto worker won't take a code change nobody approved");
155
+ assert.equal(workerMayTake({ ...autoApproved, approvedBy: "steve@theaiinc.com" }, "manual"), true, "a person's approval satisfies any worker");
156
+ assert.equal(workerMayTake({ status: "queued", taskType: "investigation", approvedAt: "x", approvedBy: "auto:auto" }, "auto"), true);
157
+ assert.equal(workerMayTake({ status: "queued", taskType: "investigation" }, undefined), true, "a registry without approval columns");
158
+ });
159
+
160
+ test("resuming keeps a person's approval and lets the current mode decide an automatic one", () => {
161
+ const human = { approvedAt: "2026-01-01", approvedBy: "steve@theaiinc.com", taskType: "code" };
162
+ assert.deepEqual(resumedApproval(human, "manual"), { approvedBy: "steve@theaiinc.com" });
163
+ const auto = { approvedAt: "2026-01-01", approvedBy: "auto:extreme", taskType: "code" };
164
+ assert.deepEqual(resumedApproval(auto, "auto"), { approvedBy: null }, "auto mode holds code changes");
165
+ assert.deepEqual(resumedApproval(auto, "extreme"), { approvedBy: "auto:extreme" });
166
+ const waiting = { approvedAt: null, approvedBy: null, taskType: "investigation" };
167
+ assert.deepEqual(resumedApproval(waiting, "auto"), { approvedBy: "auto:auto" }, "the project has since dropped the step");
168
+ assert.deepEqual(resumedApproval(waiting, "manual"), { approvedBy: null });
169
+ });
170
+
171
+ test("a machine counts as connected while its last task listing is recent", () => {
172
+ assert.equal(localWorkerConnected(null, NOW), false);
173
+ assert.equal(localWorkerConnected("not a date", NOW), false);
174
+ assert.equal(localWorkerConnected(new Date(NOW - 5_000).toISOString(), NOW), true);
175
+ assert.equal(localWorkerConnected(new Date(NOW - LOCAL_WORKER_FRESH_MS - 1).toISOString(), NOW), false);
176
+ });
177
+
178
+ test("resume sends cloud tasks to the cloud and refuses a machine-only task nothing on a cloud project will run", () => {
179
+ const recent = new Date(NOW - 5_000).toISOString();
180
+ const cloudProject = { storageMode: "managed", defaultExecutionMode: "cloud" };
181
+ assert.deepEqual(resumePlacement({ executionMode: "cloud", taskType: "discovery" }, cloudProject, NOW), { kind: "cloud" });
182
+ assert.deepEqual(resumePlacement({ executionMode: "self", taskType: "investigation" }, cloudProject, NOW), { kind: "stranded", canRunInCloud: false });
183
+ assert.deepEqual(resumePlacement({ executionMode: "self", taskType: "discovery" }, cloudProject, NOW), { kind: "stranded", canRunInCloud: true });
184
+ assert.deepEqual(resumePlacement({ executionMode: "self", taskType: "code" }, { ...cloudProject, localWorkerSeenAt: recent }, NOW), { kind: "machine" });
185
+ assert.deepEqual(
186
+ resumePlacement({ executionMode: "self", taskType: "discovery" }, { storageMode: "byo", defaultExecutionMode: "cloud" }, NOW),
187
+ { kind: "stranded", canRunInCloud: false },
188
+ "cloud by default, but cloud runs need Bugmole storage",
189
+ );
190
+ assert.deepEqual(resumePlacement({ executionMode: "self", taskType: "code" }, { storageMode: "byo", defaultExecutionMode: "self" }, NOW), { kind: "machine" }, "a machine project queues ahead of `bugmole serve`");
191
+ assert.match(strandedTaskMessage(false), /npx bugmole serve/);
192
+ assert.doesNotMatch(strandedTaskMessage(false), /Bugmole Cloud/);
193
+ assert.match(strandedTaskMessage(true), /resume it on Bugmole Cloud/);
194
+ });