@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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only access to run evidence in the artifacts bucket, for the hosted dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The cloud worker writes run evidence under `projects/<id>/...`. These routes let a project member
|
|
5
|
+
* list and read keys under that prefix and nothing else, so one project can never read another's.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The slice of the R2 binding these routes use, so tests can pass a fake bucket. */
|
|
9
|
+
export type ArtifactBucket = {
|
|
10
|
+
list(options: {
|
|
11
|
+
prefix: string;
|
|
12
|
+
cursor?: string;
|
|
13
|
+
limit?: number;
|
|
14
|
+
include?: Array<"httpMetadata" | "customMetadata">;
|
|
15
|
+
}): Promise<{
|
|
16
|
+
objects: Array<{ key: string; size: number; uploaded: Date; httpMetadata?: { contentType?: string } }>;
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
cursor?: string;
|
|
19
|
+
}>;
|
|
20
|
+
get(key: string): Promise<{ body: ReadableStream; size: number; httpMetadata?: { contentType?: string } } | null>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const ARTIFACT_STORAGE_UNCONFIGURED = "Artifact storage is not configured";
|
|
24
|
+
const LIST_LIMIT = 1000;
|
|
25
|
+
|
|
26
|
+
function json(body: unknown, status = 200): Response {
|
|
27
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function artifactProjectPrefix(projectId: string): string {
|
|
31
|
+
return `projects/${encodeURIComponent(projectId)}/`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A key is readable only under the project's own prefix, with no `.` or `..` segments. */
|
|
35
|
+
export function isProjectArtifactKey(projectId: string, key: string): boolean {
|
|
36
|
+
if (!key.startsWith(artifactProjectPrefix(projectId))) return false;
|
|
37
|
+
return !key.split("/").some((segment) => segment === "." || segment === "..");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The prefix to list: the project's whole prefix by default, or a narrower one inside it. */
|
|
41
|
+
export function normalizeArtifactPrefix(projectId: string, prefix: string | null): string | null {
|
|
42
|
+
const value = prefix?.trim() || artifactProjectPrefix(projectId);
|
|
43
|
+
return isProjectArtifactKey(projectId, value) ? value : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A run may record exactly its own manifest key, as the worker writes it. */
|
|
47
|
+
export function isRunManifestKey(projectId: string, runId: string, key: unknown): key is string {
|
|
48
|
+
return typeof key === "string" && key === `${artifactProjectPrefix(projectId)}runs/${encodeURIComponent(runId)}/manifest.json`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function listProjectArtifacts(bucket: ArtifactBucket | undefined, projectId: string, url: URL): Promise<Response> {
|
|
52
|
+
if (!bucket) return json({ error: ARTIFACT_STORAGE_UNCONFIGURED }, 503);
|
|
53
|
+
const prefix = normalizeArtifactPrefix(projectId, url.searchParams.get("prefix"));
|
|
54
|
+
if (!prefix) return json({ error: "prefix must stay under this project's artifacts" }, 400);
|
|
55
|
+
const listed = await bucket.list({
|
|
56
|
+
prefix,
|
|
57
|
+
cursor: url.searchParams.get("cursor") || undefined,
|
|
58
|
+
limit: LIST_LIMIT,
|
|
59
|
+
include: ["httpMetadata"],
|
|
60
|
+
});
|
|
61
|
+
return json({
|
|
62
|
+
objects: listed.objects.map((object) => ({
|
|
63
|
+
key: object.key,
|
|
64
|
+
size: object.size,
|
|
65
|
+
modifiedAt: object.uploaded.toISOString(),
|
|
66
|
+
contentType: object.httpMetadata?.contentType,
|
|
67
|
+
})),
|
|
68
|
+
...(listed.truncated && listed.cursor ? { cursor: listed.cursor } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getProjectArtifact(bucket: ArtifactBucket | undefined, projectId: string, url: URL): Promise<Response> {
|
|
73
|
+
if (!bucket) return json({ error: ARTIFACT_STORAGE_UNCONFIGURED }, 503);
|
|
74
|
+
const key = url.searchParams.get("key") ?? "";
|
|
75
|
+
if (!isProjectArtifactKey(projectId, key)) return json({ error: "key must stay under this project's artifacts" }, 400);
|
|
76
|
+
const object = await bucket.get(key);
|
|
77
|
+
if (!object) return json({ error: "Artifact not found" }, 404);
|
|
78
|
+
return new Response(object.body, {
|
|
79
|
+
headers: {
|
|
80
|
+
"content-type": object.httpMetadata?.contentType || "application/octet-stream",
|
|
81
|
+
"content-length": String(object.size),
|
|
82
|
+
"cache-control": "private, max-age=60",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
import { canSpend, recordUsage, usagePeriod, usageSummary } from "./ledger.js";
|
|
5
|
+
import { checkUsageThresholds } from "./thresholds.js";
|
|
6
|
+
import { enforceRetention } from "../jobs/retention.js";
|
|
7
|
+
|
|
8
|
+
test("usage periods follow the subscription day, clamped to short months", () => {
|
|
9
|
+
assert.deepEqual(usagePeriod(null, new Date("2026-09-17T10:00:00Z")), {
|
|
10
|
+
start: "2026-09-01T00:00:00.000Z",
|
|
11
|
+
end: "2026-10-01T00:00:00.000Z",
|
|
12
|
+
});
|
|
13
|
+
const jan31 = "2026-01-31T08:00:00.000Z";
|
|
14
|
+
assert.deepEqual(usagePeriod(jan31, new Date("2026-02-28T09:00:00Z")), {
|
|
15
|
+
start: "2026-02-28T08:00:00.000Z",
|
|
16
|
+
end: "2026-03-31T08:00:00.000Z",
|
|
17
|
+
});
|
|
18
|
+
assert.deepEqual(usagePeriod(jan31, new Date("2026-02-15T00:00:00Z")), {
|
|
19
|
+
start: "2026-01-31T08:00:00.000Z",
|
|
20
|
+
end: "2026-02-28T08:00:00.000Z",
|
|
21
|
+
});
|
|
22
|
+
assert.equal(usagePeriod("2026-12-10T00:00:00.000Z", new Date("2027-01-05T00:00:00Z")).start, "2026-12-10T00:00:00.000Z");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("usage is recorded once per key and Free stops at its allowance", async () => {
|
|
26
|
+
const { env } = createTestEnv();
|
|
27
|
+
const { workspaceId } = await seedProject(env);
|
|
28
|
+
const record = { workspaceId, kind: "browser_minute" as const, quantity: 290, source: "cloud_run" as const, idempotencyKey: "run:1" };
|
|
29
|
+
assert.equal(await recordUsage(env, record), true);
|
|
30
|
+
assert.equal(await recordUsage(env, record), false, "same key twice");
|
|
31
|
+
assert.deepEqual(await canSpend(env, workspaceId, "browser_minute", 10), { allowed: true });
|
|
32
|
+
const over = await canSpend(env, workspaceId, "browser_minute", 11);
|
|
33
|
+
assert.equal(over.allowed, false);
|
|
34
|
+
assert.equal(!over.allowed && over.reason, "quota");
|
|
35
|
+
assert.equal((await canSpend(env, workspaceId, "device_minute", 1)).allowed, false, "Free has no real devices");
|
|
36
|
+
const { summary } = await usageSummary(env, workspaceId);
|
|
37
|
+
assert.equal(summary.lines.browser_minute.used, 290);
|
|
38
|
+
assert.equal(summary.lines.browser_minute.remaining, 10);
|
|
39
|
+
assert.equal(summary.overageCents, 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("paid plans bill beyond the allowance until the spending limit", async () => {
|
|
43
|
+
const { env, db } = createTestEnv();
|
|
44
|
+
const { workspaceId } = await seedProject(env);
|
|
45
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan, spending_limit_cents) VALUES (?, 'team', 1000)").run(workspaceId);
|
|
46
|
+
await recordUsage(env, { workspaceId, kind: "browser_minute", quantity: 3_200, source: "cloud_run", idempotencyKey: "a" });
|
|
47
|
+
const { summary } = await usageSummary(env, workspaceId);
|
|
48
|
+
assert.equal(summary.lines.browser_minute.overageQuantity, 200);
|
|
49
|
+
assert.equal(summary.overageCents, 800, "200 minutes × $0.04");
|
|
50
|
+
assert.equal((await canSpend(env, workspaceId, "browser_minute", 50)).allowed, true, "$10.00 exactly");
|
|
51
|
+
const blocked = await canSpend(env, workspaceId, "browser_minute", 51);
|
|
52
|
+
assert.equal(!blocked.allowed && blocked.reason, "spending_limit");
|
|
53
|
+
assert.equal((await canSpend(env, workspaceId, "ai_credit", 1_500)).allowed, true, "credits are still in the allowance");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a failed payment pauses cloud usage once its grace period ends", async () => {
|
|
57
|
+
const { env, db } = createTestEnv();
|
|
58
|
+
const { workspaceId } = await seedProject(env);
|
|
59
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan, status, grace_until) VALUES (?, 'team', 'past_due', ?)")
|
|
60
|
+
.run(workspaceId, new Date(Date.now() + 86_400_000).toISOString());
|
|
61
|
+
assert.equal((await canSpend(env, workspaceId, "browser_minute", 1)).allowed, true);
|
|
62
|
+
db.db.prepare("UPDATE workspace_plans SET grace_until = ? WHERE workspace_id = ?").run(new Date(Date.now() - 1000).toISOString(), workspaceId);
|
|
63
|
+
const paused = await canSpend(env, workspaceId, "browser_minute", 1);
|
|
64
|
+
assert.equal(!paused.allowed && paused.reason, "suspended");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("owners and billing managers get one email per threshold per period", async () => {
|
|
68
|
+
const { env, email } = createTestEnv({ DASHBOARD_URL: "https://dash.test" });
|
|
69
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
70
|
+
await call(env, "GET", "/api/workspaces", { as: owner, headers: { "x-aegis-email": "Owner@Acme.test" } });
|
|
71
|
+
await recordUsage(env, { workspaceId, kind: "ai_credit", quantity: 85, source: "ai", idempotencyKey: "x" });
|
|
72
|
+
await checkUsageThresholds(env, workspaceId);
|
|
73
|
+
await checkUsageThresholds(env, workspaceId);
|
|
74
|
+
assert.equal(email.sent.length, 1);
|
|
75
|
+
assert.equal(email.sent[0].to[0].email, "owner@acme.test");
|
|
76
|
+
assert.match(email.sent[0].subject, /80% of this month's AI credits/);
|
|
77
|
+
await recordUsage(env, { workspaceId, kind: "ai_credit", quantity: 15, source: "ai", idempotencyKey: "y" });
|
|
78
|
+
await checkUsageThresholds(env, workspaceId);
|
|
79
|
+
assert.equal(email.sent.length, 2);
|
|
80
|
+
assert.match(email.sent[1].subject, /all of this month's AI credits/);
|
|
81
|
+
assert.match(email.sent[1].text, /dash\.test\/billing/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("the usage route shows plan, meters and who may change the limit", async () => {
|
|
85
|
+
const { env, db } = createTestEnv();
|
|
86
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
87
|
+
const free = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
|
|
88
|
+
assert.equal(free.status, 200);
|
|
89
|
+
assert.equal(free.json.plan.id, "free");
|
|
90
|
+
assert.equal(free.json.usage.lines.browser_minute.included, 300);
|
|
91
|
+
assert.equal((await call(env, "PUT", `/api/workspaces/${workspaceId}/billing/spending-limit`, { as: owner, body: { limitCents: 500 } })).status, 409);
|
|
92
|
+
|
|
93
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, 'business')").run(workspaceId);
|
|
94
|
+
const set = await call(env, "PUT", `/api/workspaces/${workspaceId}/billing/spending-limit`, { as: owner, body: { limitCents: 25_000 } });
|
|
95
|
+
assert.equal(set.status, 200);
|
|
96
|
+
const after = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
|
|
97
|
+
assert.equal(after.json.usage.spendingLimitCents, 25_000);
|
|
98
|
+
assert.equal(after.json.limits.parallel, 25);
|
|
99
|
+
assert.equal((await call(env, "PUT", `/api/workspaces/${workspaceId}/billing/spending-limit`, { as: owner, body: { limitCents: -1 } })).status, 422);
|
|
100
|
+
assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: "user_stranger" })).status, 403);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("with limits enforced, Free gets one project and cloud runs need the rollout and minutes", async () => {
|
|
104
|
+
const { env, db } = createTestEnv({ FEATURE_ENTITLEMENTS: "on", FEATURE_CLOUD_RUNNERS: "beta" });
|
|
105
|
+
const { workspaceId, owner, projectId } = await seedProject(env, { storageProvider: "bugmole" });
|
|
106
|
+
const second = await call(env, "POST", "/api/projects/register", {
|
|
107
|
+
as: owner,
|
|
108
|
+
body: { id: "second", name: "Second", workspaceId, environment: "staging", storageProvider: "r2" },
|
|
109
|
+
});
|
|
110
|
+
assert.equal(second.status, 402);
|
|
111
|
+
assert.equal(second.json.limit, "projects");
|
|
112
|
+
|
|
113
|
+
await seedPlan(env, projectId, owner);
|
|
114
|
+
const cloud = (key: string) =>
|
|
115
|
+
call(env, "POST", `/api/projects/${projectId}/runs`, {
|
|
116
|
+
as: owner,
|
|
117
|
+
headers: { "x-aegis-email": "owner@acme.test", "x-aegis-email-verified": "true" },
|
|
118
|
+
body: { planId: "checkout", idempotencyKey: key, executionMode: "cloud" },
|
|
119
|
+
});
|
|
120
|
+
assert.equal((await cloud("c1")).json.code, "cloud_unavailable");
|
|
121
|
+
db.db.prepare("INSERT INTO workspace_flags (workspace_id, flag) VALUES (?, 'cloud_runners')").run(workspaceId);
|
|
122
|
+
const queued = await cloud("c2");
|
|
123
|
+
assert.equal(queued.status, 201);
|
|
124
|
+
assert.equal(queued.json.run.executionMode, "cloud");
|
|
125
|
+
|
|
126
|
+
const claim = await call(env, "POST", `/api/runs/${queued.json.run.id}/claim`, { as: owner, body: { workerId: "laptop" } });
|
|
127
|
+
assert.equal(claim.status, 409, "customer workers can't take cloud runs");
|
|
128
|
+
|
|
129
|
+
await recordUsage(env, { workspaceId, kind: "browser_minute", quantity: 300, source: "cloud_run", idempotencyKey: "used" });
|
|
130
|
+
const exhausted = await cloud("c3");
|
|
131
|
+
assert.equal(exhausted.status, 402);
|
|
132
|
+
assert.equal(exhausted.json.limit, "cloud_minutes");
|
|
133
|
+
|
|
134
|
+
const local = await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: "l1" } });
|
|
135
|
+
assert.equal(local.status, 201, "runs on your own machine are never capped");
|
|
136
|
+
assert.equal(local.json.run.executionMode, "self");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("shadow mode logs a limit without blocking", async () => {
|
|
140
|
+
const { env } = createTestEnv({ FEATURE_ENTITLEMENTS: "shadow" });
|
|
141
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
142
|
+
const second = await call(env, "POST", "/api/projects/register", {
|
|
143
|
+
as: owner,
|
|
144
|
+
body: { id: "second", name: "Second", workspaceId, environment: "staging", storageProvider: "r2" },
|
|
145
|
+
});
|
|
146
|
+
assert.equal(second.status, 201);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("expired run history and its evidence are deleted daily", async () => {
|
|
150
|
+
const { env, db, artifacts } = createTestEnv({ FEATURE_ENTITLEMENTS: "on" });
|
|
151
|
+
const { workspaceId, owner, projectId } = await seedProject(env);
|
|
152
|
+
await seedPlan(env, projectId, owner);
|
|
153
|
+
const make = async (key: string) =>
|
|
154
|
+
(await call(env, "POST", `/api/projects/${projectId}/runs`, { as: owner, body: { planId: "checkout", idempotencyKey: key } })).json.run.id as string;
|
|
155
|
+
const oldRun = await make("old");
|
|
156
|
+
const newRun = await make("new");
|
|
157
|
+
const oldDate = new Date(Date.now() - 10 * 86_400_000).toISOString();
|
|
158
|
+
db.db.prepare("UPDATE runs SET status = 'success', completed_at = ?, requested_at = ? WHERE id = ?").run(oldDate, oldDate, oldRun);
|
|
159
|
+
db.db.prepare("UPDATE runs SET status = 'success', completed_at = ? WHERE id = ?").run(new Date().toISOString(), newRun);
|
|
160
|
+
await artifacts.put(`projects/${projectId}/runs/${oldRun}/manifest.json`, "{}");
|
|
161
|
+
await artifacts.put(`projects/${projectId}/runs/${newRun}/manifest.json`, "{}");
|
|
162
|
+
|
|
163
|
+
const listed = await call(env, "GET", `/api/projects/${projectId}/runs`, { as: owner });
|
|
164
|
+
assert.deepEqual(listed.json.runs.map((run: { id: string }) => run.id), [newRun], "Free shows 7 days even before cleanup");
|
|
165
|
+
|
|
166
|
+
await runScheduled(env, "17 3 * * *");
|
|
167
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM runs WHERE id = ?").get(oldRun)?.c, 0);
|
|
168
|
+
assert.equal(db.db.prepare("SELECT COUNT(*) AS c FROM runs WHERE id = ?").get(newRun)?.c, 1);
|
|
169
|
+
assert.deepEqual([...artifacts.objects.keys()], [`projects/${projectId}/runs/${newRun}/manifest.json`]);
|
|
170
|
+
const audit = await call(env, "GET", `/api/workspaces/${workspaceId}/audit-log`, { as: owner });
|
|
171
|
+
assert.ok(audit.json.entries.some((entry: { action: string }) => entry.action === "runs.expired"));
|
|
172
|
+
|
|
173
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, 'business')").run(workspaceId);
|
|
174
|
+
assert.deepEqual(await enforceRetention(env), { runs: 0, objects: 0 });
|
|
175
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Routes for buying, changing and cancelling a plan, receipts, the billing
|
|
2
|
+
// contact, and the provider's webhook; plus the daily billing jobs.
|
|
3
|
+
import { PLANS, type BillingInterval } from "../../billing/plan-catalog.js";
|
|
4
|
+
import type { Env } from "../core.js";
|
|
5
|
+
import { memberOfWorkspace, requestJson, response } from "../core.js";
|
|
6
|
+
import { flagOn } from "../flags.js";
|
|
7
|
+
import { registerJob } from "../jobs/index.js";
|
|
8
|
+
import { actorOf, recordAudit } from "../org/audit.js";
|
|
9
|
+
import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
|
|
10
|
+
import { defaultBillingProvider } from "./provider.js";
|
|
11
|
+
import {
|
|
12
|
+
BillingError,
|
|
13
|
+
cancelAtPeriodEnd,
|
|
14
|
+
chaseOverdueInvoices,
|
|
15
|
+
closeUsagePeriods,
|
|
16
|
+
completeCheckout,
|
|
17
|
+
handleProviderWebhook,
|
|
18
|
+
isPaidPlan,
|
|
19
|
+
periodPriceCents,
|
|
20
|
+
startCheckout,
|
|
21
|
+
syncAllBilling,
|
|
22
|
+
} from "./subscriptions.js";
|
|
23
|
+
|
|
24
|
+
/** Printed on every receipt. Bugmole isn't a merchant of record: taxes are the customer's to account for. */
|
|
25
|
+
export const SELLER = {
|
|
26
|
+
name: "The AI Inc.",
|
|
27
|
+
product: "Bugmole",
|
|
28
|
+
email: "info@theaiinc.com",
|
|
29
|
+
website: "https://bugmole.com",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function billingError(error: unknown): Response {
|
|
33
|
+
if (error instanceof BillingError) return response({ error: error.message, code: error.code }, error.status);
|
|
34
|
+
console.error("[billing] request failed", error);
|
|
35
|
+
return response({ error: "Billing is unavailable right now. Try again shortly.", code: "billing_error" }, 502);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function mayManage(context: AuthedRouteContext, workspaceId: string): Promise<Response | null> {
|
|
39
|
+
if (await memberOfWorkspace(context.env, context.principal, workspaceId, "billing.manage")) return null;
|
|
40
|
+
return response({ error: "Only owners and billing managers can change billing" }, 403);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function billingEnabled(env: Env, workspaceId: string): Promise<boolean> {
|
|
44
|
+
return flagOn(env, workspaceId, "billing");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function overview({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
48
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
49
|
+
const canManage = await memberOfWorkspace(env, principal, workspaceId, "billing.manage");
|
|
50
|
+
const row = await env.DB.prepare(`
|
|
51
|
+
SELECT plan, billing_interval AS interval, status, current_period_end AS periodEnd, cancel_at_period_end AS cancelAtPeriodEnd,
|
|
52
|
+
pending_plan AS pendingPlan, billing_email AS email, billing_name AS name, billing_address AS address, tax_id AS taxId
|
|
53
|
+
FROM workspace_plans WHERE workspace_id = ?
|
|
54
|
+
`).bind(workspaceId).first<Record<string, string | number | null>>();
|
|
55
|
+
const scheduled = await env.DB.prepare(`
|
|
56
|
+
SELECT plan, billing_interval AS interval FROM billing_checkouts WHERE workspace_id = ? AND status = 'scheduled' ORDER BY completed_at DESC LIMIT 1
|
|
57
|
+
`).bind(workspaceId).first<{ plan: string; interval: string }>();
|
|
58
|
+
const invoices = canManage
|
|
59
|
+
? await env.DB.prepare(`
|
|
60
|
+
SELECT id, number, kind, status, plan, period_start AS periodStart, period_end AS periodEnd, currency,
|
|
61
|
+
total_cents AS totalCents, refunded_cents AS refundedCents, provider_url AS payUrl, due_at AS dueAt, paid_at AS paidAt, created_at AS createdAt
|
|
62
|
+
FROM invoices WHERE workspace_id = ? AND status != 'none' ORDER BY created_at DESC LIMIT 50
|
|
63
|
+
`).bind(workspaceId).all()
|
|
64
|
+
: { results: [] };
|
|
65
|
+
const provider = defaultBillingProvider(env);
|
|
66
|
+
const enabled = await billingEnabled(env, workspaceId);
|
|
67
|
+
const offers = (["team", "business"] as const).flatMap((plan) => (["month", "year"] as const).map((interval) => ({
|
|
68
|
+
plan,
|
|
69
|
+
interval,
|
|
70
|
+
priceCents: periodPriceCents(plan, interval),
|
|
71
|
+
available: enabled && provider.canSell(env, plan, interval),
|
|
72
|
+
})));
|
|
73
|
+
return response({
|
|
74
|
+
canManage,
|
|
75
|
+
subscription: row && row.status !== "canceled" && isPaidPlan(row.plan)
|
|
76
|
+
? {
|
|
77
|
+
plan: row.plan,
|
|
78
|
+
interval: row.interval,
|
|
79
|
+
status: row.status,
|
|
80
|
+
periodEnd: row.periodEnd,
|
|
81
|
+
renews: !row.cancelAtPeriodEnd,
|
|
82
|
+
changesTo: scheduled ? { plan: scheduled.plan, interval: scheduled.interval } : row.cancelAtPeriodEnd ? { plan: "free", interval: null } : null,
|
|
83
|
+
}
|
|
84
|
+
: null,
|
|
85
|
+
offers,
|
|
86
|
+
contact: canManage && row ? { name: row.name, email: row.email, address: row.address, taxId: row.taxId } : null,
|
|
87
|
+
invoices: invoices.results,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function checkout(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
92
|
+
const denied = await mayManage(context, workspaceId);
|
|
93
|
+
if (denied) return denied;
|
|
94
|
+
if (!(await billingEnabled(context.env, workspaceId))) {
|
|
95
|
+
return response({ error: "Online checkout isn't open yet. Contact us to upgrade.", code: "billing_unavailable" }, 503);
|
|
96
|
+
}
|
|
97
|
+
const body = await requestJson(context.request);
|
|
98
|
+
const plan = body?.plan;
|
|
99
|
+
const interval = body?.interval as BillingInterval;
|
|
100
|
+
if (!isPaidPlan(plan) || (interval !== "month" && interval !== "year")) {
|
|
101
|
+
return response({ error: "plan must be team or business, and interval month or year" }, 422);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return response(await startCheckout(context.env, context.principal, workspaceId, plan, interval, context.request), 201);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return billingError(error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function complete(context: AuthedRouteContext, workspaceId: string, checkoutId: string): Promise<Response> {
|
|
111
|
+
const denied = await mayManage(context, workspaceId);
|
|
112
|
+
if (denied) return denied;
|
|
113
|
+
try {
|
|
114
|
+
const result = await completeCheckout(context.env, workspaceId, checkoutId);
|
|
115
|
+
return response({ status: result.status, plan: { id: result.plan.plan, name: PLANS[result.plan.plan].name, interval: result.plan.interval, periodEnd: result.plan.periodEnd } });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return billingError(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function cancel(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
122
|
+
const denied = await mayManage(context, workspaceId);
|
|
123
|
+
if (denied) return denied;
|
|
124
|
+
try {
|
|
125
|
+
const plan = await cancelAtPeriodEnd(context.env, context.principal, workspaceId, context.request);
|
|
126
|
+
return response({ plan: { id: plan.plan, periodEnd: plan.periodEnd } });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return billingError(error);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const CONTACT_LIMITS = { name: 120, email: 254, address: 500, taxId: 64 } as const;
|
|
133
|
+
|
|
134
|
+
async function updateContact(context: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
135
|
+
const denied = await mayManage(context, workspaceId);
|
|
136
|
+
if (denied) return denied;
|
|
137
|
+
const body = await requestJson(context.request);
|
|
138
|
+
const values: Record<keyof typeof CONTACT_LIMITS, string | null> = { name: null, email: null, address: null, taxId: null };
|
|
139
|
+
for (const key of Object.keys(CONTACT_LIMITS) as Array<keyof typeof CONTACT_LIMITS>) {
|
|
140
|
+
const value = body?.[key];
|
|
141
|
+
if (value === undefined || value === null || value === "") continue;
|
|
142
|
+
if (typeof value !== "string" || value.trim().length > CONTACT_LIMITS[key]) return response({ error: `${key} is too long` }, 422);
|
|
143
|
+
values[key] = value.trim();
|
|
144
|
+
}
|
|
145
|
+
if (values.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) return response({ error: "email isn't valid" }, 422);
|
|
146
|
+
await context.env.DB.prepare(`
|
|
147
|
+
INSERT INTO workspace_plans (workspace_id, plan, billing_name, billing_email, billing_address, tax_id)
|
|
148
|
+
VALUES (?, 'free', ?, ?, ?, ?)
|
|
149
|
+
ON CONFLICT(workspace_id) DO UPDATE SET billing_name = excluded.billing_name, billing_email = excluded.billing_email,
|
|
150
|
+
billing_address = excluded.billing_address, tax_id = excluded.tax_id, updated_at = CURRENT_TIMESTAMP
|
|
151
|
+
`).bind(workspaceId, values.name, values.email, values.address, values.taxId).run();
|
|
152
|
+
await recordAudit(context.env, actorOf(context.principal), { workspaceId, action: "billing.contact_updated" }, context.request);
|
|
153
|
+
return response({ contact: values });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function invoice(context: AuthedRouteContext, workspaceId: string, invoiceId: string): Promise<Response> {
|
|
157
|
+
const denied = await mayManage(context, workspaceId);
|
|
158
|
+
if (denied) return denied;
|
|
159
|
+
const row = await context.env.DB.prepare(`
|
|
160
|
+
SELECT i.id, i.number, i.kind, i.status, i.plan, i.period_start AS periodStart, i.period_end AS periodEnd, i.currency,
|
|
161
|
+
i.total_cents AS totalCents, i.refunded_cents AS refundedCents, i.lines_json AS linesJson, i.provider,
|
|
162
|
+
i.provider_ref AS providerRef, i.provider_url AS payUrl, i.due_at AS dueAt, i.paid_at AS paidAt, i.created_at AS createdAt,
|
|
163
|
+
w.name AS workspaceName, p.billing_name AS billingName, p.billing_email AS billingEmail,
|
|
164
|
+
p.billing_address AS billingAddress, p.tax_id AS taxId
|
|
165
|
+
FROM invoices i
|
|
166
|
+
JOIN workspaces w ON w.id = i.workspace_id
|
|
167
|
+
LEFT JOIN workspace_plans p ON p.workspace_id = i.workspace_id
|
|
168
|
+
WHERE i.id = ? AND i.workspace_id = ? AND i.status != 'none'
|
|
169
|
+
`).bind(invoiceId, workspaceId).first<Record<string, unknown> & { linesJson: string }>();
|
|
170
|
+
if (!row) return response({ error: "Invoice not found" }, 404);
|
|
171
|
+
const { linesJson, ...rest } = row;
|
|
172
|
+
return response({
|
|
173
|
+
invoice: { ...rest, lines: JSON.parse(linesJson) },
|
|
174
|
+
seller: SELLER,
|
|
175
|
+
note: "Prices exclude taxes. Bugmole is sold by The AI Inc.; any sales tax or VAT due in your country is yours to account for.",
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
registerRoutes(async (context) => {
|
|
180
|
+
const { request, url } = context;
|
|
181
|
+
let params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing");
|
|
182
|
+
if (params && request.method === "GET") return overview(context, params.workspaceId);
|
|
183
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/checkout");
|
|
184
|
+
if (params && request.method === "POST") return checkout(context, params.workspaceId);
|
|
185
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/checkout/:checkoutId/complete");
|
|
186
|
+
if (params && request.method === "POST") return complete(context, params.workspaceId, params.checkoutId);
|
|
187
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/cancel");
|
|
188
|
+
if (params && request.method === "POST") return cancel(context, params.workspaceId);
|
|
189
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/billing/contact");
|
|
190
|
+
if (params && request.method === "PUT") return updateContact(context, params.workspaceId);
|
|
191
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/invoices/:invoiceId");
|
|
192
|
+
if (params && request.method === "GET") return invoice(context, params.workspaceId, params.invoiceId);
|
|
193
|
+
return null;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// The provider calls this; it proves itself with its own signature.
|
|
197
|
+
registerPublicRoutes(async ({ request, env, url }) => {
|
|
198
|
+
const params = matchPath(url.pathname, "/api/billing/webhooks/:provider");
|
|
199
|
+
if (!params || request.method !== "POST") return null;
|
|
200
|
+
return handleProviderWebhook(env, params.provider, request);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
registerJob("daily", "billing-sync", async (env, _ctx, now) => {
|
|
204
|
+
await syncAllBilling(env, now);
|
|
205
|
+
});
|
|
206
|
+
registerJob("daily", "billing-close", async (env, _ctx, now) => {
|
|
207
|
+
await closeUsagePeriods(env, now);
|
|
208
|
+
await chaseOverdueInvoices(env, now);
|
|
209
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Plan limits at the points where they apply. The "entitlements" flag lets
|
|
2
|
+
// limits run in shadow mode first: violations are logged, nothing is blocked.
|
|
3
|
+
import type { Env } from "../core.js";
|
|
4
|
+
import { response } from "../core.js";
|
|
5
|
+
import { flagMode, flagOn } from "../flags.js";
|
|
6
|
+
import { getEntitlements } from "./entitlements.js";
|
|
7
|
+
import { canSpend } from "./ledger.js";
|
|
8
|
+
|
|
9
|
+
export type EnforcementMode = "enforce" | "shadow" | "off";
|
|
10
|
+
|
|
11
|
+
export async function entitlementMode(env: Env, workspaceId: string | null): Promise<EnforcementMode> {
|
|
12
|
+
const mode = flagMode(env, "entitlements");
|
|
13
|
+
if (mode === "on") return "enforce";
|
|
14
|
+
if (mode === "shadow") return "shadow";
|
|
15
|
+
if (mode === "off" || !workspaceId) return "off";
|
|
16
|
+
return (await flagOn(env, workspaceId, "entitlements")) ? "enforce" : "off";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function planLimit(message: string, limit: string, extra: Record<string, unknown> = {}): Response {
|
|
20
|
+
return response({ error: message, code: "plan_limit", limit, upgradeUrl: "/billing", ...extra }, 402);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Null when allowed; otherwise the response to return. */
|
|
24
|
+
async function decide(env: Env, workspaceId: string, violation: Response | null, label: string): Promise<Response | null> {
|
|
25
|
+
if (!violation) return null;
|
|
26
|
+
const mode = await entitlementMode(env, workspaceId);
|
|
27
|
+
if (mode === "enforce") return violation;
|
|
28
|
+
if (mode === "shadow") console.warn(`[entitlements] shadow: would block ${label} for ${workspaceId}`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function checkProjectLimit(env: Env, workspaceId: string): Promise<Response | null> {
|
|
33
|
+
const { projects } = await getEntitlements(env, workspaceId);
|
|
34
|
+
if (projects === null) return null;
|
|
35
|
+
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ? AND deleted_at IS NULL")
|
|
36
|
+
.bind(workspaceId).first<{ count: number }>();
|
|
37
|
+
const violation = (row?.count ?? 0) >= projects
|
|
38
|
+
? planLimit(`Your plan includes ${projects} project${projects === 1 ? "" : "s"}. Upgrade to add more.`, "projects", { included: projects })
|
|
39
|
+
: null;
|
|
40
|
+
return decide(env, workspaceId, violation, "project creation");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ExecutionMode = "self" | "cloud";
|
|
44
|
+
|
|
45
|
+
export function executionModeOf(value: unknown): ExecutionMode | null {
|
|
46
|
+
if (value === undefined || value === null || value === "self") return "self";
|
|
47
|
+
return value === "cloud" ? "cloud" : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Runs on the customer's own machines and CI are never metered or capped.
|
|
52
|
+
* A cloud run needs the cloud-runner rollout and minutes (or budget) left.
|
|
53
|
+
*/
|
|
54
|
+
export async function checkCloudRun(env: Env, workspaceId: string): Promise<Response | null> {
|
|
55
|
+
if (!(await flagOn(env, workspaceId, "cloud_runners"))) {
|
|
56
|
+
return response({ error: "Bugmole Cloud runs aren't available for this workspace yet. Run on your own machine or CI.", code: "cloud_unavailable" }, 422);
|
|
57
|
+
}
|
|
58
|
+
const decision = await canSpend(env, workspaceId, "browser_minute", 1);
|
|
59
|
+
const violation = decision.allowed
|
|
60
|
+
? null
|
|
61
|
+
: planLimit(decision.message, decision.reason === "spending_limit" ? "spending_limit" : decision.reason === "suspended" ? "payment" : "cloud_minutes");
|
|
62
|
+
return decide(env, workspaceId, violation, "cloud run");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function historyCutoffFor(env: Env, workspaceId: string | null, now = new Date()): Promise<string | null> {
|
|
66
|
+
if (!workspaceId || (await entitlementMode(env, workspaceId)) !== "enforce") return null;
|
|
67
|
+
const { historyDays } = await getEntitlements(env, workspaceId);
|
|
68
|
+
return historyDays === null ? null : new Date(now.getTime() - historyDays * 86_400_000).toISOString();
|
|
69
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// What a workspace's plan allows right now, from workspace_plans plus the
|
|
2
|
+
// shared catalog. No row means Free.
|
|
3
|
+
import {
|
|
4
|
+
type BillingInterval,
|
|
5
|
+
type Entitlements,
|
|
6
|
+
type PlanId,
|
|
7
|
+
type PlanOverrides,
|
|
8
|
+
entitlementsFor,
|
|
9
|
+
isPlanId,
|
|
10
|
+
} from "../../billing/plan-catalog.js";
|
|
11
|
+
import type { Env } from "../core.js";
|
|
12
|
+
|
|
13
|
+
export type PlanStatus = "active" | "past_due" | "grace" | "canceled";
|
|
14
|
+
|
|
15
|
+
export type WorkspacePlan = {
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
plan: PlanId;
|
|
18
|
+
interval: BillingInterval | null;
|
|
19
|
+
status: PlanStatus;
|
|
20
|
+
periodStart: string | null;
|
|
21
|
+
periodEnd: string | null;
|
|
22
|
+
spendingLimitCents: number | null;
|
|
23
|
+
overrides: PlanOverrides;
|
|
24
|
+
provider: string | null;
|
|
25
|
+
providerSubscriptionId: string | null;
|
|
26
|
+
pendingPlan: PlanId | null;
|
|
27
|
+
graceUntil: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type PlanRow = {
|
|
31
|
+
plan: string;
|
|
32
|
+
billing_interval: string | null;
|
|
33
|
+
status: string;
|
|
34
|
+
current_period_start: string | null;
|
|
35
|
+
current_period_end: string | null;
|
|
36
|
+
spending_limit_cents: number | null;
|
|
37
|
+
overrides_json: string | null;
|
|
38
|
+
provider: string | null;
|
|
39
|
+
provider_subscription_id: string | null;
|
|
40
|
+
pending_plan: string | null;
|
|
41
|
+
grace_until: string | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function freePlan(workspaceId: string): WorkspacePlan {
|
|
45
|
+
return {
|
|
46
|
+
workspaceId,
|
|
47
|
+
plan: "free",
|
|
48
|
+
interval: null,
|
|
49
|
+
status: "active",
|
|
50
|
+
periodStart: null,
|
|
51
|
+
periodEnd: null,
|
|
52
|
+
spendingLimitCents: null,
|
|
53
|
+
overrides: {},
|
|
54
|
+
provider: null,
|
|
55
|
+
providerSubscriptionId: null,
|
|
56
|
+
pendingPlan: null,
|
|
57
|
+
graceUntil: null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getWorkspacePlan(env: Env, workspaceId: string): Promise<WorkspacePlan> {
|
|
62
|
+
const row = await env.DB.prepare("SELECT * FROM workspace_plans WHERE workspace_id = ?")
|
|
63
|
+
.bind(workspaceId).first<PlanRow>();
|
|
64
|
+
if (!row || !isPlanId(row.plan)) return freePlan(workspaceId);
|
|
65
|
+
const status = (["active", "past_due", "grace", "canceled"] as const).find((value) => value === row.status) ?? "active";
|
|
66
|
+
// A canceled paid plan behaves like Free until someone subscribes again.
|
|
67
|
+
if (status === "canceled") return { ...freePlan(workspaceId), spendingLimitCents: row.spending_limit_cents };
|
|
68
|
+
let overrides: PlanOverrides = {};
|
|
69
|
+
try {
|
|
70
|
+
overrides = row.overrides_json ? JSON.parse(row.overrides_json) as PlanOverrides : {};
|
|
71
|
+
} catch {
|
|
72
|
+
overrides = {};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
workspaceId,
|
|
76
|
+
plan: row.plan,
|
|
77
|
+
interval: row.billing_interval === "month" || row.billing_interval === "year" ? row.billing_interval : null,
|
|
78
|
+
status,
|
|
79
|
+
periodStart: row.current_period_start,
|
|
80
|
+
periodEnd: row.current_period_end,
|
|
81
|
+
spendingLimitCents: row.spending_limit_cents,
|
|
82
|
+
overrides,
|
|
83
|
+
provider: row.provider,
|
|
84
|
+
providerSubscriptionId: row.provider_subscription_id,
|
|
85
|
+
pendingPlan: isPlanId(row.pending_plan) ? row.pending_plan : null,
|
|
86
|
+
graceUntil: row.grace_until,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function getEntitlements(env: Env, workspaceId: string): Promise<Entitlements & { plan: WorkspacePlan }> {
|
|
91
|
+
const plan = await getWorkspacePlan(env, workspaceId);
|
|
92
|
+
return { ...entitlementsFor(plan.plan, plan.overrides), plan };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Cloud usage stops once a failed payment has run past its grace period.
|
|
97
|
+
* Local and CI runs are never affected.
|
|
98
|
+
*/
|
|
99
|
+
export function cloudUsageSuspended(plan: WorkspacePlan, now = new Date()): boolean {
|
|
100
|
+
if (plan.status === "grace") return true;
|
|
101
|
+
return plan.status === "past_due" && Boolean(plan.graceUntil) && Date.parse(plan.graceUntil!) <= now.getTime();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function workspaceOfProject(env: Env, projectId: string): Promise<string | null> {
|
|
105
|
+
const row = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ?")
|
|
106
|
+
.bind(projectId).first<{ workspaceId: string | null }>();
|
|
107
|
+
return row?.workspaceId ?? null;
|
|
108
|
+
}
|