@bugmole/cli 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedPlan, 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`, {
|
|
7
|
+
as: inviter,
|
|
8
|
+
body: { email: `${who}@example.com`, role },
|
|
9
|
+
});
|
|
10
|
+
assert.equal(invited.status, 201, JSON.stringify(invited.json));
|
|
11
|
+
const token = invited.json.invitation.acceptUrl.split("/invite/")[1];
|
|
12
|
+
const accepted = await call(env, "POST", `/api/invitations/${token}/accept`, { as: who });
|
|
13
|
+
assert.equal(accepted.status, 200, JSON.stringify(accepted.json));
|
|
14
|
+
return token;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test("an invitation emails a single-use link that adds the person with the invited role", async () => {
|
|
18
|
+
const { env, email } = createTestEnv({ DASHBOARD_URL: "https://dash.test" });
|
|
19
|
+
const { owner, workspaceId } = await seedProject(env);
|
|
20
|
+
const token = await inviteAndJoin(env, owner, workspaceId, "user_bea", "member");
|
|
21
|
+
|
|
22
|
+
assert.equal(email.sent.length, 1);
|
|
23
|
+
assert.equal(email.sent[0].to[0].email, "user_bea@example.com");
|
|
24
|
+
assert.match(email.sent[0].text, /https:\/\/dash\.test\/invite\//);
|
|
25
|
+
|
|
26
|
+
const reused = await call(env, "POST", `/api/invitations/${token}/accept`, { as: "user_cal" });
|
|
27
|
+
assert.equal(reused.status, 410);
|
|
28
|
+
|
|
29
|
+
const members = await call(env, "GET", `/api/workspaces/${workspaceId}/members`, { as: "user_bea" });
|
|
30
|
+
assert.deepEqual(members.json.members.map((m: { aegisId: string; role: string }) => [m.aegisId, m.role]), [
|
|
31
|
+
[owner, "owner"],
|
|
32
|
+
["user_bea", "member"],
|
|
33
|
+
]);
|
|
34
|
+
assert.deepEqual(members.json.invitations, [], "members don't see pending invitations");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("roles decide who can run tests, manage the project, and change people", async () => {
|
|
38
|
+
const { env } = createTestEnv();
|
|
39
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
40
|
+
await seedPlan(env, projectId, owner);
|
|
41
|
+
await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
|
|
42
|
+
await inviteAndJoin(env, owner, workspaceId, "user_billing", "billing");
|
|
43
|
+
await inviteAndJoin(env, owner, workspaceId, "user_admin", "admin");
|
|
44
|
+
|
|
45
|
+
const runAs = (who: string, key: string) =>
|
|
46
|
+
call(env, "POST", `/api/projects/${projectId}/runs`, { as: who, body: { planId: "checkout", idempotencyKey: key } });
|
|
47
|
+
assert.equal((await runAs("user_member", "a")).status, 201);
|
|
48
|
+
assert.equal((await runAs("user_billing", "b")).status, 403, "billing managers can't start runs");
|
|
49
|
+
assert.equal((await call(env, "GET", `/api/projects/${projectId}/runs`, { as: "user_billing" })).status, 200, "but can read them");
|
|
50
|
+
|
|
51
|
+
const editEnv = (who: string) =>
|
|
52
|
+
call(env, "POST", `/api/projects/${projectId}/environments`, { as: who, body: { name: `Env ${who}`, baseUrl: "https://example.com" } });
|
|
53
|
+
assert.equal((await editEnv("user_member")).status, 403, "members don't manage project settings");
|
|
54
|
+
assert.notEqual((await editEnv("user_admin")).status, 403);
|
|
55
|
+
|
|
56
|
+
const promote = (who: string, target: string, role: string) =>
|
|
57
|
+
call(env, "PATCH", `/api/workspaces/${workspaceId}/members/${target}`, { as: who, body: { role } });
|
|
58
|
+
assert.equal((await promote("user_member", "user_billing", "admin")).status, 403);
|
|
59
|
+
assert.equal((await promote("user_admin", "user_member", "owner")).status, 403, "only owners create owners");
|
|
60
|
+
assert.equal((await promote("user_admin", "user_member", "admin")).status, 200);
|
|
61
|
+
assert.equal((await promote(owner, owner, "member")).status, 409, "the last owner can't step down");
|
|
62
|
+
|
|
63
|
+
const invites = await call(env, "POST", `/api/workspaces/${workspaceId}/invitations`, {
|
|
64
|
+
as: "user_admin",
|
|
65
|
+
body: { email: "x@example.com", role: "billing" },
|
|
66
|
+
});
|
|
67
|
+
assert.equal(invites.status, 403, "admins can't hand out billing access");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("removing someone cuts off their API keys and re-registering can't restore access", async () => {
|
|
71
|
+
const { env } = createTestEnv();
|
|
72
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
73
|
+
await inviteAndJoin(env, owner, workspaceId, "user_leaver", "member");
|
|
74
|
+
const key = await call(env, "POST", "/api/api-keys", {
|
|
75
|
+
as: "user_leaver",
|
|
76
|
+
body: { name: "ci", scopes: ["runs:write", "projects:read", "projects:write"], projectIds: [projectId] },
|
|
77
|
+
});
|
|
78
|
+
assert.equal(key.status, 201, JSON.stringify(key.json));
|
|
79
|
+
const token = key.json.token ?? key.json.apiKey?.token;
|
|
80
|
+
assert.ok(token);
|
|
81
|
+
assert.equal((await call(env, "GET", `/api/projects/${projectId}/runs`, { token })).status, 200);
|
|
82
|
+
|
|
83
|
+
assert.equal((await call(env, "DELETE", `/api/workspaces/${workspaceId}/members/user_leaver`, { as: owner })).status, 200);
|
|
84
|
+
assert.equal((await call(env, "GET", `/api/projects/${projectId}/runs`, { token })).status, 403);
|
|
85
|
+
|
|
86
|
+
const reregister = await call(env, "POST", "/api/projects/register", {
|
|
87
|
+
token,
|
|
88
|
+
body: { id: projectId, name: "Shop", workspaceId, environment: "staging", storageProvider: "r2" },
|
|
89
|
+
});
|
|
90
|
+
assert.equal(reregister.status, 403);
|
|
91
|
+
assert.equal((await call(env, "GET", `/api/projects/${projectId}/runs`, { token })).status, 403);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a project id can't be used to move someone else's project", async () => {
|
|
95
|
+
const { env } = createTestEnv();
|
|
96
|
+
const { projectId } = await seedProject(env);
|
|
97
|
+
await call(env, "POST", "/api/workspaces", { as: "user_attacker", body: { id: "evil", name: "Evil" } });
|
|
98
|
+
const hijack = await call(env, "POST", "/api/projects/register", {
|
|
99
|
+
as: "user_attacker",
|
|
100
|
+
body: { id: projectId, name: "Mine now", workspaceId: "evil", environment: "prod", storageProvider: "local" },
|
|
101
|
+
});
|
|
102
|
+
assert.equal(hijack.status, 409);
|
|
103
|
+
const list = await call(env, "GET", "/api/projects", { as: "user_attacker" });
|
|
104
|
+
assert.deepEqual(list.json.projects, []);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
async function claimDevice(env: Parameters<typeof call>[0], who: string, projectId: string, projectName: string, email?: string) {
|
|
108
|
+
const start = await call(env, "POST", "/api/device-authorizations", {
|
|
109
|
+
body: { projectId, projectName, environment: "staging", storageProvider: "r2" },
|
|
110
|
+
});
|
|
111
|
+
assert.equal(start.status, 201, JSON.stringify(start.json));
|
|
112
|
+
return call(env, "POST", "/api/device-authorizations/claim", {
|
|
113
|
+
as: who,
|
|
114
|
+
body: { attemptId: start.json.attemptId, code: start.json.code, deviceId: projectId, ...(email ? { email } : {}) },
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test("claiming a device authorization names the new workspace, never the raw workspace id", async () => {
|
|
119
|
+
const { env, db } = createTestEnv();
|
|
120
|
+
// No email, and no email at all: falls back to the claimed project's own name.
|
|
121
|
+
const noEmail = await claimDevice(env, "user_device", "mobile-app", "Mobile App");
|
|
122
|
+
assert.equal(noEmail.status, 200, JSON.stringify(noEmail.json));
|
|
123
|
+
const row1 = db.db.prepare("SELECT name FROM workspaces WHERE id = ?").get("workspace_user_device") as { name: string };
|
|
124
|
+
assert.equal(row1.name, "Mobile App workspace", "falls back to the claimed project's name, never the raw workspace id");
|
|
125
|
+
|
|
126
|
+
// A free-mail domain says nothing about the person, so it still falls back to the project name.
|
|
127
|
+
const freeMail = await claimDevice(env, "user_freemail", "storefront", "Storefront", "steve@gmail.com");
|
|
128
|
+
assert.equal(freeMail.status, 200, JSON.stringify(freeMail.json));
|
|
129
|
+
const row2 = db.db.prepare("SELECT name FROM workspaces WHERE id = ?").get("workspace_user_freemail") as { name: string };
|
|
130
|
+
assert.equal(row2.name, "Storefront workspace");
|
|
131
|
+
|
|
132
|
+
// A company domain wins over the project name.
|
|
133
|
+
const companyMail = await claimDevice(env, "user_company", "internal-tool", "Internal Tool", "ada@northwind.io");
|
|
134
|
+
assert.equal(companyMail.status, 200, JSON.stringify(companyMail.json));
|
|
135
|
+
const row3 = db.db.prepare("SELECT name FROM workspaces WHERE id = ?").get("workspace_user_company") as { name: string };
|
|
136
|
+
assert.equal(row3.name, "Northwind");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("the audit log records changes; export needs the Business plan", async () => {
|
|
140
|
+
const { env, db } = createTestEnv();
|
|
141
|
+
const { owner, workspaceId, projectId } = await seedProject(env);
|
|
142
|
+
await seedPlan(env, projectId, owner);
|
|
143
|
+
await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "a" } });
|
|
144
|
+
await inviteAndJoin(env, owner, workspaceId, "user_member", "member");
|
|
145
|
+
|
|
146
|
+
const log = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: owner });
|
|
147
|
+
assert.equal(log.status, 200);
|
|
148
|
+
const actions = log.json.entries.map((entry: { action: string }) => entry.action);
|
|
149
|
+
for (const action of ["workspace.created", "project.created", "run.created", "invitation.created", "invitation.accepted"]) {
|
|
150
|
+
assert.ok(actions.includes(action), `${action} in ${actions}`);
|
|
151
|
+
}
|
|
152
|
+
assert.equal(log.json.historyDays, 7);
|
|
153
|
+
assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: "user_member" })).status, 403);
|
|
154
|
+
|
|
155
|
+
const blocked = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log?format=csv`, { as: owner });
|
|
156
|
+
assert.equal(blocked.status, 402);
|
|
157
|
+
|
|
158
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, 'business')").run(workspaceId);
|
|
159
|
+
const csv = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log?format=csv`, { as: owner });
|
|
160
|
+
assert.equal(csv.status, 200);
|
|
161
|
+
assert.match(csv.headers.get("content-type") ?? "", /text\/csv/);
|
|
162
|
+
assert.match(String(csv.json), /"run\.created"/);
|
|
163
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Workspace members, invitations and the audit log.
|
|
2
|
+
import {
|
|
3
|
+
type Env,
|
|
4
|
+
type WorkspaceRole,
|
|
5
|
+
isWorkspaceRole,
|
|
6
|
+
memberOfWorkspace,
|
|
7
|
+
newId,
|
|
8
|
+
randomToken,
|
|
9
|
+
requestJson,
|
|
10
|
+
response,
|
|
11
|
+
sha256,
|
|
12
|
+
workspaceRole,
|
|
13
|
+
} from "../core.js";
|
|
14
|
+
import { emailSender, escapeHtml } from "../email/sender.js";
|
|
15
|
+
import { getEntitlements } from "../billing/entitlements.js";
|
|
16
|
+
import { type AuthedRouteContext, matchPath, registerRoutes } from "../routes/index.js";
|
|
17
|
+
import { actorOf, auditCsv, listAudit, recordAudit } from "./audit.js";
|
|
18
|
+
|
|
19
|
+
const INVITATION_DAYS = 7;
|
|
20
|
+
/** Audit history every plan can see; exports and older history need `auditLogExport`. */
|
|
21
|
+
const BASIC_AUDIT_DAYS = 7;
|
|
22
|
+
|
|
23
|
+
function dashboardUrl(env: Env): string {
|
|
24
|
+
return (env.DASHBOARD_URL ?? "https://app.bugmole.com").replace(/\/$/, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Only owners hand out ownership or billing access. */
|
|
28
|
+
function canGrant(actorRole: WorkspaceRole, role: WorkspaceRole): boolean {
|
|
29
|
+
if (actorRole === "owner") return true;
|
|
30
|
+
return actorRole === "admin" && (role === "admin" || role === "member");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function activeOwnerCount(env: Env, workspaceId: string): Promise<number> {
|
|
34
|
+
const row = await env.DB.prepare(`
|
|
35
|
+
SELECT COUNT(*) AS count FROM workspace_memberships
|
|
36
|
+
WHERE workspace_id = ? AND role = 'owner' AND status = 'active'
|
|
37
|
+
`).bind(workspaceId).first<{ count: number }>();
|
|
38
|
+
return row?.count ?? 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function listMembers({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
42
|
+
const role = await workspaceRole(env, principal.aegisId, workspaceId);
|
|
43
|
+
if (!role) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
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
|
|
49
|
+
`).bind(workspaceId).all();
|
|
50
|
+
const canManage = role === "owner" || role === "admin";
|
|
51
|
+
const invitations = canManage
|
|
52
|
+
? (await env.DB.prepare(`
|
|
53
|
+
SELECT id, email, role, invited_by AS invitedBy, expires_at AS expiresAt, created_at AS createdAt
|
|
54
|
+
FROM workspace_invitations
|
|
55
|
+
WHERE workspace_id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?
|
|
56
|
+
ORDER BY created_at DESC
|
|
57
|
+
`).bind(workspaceId, new Date().toISOString()).all()).results
|
|
58
|
+
: [];
|
|
59
|
+
return response({ members: members.results, invitations, viewerRole: role });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function changeMember(context: AuthedRouteContext, workspaceId: string, aegisId: string): Promise<Response> {
|
|
63
|
+
const { env, principal, request } = context;
|
|
64
|
+
const actorRole = await workspaceRole(env, principal.aegisId, workspaceId);
|
|
65
|
+
if (!actorRole || !(actorRole === "owner" || actorRole === "admin")) {
|
|
66
|
+
return response({ error: "Only owners and admins can change members" }, 403);
|
|
67
|
+
}
|
|
68
|
+
const target = await workspaceRole(env, aegisId, workspaceId);
|
|
69
|
+
if (!target) return response({ error: "Member not found" }, 404);
|
|
70
|
+
const body = await requestJson(request);
|
|
71
|
+
const role = body?.role;
|
|
72
|
+
if (!isWorkspaceRole(role)) return response({ error: "role must be owner, admin, member, or billing" }, 422);
|
|
73
|
+
if (!canGrant(actorRole, role) || !canGrant(actorRole, target)) {
|
|
74
|
+
return response({ error: "Only owners can change owner or billing roles" }, 403);
|
|
75
|
+
}
|
|
76
|
+
if (target === "owner" && role !== "owner" && (await activeOwnerCount(env, workspaceId)) <= 1) {
|
|
77
|
+
return response({ error: "A workspace needs at least one owner" }, 409);
|
|
78
|
+
}
|
|
79
|
+
await env.DB.prepare("UPDATE workspace_memberships SET role = ? WHERE workspace_id = ? AND aegis_id = ?")
|
|
80
|
+
.bind(role, workspaceId, aegisId).run();
|
|
81
|
+
await recordAudit(env, actorOf(principal), {
|
|
82
|
+
workspaceId, action: "member.role_changed", targetType: "user", targetId: aegisId, data: { from: target, to: role },
|
|
83
|
+
}, request);
|
|
84
|
+
return response({ member: { aegisId, role } });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function removeMember(context: AuthedRouteContext, workspaceId: string, aegisId: string): Promise<Response> {
|
|
88
|
+
const { env, principal, request } = context;
|
|
89
|
+
const actorRole = await workspaceRole(env, principal.aegisId, workspaceId);
|
|
90
|
+
const leaving = aegisId === principal.aegisId;
|
|
91
|
+
const target = await workspaceRole(env, aegisId, workspaceId);
|
|
92
|
+
if (!actorRole) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
93
|
+
if (!target) return response({ error: "Member not found" }, 404);
|
|
94
|
+
if (!leaving && !(actorRole === "owner" || (actorRole === "admin" && canGrant("admin", target)))) {
|
|
95
|
+
return response({ error: "You can't remove this member" }, 403);
|
|
96
|
+
}
|
|
97
|
+
if (target === "owner" && (await activeOwnerCount(env, workspaceId)) <= 1) {
|
|
98
|
+
return response({ error: "A workspace needs at least one owner" }, 409);
|
|
99
|
+
}
|
|
100
|
+
// The row stays (status 'removed') so re-registering a project from an old
|
|
101
|
+
// CLI session can't quietly restore access.
|
|
102
|
+
await env.DB.prepare("UPDATE workspace_memberships SET status = 'removed' WHERE workspace_id = ? AND aegis_id = ?")
|
|
103
|
+
.bind(workspaceId, aegisId).run();
|
|
104
|
+
await env.DB.prepare(`
|
|
105
|
+
UPDATE project_memberships SET status = 'removed'
|
|
106
|
+
WHERE aegis_id = ? AND project_id IN (SELECT id FROM projects WHERE workspace_id = ?)
|
|
107
|
+
`).bind(aegisId, workspaceId).run();
|
|
108
|
+
await recordAudit(env, actorOf(principal), {
|
|
109
|
+
workspaceId, action: leaving ? "member.left" : "member.removed", targetType: "user", targetId: aegisId, data: { role: target },
|
|
110
|
+
}, request);
|
|
111
|
+
return response({ removed: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function inviteMember(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
115
|
+
const { env, principal, request } = context;
|
|
116
|
+
const actorRole = await workspaceRole(env, principal.aegisId, workspaceId);
|
|
117
|
+
if (!actorRole || !(actorRole === "owner" || actorRole === "admin")) {
|
|
118
|
+
return response({ error: "Only owners and admins can invite people" }, 403);
|
|
119
|
+
}
|
|
120
|
+
const body = await requestJson(request);
|
|
121
|
+
const email = typeof body?.email === "string" ? body.email.trim().toLowerCase() : "";
|
|
122
|
+
const role = body?.role ?? "member";
|
|
123
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return response({ error: "A valid email is required" }, 422);
|
|
124
|
+
if (!isWorkspaceRole(role)) return response({ error: "role must be owner, admin, member, or billing" }, 422);
|
|
125
|
+
if (!canGrant(actorRole, role)) return response({ error: "Only owners can invite owners or billing managers" }, 403);
|
|
126
|
+
const workspace = await env.DB.prepare("SELECT name FROM workspaces WHERE id = ?").bind(workspaceId).first<{ name: string }>();
|
|
127
|
+
if (!workspace) return response({ error: "Workspace not found" }, 404);
|
|
128
|
+
|
|
129
|
+
const token = randomToken(32);
|
|
130
|
+
const id = newId("inv");
|
|
131
|
+
const expiresAt = new Date(Date.now() + INVITATION_DAYS * 86_400_000).toISOString();
|
|
132
|
+
// A newer invitation for the same address replaces any open one.
|
|
133
|
+
await env.DB.batch([
|
|
134
|
+
env.DB.prepare(`
|
|
135
|
+
UPDATE workspace_invitations SET revoked_at = CURRENT_TIMESTAMP
|
|
136
|
+
WHERE workspace_id = ? AND email = ? AND accepted_at IS NULL AND revoked_at IS NULL
|
|
137
|
+
`).bind(workspaceId, email),
|
|
138
|
+
env.DB.prepare(`
|
|
139
|
+
INSERT INTO workspace_invitations (id, workspace_id, email, role, token_hash, invited_by, expires_at)
|
|
140
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
141
|
+
`).bind(id, workspaceId, email, role, await sha256(token), principal.aegisId, expiresAt),
|
|
142
|
+
]);
|
|
143
|
+
const acceptUrl = `${dashboardUrl(env)}/invite/${token}`;
|
|
144
|
+
const name = escapeHtml(workspace.name);
|
|
145
|
+
try {
|
|
146
|
+
await emailSender(env).send({
|
|
147
|
+
to: [{ email }],
|
|
148
|
+
subject: `You're invited to ${workspace.name} on Bugmole`,
|
|
149
|
+
tag: "invitation",
|
|
150
|
+
text: `You've been invited to join ${workspace.name} on Bugmole as ${role}.\n\nAccept: ${acceptUrl}\n\nThis link expires in ${INVITATION_DAYS} days.`,
|
|
151
|
+
html: `<p>You've been invited to join <strong>${name}</strong> on Bugmole as ${role}.</p><p><a href="${acceptUrl}">Accept the invitation</a></p><p>This link expires in ${INVITATION_DAYS} days.</p>`,
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error("[invitations] email failed", error);
|
|
155
|
+
}
|
|
156
|
+
await recordAudit(env, actorOf(principal), {
|
|
157
|
+
workspaceId, action: "invitation.created", targetType: "invitation", targetId: id, data: { email, role },
|
|
158
|
+
}, request);
|
|
159
|
+
// The link is returned so an admin can share it directly if email is slow.
|
|
160
|
+
return response({ invitation: { id, email, role, expiresAt, acceptUrl } }, 201);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function revokeInvitation(context: AuthedRouteContext, workspaceId: string, invitationId: string): Promise<Response> {
|
|
164
|
+
const { env, principal, request } = context;
|
|
165
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId, "members.manage"))) {
|
|
166
|
+
return response({ error: "Only owners and admins can revoke invitations" }, 403);
|
|
167
|
+
}
|
|
168
|
+
const result = await env.DB.prepare(`
|
|
169
|
+
UPDATE workspace_invitations SET revoked_at = CURRENT_TIMESTAMP
|
|
170
|
+
WHERE id = ? AND workspace_id = ? AND accepted_at IS NULL AND revoked_at IS NULL
|
|
171
|
+
`).bind(invitationId, workspaceId).run();
|
|
172
|
+
if (!result.meta?.changes) return response({ error: "Invitation not found" }, 404);
|
|
173
|
+
await recordAudit(env, actorOf(principal), {
|
|
174
|
+
workspaceId, action: "invitation.revoked", targetType: "invitation", targetId: invitationId,
|
|
175
|
+
}, request);
|
|
176
|
+
return response({ revoked: true });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
type InvitationRow = {
|
|
180
|
+
id: string;
|
|
181
|
+
workspaceId: string;
|
|
182
|
+
workspaceName: string;
|
|
183
|
+
email: string;
|
|
184
|
+
role: string;
|
|
185
|
+
expiresAt: string;
|
|
186
|
+
acceptedAt: string | null;
|
|
187
|
+
revokedAt: string | null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
async function findInvitation(env: Env, token: string): Promise<InvitationRow | null> {
|
|
191
|
+
return env.DB.prepare(`
|
|
192
|
+
SELECT i.id, i.workspace_id AS workspaceId, w.name AS workspaceName, i.email, i.role,
|
|
193
|
+
i.expires_at AS expiresAt, i.accepted_at AS acceptedAt, i.revoked_at AS revokedAt
|
|
194
|
+
FROM workspace_invitations i JOIN workspaces w ON w.id = i.workspace_id
|
|
195
|
+
WHERE i.token_hash = ?
|
|
196
|
+
`).bind(await sha256(token)).first<InvitationRow>();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function invitationState(invitation: InvitationRow): "open" | "accepted" | "revoked" | "expired" {
|
|
200
|
+
if (invitation.acceptedAt) return "accepted";
|
|
201
|
+
if (invitation.revokedAt) return "revoked";
|
|
202
|
+
return Date.parse(invitation.expiresAt) <= Date.now() ? "expired" : "open";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function previewInvitation({ env }: AuthedRouteContext, token: string): Promise<Response> {
|
|
206
|
+
const invitation = await findInvitation(env, token);
|
|
207
|
+
if (!invitation) return response({ error: "Invitation not found" }, 404);
|
|
208
|
+
return response({
|
|
209
|
+
invitation: {
|
|
210
|
+
workspaceId: invitation.workspaceId,
|
|
211
|
+
workspaceName: invitation.workspaceName,
|
|
212
|
+
email: invitation.email,
|
|
213
|
+
role: invitation.role,
|
|
214
|
+
expiresAt: invitation.expiresAt,
|
|
215
|
+
state: invitationState(invitation),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function acceptInvitation(context: AuthedRouteContext, token: string): Promise<Response> {
|
|
221
|
+
const { env, principal, request } = context;
|
|
222
|
+
if (principal.apiKeyId) return response({ error: "Invitations are accepted by a person, not an API key" }, 403);
|
|
223
|
+
const invitation = await findInvitation(env, token);
|
|
224
|
+
if (!invitation) return response({ error: "Invitation not found" }, 404);
|
|
225
|
+
const state = invitationState(invitation);
|
|
226
|
+
if (state !== "open") return response({ error: `This invitation is ${state}` }, 410);
|
|
227
|
+
const claimed = await env.DB.prepare(`
|
|
228
|
+
UPDATE workspace_invitations SET accepted_at = CURRENT_TIMESTAMP, accepted_by = ?
|
|
229
|
+
WHERE id = ? AND accepted_at IS NULL AND revoked_at IS NULL
|
|
230
|
+
`).bind(principal.aegisId, invitation.id).run();
|
|
231
|
+
if (claimed.meta?.changes !== 1) return response({ error: "This invitation was already used" }, 409);
|
|
232
|
+
await env.DB.batch([
|
|
233
|
+
env.DB.prepare("INSERT INTO aegis_users (id) VALUES (?) ON CONFLICT(id) DO NOTHING").bind(principal.aegisId),
|
|
234
|
+
// Accepting never lowers an existing role.
|
|
235
|
+
env.DB.prepare(`
|
|
236
|
+
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status, invited_by, created_at)
|
|
237
|
+
SELECT ?, ?, ?, 'active', invited_by, ? FROM workspace_invitations WHERE id = ?
|
|
238
|
+
ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET
|
|
239
|
+
status = 'active',
|
|
240
|
+
role = CASE WHEN workspace_memberships.status = 'active' THEN workspace_memberships.role ELSE excluded.role END
|
|
241
|
+
`).bind(invitation.workspaceId, principal.aegisId, invitation.role, new Date().toISOString(), invitation.id),
|
|
242
|
+
]);
|
|
243
|
+
await recordAudit(env, actorOf(principal), {
|
|
244
|
+
workspaceId: invitation.workspaceId, action: "invitation.accepted", targetType: "invitation", targetId: invitation.id,
|
|
245
|
+
data: { role: invitation.role },
|
|
246
|
+
}, request);
|
|
247
|
+
return response({ workspace: { id: invitation.workspaceId, name: invitation.workspaceName }, role: invitation.role });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function auditLog(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
251
|
+
const { env, principal, url } = context;
|
|
252
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId, "audit.read"))) {
|
|
253
|
+
return response({ error: "Only owners and admins can view the audit log" }, 403);
|
|
254
|
+
}
|
|
255
|
+
const entitlements = await getEntitlements(env, workspaceId);
|
|
256
|
+
const csv = url.searchParams.get("format") === "csv";
|
|
257
|
+
if (csv && !entitlements.features.auditLogExport) {
|
|
258
|
+
return response({ error: "Audit log export is available on the Business plan", code: "plan_feature", feature: "auditLogExport" }, 402);
|
|
259
|
+
}
|
|
260
|
+
const fullHistory = entitlements.features.auditLogExport;
|
|
261
|
+
const basicSince = new Date(Date.now() - BASIC_AUDIT_DAYS * 86_400_000).toISOString();
|
|
262
|
+
const requestedSince = url.searchParams.get("since") ?? undefined;
|
|
263
|
+
const since = fullHistory ? requestedSince : requestedSince && requestedSince > basicSince ? requestedSince : basicSince;
|
|
264
|
+
const before = url.searchParams.get("before") ?? undefined;
|
|
265
|
+
const limit = Math.min(Math.max(Number(url.searchParams.get("limit")) || 100, 1), csv ? 10_000 : 200);
|
|
266
|
+
const rows = await listAudit(env, workspaceId, { since, before, limit });
|
|
267
|
+
if (csv) {
|
|
268
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "audit_log.exported", data: { rows: rows.length } }, context.request);
|
|
269
|
+
return new Response(auditCsv(rows), {
|
|
270
|
+
headers: {
|
|
271
|
+
"content-type": "text/csv; charset=utf-8",
|
|
272
|
+
"content-disposition": `attachment; filename="bugmole-audit-${workspaceId}.csv"`,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return response({
|
|
277
|
+
entries: rows,
|
|
278
|
+
nextBefore: rows.length === limit ? rows[rows.length - 1].createdAt : null,
|
|
279
|
+
historyDays: fullHistory ? null : BASIC_AUDIT_DAYS,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
registerRoutes(async (context) => {
|
|
284
|
+
const { request, url } = context;
|
|
285
|
+
const path = url.pathname;
|
|
286
|
+
let params = matchPath(path, "/api/workspaces/:workspaceId/members");
|
|
287
|
+
if (params && request.method === "GET") return listMembers(context, params.workspaceId);
|
|
288
|
+
params = matchPath(path, "/api/workspaces/:workspaceId/members/:aegisId");
|
|
289
|
+
if (params && request.method === "PATCH") return changeMember(context, params.workspaceId, params.aegisId);
|
|
290
|
+
if (params && request.method === "DELETE") return removeMember(context, params.workspaceId, params.aegisId);
|
|
291
|
+
params = matchPath(path, "/api/workspaces/:workspaceId/invitations");
|
|
292
|
+
if (params && request.method === "POST") return inviteMember(context, params.workspaceId);
|
|
293
|
+
params = matchPath(path, "/api/workspaces/:workspaceId/invitations/:invitationId");
|
|
294
|
+
if (params && request.method === "DELETE") return revokeInvitation(context, params.workspaceId, params.invitationId);
|
|
295
|
+
params = matchPath(path, "/api/invitations/:token");
|
|
296
|
+
if (params && request.method === "GET") return previewInvitation(context, params.token);
|
|
297
|
+
params = matchPath(path, "/api/invitations/:token/accept");
|
|
298
|
+
if (params && request.method === "POST") return acceptInvitation(context, params.token);
|
|
299
|
+
params = matchPath(path, "/api/workspaces/:workspaceId/audit-log");
|
|
300
|
+
if (params && request.method === "GET") return auditLog(context, params.workspaceId);
|
|
301
|
+
return null;
|
|
302
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedProject } from "../testing/harness.js";
|
|
4
|
+
|
|
5
|
+
test("listing personas lazily creates the default Curious persona once, not on every read", async () => {
|
|
6
|
+
const { env, db } = createTestEnv();
|
|
7
|
+
const { projectId, owner } = await seedProject(env);
|
|
8
|
+
const first = await call(env, "GET", `/api/projects/${projectId}/personas`, { as: owner });
|
|
9
|
+
assert.equal(first.status, 200);
|
|
10
|
+
assert.equal(first.json.personas.length, 1);
|
|
11
|
+
assert.equal(first.json.personas[0].name, "Curious");
|
|
12
|
+
assert.equal(first.json.personas[0].isDefault, true);
|
|
13
|
+
|
|
14
|
+
const second = await call(env, "GET", `/api/projects/${projectId}/personas`, { as: owner });
|
|
15
|
+
assert.equal(second.json.personas.length, 1, "the default is only ever created once");
|
|
16
|
+
const rows = db.db.prepare("SELECT COUNT(*) AS c FROM project_personas WHERE project_id = ?").get(projectId) as { c: number };
|
|
17
|
+
assert.equal(rows.c, 1);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("only a project manager can add, edit, or remove a persona, and reading needs project membership", async () => {
|
|
21
|
+
const { env } = createTestEnv();
|
|
22
|
+
const { projectId, owner } = await seedProject(env);
|
|
23
|
+
assert.equal((await call(env, "GET", `/api/projects/${projectId}/personas`, { as: "user_stranger" })).status, 403);
|
|
24
|
+
|
|
25
|
+
const created = await call(env, "POST", `/api/projects/${projectId}/personas`, {
|
|
26
|
+
as: "user_stranger",
|
|
27
|
+
body: { name: "Impatient", description: "Skip straight to checkout." },
|
|
28
|
+
});
|
|
29
|
+
assert.equal(created.status, 403);
|
|
30
|
+
|
|
31
|
+
const ok = await call(env, "POST", `/api/projects/${projectId}/personas`, {
|
|
32
|
+
as: owner,
|
|
33
|
+
body: { name: "Impatient power user", description: "Skip straight to checkout; ignore marketing pages." },
|
|
34
|
+
});
|
|
35
|
+
assert.equal(ok.status, 201, JSON.stringify(ok.json));
|
|
36
|
+
const personaId = ok.json.persona.id;
|
|
37
|
+
|
|
38
|
+
assert.equal((await call(env, "PATCH", `/api/projects/${projectId}/personas/${personaId}`, {
|
|
39
|
+
as: "user_stranger", body: { name: "Hijacked", description: "x" },
|
|
40
|
+
})).status, 403);
|
|
41
|
+
assert.equal((await call(env, "DELETE", `/api/projects/${projectId}/personas/${personaId}`, { as: "user_stranger" })).status, 403);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("adding, renaming, and removing a persona, with at least one always kept", async () => {
|
|
45
|
+
const { env } = createTestEnv();
|
|
46
|
+
const { projectId, owner } = await seedProject(env);
|
|
47
|
+
const list0 = await call(env, "GET", `/api/projects/${projectId}/personas`, { as: owner });
|
|
48
|
+
const defaultId = list0.json.personas[0].id;
|
|
49
|
+
|
|
50
|
+
const missingName = await call(env, "POST", `/api/projects/${projectId}/personas`, { as: owner, body: { name: "", description: "x" } });
|
|
51
|
+
assert.equal(missingName.status, 422);
|
|
52
|
+
const missingDescription = await call(env, "POST", `/api/projects/${projectId}/personas`, { as: owner, body: { name: "Skeptic", description: "" } });
|
|
53
|
+
assert.equal(missingDescription.status, 422);
|
|
54
|
+
|
|
55
|
+
const added = await call(env, "POST", `/api/projects/${projectId}/personas`, {
|
|
56
|
+
as: owner, body: { name: "Skeptic", description: "Doubt every claim; look for what's broken." },
|
|
57
|
+
});
|
|
58
|
+
assert.equal(added.status, 201);
|
|
59
|
+
const skepticId = added.json.persona.id;
|
|
60
|
+
|
|
61
|
+
const duplicate = await call(env, "POST", `/api/projects/${projectId}/personas`, {
|
|
62
|
+
as: owner, body: { name: "Skeptic", description: "Different text, same name." },
|
|
63
|
+
});
|
|
64
|
+
assert.equal(duplicate.status, 409);
|
|
65
|
+
|
|
66
|
+
const renamed = await call(env, "PATCH", `/api/projects/${projectId}/personas/${skepticId}`, {
|
|
67
|
+
as: owner, body: { name: "The Skeptic", description: "Doubt every claim; look for what's broken." },
|
|
68
|
+
});
|
|
69
|
+
assert.equal(renamed.status, 200, JSON.stringify(renamed.json));
|
|
70
|
+
|
|
71
|
+
const list = await call(env, "GET", `/api/projects/${projectId}/personas`, { as: owner });
|
|
72
|
+
assert.deepEqual(list.json.personas.map((p: { name: string }) => p.name).sort(), ["Curious", "The Skeptic"]);
|
|
73
|
+
|
|
74
|
+
const removeDefault = await call(env, "DELETE", `/api/projects/${projectId}/personas/${skepticId}`, { as: owner });
|
|
75
|
+
assert.equal(removeDefault.status, 200);
|
|
76
|
+
const removeLast = await call(env, "DELETE", `/api/projects/${projectId}/personas/${defaultId}`, { as: owner });
|
|
77
|
+
assert.equal(removeLast.status, 409, "the last persona in a project can't be removed");
|
|
78
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Personas shape how AI exploration decides what's "worth testing": a
|
|
2
|
+
// curious first-timer tries more paths than an impatient power user going
|
|
3
|
+
// straight for checkout. Every project gets the built-in "Curious" persona
|
|
4
|
+
// the first time its personas are listed — lazily, so no migration has to
|
|
5
|
+
// backfill every existing project.
|
|
6
|
+
import type { AuthedRouteContext } from "../routes/index.js";
|
|
7
|
+
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
8
|
+
import { memberOfProject, newId, requestJson, response } from "../core.js";
|
|
9
|
+
import { auditProject } from "../org/audit.js";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_PERSONA = {
|
|
12
|
+
name: "Curious",
|
|
13
|
+
description:
|
|
14
|
+
"Explore like a curious first-time visitor. Click into anything unfamiliar, wander into secondary sections " +
|
|
15
|
+
"instead of only the shortest path to a goal, and open things out of order the way someone browsing for the " +
|
|
16
|
+
"first time would. Prefer journeys that reveal more of the app over the single fastest way through it.",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const NAME_MAX = 60;
|
|
20
|
+
const DESCRIPTION_MAX = 1000;
|
|
21
|
+
|
|
22
|
+
type PersonaRow = { id: string; name: string; description: string; isDefault: number; createdAt: string };
|
|
23
|
+
|
|
24
|
+
async function ensureDefault(env: AuthedRouteContext["env"], projectId: string, createdBy: string): Promise<void> {
|
|
25
|
+
const existing = await env.DB.prepare("SELECT id FROM project_personas WHERE project_id = ? LIMIT 1").bind(projectId).first();
|
|
26
|
+
if (existing) return;
|
|
27
|
+
await env.DB.prepare(`
|
|
28
|
+
INSERT INTO project_personas (id, project_id, name, description, is_default, created_by)
|
|
29
|
+
VALUES (?, ?, ?, ?, 1, ?)
|
|
30
|
+
ON CONFLICT(project_id, name) DO NOTHING
|
|
31
|
+
`).bind(newId("persona"), projectId, DEFAULT_PERSONA.name, DEFAULT_PERSONA.description, createdBy).run();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function listPersonas({ env, principal }: AuthedRouteContext, projectId: string): Promise<Response> {
|
|
35
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
36
|
+
await ensureDefault(env, projectId, principal.aegisId);
|
|
37
|
+
const rows = await env.DB.prepare(`
|
|
38
|
+
SELECT id, name, description, is_default AS isDefault, created_at AS createdAt
|
|
39
|
+
FROM project_personas WHERE project_id = ? ORDER BY is_default DESC, created_at ASC
|
|
40
|
+
`).bind(projectId).all<PersonaRow>();
|
|
41
|
+
return response({ personas: rows.results.map((row) => ({ ...row, isDefault: Boolean(row.isDefault) })) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function validated(body: Record<string, unknown> | null): { name: string; description: string } | { error: string } {
|
|
45
|
+
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
|
46
|
+
const description = typeof body?.description === "string" ? body.description.trim() : "";
|
|
47
|
+
if (!name || name.length > NAME_MAX) return { error: `name is required, up to ${NAME_MAX} characters` };
|
|
48
|
+
if (!description || description.length > DESCRIPTION_MAX) return { error: `description is required, up to ${DESCRIPTION_MAX} characters` };
|
|
49
|
+
return { name, description };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function createPersona({ env, principal, request }: AuthedRouteContext, projectId: string): Promise<Response> {
|
|
53
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
54
|
+
const input = validated(await requestJson(request));
|
|
55
|
+
if ("error" in input) return response(input, 422);
|
|
56
|
+
const id = newId("persona");
|
|
57
|
+
try {
|
|
58
|
+
await env.DB.prepare(`
|
|
59
|
+
INSERT INTO project_personas (id, project_id, name, description, created_by) VALUES (?, ?, ?, ?, ?)
|
|
60
|
+
`).bind(id, projectId, input.name, input.description, principal.aegisId).run();
|
|
61
|
+
} catch {
|
|
62
|
+
return response({ error: "A persona with that name already exists" }, 409);
|
|
63
|
+
}
|
|
64
|
+
await auditProject(env, principal, projectId, { action: "persona.created", targetType: "persona", targetId: id, data: { name: input.name } }, request);
|
|
65
|
+
return response({ persona: { id, name: input.name, description: input.description, isDefault: false } }, 201);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function updatePersona({ env, principal, request }: AuthedRouteContext, projectId: string, personaId: string): Promise<Response> {
|
|
69
|
+
if (!(await memberOfProject(env, principal, projectId, "project.manage"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
70
|
+
if (request.method === "DELETE") {
|
|
71
|
+
const count = await env.DB.prepare("SELECT COUNT(*) AS c FROM project_personas WHERE project_id = ?").bind(projectId).first<{ c: number }>();
|
|
72
|
+
if ((count?.c ?? 0) <= 1) return response({ error: "A project needs at least one persona; add another before removing this one" }, 409);
|
|
73
|
+
const deleted = await env.DB.prepare("DELETE FROM project_personas WHERE id = ? AND project_id = ?").bind(personaId, projectId).run();
|
|
74
|
+
if ((deleted.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
|
|
75
|
+
await auditProject(env, principal, projectId, { action: "persona.deleted", targetType: "persona", targetId: personaId }, request);
|
|
76
|
+
return response({ deleted: true });
|
|
77
|
+
}
|
|
78
|
+
const input = validated(await requestJson(request));
|
|
79
|
+
if ("error" in input) return response(input, 422);
|
|
80
|
+
try {
|
|
81
|
+
const updated = await env.DB.prepare(`
|
|
82
|
+
UPDATE project_personas SET name = ?, description = ? WHERE id = ? AND project_id = ?
|
|
83
|
+
`).bind(input.name, input.description, personaId, projectId).run();
|
|
84
|
+
if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Not found" }, 404);
|
|
85
|
+
} catch {
|
|
86
|
+
return response({ error: "A persona with that name already exists" }, 409);
|
|
87
|
+
}
|
|
88
|
+
await auditProject(env, principal, projectId, { action: "persona.updated", targetType: "persona", targetId: personaId, data: { name: input.name } }, request);
|
|
89
|
+
return response({ persona: { id: personaId, name: input.name, description: input.description } });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
registerRoutes(async (context) => {
|
|
93
|
+
const { url, request } = context;
|
|
94
|
+
let params = matchPath(url.pathname, "/api/projects/:projectId/personas");
|
|
95
|
+
if (params && request.method === "GET") return listPersonas(context, params.projectId);
|
|
96
|
+
if (params && request.method === "POST") return createPersona(context, params.projectId);
|
|
97
|
+
params = matchPath(url.pathname, "/api/projects/:projectId/personas/:personaId");
|
|
98
|
+
if (params && (request.method === "PATCH" || request.method === "DELETE")) return updatePersona(context, params.projectId, params.personaId);
|
|
99
|
+
return null;
|
|
100
|
+
});
|