@bugmole/cli 0.4.0 → 0.5.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 (38) hide show
  1. package/LICENSE +18 -4
  2. package/package.json +4 -4
  3. package/scripts/bugmole.ts +60 -0
  4. package/scripts/ensure-playwright.cjs +87 -0
  5. package/scripts/ensure-playwright.test.ts +36 -0
  6. package/scripts/sync-launcher-version.cjs +24 -0
  7. package/scripts/sync-launcher-version.test.ts +28 -0
  8. package/src/registry/migrations/0034_signup_attribution.sql +19 -0
  9. package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
  10. package/src/registry-worker/core.ts +2 -0
  11. package/src/registry-worker/domains/domains.test.ts +64 -1
  12. package/src/registry-worker/domains/routes.ts +32 -15
  13. package/src/registry-worker/domains.ts +40 -2
  14. package/src/registry-worker/features.ts +2 -0
  15. package/src/registry-worker/feedback/feedback.test.ts +47 -0
  16. package/src/registry-worker/feedback/website.ts +77 -0
  17. package/src/registry-worker/index.ts +25 -7
  18. package/src/registry-worker/org/routes.test.ts +24 -1
  19. package/src/registry-worker/org/routes.ts +5 -4
  20. package/src/registry-worker/runner/runner.test.ts +38 -0
  21. package/src/registry-worker/runner/tokens.ts +4 -1
  22. package/src/registry-worker/signup/attribution.test.ts +147 -0
  23. package/src/registry-worker/signup/attribution.ts +194 -0
  24. package/src/runtime/ai-exploration.test.ts +34 -1
  25. package/src/runtime/ai-exploration.ts +97 -3
  26. package/src/runtime/appium-driver.ts +6 -0
  27. package/src/runtime/discovery-task.ts +24 -5
  28. package/src/runtime/flow-language.ts +21 -0
  29. package/src/runtime/journey-editor.ts +39 -0
  30. package/src/runtime/journey-graph.test.ts +42 -0
  31. package/src/runtime/playwright-driver.test.ts +145 -0
  32. package/src/runtime/playwright-driver.ts +237 -8
  33. package/src/runtime/run-job.test.ts +21 -0
  34. package/src/runtime/run-job.ts +8 -0
  35. package/src/runtime/site-discovery.test.ts +135 -1
  36. package/src/runtime/site-discovery.ts +173 -24
  37. package/src/runtime/web-suite.test.ts +65 -0
  38. package/src/runtime/web-suite.ts +3 -0
@@ -6,6 +6,7 @@ import "./jobs/retention.js";
6
6
  import "./ai/routes.js";
7
7
  import "./runner/routes.js";
8
8
  import "./signup/routes.js";
9
+ import "./signup/attribution.js";
9
10
  import "./billing/checkout-routes.js";
10
11
  import "./notifications/outbox.js";
11
12
  import "./notifications/alerts.js";
@@ -14,6 +15,7 @@ import "./github/checks.js";
14
15
  import "./gitlab/checks.js";
15
16
  import "./personas/routes.js";
16
17
  import "./feedback/routes.js";
18
+ import "./feedback/website.js";
17
19
  import "./devices/routes.js";
18
20
  import "./domains/routes.js";
19
21
  import "./sso/routes.js";
@@ -228,3 +228,50 @@ test("a missing GitHub setup keeps the report and stops retrying", async () => {
228
228
  assert.equal((db.db.prepare("SELECT status FROM outbox").get() as any).status, "dead");
229
229
  assert.equal((db.db.prepare("SELECT status FROM feedback_reports WHERE id = ?").get(sent.json.id) as any).status, "new");
230
230
  });
231
+
232
+ // ── The public website's "Questions?" panel ──
233
+
234
+ const SITE = { origin: "https://bugmole.com", "cf-connecting-ip": "203.0.113.9" };
235
+
236
+ test("website feedback is stored for triage, never filed on GitHub", async () => {
237
+ const gh = fakeGithub();
238
+ const { env, db } = feedbackEnv(gh);
239
+ const preflight = await call(env, "OPTIONS", "/api/public/feedback", { headers: { origin: "https://bugmole.com" } });
240
+ assert.equal(preflight.status, 204);
241
+ assert.equal(preflight.headers.get("access-control-allow-origin"), "https://bugmole.com");
242
+ const sent = await call(env, "POST", "/api/public/feedback", {
243
+ headers: SITE,
244
+ body: { topic: "question", message: "Does Bugmole test Flutter apps?", email: "ann@example.com", page: "https://bugmole.com/pricing/?token=abc" },
245
+ });
246
+ assert.equal(sent.status, 201);
247
+ assert.equal(sent.headers.get("access-control-allow-origin"), "https://bugmole.com");
248
+ const row = db.db.prepare("SELECT aegis_id, kind, category, contact, page, ip, github_issue_number FROM feedback_reports WHERE id = ?").get(sent.json.id) as any;
249
+ assert.deepEqual({ ...row }, { aegis_id: "website", kind: "feedback", category: "website:question", contact: "ann@example.com", page: redactUrl("https://bugmole.com/pricing/?token=abc"), ip: "203.0.113.9", github_issue_number: null });
250
+ await drainOutbox(env);
251
+ assert.equal(gh.state.issues.length, 0);
252
+ });
253
+
254
+ test("website feedback only from bugmole.com, and not from bots", async () => {
255
+ const { env, db } = feedbackEnv(fakeGithub());
256
+ const other = await call(env, "POST", "/api/public/feedback", { headers: { origin: "https://evil.example" }, body: { topic: "bug", message: "hello there" } });
257
+ assert.equal(other.status, 403);
258
+ const noOrigin = await call(env, "POST", "/api/public/feedback", { body: { topic: "bug", message: "hello there" } });
259
+ assert.equal(noOrigin.status, 403);
260
+ const bot = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "bug", message: "cheap pills", website: "http://spam" } });
261
+ assert.equal(bot.status, 201);
262
+ const bad = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "bug", message: "x" } });
263
+ assert.equal(bad.status, 422);
264
+ assert.equal((db.db.prepare("SELECT count(*) AS n FROM feedback_reports").get() as any).n, 0);
265
+ });
266
+
267
+ test("website feedback is rate limited per visitor", async () => {
268
+ const { env } = feedbackEnv(fakeGithub());
269
+ for (let i = 0; i < 5; i++) {
270
+ const ok = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "idea", message: `idea number ${i}` } });
271
+ assert.equal(ok.status, 201);
272
+ }
273
+ const limited = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "idea", message: "one more idea" } });
274
+ assert.equal(limited.status, 429);
275
+ const someoneElse = await call(env, "POST", "/api/public/feedback", { headers: { ...SITE, "cf-connecting-ip": "198.51.100.4" }, body: { topic: "idea", message: "a different person" } });
276
+ assert.equal(someoneElse.status, 201);
277
+ });
@@ -0,0 +1,77 @@
1
+ // Questions and feedback from the public website (bugmole.com's "Questions?"
2
+ // panel), from people who aren't signed in. They land in the same triage list
3
+ // as dashboard reports (aegis_id "website"), but are never filed as GitHub
4
+ // issues automatically: anyone on the internet can send one.
5
+ import type { Env } from "../core.js";
6
+ import { newId, response } from "../core.js";
7
+ import { registerPublicRoutes } from "../routes/index.js";
8
+ import { fingerprintOf, redactSecrets, redactUrl } from "./format.js";
9
+
10
+ export const WEBSITE_AEGIS_ID = "website";
11
+ /** Per visitor (IP) and for the whole site, per hour. */
12
+ export const WEBSITE_IP_LIMIT_PER_HOUR = 5;
13
+ export const WEBSITE_LIMIT_PER_HOUR = 100;
14
+ const ALLOWED_ORIGINS = new Set(["https://bugmole.com"]);
15
+ const KINDS = { question: "feedback", idea: "feedback", bug: "bug" } as const;
16
+
17
+ function cors(origin: string | null): Record<string, string> {
18
+ return origin && ALLOWED_ORIGINS.has(origin)
19
+ ? { "access-control-allow-origin": origin, "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "86400", vary: "Origin" }
20
+ : {};
21
+ }
22
+
23
+ function withCors(res: Response, origin: string | null): Response {
24
+ const headers = new Headers(res.headers);
25
+ for (const [k, v] of Object.entries(cors(origin))) headers.set(k, v);
26
+ return new Response(res.body, { status: res.status, headers });
27
+ }
28
+
29
+ const text = (value: unknown, max: number) => (typeof value === "string" ? value.trim().slice(0, max) : "");
30
+ const validEmail = (s: string) => /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/.test(s);
31
+
32
+ export async function submitWebsiteFeedback(request: Request, env: Env): Promise<Response> {
33
+ const origin = request.headers.get("origin");
34
+ if (!origin || !ALLOWED_ORIGINS.has(origin)) return response({ error: "Not allowed" }, 403);
35
+ const body = await request.json().catch(() => null) as Record<string, unknown> | null;
36
+ if (!body) return withCors(response({ error: "Send JSON" }, 400), origin);
37
+ // A field people never see: bots fill it in. Pretend it worked.
38
+ if (text(body.website, 200)) return withCors(response({ ok: true }, 201), origin);
39
+ const topic = text(body.topic, 20) as keyof typeof KINDS;
40
+ if (!(topic in KINDS)) return withCors(response({ error: "topic is question, idea or bug" }, 422), origin);
41
+ const message = text(body.message, 2_000);
42
+ if (message.length < 3) return withCors(response({ error: "Write a few words first" }, 422), origin);
43
+ const email = text(body.email, 200);
44
+ if (email && !validEmail(email)) return withCors(response({ error: "That email address doesn't look right" }, 422), origin);
45
+
46
+ const ip = request.headers.get("cf-connecting-ip");
47
+ const hourAgo = new Date(Date.now() - 3_600_000).toISOString().replace("T", " ").slice(0, 19);
48
+ const recent = await env.DB.prepare(`
49
+ SELECT count(*) AS total, SUM(CASE WHEN ip IS NOT NULL AND ip = ? THEN 1 ELSE 0 END) AS fromIp
50
+ FROM feedback_reports WHERE aegis_id = ? AND created_at > ?
51
+ `).bind(ip, WEBSITE_AEGIS_ID, hourAgo).first<{ total: number | null; fromIp: number | null }>();
52
+ if ((recent?.total ?? 0) >= WEBSITE_LIMIT_PER_HOUR || (ip && (recent?.fromIp ?? 0) >= WEBSITE_IP_LIMIT_PER_HOUR)) {
53
+ return withCors(response({ error: "Thanks, we've had a lot of messages just now. Please try again later, or ask on Discord." }, 429), origin);
54
+ }
55
+
56
+ const kind = KINDS[topic];
57
+ const page = text(body.page, 500) ? redactUrl(text(body.page, 500)) : null;
58
+ const clean = redactSecrets(message);
59
+ const id = newId("fb");
60
+ await env.DB.prepare(`
61
+ INSERT INTO feedback_reports (id, aegis_id, kind, category, message, page, contact, data_json, fingerprint, ip)
62
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
63
+ `).bind(
64
+ id, WEBSITE_AEGIS_ID, kind, `website:${topic}`, clean, page, email || null,
65
+ JSON.stringify({ source: "website", userAgent: text(request.headers.get("user-agent"), 300) || undefined }),
66
+ await fingerprintOf(kind, clean, page, []), ip,
67
+ ).run();
68
+ return withCors(response({ ok: true, id }, 201), origin);
69
+ }
70
+
71
+ registerPublicRoutes(async ({ request, env, url }) => {
72
+ if (url.pathname !== "/api/public/feedback") return null;
73
+ const origin = request.headers.get("origin");
74
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors(origin) });
75
+ if (request.method !== "POST") return withCors(response({ error: "Not found" }, 404), origin);
76
+ return submitWebsiteFeedback(request, env);
77
+ });
@@ -184,10 +184,12 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
184
184
  ON CONFLICT(project_id, id) DO UPDATE SET name = excluded.name
185
185
  `).bind(attempt.projectId, attempt.projectName),
186
186
  env.DB.prepare(`
187
+ -- A project that already has an environment keeps it: pairing
188
+ -- another machine must not add a same-named twin beside it.
187
189
  INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
188
- VALUES (?, ?, 'default', ?, '', 'active', 1)
189
- ON CONFLICT(project_id, id) DO UPDATE SET app_id = excluded.app_id, name = excluded.name, is_default = 1
190
- `).bind(environmentId, attempt.projectId, attempt.environment),
190
+ SELECT ?, ?, 'default', ?, '', 'active', 1
191
+ WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
192
+ `).bind(environmentId, attempt.projectId, attempt.environment, attempt.projectId),
191
193
  env.DB.prepare(`
192
194
  INSERT INTO project_memberships (project_id, aegis_id, role, status)
193
195
  VALUES (?, ?, 'owner', 'active')
@@ -234,9 +236,9 @@ async function claimDeviceAuthorization(request: Request, env: Env, principal: P
234
236
  `).bind(attempt.projectId, attempt.projectName),
235
237
  env.DB.prepare(`
236
238
  INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
237
- VALUES (?, ?, 'default', ?, '', 'active', 1)
238
- ON CONFLICT(project_id, id) DO UPDATE SET app_id = excluded.app_id, name = excluded.name, is_default = 1
239
- `).bind(environmentId, attempt.projectId, attempt.environment),
239
+ SELECT ?, ?, 'default', ?, '', 'active', 1
240
+ WHERE NOT EXISTS (SELECT 1 FROM project_environments WHERE project_id = ?)
241
+ `).bind(environmentId, attempt.projectId, attempt.environment, attempt.projectId),
240
242
  env.DB.prepare(`
241
243
  INSERT INTO project_memberships (project_id, aegis_id, role, status)
242
244
  VALUES (?, ?, 'owner', 'active')
@@ -717,6 +719,11 @@ async function createEnvironment(
717
719
  "SELECT id FROM project_apps WHERE project_id = ? AND id = ?",
718
720
  ).bind(projectId, appId).first<{ id: string }>();
719
721
  if (!app) return response({ error: "App does not belong to this project" }, 422);
722
+ // Two environments with one name can't be told apart in the picker.
723
+ const sameName = await env.DB.prepare(
724
+ "SELECT id FROM project_environments WHERE project_id = ? AND COALESCE(app_id, 'default') = ? AND lower(trim(name)) = lower(?)",
725
+ ).bind(projectId, appId, body.name.trim()).first<{ id: string }>();
726
+ if (sameName) return response({ error: `This app already has an environment named "${body.name.trim()}" (${sameName.id})` }, 409);
720
727
  try {
721
728
  await env.DB.prepare(`
722
729
  INSERT INTO project_environments (id, project_id, app_id, name, base_url, api_url)
@@ -940,7 +947,7 @@ async function listRuns(env: Env, principal: Principal, projectId: string): Prom
940
947
  targets_json AS targetsJson, environment_base_url AS environmentBaseUrl,
941
948
  target_results_json AS targetResultsJson, fix_from_json AS fixFromJson,
942
949
  execution_mode AS executionMode, blocked_reason AS blockedReason, billed_minutes AS billedMinutes,
943
- trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber,
950
+ trigger_source AS triggerSource, repo_full_name AS repoFullName, commit_sha AS commitSha, pr_number AS prNumber, check_run_id AS checkRunId,
944
951
  gitlab_binding_id AS gitlabBindingId
945
952
  FROM runs
946
953
  WHERE project_id = ? AND (? IS NULL OR COALESCE(completed_at, requested_at) >= ? OR status NOT IN ('success', 'partial', 'error', 'cancelled'))
@@ -1251,6 +1258,17 @@ async function fillDiscoveredEnvironmentUrl(env: Env, projectId: string, descrip
1251
1258
  .bind(origin, projectId, existing.id).run();
1252
1259
  return;
1253
1260
  }
1261
+ // An environment that isn't marked default (made by hand, or by an older
1262
+ // onboarding) is still the project's environment: promote it rather than
1263
+ // add a second "Production" beside it.
1264
+ const other = await env.DB.prepare(
1265
+ "SELECT id, base_url AS baseUrl FROM project_environments WHERE project_id = ? ORDER BY rowid LIMIT 1",
1266
+ ).bind(projectId).first<{ id: string; baseUrl: string }>();
1267
+ if (other) {
1268
+ await env.DB.prepare("UPDATE project_environments SET is_default = 1, base_url = CASE WHEN base_url = '' THEN ? ELSE base_url END WHERE project_id = ? AND id = ?")
1269
+ .bind(origin, projectId, other.id).run();
1270
+ return;
1271
+ }
1254
1272
  const app = await env.DB.prepare("SELECT id FROM project_apps WHERE project_id = ? AND is_default = 1")
1255
1273
  .bind(projectId).first<{ id: string }>();
1256
1274
  if (!app) return;
@@ -26,11 +26,20 @@ test("an invitation emails a single-use link that adds the person with the invit
26
26
  const reused = await call(env, "POST", `/api/invitations/${token}/accept`, { as: "user_cal" });
27
27
  assert.equal(reused.status, 410);
28
28
 
29
- const members = await call(env, "GET", `/api/workspaces/${workspaceId}/members`, { as: "user_bea" });
29
+ const members = await call(env, "GET", `/api/workspaces/${workspaceId}/members`, {
30
+ as: "user_bea",
31
+ // The dashboard forwards the signed-in email, which the registry remembers.
32
+ headers: { "x-aegis-email": "user_bea@example.com", "x-aegis-email-verified": "true" },
33
+ });
30
34
  assert.deepEqual(members.json.members.map((m: { aegisId: string; role: string }) => [m.aegisId, m.role]), [
31
35
  [owner, "owner"],
32
36
  ["user_bea", "member"],
33
37
  ]);
38
+ assert.equal(
39
+ members.json.members.find((m: { aegisId: string }) => m.aegisId === "user_bea")?.email,
40
+ "user_bea@example.com",
41
+ "people are listed by email, not their Aegis id",
42
+ );
34
43
  assert.deepEqual(members.json.invitations, [], "members don't see pending invitations");
35
44
  });
36
45
 
@@ -161,3 +170,17 @@ test("the audit log records changes; export needs the Business plan", async () =
161
170
  assert.match(csv.headers.get("content-type") ?? "", /text\/csv/);
162
171
  assert.match(String(csv.json), /"run\.created"/);
163
172
  });
173
+
174
+ test("pairing a machine keeps a project's existing environment instead of adding a same-named twin", async () => {
175
+ const { env, db } = createTestEnv();
176
+ const fresh = await claimDevice(env, "user_pair", "shop", "Shop");
177
+ assert.equal(fresh.status, 200, JSON.stringify(fresh.json));
178
+ const first = db.db.prepare("SELECT id, is_default AS isDefault FROM project_environments WHERE project_id = 'shop'").all().map((row: any) => ({ ...row }));
179
+ assert.deepEqual(first, [{ id: "staging", isDefault: 1 }], "a brand-new project gets the CLI's environment");
180
+
181
+ db.db.prepare("UPDATE project_environments SET id = 'default', name = 'Production' WHERE project_id = 'shop'").run();
182
+ const again = await claimDevice(env, "user_pair", "shop", "Shop");
183
+ assert.equal(again.status, 200, JSON.stringify(again.json));
184
+ const after = db.db.prepare("SELECT id FROM project_environments WHERE project_id = 'shop'").all().map((row: any) => row.id);
185
+ assert.deepEqual(after, ["default"], "pairing again doesn't add a second environment");
186
+ });
@@ -42,10 +42,11 @@ async function listMembers({ env, principal }: AuthedRouteContext, workspaceId:
42
42
  const role = await workspaceRole(env, principal.aegisId, workspaceId);
43
43
  if (!role) return response({ error: "Workspace scope is not allowed" }, 403);
44
44
  const members = await env.DB.prepare(`
45
- SELECT aegis_id AS aegisId, role, status, invited_by AS invitedBy, created_at AS joinedAt
46
- FROM workspace_memberships
47
- WHERE workspace_id = ? AND status = 'active'
48
- ORDER BY CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'billing' THEN 2 ELSE 3 END, created_at
45
+ SELECT m.aegis_id AS aegisId, u.email, m.role, m.status, m.invited_by AS invitedBy, m.created_at AS joinedAt
46
+ FROM workspace_memberships m
47
+ LEFT JOIN aegis_users u ON u.id = m.aegis_id
48
+ WHERE m.workspace_id = ? AND m.status = 'active'
49
+ ORDER BY CASE m.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'billing' THEN 2 ELSE 3 END, m.created_at
49
50
  `).bind(workspaceId).all();
50
51
  const canManage = role === "owner" || role === "admin";
51
52
  const invitations = canManage
@@ -58,6 +58,9 @@ test("runner tokens name one run, can't be altered and expire", async () => {
58
58
  assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/plans/a.yaml"), false);
59
59
  assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/runs/run_1/video.webm"), true);
60
60
  assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/runs/run_10/video.webm"), false);
61
+ assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/journeys/graph.json"), true, "a passing run promotes the journey it proved");
62
+ assert.equal(allowed("PUT", "/api/projects/shop/artifacts/object?key=projects/shop/journeys/other.json"), false);
63
+ assert.equal(allowed("PUT", "/api/projects/other/artifacts/object?key=projects/other/journeys/graph.json"), false);
61
64
  assert.equal(allowed("GET", "/api/projects/other/artifacts"), false);
62
65
  assert.equal(allowed("GET", "/api/projects"), false);
63
66
  assert.equal(allowed("POST", "/api/tasks/run_1"), false);
@@ -214,6 +217,41 @@ test("a discovered URL fills a project's empty default environment but never ove
214
217
  }
215
218
  });
216
219
 
220
+ test("a discovered URL makes an existing environment the default instead of adding a second Production", async () => {
221
+ const launcher = fakeLauncher();
222
+ try {
223
+ const { env, db, projectId, owner } = await cloudProject();
224
+ db.db.prepare(`
225
+ INSERT INTO project_environments (id, project_id, app_id, name, base_url, status, is_default)
226
+ VALUES ('production', ?, 'default', 'Production', '', 'active', 0)
227
+ `).run(projectId);
228
+ const created = await call(env, "POST", `/api/projects/${projectId}/tasks`, {
229
+ as: owner,
230
+ body: { title: "Explore https://shop.test", description: "Explore https://shop.test", taskType: "discovery", idempotencyKey: "d1", executionMode: "cloud" },
231
+ });
232
+ const taskId = created.json.task.id;
233
+ const { token } = launcher.launches[0];
234
+ await call(env, "POST", `/api/tasks/${taskId}`, { token, body: { workerId: `cloud:task:${taskId}`, status: "completed", completionSummary: "Explored 1 page" } });
235
+ const environments = db.db.prepare("SELECT id, base_url AS baseUrl, is_default AS isDefault FROM project_environments WHERE project_id = ?").all(projectId).map((row: any) => ({ ...row }));
236
+ assert.deepEqual(environments, [{ id: "production", baseUrl: "https://shop.test", isDefault: 1 }]);
237
+ } finally {
238
+ launcher.restore();
239
+ }
240
+ });
241
+
242
+ test("an app can't have two environments with the same name", async () => {
243
+ const { env, projectId, owner } = await cloudProject();
244
+ const create = (id: string, name: string) => call(env, "POST", `/api/projects/${projectId}/environments`, {
245
+ as: owner,
246
+ body: { id, name, baseUrl: "https://shop.test" },
247
+ });
248
+ assert.equal((await create("production", "Production")).status, 201);
249
+ const twin = await create("prod-2", " production ");
250
+ assert.equal(twin.status, 409);
251
+ assert.match(twin.json.error, /already has an environment named "production" \(production\)/);
252
+ assert.equal((await create("staging", "Staging")).status, 201);
253
+ });
254
+
217
255
  test("runs and discovery share the workspace's parallel slots", async () => {
218
256
  const launcher = fakeLauncher();
219
257
  try {
@@ -78,7 +78,10 @@ export function runnerMayCall(identity: RunnerIdentity, method: string, url: URL
78
78
  if (path.startsWith(`/api/runs/${id}/device-sessions/`)) return method === "GET" || method === "POST";
79
79
  if (artifacts) {
80
80
  if (method === "GET") return true;
81
- return (method === "PUT" || method === "DELETE") && key.startsWith(`projects/${project}/runs/${id}/`);
81
+ if ((method === "PUT" || method === "DELETE") && key.startsWith(`projects/${project}/runs/${id}/`)) return true;
82
+ // A passing run promotes the draft journey it proved (run-job.ts), so
83
+ // it may rewrite the graph, and only the graph, of its own project.
84
+ return method === "PUT" && key === `projects/${project}/journeys/graph.json`;
82
85
  }
83
86
  return false;
84
87
  }
@@ -0,0 +1,147 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { call, createTestEnv, seedProject } from "../testing/harness.js";
4
+ import { normalizeAttribution } from "./attribution.js";
5
+
6
+ const now = Date.parse("2026-09-25T12:00:00Z");
7
+
8
+ test("attribution keeps only values that match the contract", () => {
9
+ const cleaned = normalizeAttribution({
10
+ ref: " Partner_42 ",
11
+ utm_source: " newsletter ",
12
+ utm_medium: "email",
13
+ utm_campaign: "x".repeat(150),
14
+ utm_content: "line\nbreak",
15
+ utm_term: 7,
16
+ landing_path: "/pricing/?utm_source=newsletter#plans",
17
+ referrer_host: "News.Example.com",
18
+ first_seen_at: "2026-09-20T08:30:00.000Z",
19
+ }, now);
20
+ assert.deepEqual(cleaned, {
21
+ ref: "partner_42",
22
+ utm_source: "newsletter",
23
+ utm_medium: "email",
24
+ utm_campaign: "x".repeat(100),
25
+ utm_content: null,
26
+ utm_term: null,
27
+ landing_path: "/pricing/",
28
+ referrer_host: "news.example.com",
29
+ first_seen_at: "2026-09-20T08:30:00.000Z",
30
+ });
31
+
32
+ const invalid = normalizeAttribution({
33
+ ref: "bad code!",
34
+ utm_source: "ok",
35
+ landing_path: "//evil.test/path",
36
+ referrer_host: "www.bugmole.com",
37
+ first_seen_at: "2027-01-01T00:00:00Z",
38
+ }, now);
39
+ assert.equal(invalid?.ref, null);
40
+ assert.equal(invalid?.landing_path, null);
41
+ assert.equal(invalid?.referrer_host, null);
42
+ assert.equal(invalid?.first_seen_at, null);
43
+ assert.equal(invalid?.utm_source, "ok");
44
+
45
+ assert.equal(normalizeAttribution({ ref: "a".repeat(41) }, now), null);
46
+ assert.equal(normalizeAttribution({ referrer_host: "https://news.example.com/x" }, now), null);
47
+ assert.equal(normalizeAttribution({ first_seen_at: "2026-09-20T08:30:00Z" }, now), null, "a date alone says nothing");
48
+ assert.equal(normalizeAttribution({ landing_path: "/docs/", first_seen_at: "2025-01-01T00:00:00Z" }, now)?.first_seen_at, null);
49
+ assert.equal(normalizeAttribution([], now), null);
50
+ assert.equal(normalizeAttribution("ref=x", now), null);
51
+ });
52
+
53
+ test("the first touch is recorded once and never overwritten", async () => {
54
+ const { env, db } = createTestEnv();
55
+ const first = await call(env, "POST", "/api/attribution", {
56
+ as: "user_new",
57
+ body: { ref: "Partner", utm_campaign: "launch", landing_path: "/", referrer_host: "news.example.com", bogus: "ignored" },
58
+ });
59
+ assert.equal(first.status, 201, JSON.stringify(first.json));
60
+ assert.equal(first.json.recorded, true);
61
+
62
+ const second = await call(env, "POST", "/api/attribution", { as: "user_new", body: { ref: "other", utm_campaign: "later" } });
63
+ assert.equal(second.status, 200);
64
+ assert.deepEqual(second.json, { recorded: false, reason: "already_recorded" });
65
+
66
+ const row = db.db.prepare("SELECT ref, utm_campaign, utm_source, landing_path, referrer_host FROM signup_attribution WHERE aegis_id = ?").get("user_new");
67
+ assert.deepEqual({ ...row }, { ref: "partner", utm_campaign: "launch", utm_source: null, landing_path: "/", referrer_host: "news.example.com" });
68
+ });
69
+
70
+ test("an account older than a day can't be attributed", async () => {
71
+ const { env, db } = createTestEnv();
72
+ db.db.prepare("INSERT INTO aegis_users (id, created_at) VALUES ('user_old', datetime('now', '-3 days'))").run();
73
+ const result = await call(env, "POST", "/api/attribution", { as: "user_old", body: { ref: "partner" } });
74
+ assert.deepEqual(result.json, { recorded: false, reason: "account_too_old" });
75
+ assert.equal(db.db.prepare("SELECT COUNT(*) AS n FROM signup_attribution").get()?.n, 0);
76
+ });
77
+
78
+ test("nothing usable, API keys and the dashboard service record nothing", async () => {
79
+ const { env, db } = createTestEnv();
80
+ const empty = await call(env, "POST", "/api/attribution", { as: "user_new", body: { ref: "no spaces allowed", utm_source: " " } });
81
+ assert.deepEqual(empty.json, { recorded: false, reason: "nothing_to_record" });
82
+
83
+ const { owner, projectId } = await seedProject(env);
84
+ const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "ci", projectIds: [projectId] } });
85
+ const byKey = await call(env, "POST", "/api/attribution", { token: key.json.token, body: { ref: "partner" } });
86
+ assert.equal(byKey.status, 403);
87
+ const byService = await call(env, "POST", "/api/attribution", { as: "bugmole-dashboard-service", body: { ref: "partner" } });
88
+ assert.equal(byService.status, 403);
89
+ assert.equal(db.db.prepare("SELECT COUNT(*) AS n FROM signup_attribution").get()?.n, 0);
90
+ });
91
+
92
+ function withScents(env: Record<string, unknown>, codes: string[]) {
93
+ env.SCENTS = {
94
+ fetch: async (url: string) => {
95
+ const code = decodeURIComponent(String(url).split("/public/ref/")[1] ?? "");
96
+ return new Response(JSON.stringify({ valid: codes.includes(code) }), { headers: { "content-type": "application/json" } });
97
+ },
98
+ };
99
+ return env;
100
+ }
101
+
102
+ test("a typed referral code credits a recent account once, and only real codes", async () => {
103
+ const { env, db } = createTestEnv();
104
+ withScents(env as unknown as Record<string, unknown>, ["jane"]);
105
+ db.db.prepare("INSERT INTO aegis_users (id, created_at) VALUES ('user_new', datetime('now', '-5 days'))").run();
106
+
107
+ const before = await call(env, "GET", "/api/attribution/me", { as: "user_new" });
108
+ assert.equal(before.json.ref, null);
109
+ assert.equal(before.json.canEnterCode, true);
110
+
111
+ const typo = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "jan" } });
112
+ assert.equal(typo.status, 404);
113
+ const bad = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "not a code!" } });
114
+ assert.equal(bad.status, 400);
115
+
116
+ const ok = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: " JANE " } });
117
+ assert.equal(ok.status, 201, JSON.stringify(ok.json));
118
+ assert.equal(db.db.prepare("SELECT ref FROM signup_attribution WHERE aegis_id = 'user_new'").get()?.ref, "jane");
119
+
120
+ const again = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "jane" } });
121
+ assert.equal(again.status, 409);
122
+ const after = await call(env, "GET", "/api/attribution/me", { as: "user_new" });
123
+ assert.deepEqual([after.json.ref, after.json.canEnterCode], ["jane", false]);
124
+ });
125
+
126
+ test("a code fills in a campaign-only attribution but never replaces a code", async () => {
127
+ const { env, db } = createTestEnv();
128
+ withScents(env as unknown as Record<string, unknown>, ["jane", "bob"]);
129
+ await call(env, "POST", "/api/attribution", { as: "user_new", body: { utm_campaign: "launch" } });
130
+ const ok = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "jane" } });
131
+ assert.equal(ok.status, 201);
132
+ const row = db.db.prepare("SELECT ref, utm_campaign FROM signup_attribution WHERE aegis_id = 'user_new'").get();
133
+ assert.deepEqual({ ...row }, { ref: "jane", utm_campaign: "launch" });
134
+ const swap = await call(env, "POST", "/api/attribution/code", { as: "user_new", body: { code: "bob" } });
135
+ assert.equal(swap.status, 409);
136
+ });
137
+
138
+ test("codes can't be added after 30 days, or when Scents can't be asked", async () => {
139
+ const { env, db } = createTestEnv();
140
+ db.db.prepare("INSERT INTO aegis_users (id, created_at) VALUES ('user_old', datetime('now', '-40 days'))").run();
141
+ const unreachable = await call(env, "POST", "/api/attribution/code", { as: "user_old", body: { code: "jane" } });
142
+ assert.equal(unreachable.status, 503);
143
+ withScents(env as unknown as Record<string, unknown>, ["jane"]);
144
+ const late = await call(env, "POST", "/api/attribution/code", { as: "user_old", body: { code: "jane" } });
145
+ assert.equal(late.status, 409);
146
+ assert.equal((await call(env, "GET", "/api/attribution/me", { as: "user_old" })).json.canEnterCode, false);
147
+ });