@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,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, runScheduled, seedPlan, seedProject } from "./testing/harness.js";
|
|
4
|
+
|
|
5
|
+
test("a queued run is claimed once and finishes with its target results", async () => {
|
|
6
|
+
const { env, coordinator } = createTestEnv();
|
|
7
|
+
const { owner, projectId } = await seedProject(env);
|
|
8
|
+
await seedPlan(env, projectId, owner);
|
|
9
|
+
|
|
10
|
+
const created = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
11
|
+
as: owner,
|
|
12
|
+
body: { planId: "checkout", idempotencyKey: "k1", targets: [{ browser: "chromium" }] },
|
|
13
|
+
});
|
|
14
|
+
assert.equal(created.status, 201);
|
|
15
|
+
const runId = created.json.run.id;
|
|
16
|
+
assert.deepEqual(coordinator.broadcasts.map((b) => (b.event as { type: string }).type), ["job_queued"]);
|
|
17
|
+
|
|
18
|
+
const again = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
19
|
+
as: owner,
|
|
20
|
+
body: { planId: "checkout", idempotencyKey: "k1" },
|
|
21
|
+
});
|
|
22
|
+
assert.equal(again.status, 200);
|
|
23
|
+
assert.equal(again.json.run.id, runId);
|
|
24
|
+
|
|
25
|
+
const claim = await call(env, "POST", `/api/runs/${runId}/claim`, { as: owner, body: { workerId: "w1" } });
|
|
26
|
+
assert.equal(claim.status, 200);
|
|
27
|
+
const second = await call(env, "POST", `/api/runs/${runId}/claim`, { as: owner, body: { workerId: "w2" } });
|
|
28
|
+
assert.equal(second.status, 409);
|
|
29
|
+
|
|
30
|
+
const done = await call(env, "POST", `/api/runs/${runId}`, {
|
|
31
|
+
as: owner,
|
|
32
|
+
body: { workerId: "w1", status: "success", stepsPassed: 3, stepsFailed: 0, targetResults: [{ targetId: "chromium", status: "passed" }] },
|
|
33
|
+
});
|
|
34
|
+
assert.equal(done.status, 200);
|
|
35
|
+
const fetched = await call(env, "GET", `/api/runs/${runId}`, { as: owner });
|
|
36
|
+
assert.equal(fetched.json.run.status, "success");
|
|
37
|
+
assert.equal(fetched.json.run.targetResults[0].status, "passed");
|
|
38
|
+
|
|
39
|
+
const reopened = await call(env, "POST", `/api/runs/${runId}`, { as: owner, body: { workerId: "w1", status: "running" } });
|
|
40
|
+
assert.equal(reopened.status, 409);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("someone outside the project cannot see or create its runs", async () => {
|
|
44
|
+
const { env } = createTestEnv();
|
|
45
|
+
const { owner, projectId } = await seedProject(env);
|
|
46
|
+
await seedPlan(env, projectId, owner);
|
|
47
|
+
const denied = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
48
|
+
as: "user_stranger",
|
|
49
|
+
body: { planId: "checkout", idempotencyKey: "k1" },
|
|
50
|
+
});
|
|
51
|
+
assert.equal(denied.status, 403);
|
|
52
|
+
const unauthenticated = await call(env, "GET", `/api/projects/${projectId}/runs`);
|
|
53
|
+
assert.equal(unauthenticated.status, 401);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("scheduled ticks run without registered jobs", async () => {
|
|
57
|
+
const { env } = createTestEnv();
|
|
58
|
+
await runScheduled(env, "* * * * *");
|
|
59
|
+
await runScheduled(env, "17 3 * * *");
|
|
60
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Keeps the Free plan a trial of the product rather than a source of free
|
|
2
|
+
// compute: one Free workspace per person, and cloud minutes only for people
|
|
3
|
+
// whose email Aegis confirmed.
|
|
4
|
+
import type { Env, Principal } from "../core.js";
|
|
5
|
+
import { response } from "../core.js";
|
|
6
|
+
import { entitlementMode } from "../billing/enforcement.js";
|
|
7
|
+
import { getWorkspacePlan } from "../billing/entitlements.js";
|
|
8
|
+
import { flagMode } from "../flags.js";
|
|
9
|
+
|
|
10
|
+
/** The Free workspaces this person owns (their own, or any under the same verified email). */
|
|
11
|
+
export async function ownedFreeWorkspaces(env: Env, principal: Pick<Principal, "aegisId" | "email">): Promise<string[]> {
|
|
12
|
+
const rows = await env.DB.prepare(`
|
|
13
|
+
SELECT DISTINCT wm.workspace_id AS workspaceId
|
|
14
|
+
FROM workspace_memberships wm
|
|
15
|
+
LEFT JOIN aegis_users u ON u.id = wm.aegis_id
|
|
16
|
+
LEFT JOIN workspace_plans wp ON wp.workspace_id = wm.workspace_id
|
|
17
|
+
WHERE wm.role = 'owner' AND wm.status = 'active'
|
|
18
|
+
AND (wm.aegis_id = ? OR (? IS NOT NULL AND u.email = ? AND u.email_verified = 1))
|
|
19
|
+
AND (wp.plan IS NULL OR wp.plan = 'free')
|
|
20
|
+
`).bind(principal.aegisId, principal.email ?? null, principal.email ?? null).all<{ workspaceId: string }>();
|
|
21
|
+
return rows.results.map((row) => row.workspaceId);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Refuses a second Free workspace once plan limits are enforced for everyone. */
|
|
25
|
+
export async function freeWorkspaceLimit(env: Env, principal: Principal): Promise<Response | null> {
|
|
26
|
+
if (flagMode(env, "entitlements") !== "on") return null;
|
|
27
|
+
const owned = await ownedFreeWorkspaces(env, principal);
|
|
28
|
+
if (!owned.length) return null;
|
|
29
|
+
return response({
|
|
30
|
+
error: "You already have a Free workspace. Upgrade it, or ask a teammate to invite you to theirs.",
|
|
31
|
+
code: "free_workspace_exists",
|
|
32
|
+
workspaceId: owned[0],
|
|
33
|
+
upgradeUrl: "/billing",
|
|
34
|
+
}, 402);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function emailVerified(env: Env, aegisId: string): Promise<boolean> {
|
|
38
|
+
const row = await env.DB.prepare("SELECT email_verified AS verified FROM aegis_users WHERE id = ?")
|
|
39
|
+
.bind(aegisId).first<{ verified: number }>();
|
|
40
|
+
return row?.verified === 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Free cloud minutes need a confirmed email. The check follows the person
|
|
45
|
+
* behind the request: the signed-in user, or whoever created the API key.
|
|
46
|
+
*/
|
|
47
|
+
export async function cloudAccountError(env: Env, principal: Principal, workspaceId: string): Promise<Response | null> {
|
|
48
|
+
if (principal.runner) return null;
|
|
49
|
+
if ((await entitlementMode(env, workspaceId)) !== "enforce") return null;
|
|
50
|
+
const plan = await getWorkspacePlan(env, workspaceId);
|
|
51
|
+
if (plan.plan !== "free") return null;
|
|
52
|
+
if (principal.emailVerified || (await emailVerified(env, principal.aegisId))) return null;
|
|
53
|
+
return response({
|
|
54
|
+
error: "Confirm your email to use Bugmole Cloud on the Free plan: sign out, then sign in with the emailed link.",
|
|
55
|
+
code: "email_unverified",
|
|
56
|
+
}, 403);
|
|
57
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// First-run setup for someone who signed up on the website: a Free
|
|
2
|
+
// workspace, a project on Bugmole storage that runs in the cloud, and the
|
|
3
|
+
// site to test, in one step.
|
|
4
|
+
import type { Env, Principal } from "../core.js";
|
|
5
|
+
import { newId, response, requestJson } from "../core.js";
|
|
6
|
+
import { recordAudit, actorOf } from "../org/audit.js";
|
|
7
|
+
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
8
|
+
import { ownedFreeWorkspaces } from "./policy.js";
|
|
9
|
+
|
|
10
|
+
const MAX_NAME = 80;
|
|
11
|
+
|
|
12
|
+
export function slugify(value: string, fallback: string): string {
|
|
13
|
+
const slug = value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
14
|
+
return slug || fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function suffix(): string {
|
|
18
|
+
return newId("x").slice(-6).toLowerCase().replace(/[^a-z0-9]/g, "0");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeSiteUrl(value: unknown): string | null | undefined {
|
|
22
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
23
|
+
if (typeof value !== "string") return null;
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(value.trim());
|
|
26
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
27
|
+
if (url.username || url.password) return null;
|
|
28
|
+
return url.toString();
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function onboard(env: Env, principal: Principal, request: Request): Promise<Response> {
|
|
35
|
+
// Only a person signed in on the dashboard, never an API key or runner.
|
|
36
|
+
if (principal.apiKeyId || principal.runner || !principal.scopes.includes("*")) {
|
|
37
|
+
return response({ error: "Sign in on the dashboard to set up a workspace" }, 403);
|
|
38
|
+
}
|
|
39
|
+
if (!principal.email || !principal.emailVerified) {
|
|
40
|
+
return response({ error: "Confirm your email first: sign in with the link Aegis emails you.", code: "email_unverified" }, 403);
|
|
41
|
+
}
|
|
42
|
+
const body = await requestJson(request);
|
|
43
|
+
const workspaceName = typeof body?.workspaceName === "string" ? body.workspaceName.trim().slice(0, MAX_NAME) : "";
|
|
44
|
+
const projectName = typeof body?.projectName === "string" ? body.projectName.trim().slice(0, MAX_NAME) : "";
|
|
45
|
+
const siteUrl = normalizeSiteUrl(body?.siteUrl);
|
|
46
|
+
if (!workspaceName || !projectName) return response({ error: "workspaceName and projectName are required" }, 422);
|
|
47
|
+
if (siteUrl === null) return response({ error: "siteUrl must be an http(s) address" }, 422);
|
|
48
|
+
|
|
49
|
+
const owned = await ownedFreeWorkspaces(env, principal);
|
|
50
|
+
if (owned.length) {
|
|
51
|
+
return response({ error: "You already have a Free workspace.", code: "free_workspace_exists", workspaceId: owned[0] }, 409);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const workspaceId = `${slugify(workspaceName, "workspace")}-${suffix()}`;
|
|
55
|
+
const projectId = `${slugify(projectName, "project")}-${suffix()}`;
|
|
56
|
+
const environment = siteUrl ? { id: "production", name: "Production", baseUrl: siteUrl } : null;
|
|
57
|
+
const statements = [
|
|
58
|
+
env.DB.prepare(`
|
|
59
|
+
INSERT INTO aegis_users (id, email, email_verified) VALUES (?, ?, 1)
|
|
60
|
+
ON CONFLICT(id) DO UPDATE SET email = excluded.email, email_verified = 1
|
|
61
|
+
`).bind(principal.aegisId, principal.email),
|
|
62
|
+
env.DB.prepare("INSERT INTO workspaces (id, name, created_by) VALUES (?, ?, ?)").bind(workspaceId, workspaceName, principal.aegisId),
|
|
63
|
+
env.DB.prepare("INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status) VALUES (?, ?, 'owner', 'active')")
|
|
64
|
+
.bind(workspaceId, principal.aegisId),
|
|
65
|
+
env.DB.prepare(`
|
|
66
|
+
INSERT INTO projects (id, workspace_id, tenant_id, name, environment, storage_provider, storage_mode, default_execution_mode, created_at)
|
|
67
|
+
VALUES (?, ?, ?, ?, ?, 'bugmole', 'managed', 'cloud', CURRENT_TIMESTAMP)
|
|
68
|
+
`).bind(projectId, workspaceId, workspaceId, projectName, environment?.id ?? "production"),
|
|
69
|
+
env.DB.prepare("INSERT INTO project_apps (id, project_id, name, type, is_default) VALUES ('default', ?, ?, 'web', 1)")
|
|
70
|
+
.bind(projectId, projectName),
|
|
71
|
+
];
|
|
72
|
+
if (environment) {
|
|
73
|
+
statements.push(env.DB.prepare(`
|
|
74
|
+
INSERT INTO project_environments (id, project_id, app_id, name, base_url, is_default)
|
|
75
|
+
VALUES (?, ?, 'default', ?, ?, 1)
|
|
76
|
+
`).bind(environment.id, projectId, environment.name, environment.baseUrl));
|
|
77
|
+
}
|
|
78
|
+
await env.DB.batch(statements);
|
|
79
|
+
const actor = actorOf(principal);
|
|
80
|
+
await recordAudit(env, actor, { workspaceId, action: "workspace.created", targetType: "workspace", targetId: workspaceId, data: { via: "signup" } }, request);
|
|
81
|
+
await recordAudit(env, actor, { workspaceId, projectId, action: "project.created", targetType: "project", targetId: projectId }, request);
|
|
82
|
+
return response({
|
|
83
|
+
workspace: { id: workspaceId, name: workspaceName, plan: "free" },
|
|
84
|
+
project: { id: projectId, name: projectName, workspaceId, storageMode: "managed", defaultExecutionMode: "cloud" },
|
|
85
|
+
environment,
|
|
86
|
+
}, 201);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function status(env: Env, principal: Principal): Promise<Response> {
|
|
90
|
+
const workspaces = await env.DB.prepare(`
|
|
91
|
+
SELECT workspace_id AS workspaceId, role FROM workspace_memberships
|
|
92
|
+
WHERE aegis_id = ? AND status = 'active'
|
|
93
|
+
`).bind(principal.aegisId).all<{ workspaceId: string; role: string }>();
|
|
94
|
+
return response({
|
|
95
|
+
emailVerified: principal.emailVerified === true,
|
|
96
|
+
workspaces: workspaces.results,
|
|
97
|
+
freeWorkspaces: await ownedFreeWorkspaces(env, principal),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
registerRoutes(async ({ request, env, principal, url }) => {
|
|
102
|
+
if (!matchPath(url.pathname, "/api/onboarding")) return null;
|
|
103
|
+
if (request.method === "POST") return onboard(env, principal, request);
|
|
104
|
+
if (request.method === "GET") return status(env, principal);
|
|
105
|
+
return null;
|
|
106
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
4
|
+
import { normalizeSiteUrl, slugify } from "./routes.js";
|
|
5
|
+
|
|
6
|
+
const verified = { "x-aegis-email": "ada@shop.test", "x-aegis-email-verified": "true" };
|
|
7
|
+
|
|
8
|
+
test("site addresses and names are cleaned up for new workspaces", () => {
|
|
9
|
+
assert.equal(slugify("Ada's Shöp & Co.", "w"), "ada-s-shop-co");
|
|
10
|
+
assert.equal(slugify("!!!", "workspace"), "workspace");
|
|
11
|
+
assert.equal(normalizeSiteUrl("https://shop.test/app"), "https://shop.test/app");
|
|
12
|
+
assert.equal(normalizeSiteUrl(""), undefined);
|
|
13
|
+
assert.equal(normalizeSiteUrl("javascript:alert(1)"), null);
|
|
14
|
+
assert.equal(normalizeSiteUrl("https://user:pass@shop.test"), null);
|
|
15
|
+
assert.equal(normalizeSiteUrl(42), null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("sign-up creates one Free workspace with a cloud-ready project", async () => {
|
|
19
|
+
const { env } = createTestEnv();
|
|
20
|
+
const body = { workspaceName: "Ada's Shop", projectName: "Storefront", siteUrl: "https://shop.test" };
|
|
21
|
+
const unverified = await call(env, "POST", "/api/onboarding", { as: "user_ada", body, headers: { "x-aegis-email": "ada@shop.test" } });
|
|
22
|
+
assert.equal(unverified.status, 403);
|
|
23
|
+
assert.equal(unverified.json.code, "email_unverified");
|
|
24
|
+
|
|
25
|
+
const created = await call(env, "POST", "/api/onboarding", { as: "user_ada", body, headers: verified });
|
|
26
|
+
assert.equal(created.status, 201, JSON.stringify(created.json));
|
|
27
|
+
const { workspace, project } = created.json;
|
|
28
|
+
assert.match(workspace.id, /^ada-s-shop-[a-z0-9]{6}$/);
|
|
29
|
+
assert.equal(workspace.plan, "free");
|
|
30
|
+
|
|
31
|
+
const listed = await call(env, "GET", "/api/projects", { as: "user_ada" });
|
|
32
|
+
const saved = listed.json.projects[0];
|
|
33
|
+
assert.equal(saved.id, project.id);
|
|
34
|
+
assert.equal(saved.storageMode, "managed");
|
|
35
|
+
assert.equal(saved.defaultExecutionMode, "cloud");
|
|
36
|
+
assert.deepEqual(saved.environments.map((item: { baseUrl: string; isDefault: boolean }) => [item.baseUrl, item.isDefault]), [["https://shop.test/", true]]);
|
|
37
|
+
|
|
38
|
+
const again = await call(env, "POST", "/api/onboarding", { as: "user_ada", body, headers: verified });
|
|
39
|
+
assert.equal(again.status, 409);
|
|
40
|
+
assert.equal(again.json.workspaceId, workspace.id);
|
|
41
|
+
// The same verified email under a second Aegis account doesn't get another one.
|
|
42
|
+
const twin = await call(env, "POST", "/api/onboarding", { as: "user_ada_2", body, headers: verified });
|
|
43
|
+
assert.equal(twin.status, 409);
|
|
44
|
+
|
|
45
|
+
const state = await call(env, "GET", "/api/onboarding", { as: "user_ada", headers: verified });
|
|
46
|
+
assert.deepEqual(state.json.freeWorkspaces, [workspace.id]);
|
|
47
|
+
assert.equal(state.json.emailVerified, true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("API keys can't run sign-up", async () => {
|
|
51
|
+
const { env } = createTestEnv();
|
|
52
|
+
const { owner, projectId } = await seedProject(env);
|
|
53
|
+
const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "ci", projectIds: [projectId] } });
|
|
54
|
+
const attempt = await call(env, "POST", "/api/onboarding", { token: key.json.token, body: { workspaceName: "x", projectName: "y" } });
|
|
55
|
+
assert.equal(attempt.status, 403);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("with limits on, a person keeps one Free workspace and needs a confirmed email for cloud minutes", async () => {
|
|
59
|
+
const { env } = createTestEnv({ FEATURE_ENTITLEMENTS: "on", FEATURE_CLOUD_RUNNERS: "on" });
|
|
60
|
+
const { owner, projectId } = await seedProject(env, { storageProvider: "bugmole" });
|
|
61
|
+
await seedPlan(env, projectId, owner);
|
|
62
|
+
const second = await call(env, "POST", "/api/workspaces", { as: owner, body: { id: "side", name: "Side project" } });
|
|
63
|
+
assert.equal(second.status, 402);
|
|
64
|
+
assert.equal(second.json.code, "free_workspace_exists");
|
|
65
|
+
|
|
66
|
+
const cloud = (key: string, headers: Record<string, string>) => call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
67
|
+
as: owner, headers, body: { planId: "checkout", idempotencyKey: key, executionMode: "cloud" },
|
|
68
|
+
});
|
|
69
|
+
const blocked = await cloud("u1", { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "false" });
|
|
70
|
+
assert.equal(blocked.status, 403);
|
|
71
|
+
assert.equal(blocked.json.code, "email_unverified");
|
|
72
|
+
const allowed = await cloud("v1", { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" });
|
|
73
|
+
assert.equal(allowed.status, 201);
|
|
74
|
+
|
|
75
|
+
// A key the confirmed person created works from CI too.
|
|
76
|
+
const key = await call(env, "POST", "/api/api-keys", { as: owner, body: { name: "ci", projectIds: [projectId] } });
|
|
77
|
+
const fromCi = await call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
78
|
+
token: key.json.token, body: { planId: "checkout", idempotencyKey: "k1", executionMode: "cloud" },
|
|
79
|
+
});
|
|
80
|
+
assert.equal(fromCi.status, 201);
|
|
81
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Client for Aegis's organization API (SSO connections, domains, SCIM tokens),
|
|
2
|
+
// using Bugmole's own client-credentials token with organizations:manage.
|
|
3
|
+
|
|
4
|
+
export type AegisOrgEnv = {
|
|
5
|
+
/** Aegis issuer; the token endpoint is `${AEGIS_ISSUER}/token`. */
|
|
6
|
+
AEGIS_ISSUER?: string;
|
|
7
|
+
/** Where Aegis serves /organizations (defaults to the issuer). */
|
|
8
|
+
AEGIS_MANAGEMENT_URL?: string;
|
|
9
|
+
/** Client with client_credentials + organizations:manage; defaults to the Bugmole client (AEGIS_CLIENT_ID). */
|
|
10
|
+
AEGIS_ORG_CLIENT_ID?: string;
|
|
11
|
+
AEGIS_ORG_CLIENT_SECRET?: string;
|
|
12
|
+
AEGIS_CLIENT_ID?: string;
|
|
13
|
+
AEGIS_CLIENT_SECRET?: string;
|
|
14
|
+
/** Shared with Aegis's ORG_EVENTS_SECRET; signs deprovisioning events. */
|
|
15
|
+
AEGIS_EVENTS_SECRET?: string;
|
|
16
|
+
/** Test seam. */
|
|
17
|
+
AEGIS_FETCH?: typeof fetch;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class AegisError extends Error {
|
|
21
|
+
constructor(readonly status: number, readonly body: Record<string, unknown>) {
|
|
22
|
+
super(typeof body.message === "string" ? body.message : typeof body.error === "string" ? body.error : `Aegis returned ${status}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_ISSUER = "https://id.theaiinc.com";
|
|
27
|
+
const trim = (value: string) => value.replace(/\/+$/, "");
|
|
28
|
+
|
|
29
|
+
function orgClient(env: AegisOrgEnv): { id: string; secret: string } | null {
|
|
30
|
+
const id = env.AEGIS_ORG_CLIENT_ID || env.AEGIS_CLIENT_ID;
|
|
31
|
+
const secret = env.AEGIS_ORG_CLIENT_ID ? env.AEGIS_ORG_CLIENT_SECRET : env.AEGIS_CLIENT_SECRET;
|
|
32
|
+
return id && secret ? { id, secret } : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function aegisOrgConfigured(env: AegisOrgEnv): boolean {
|
|
36
|
+
return Boolean(orgClient(env) && env.AEGIS_EVENTS_SECRET);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let cachedToken: { key: string; token: string; expiresAt: number } | null = null;
|
|
40
|
+
|
|
41
|
+
/** Test seam: forget the cached token. */
|
|
42
|
+
export function resetAegisTokenCache(): void {
|
|
43
|
+
cachedToken = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function accessToken(env: AegisOrgEnv): Promise<string> {
|
|
47
|
+
const issuer = trim(env.AEGIS_ISSUER || DEFAULT_ISSUER);
|
|
48
|
+
const client = orgClient(env);
|
|
49
|
+
if (!client) throw new AegisError(503, { error: "sso_unavailable" });
|
|
50
|
+
const key = `${issuer}|${client.id}`;
|
|
51
|
+
if (cachedToken && cachedToken.key === key && cachedToken.expiresAt > Date.now() + 30_000) return cachedToken.token;
|
|
52
|
+
const basic = btoa(`${encodeURIComponent(client.id)}:${encodeURIComponent(client.secret)}`);
|
|
53
|
+
const res = await (env.AEGIS_FETCH ?? fetch)(`${issuer}/token`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${basic}` },
|
|
56
|
+
body: new URLSearchParams({ grant_type: "client_credentials", scope: "organizations:manage" }),
|
|
57
|
+
});
|
|
58
|
+
const body = await res.json().catch(() => ({})) as { access_token?: string; expires_in?: number };
|
|
59
|
+
if (!res.ok || !body.access_token) throw new AegisError(res.status || 502, { error: "aegis_token_failed" });
|
|
60
|
+
cachedToken = { key, token: body.access_token, expiresAt: Date.now() + (body.expires_in ?? 300) * 1000 };
|
|
61
|
+
return body.access_token;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function aegisOrgRequest<T = Record<string, unknown>>(
|
|
65
|
+
env: AegisOrgEnv,
|
|
66
|
+
method: string,
|
|
67
|
+
path: string,
|
|
68
|
+
body?: unknown,
|
|
69
|
+
): Promise<T> {
|
|
70
|
+
if (!aegisOrgConfigured(env)) throw new AegisError(503, { error: "sso_unavailable", message: "Single sign-on isn't available yet." });
|
|
71
|
+
const base = trim(env.AEGIS_MANAGEMENT_URL || env.AEGIS_ISSUER || DEFAULT_ISSUER);
|
|
72
|
+
const send = async (token: string) => (env.AEGIS_FETCH ?? fetch)(`${base}${path}`, {
|
|
73
|
+
method,
|
|
74
|
+
headers: {
|
|
75
|
+
authorization: `Bearer ${token}`,
|
|
76
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
77
|
+
},
|
|
78
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
79
|
+
});
|
|
80
|
+
let res = await send(await accessToken(env));
|
|
81
|
+
if (res.status === 401) {
|
|
82
|
+
// The cached token may have been revoked or rotated; try once with a fresh one.
|
|
83
|
+
cachedToken = null;
|
|
84
|
+
res = await send(await accessToken(env));
|
|
85
|
+
}
|
|
86
|
+
if (res.status === 204) return undefined as T;
|
|
87
|
+
const text = await res.text();
|
|
88
|
+
let parsed: Record<string, unknown> = {};
|
|
89
|
+
try {
|
|
90
|
+
parsed = text ? JSON.parse(text) as Record<string, unknown> : {};
|
|
91
|
+
} catch {
|
|
92
|
+
parsed = { error: "aegis_unreadable_response" };
|
|
93
|
+
}
|
|
94
|
+
if (!res.ok) throw new AegisError(res.status, parsed);
|
|
95
|
+
return parsed as T;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type AegisOrganization = {
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
externalRef: string | null;
|
|
102
|
+
entitlements: { sso: boolean; scim: boolean };
|
|
103
|
+
ssoRequired: boolean;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type AegisOrganizationDetail = AegisOrganization & {
|
|
107
|
+
domains: Array<{ id: string; domain: string; status: string; txtRecord: { name: string; value: string } }>;
|
|
108
|
+
connections: Array<Record<string, unknown> & { id: string; protocol: string; enabled: boolean }>;
|
|
109
|
+
scimTokens: Array<{ id: string; name: string; prefix: string; lastUsedAt: string | null }>;
|
|
110
|
+
scimBaseUrl: string;
|
|
111
|
+
memberCount: number;
|
|
112
|
+
groupCount: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export function ensureOrganization(
|
|
116
|
+
env: AegisOrgEnv,
|
|
117
|
+
input: { name: string; externalRef: string; eventsUrl: string | null },
|
|
118
|
+
): Promise<AegisOrganization> {
|
|
119
|
+
return aegisOrgRequest<AegisOrganization>(env, "POST", "/organizations", input);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setOrganizationEntitlements(env: AegisOrgEnv, orgId: string, entitlements: { sso: boolean; scim: boolean }) {
|
|
123
|
+
return aegisOrgRequest<AegisOrganization>(env, "PUT", `/organizations/${encodeURIComponent(orgId)}/entitlements`, entitlements);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getOrganization(env: AegisOrgEnv, orgId: string) {
|
|
127
|
+
return aegisOrgRequest<AegisOrganizationDetail>(env, "GET", `/organizations/${encodeURIComponent(orgId)}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function verifyAegisEvent(secret: string, header: string | null, body: string, now = Date.now()): Promise<boolean> {
|
|
131
|
+
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
|
|
132
|
+
if (!match) return false;
|
|
133
|
+
const timestamp = Number(match[1]);
|
|
134
|
+
if (Math.abs(now / 1000 - timestamp) > 300) return false;
|
|
135
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
136
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${body}`)));
|
|
137
|
+
const expected = [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
138
|
+
let diff = expected.length ^ match[2].length;
|
|
139
|
+
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ match[2].charCodeAt(i);
|
|
140
|
+
return diff === 0;
|
|
141
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Workspace membership for people who sign in through a workspace's identity
|
|
2
|
+
// provider: roles follow their IdP groups, and the IdP can remove them.
|
|
3
|
+
import { type Env, type WorkspaceRole, isWorkspaceRole } from "../core.js";
|
|
4
|
+
import { flagOn } from "../flags.js";
|
|
5
|
+
import { type AuditActor, recordAudit } from "../org/audit.js";
|
|
6
|
+
|
|
7
|
+
/** Roles an IdP group may grant. Ownership is only ever given by a person. */
|
|
8
|
+
export const SSO_ROLES = ["admin", "member", "billing"] as const satisfies readonly WorkspaceRole[];
|
|
9
|
+
export type SsoRole = (typeof SSO_ROLES)[number];
|
|
10
|
+
const RANK: Record<SsoRole, number> = { admin: 3, member: 2, billing: 1 };
|
|
11
|
+
|
|
12
|
+
export function isSsoRole(value: unknown): value is SsoRole {
|
|
13
|
+
return typeof value === "string" && (SSO_ROLES as readonly string[]).includes(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type OrgIdentity = { id: string; groups: string[] };
|
|
17
|
+
|
|
18
|
+
const SCIM_ACTOR: AuditActor = { type: "scim", id: "aegis" };
|
|
19
|
+
|
|
20
|
+
/** The most privileged role any of the person's groups maps to, else the workspace default. */
|
|
21
|
+
export function pickRole(
|
|
22
|
+
mappings: Array<{ group: string; role: string }>,
|
|
23
|
+
groups: string[],
|
|
24
|
+
defaultRole: string,
|
|
25
|
+
): SsoRole | null {
|
|
26
|
+
const member = new Set(groups);
|
|
27
|
+
let best: SsoRole | null = null;
|
|
28
|
+
for (const { group, role } of mappings) {
|
|
29
|
+
if (!member.has(group) || !isSsoRole(role)) continue;
|
|
30
|
+
if (!best || RANK[role] > RANK[best]) best = role;
|
|
31
|
+
}
|
|
32
|
+
if (best) return best;
|
|
33
|
+
return isSsoRole(defaultRole) ? defaultRole : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function groupRoleMappings(env: Env, workspaceId: string): Promise<Array<{ group: string; role: string }>> {
|
|
37
|
+
const rows = await env.DB.prepare(
|
|
38
|
+
"SELECT group_name AS \"group\", role FROM workspace_group_roles WHERE workspace_id = ? ORDER BY group_name",
|
|
39
|
+
).bind(workspaceId).all<{ group: string; role: string }>();
|
|
40
|
+
return rows.results;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type LinkedWorkspace = { id: string; defaultRole: string };
|
|
44
|
+
|
|
45
|
+
async function workspaceForOrg(env: Env, orgId: string): Promise<LinkedWorkspace | null> {
|
|
46
|
+
return env.DB.prepare(
|
|
47
|
+
"SELECT id, sso_default_role AS defaultRole FROM workspaces WHERE aegis_org_id = ?",
|
|
48
|
+
).bind(orgId).first<LinkedWorkspace>();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function isDeprovisioned(env: Env, aegisId: string): Promise<boolean> {
|
|
52
|
+
const row = await env.DB.prepare("SELECT deprovisioned_at AS at FROM aegis_users WHERE id = ?")
|
|
53
|
+
.bind(aegisId).first<{ at: string | null }>();
|
|
54
|
+
return Boolean(row?.at);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Brings an SSO member's workspace membership in line with their groups.
|
|
59
|
+
* Memberships people granted by invitation are left alone.
|
|
60
|
+
*/
|
|
61
|
+
export async function applyOrgMembership(
|
|
62
|
+
env: Env,
|
|
63
|
+
identity: { aegisId: string; email?: string },
|
|
64
|
+
org: OrgIdentity,
|
|
65
|
+
actor: AuditActor = { type: "user", id: identity.aegisId },
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const workspace = await workspaceForOrg(env, org.id);
|
|
68
|
+
if (!workspace || !(await flagOn(env, workspace.id, "sso"))) return;
|
|
69
|
+
const role = pickRole(await groupRoleMappings(env, workspace.id), org.groups, workspace.defaultRole);
|
|
70
|
+
const existing = await env.DB.prepare(
|
|
71
|
+
"SELECT role, status, source FROM workspace_memberships WHERE workspace_id = ? AND aegis_id = ?",
|
|
72
|
+
).bind(workspace.id, identity.aegisId).first<{ role: string; status: string; source: string }>();
|
|
73
|
+
if (existing && existing.source !== "sso") return;
|
|
74
|
+
|
|
75
|
+
if (!role) {
|
|
76
|
+
if (existing) {
|
|
77
|
+
await env.DB.prepare("DELETE FROM workspace_memberships WHERE workspace_id = ? AND aegis_id = ?")
|
|
78
|
+
.bind(workspace.id, identity.aegisId).run();
|
|
79
|
+
await recordAudit(env, actor, {
|
|
80
|
+
workspaceId: workspace.id,
|
|
81
|
+
action: "sso.member.removed",
|
|
82
|
+
targetType: "member",
|
|
83
|
+
targetId: identity.aegisId,
|
|
84
|
+
data: { reason: "no_matching_group" },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (existing && existing.role === role && existing.status === "active") return;
|
|
90
|
+
|
|
91
|
+
await env.DB.prepare("INSERT OR IGNORE INTO aegis_users (id, email) VALUES (?, ?)")
|
|
92
|
+
.bind(identity.aegisId, identity.email ?? null).run();
|
|
93
|
+
await env.DB.prepare(`
|
|
94
|
+
INSERT INTO workspace_memberships (workspace_id, aegis_id, role, status, invited_by, created_at, source)
|
|
95
|
+
VALUES (?, ?, ?, 'active', 'sso', ?, 'sso')
|
|
96
|
+
ON CONFLICT(workspace_id, aegis_id) DO UPDATE SET role = excluded.role, status = 'active'
|
|
97
|
+
WHERE workspace_memberships.source = 'sso'
|
|
98
|
+
`).bind(workspace.id, identity.aegisId, role, new Date().toISOString()).run();
|
|
99
|
+
await recordAudit(env, actor, {
|
|
100
|
+
workspaceId: workspace.id,
|
|
101
|
+
action: existing ? "sso.member.role_changed" : "sso.member.added",
|
|
102
|
+
targetType: "member",
|
|
103
|
+
targetId: identity.aegisId,
|
|
104
|
+
data: { role, ...(existing ? { from: existing.role } : {}) },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Removes someone the IdP deprovisioned: their access to the workspace, and
|
|
110
|
+
* every API key they hold (an organization identity belongs to that
|
|
111
|
+
* organization alone).
|
|
112
|
+
*/
|
|
113
|
+
export async function deprovisionMember(env: Env, workspaceId: string, aegisId: string, email?: string): Promise<void> {
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const owner = await env.DB.prepare(
|
|
116
|
+
"SELECT role FROM workspace_memberships WHERE workspace_id = ? AND aegis_id = ? AND status = 'active'",
|
|
117
|
+
).bind(workspaceId, aegisId).first<{ role: string }>();
|
|
118
|
+
await env.DB.batch([
|
|
119
|
+
env.DB.prepare(`
|
|
120
|
+
INSERT INTO aegis_users (id, email, deprovisioned_at) VALUES (?, ?, ?)
|
|
121
|
+
ON CONFLICT(id) DO UPDATE SET deprovisioned_at = excluded.deprovisioned_at
|
|
122
|
+
`).bind(aegisId, email ?? null, now),
|
|
123
|
+
env.DB.prepare("DELETE FROM workspace_memberships WHERE workspace_id = ? AND aegis_id = ?").bind(workspaceId, aegisId),
|
|
124
|
+
env.DB.prepare(`
|
|
125
|
+
DELETE FROM project_memberships
|
|
126
|
+
WHERE aegis_id = ? AND project_id IN (SELECT id FROM projects WHERE workspace_id = ?)
|
|
127
|
+
`).bind(aegisId, workspaceId),
|
|
128
|
+
env.DB.prepare("UPDATE api_keys SET revoked_at = ? WHERE aegis_id = ? AND revoked_at IS NULL").bind(now, aegisId),
|
|
129
|
+
]);
|
|
130
|
+
const owners = await env.DB.prepare(
|
|
131
|
+
"SELECT COUNT(*) AS n FROM workspace_memberships WHERE workspace_id = ? AND role = 'owner' AND status = 'active'",
|
|
132
|
+
).bind(workspaceId).first<{ n: number }>();
|
|
133
|
+
await recordAudit(env, SCIM_ACTOR, {
|
|
134
|
+
workspaceId,
|
|
135
|
+
action: "sso.member.deprovisioned",
|
|
136
|
+
targetType: "member",
|
|
137
|
+
targetId: aegisId,
|
|
138
|
+
data: {
|
|
139
|
+
...(isWorkspaceRole(owner?.role) ? { role: owner.role } : {}),
|
|
140
|
+
...((owners?.n ?? 0) === 0 ? { warning: "workspace_has_no_owner" } : {}),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function reactivateMember(env: Env, workspaceId: string, aegisId: string): Promise<void> {
|
|
146
|
+
const result = await env.DB.prepare("UPDATE aegis_users SET deprovisioned_at = NULL WHERE id = ? AND deprovisioned_at IS NOT NULL")
|
|
147
|
+
.bind(aegisId).run();
|
|
148
|
+
if (result.meta.changes) {
|
|
149
|
+
await recordAudit(env, SCIM_ACTOR, { workspaceId, action: "sso.member.reactivated", targetType: "member", targetId: aegisId });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function linkedWorkspace(env: Env, orgId: string, externalRef: unknown): Promise<LinkedWorkspace | null> {
|
|
154
|
+
const workspace = await workspaceForOrg(env, orgId);
|
|
155
|
+
// The organization was created for exactly this workspace.
|
|
156
|
+
return workspace && externalRef === workspace.id ? workspace : null;
|
|
157
|
+
}
|