@bugmole/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/scripts/bugmole-admin.mjs +194 -0
- package/scripts/bugmole-admin.test.mjs +62 -0
- package/scripts/bugmole.test.ts +94 -2
- package/scripts/bugmole.ts +233 -46
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -6
- package/spec/domain_rules.yaml +139 -0
- package/spec/roles.yaml +20 -0
- package/spec/test-case-results.schema.json +20 -4
- package/spec/test-cases.schema.json +131 -16
- package/src/billing/plan-catalog.test.ts +82 -2
- package/src/billing/plan-catalog.ts +212 -9
- package/src/integrations/jira.ts +264 -0
- package/src/mcp/roles-and-review.test.ts +111 -0
- package/src/mcp/server.ts +358 -22
- package/src/mcp/write-test-cases.test.ts +68 -0
- package/src/registry/control-plane-client.ts +121 -6
- package/src/registry/migrations/0036_task_approval.sql +11 -0
- package/src/registry/migrations/0037_spec_proposals.sql +27 -0
- package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
- package/src/registry/migrations/0039_project_secrets.sql +37 -0
- package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
- package/src/registry/migrations/0041_jira_integration.sql +75 -0
- package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
- package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
- package/src/registry/migrations/0044_project_identity.sql +31 -0
- package/src/registry/project-identity.test.ts +99 -0
- package/src/registry/project-identity.ts +206 -0
- package/src/registry/roles.test.ts +66 -0
- package/src/registry/roles.ts +201 -0
- package/src/registry/task-scheduling.test.ts +94 -0
- package/src/registry/task-scheduling.ts +199 -2
- package/src/registry/test-case-revisions.test.ts +57 -0
- package/src/registry/test-case-revisions.ts +132 -0
- package/src/registry-worker/ai/routes.ts +3 -3
- package/src/registry-worker/artifacts.ts +23 -4
- package/src/registry-worker/billing/billing-core.test.ts +1 -1
- package/src/registry-worker/billing/checkout-routes.ts +53 -4
- package/src/registry-worker/billing/enforcement.ts +67 -12
- package/src/registry-worker/billing/paypal/api.ts +14 -0
- package/src/registry-worker/billing/paypal/client.ts +9 -0
- package/src/registry-worker/billing/paypal/provider.ts +10 -1
- package/src/registry-worker/billing/paypal.test.ts +108 -1
- package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
- package/src/registry-worker/billing/provider.ts +8 -0
- package/src/registry-worker/billing/routes.ts +2 -1
- package/src/registry-worker/billing/subscriptions.ts +114 -4
- package/src/registry-worker/core.ts +22 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/feature-access.ts +165 -0
- package/src/registry-worker/feature-flags/admin.ts +300 -0
- package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
- package/src/registry-worker/feature-flags/routes.ts +102 -0
- package/src/registry-worker/features.ts +9 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/flags.ts +80 -18
- package/src/registry-worker/github/checks.ts +4 -4
- package/src/registry-worker/hooks.ts +9 -0
- package/src/registry-worker/identity/oidc.test.ts +103 -0
- package/src/registry-worker/identity/oidc.ts +176 -0
- package/src/registry-worker/index.ts +477 -174
- package/src/registry-worker/jira/connection.ts +111 -0
- package/src/registry-worker/jira/jira.test.ts +404 -0
- package/src/registry-worker/jira/routes.ts +432 -0
- package/src/registry-worker/jira/workflow.ts +577 -0
- package/src/registry-worker/jobs/retention.ts +21 -5
- package/src/registry-worker/mcp/tools.ts +2 -1
- package/src/registry-worker/notifications/alerts.ts +4 -4
- package/src/registry-worker/notifications/notifications.test.ts +11 -0
- package/src/registry-worker/notifications/routes.ts +9 -10
- package/src/registry-worker/notifications/teams.ts +6 -2
- package/src/registry-worker/org/routes.ts +4 -4
- package/src/registry-worker/projects/identity.ts +194 -0
- package/src/registry-worker/projects/inactivity.ts +162 -0
- package/src/registry-worker/projects/projects.test.ts +283 -0
- package/src/registry-worker/proposals/proposals.test.ts +80 -0
- package/src/registry-worker/proposals/routes.ts +183 -0
- package/src/registry-worker/roles/roles.test.ts +84 -0
- package/src/registry-worker/roles/routes.ts +141 -0
- package/src/registry-worker/runner/dispatch.ts +1 -0
- package/src/registry-worker/runner/routes.ts +22 -5
- package/src/registry-worker/runner/runner.test.ts +21 -0
- package/src/registry-worker/runner/tokens.ts +8 -0
- package/src/registry-worker/secrets/crypto.ts +135 -0
- package/src/registry-worker/secrets/routes.ts +296 -0
- package/src/registry-worker/secrets/secrets.test.ts +237 -0
- package/src/registry-worker/signup/policy.ts +2 -2
- package/src/registry-worker/signup/routes.ts +12 -3
- package/src/registry-worker/sso/membership.ts +4 -2
- package/src/registry-worker/sso/routes.ts +24 -8
- package/src/registry-worker/sso/sso.test.ts +3 -2
- package/src/registry-worker/task-approval.test.ts +67 -0
- package/src/registry-worker/task-resume.test.ts +196 -0
- package/src/registry-worker/task-review.test.ts +121 -0
- package/src/runtime/appium-driver.ts +1 -1
- package/src/runtime/apply-proposals.test.ts +74 -0
- package/src/runtime/apply-proposals.ts +41 -0
- package/src/runtime/cloud-secrets.test.ts +201 -0
- package/src/runtime/cloud-secrets.ts +210 -0
- package/src/runtime/config-validate.ts +12 -9
- package/src/runtime/cursor-driver-run.ts +14 -3
- package/src/runtime/discovery-task.test.ts +26 -1
- package/src/runtime/discovery-task.ts +67 -6
- package/src/runtime/executor.ts +18 -10
- package/src/runtime/explorer.test.ts +32 -0
- package/src/runtime/explorer.ts +48 -0
- package/src/runtime/flow-language.ts +22 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +39 -2
- package/src/runtime/journey-graph.test.ts +18 -0
- package/src/runtime/local-registry-stub.test.ts +64 -0
- package/src/runtime/local-registry-stub.ts +255 -7
- package/src/runtime/local-vault.ts +65 -0
- package/src/runtime/pipeline.test.ts +23 -0
- package/src/runtime/pipeline.ts +61 -20
- package/src/runtime/planner.test.ts +24 -1
- package/src/runtime/planner.ts +88 -23
- package/src/runtime/playwright-driver.test.ts +21 -2
- package/src/runtime/playwright-driver.ts +49 -7
- package/src/runtime/project-identity.test.ts +161 -0
- package/src/runtime/project-identity.ts +232 -0
- package/src/runtime/project-roles.test.ts +107 -0
- package/src/runtime/project-roles.ts +158 -0
- package/src/runtime/propose-cli.ts +66 -0
- package/src/runtime/record-run-verdicts.test.ts +58 -0
- package/src/runtime/record-run-verdicts.ts +38 -4
- package/src/runtime/reporter.ts +1 -1
- package/src/runtime/reset.ts +2 -0
- package/src/runtime/roles-cli.test.ts +47 -0
- package/src/runtime/roles-cli.ts +60 -0
- package/src/runtime/run-once.ts +68 -5
- package/src/runtime/scenario-matrix.test.ts +41 -0
- package/src/runtime/scenario-matrix.ts +98 -0
- package/src/runtime/secret-driver.ts +77 -0
- package/src/runtime/secret-redaction.ts +69 -0
- package/src/runtime/secret-sources.test.ts +442 -0
- package/src/runtime/secret-sources.ts +176 -0
- package/src/runtime/secrets-cli.ts +146 -0
- package/src/runtime/serve-worker.ts +64 -6
- package/src/runtime/site-discovery.test.ts +47 -4
- package/src/runtime/site-discovery.ts +79 -9
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.ts +5 -2
- package/src/storage/create-object-store.ts +5 -1
- package/src/storage/object-store.ts +12 -1
- package/src/storage/test-case-results.test.ts +64 -0
- package/src/storage/test-case-results.ts +49 -0
- package/src/vendor/byok.ts +371 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the outbox handlers that deliver them.
|
|
3
3
|
import type { Env } from "../core.js";
|
|
4
4
|
import { emailSender, escapeHtml, type EmailMessage } from "../email/sender.js";
|
|
5
|
-
import {
|
|
5
|
+
import { effectiveFeatures } from "../feature-access.js";
|
|
6
6
|
import { onUsageAlert } from "../billing/thresholds.js";
|
|
7
7
|
import { runChangedHooks } from "../hooks.js";
|
|
8
8
|
import { enqueue, registerOutboxHandler } from "./outbox.js";
|
|
@@ -48,10 +48,10 @@ async function matchingRules(env: Env, workspaceId: string, event: AlertEvent, p
|
|
|
48
48
|
)
|
|
49
49
|
`).bind(workspaceId, event, projectId).all<Rule>();
|
|
50
50
|
if (!rows.results.some((rule) => PAID_CHANNELS.has(rule.channel))) return rows.results;
|
|
51
|
-
const { features } = await
|
|
51
|
+
const { features } = await effectiveFeatures(env, workspaceId);
|
|
52
52
|
return rows.results.filter((rule) => {
|
|
53
|
-
if (rule.channel === "slack") return features.slack;
|
|
54
|
-
if (rule.channel === "teams") return features.teams;
|
|
53
|
+
if (rule.channel === "slack") return features.slack.enabled;
|
|
54
|
+
if (rule.channel === "teams") return features.teams.enabled;
|
|
55
55
|
return true;
|
|
56
56
|
});
|
|
57
57
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { validTeamsWebhookUrl } from "./teams.js";
|
|
3
4
|
import type { Env } from "../core.js";
|
|
4
5
|
import { call, createTestEnv, seedPlan, seedProject } from "../testing/harness.js";
|
|
5
6
|
import { recordUsage } from "../billing/ledger.js";
|
|
@@ -296,3 +297,13 @@ test("a failed run reaches both Slack and Teams; a revoked Teams webhook disconn
|
|
|
296
297
|
// Already revoked by the failed delivery above, so there's nothing left to disconnect.
|
|
297
298
|
assert.equal((await call(setup.env, "DELETE", `/api/workspaces/${project.workspaceId}/integrations/teams/${teamsInstallationId}`, { as: project.owner })).status, 404);
|
|
298
299
|
});
|
|
300
|
+
|
|
301
|
+
test("Teams accepts the Power Automate webhook URLs Microsoft issues now, and still nothing else", () => {
|
|
302
|
+
// Microsoft moved workflow triggers from logic.azure.com to api.powerplatform.com in 2025.
|
|
303
|
+
assert.equal(validTeamsWebhookUrl("https://default1234.56.environment.api.powerplatform.com:443/powerautomate/automations/direct/workflows/abc/triggers/manual/paths/invoke?api-version=1"), true);
|
|
304
|
+
assert.equal(validTeamsWebhookUrl("https://acme.logic.azure.com/workflows/abc123/triggers/manual/paths/invoke"), true);
|
|
305
|
+
assert.equal(validTeamsWebhookUrl("http://api.powerplatform.com/x"), false, "https only");
|
|
306
|
+
assert.equal(validTeamsWebhookUrl("https://api.powerplatform.com.evil.example/x"), false);
|
|
307
|
+
assert.equal(validTeamsWebhookUrl("https://evilapi.powerplatform.com.attacker.net/x"), false);
|
|
308
|
+
assert.equal(validTeamsWebhookUrl("https://notpowerplatform.com/x"), false);
|
|
309
|
+
});
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// Routes for connecting Slack and choosing which alerts go where.
|
|
2
2
|
import type { Env, Principal } from "../core.js";
|
|
3
3
|
import { memberOfWorkspace, newId, requestJson, response } from "../core.js";
|
|
4
|
-
import {
|
|
5
|
-
import { flagOn } from "../flags.js";
|
|
4
|
+
import { blockedBy, featureState } from "../feature-access.js";
|
|
6
5
|
import { actorOf, recordAudit } from "../org/audit.js";
|
|
7
6
|
import { matchPath, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
|
|
8
7
|
import { ALERT_EVENTS, isAlertEvent, slackBlocks } from "./alerts.js";
|
|
@@ -15,20 +14,20 @@ async function canManage(env: Env, principal: Principal, workspaceId: string): P
|
|
|
15
14
|
return memberOfWorkspace(env, principal, workspaceId, "workspace.manage");
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
async function slackAvailability(env: Env, workspaceId: string): Promise<{ available: boolean; reason?: string }> {
|
|
19
|
-
|
|
17
|
+
async function slackAvailability(env: Env, workspaceId: string): Promise<{ available: boolean; reason?: string; planIncludes?: boolean }> {
|
|
18
|
+
const blocked = blockedBy(await featureState(env, workspaceId, "slack"));
|
|
19
|
+
if (blocked === "rollout" || !slackConfigured(env) || !canStoreSecrets(env)) {
|
|
20
20
|
return { available: false, reason: "Slack isn't available yet." };
|
|
21
21
|
}
|
|
22
|
-
|
|
23
|
-
return features.slack ? { available: true } : { available: false, reason: "Slack alerts are included in Team and Business." };
|
|
22
|
+
return blocked ? { available: false, reason: "Slack alerts are included in Team and Business.", planIncludes: false } : { available: true, planIncludes: true };
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
async function teamsAvailability(env: Env, workspaceId: string): Promise<{ available: boolean; reason?: string }> {
|
|
27
|
-
|
|
25
|
+
async function teamsAvailability(env: Env, workspaceId: string): Promise<{ available: boolean; reason?: string; planIncludes?: boolean }> {
|
|
26
|
+
const blocked = blockedBy(await featureState(env, workspaceId, "teams"));
|
|
27
|
+
if (blocked === "rollout" || !canStoreSecrets(env)) {
|
|
28
28
|
return { available: false, reason: "Microsoft Teams isn't available yet." };
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
return features.teams ? { available: true } : { available: false, reason: "Microsoft Teams alerts are included in Team and Business." };
|
|
30
|
+
return blocked ? { available: false, reason: "Microsoft Teams alerts are included in Team and Business.", planIncludes: false } : { available: true, planIncludes: true };
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
async function overview({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
@@ -15,11 +15,15 @@ export function teamsFetchEnv(env: object): TeamsFetchEnv {
|
|
|
15
15
|
return env as TeamsFetchEnv;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* A Workflows webhook is a plain HTTPS URL on Microsoft's Power Automate
|
|
20
|
+
* domains: api.powerplatform.com (and its per-environment subdomains) since
|
|
21
|
+
* Microsoft moved the triggers there in 2025, and the older Logic Apps hosts.
|
|
22
|
+
*/
|
|
19
23
|
export function validTeamsWebhookUrl(value: string): boolean {
|
|
20
24
|
try {
|
|
21
25
|
const url = new URL(value);
|
|
22
|
-
return url.protocol === "https:" && /(^|\.)(logic\.azure\.com|azure-automation\.net|flow\.microsoft\.com)$/i.test(url.hostname);
|
|
26
|
+
return url.protocol === "https:" && /(^|\.)(api\.powerplatform\.com|logic\.azure\.com|azure-automation\.net|flow\.microsoft\.com)$/i.test(url.hostname);
|
|
23
27
|
} catch {
|
|
24
28
|
return false;
|
|
25
29
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
workspaceRole,
|
|
13
13
|
} from "../core.js";
|
|
14
14
|
import { emailSender, escapeHtml } from "../email/sender.js";
|
|
15
|
-
import {
|
|
15
|
+
import { featureEnabled } from "../feature-access.js";
|
|
16
16
|
import { type AuthedRouteContext, matchPath, registerRoutes } from "../routes/index.js";
|
|
17
17
|
import { actorOf, auditCsv, listAudit, recordAudit } from "./audit.js";
|
|
18
18
|
|
|
@@ -253,12 +253,12 @@ async function auditLog(context: AuthedRouteContext, workspaceId: string): Promi
|
|
|
253
253
|
if (!(await memberOfWorkspace(env, principal, workspaceId, "audit.read"))) {
|
|
254
254
|
return response({ error: "Only owners and admins can view the audit log" }, 403);
|
|
255
255
|
}
|
|
256
|
-
const
|
|
256
|
+
const auditExport = await featureEnabled(env, workspaceId, "audit_export");
|
|
257
257
|
const csv = url.searchParams.get("format") === "csv";
|
|
258
|
-
if (csv && !
|
|
258
|
+
if (csv && !auditExport) {
|
|
259
259
|
return response({ error: "Audit log export is available on the Business plan", code: "plan_feature", feature: "auditLogExport" }, 402);
|
|
260
260
|
}
|
|
261
|
-
const fullHistory =
|
|
261
|
+
const fullHistory = auditExport;
|
|
262
262
|
const basicSince = new Date(Date.now() - BASIC_AUDIT_DAYS * 86_400_000).toISOString();
|
|
263
263
|
const requestedSince = url.searchParams.get("since") ?? undefined;
|
|
264
264
|
const since = fullHistory ? requestedSince : requestedSince && requestedSince > basicSince ? requestedSince : basicSince;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// One app is one project. A project's identity (src/registry/project-identity.ts)
|
|
2
|
+
// is its app's domain, or its repository plus the app's folder. In a workspace
|
|
3
|
+
// the same identity always resolves to the existing project, and on Free an
|
|
4
|
+
// identity that already belongs to someone else's Free workspace can't be
|
|
5
|
+
// claimed by another (the owner invites you, or you upgrade).
|
|
6
|
+
import {
|
|
7
|
+
describeIdentity,
|
|
8
|
+
domainIdentityFromUrl,
|
|
9
|
+
identityHash,
|
|
10
|
+
parseProjectIdentity,
|
|
11
|
+
type ProjectIdentity,
|
|
12
|
+
} from "../../registry/project-identity.js";
|
|
13
|
+
import type { Env, Principal } from "../core.js";
|
|
14
|
+
import { can, memberOfProject, requestJson, response } from "../core.js";
|
|
15
|
+
import { billingLinks, entitlementMode } from "../billing/enforcement.js";
|
|
16
|
+
import { getWorkspacePlan } from "../billing/entitlements.js";
|
|
17
|
+
import { auditProject } from "../org/audit.js";
|
|
18
|
+
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
19
|
+
import { activateUrl } from "./inactivity.js";
|
|
20
|
+
|
|
21
|
+
export type StoredIdentity = ProjectIdentity & { hash: string };
|
|
22
|
+
|
|
23
|
+
export type IdentityProject = { id: string; name: string; workspaceId: string; inactiveAt: string | null };
|
|
24
|
+
|
|
25
|
+
/** The registry's normalized, hashed reading of a client's identity; null when none was sent. */
|
|
26
|
+
export async function readIdentity(input: unknown): Promise<StoredIdentity | null | "invalid"> {
|
|
27
|
+
const parsed = parseProjectIdentity(input);
|
|
28
|
+
if (parsed === null || parsed === "invalid") return parsed;
|
|
29
|
+
return { ...parsed, hash: await identityHash(parsed.key) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A cloud project's identity from a site URL; null for a private host or no URL. */
|
|
33
|
+
export async function domainIdentity(appUrl: unknown): Promise<StoredIdentity | null> {
|
|
34
|
+
const parsed = domainIdentityFromUrl(appUrl);
|
|
35
|
+
return parsed ? { ...parsed, hash: await identityHash(parsed.key) } : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const invalidIdentity = () => response({
|
|
39
|
+
error: "identity must name a git remote (with the app's folder) or a public domain",
|
|
40
|
+
code: "invalid_identity",
|
|
41
|
+
}, 422);
|
|
42
|
+
|
|
43
|
+
/** The columns an identity is stored in, in INSERT order: kind, key, hash, origin. */
|
|
44
|
+
export function identityValues(identity: StoredIdentity | null): [string | null, string | null, string | null, string | null] {
|
|
45
|
+
return identity ? [identity.kind, identity.key, identity.hash, identity.origin ?? null] : [null, null, null, null];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The existing project in a workspace with this identity. */
|
|
49
|
+
export async function workspaceProjectWithIdentity(env: Env, workspaceId: string, hash: string): Promise<IdentityProject | null> {
|
|
50
|
+
return env.DB.prepare(`
|
|
51
|
+
SELECT id, name, workspace_id AS workspaceId, inactive_at AS inactiveAt FROM projects
|
|
52
|
+
WHERE workspace_id = ? AND identity_hash = ? AND deleted_at IS NULL
|
|
53
|
+
`).bind(workspaceId, hash).first<IdentityProject>();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A project with this identity in a workspace the person can work in (owner,
|
|
58
|
+
* admin or member). Pairing a teammate's clone of the same repository lands
|
|
59
|
+
* here instead of creating a second project.
|
|
60
|
+
*/
|
|
61
|
+
export async function memberProjectWithIdentity(env: Env, principal: Principal, hash: string): Promise<IdentityProject | null> {
|
|
62
|
+
const row = await env.DB.prepare(`
|
|
63
|
+
SELECT p.id, p.name, p.workspace_id AS workspaceId, p.inactive_at AS inactiveAt FROM projects p
|
|
64
|
+
JOIN workspace_memberships wm ON wm.workspace_id = p.workspace_id AND wm.aegis_id = ? AND wm.status = 'active'
|
|
65
|
+
AND wm.role IN ('owner', 'admin', 'member')
|
|
66
|
+
WHERE p.identity_hash = ? AND p.deleted_at IS NULL
|
|
67
|
+
ORDER BY p.created_at ASC, p.rowid ASC LIMIT 1
|
|
68
|
+
`).bind(principal.aegisId, hash).first<IdentityProject>();
|
|
69
|
+
if (!row) return null;
|
|
70
|
+
return principal.projectIds.length === 0 || principal.projectIds.includes(row.id) ? row : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Refuses a Free workspace an app that already belongs to an active project
|
|
75
|
+
* in someone else's Free workspace. Paid workspaces are never refused. Follows
|
|
76
|
+
* the "entitlements" rollout: enforced it refuses, in shadow it only logs.
|
|
77
|
+
*/
|
|
78
|
+
export async function identityInUse(env: Env, principal: Principal, workspaceId: string, identity: StoredIdentity): Promise<Response | null> {
|
|
79
|
+
if ((await getWorkspacePlan(env, workspaceId)).plan !== "free") return null;
|
|
80
|
+
const other = await env.DB.prepare(`
|
|
81
|
+
SELECT p.id, p.workspace_id AS workspaceId FROM projects p
|
|
82
|
+
LEFT JOIN workspace_plans wp ON wp.workspace_id = p.workspace_id
|
|
83
|
+
WHERE p.identity_hash = ? AND p.workspace_id <> ? AND p.deleted_at IS NULL AND p.inactive_at IS NULL
|
|
84
|
+
AND (wp.workspace_id IS NULL OR wp.plan = 'free' OR wp.status = 'canceled')
|
|
85
|
+
AND NOT EXISTS (
|
|
86
|
+
SELECT 1 FROM workspace_memberships wm
|
|
87
|
+
WHERE wm.workspace_id = p.workspace_id AND wm.aegis_id = ? AND wm.role = 'owner' AND wm.status = 'active'
|
|
88
|
+
)
|
|
89
|
+
LIMIT 1
|
|
90
|
+
`).bind(identity.hash, workspaceId, principal.aegisId).first<{ id: string; workspaceId: string }>();
|
|
91
|
+
if (!other) return null;
|
|
92
|
+
const mode = await entitlementMode(env, workspaceId);
|
|
93
|
+
if (mode === "off") return null;
|
|
94
|
+
if (mode === "shadow") {
|
|
95
|
+
console.warn(`[entitlements] shadow: would refuse ${identity.kind} identity already used by a Free workspace (${other.workspaceId}) for ${workspaceId}`);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const name = describeIdentity(identity);
|
|
99
|
+
return response({
|
|
100
|
+
error: `${name} is already a project in another Free workspace. Ask that project's owner to invite you to their workspace, or upgrade this workspace to add it here.`,
|
|
101
|
+
code: "identity_in_use",
|
|
102
|
+
identity: name,
|
|
103
|
+
...billingLinks(env, workspaceId, "team"),
|
|
104
|
+
}, 409);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Gives a project that has none an identity. False when the workspace already
|
|
109
|
+
* has another project with it (the unique index), so the caller can say so.
|
|
110
|
+
*/
|
|
111
|
+
export async function assignIdentity(env: Env, projectId: string, identity: StoredIdentity): Promise<boolean> {
|
|
112
|
+
try {
|
|
113
|
+
const result = await env.DB.prepare(`
|
|
114
|
+
UPDATE projects SET identity_kind = ?, identity_key = ?, identity_hash = ?, identity_origin = ?
|
|
115
|
+
WHERE id = ? AND identity_hash IS NULL
|
|
116
|
+
`).bind(...identityValues(identity), projectId).run() as { meta?: { changes?: number } };
|
|
117
|
+
return result.meta?.changes === 1;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* When a project without an identity gets a public site URL (an environment),
|
|
125
|
+
* that domain becomes its identity. Refused on Free if the domain is someone
|
|
126
|
+
* else's Free project; left unset if the workspace already has a project for it.
|
|
127
|
+
*/
|
|
128
|
+
export async function claimDomainIdentity(env: Env, principal: Principal, projectId: string, baseUrl: unknown): Promise<Response | null> {
|
|
129
|
+
const identity = await domainIdentity(baseUrl);
|
|
130
|
+
if (!identity) return null;
|
|
131
|
+
const project = await env.DB.prepare("SELECT workspace_id AS workspaceId, identity_hash AS identityHash FROM projects WHERE id = ?")
|
|
132
|
+
.bind(projectId).first<{ workspaceId: string | null; identityHash: string | null }>();
|
|
133
|
+
if (!project?.workspaceId || project.identityHash) return null;
|
|
134
|
+
if (await workspaceProjectWithIdentity(env, project.workspaceId, identity.hash)) return null;
|
|
135
|
+
const refusal = await identityInUse(env, principal, project.workspaceId, identity);
|
|
136
|
+
if (refusal) return refusal;
|
|
137
|
+
await assignIdentity(env, projectId, identity);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What a project's identity looks like to the dashboard, the MCP and the CLI. */
|
|
142
|
+
export function identityView(row: { identityKind?: string | null; identityKey?: string | null }) {
|
|
143
|
+
if (!row.identityKind || !row.identityKey) return null;
|
|
144
|
+
const kind = row.identityKind === "domain" ? "domain" : "repo";
|
|
145
|
+
return { kind, key: row.identityKey, label: describeIdentity({ kind, key: row.identityKey }) } as const;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* POST /api/projects/:id/identity — the CLI (`bugmole serve`, `bugmole init`)
|
|
150
|
+
* reports which app a project is. Sets it once; afterwards it must match, so
|
|
151
|
+
* two apps of a monorepo never share one project.
|
|
152
|
+
*/
|
|
153
|
+
async function setProjectIdentity(env: Env, principal: Principal, projectId: string, request: Request): Promise<Response> {
|
|
154
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project editing is not allowed" }, 403);
|
|
155
|
+
if (!(await memberOfProject(env, principal, projectId))) return response({ error: "Project scope is not allowed" }, 403);
|
|
156
|
+
const body = await requestJson(request);
|
|
157
|
+
const identity = await readIdentity(body?.identity);
|
|
158
|
+
if (identity === "invalid" || identity === null) return invalidIdentity();
|
|
159
|
+
const project = await env.DB.prepare(`
|
|
160
|
+
SELECT workspace_id AS workspaceId, identity_hash AS identityHash, identity_kind AS identityKind, identity_key AS identityKey,
|
|
161
|
+
inactive_at AS inactiveAt
|
|
162
|
+
FROM projects WHERE id = ? AND deleted_at IS NULL
|
|
163
|
+
`).bind(projectId).first<{ workspaceId: string | null; identityHash: string | null; identityKind: string | null; identityKey: string | null; inactiveAt: string | null }>();
|
|
164
|
+
if (!project?.workspaceId) return response({ error: "Project not found" }, 404);
|
|
165
|
+
const status = { inactiveAt: project.inactiveAt, ...(project.inactiveAt ? { activateUrl: activateUrl(env, projectId) } : {}) };
|
|
166
|
+
if (project.identityHash) {
|
|
167
|
+
if (project.identityHash === identity.hash) return response({ identity: identityView(project), ...status });
|
|
168
|
+
return response({
|
|
169
|
+
error: `This project is ${describeIdentity({ kind: project.identityKind === "domain" ? "domain" : "repo", key: project.identityKey ?? "" })}, not ${describeIdentity(identity)}. Each app is its own project: run \`bugmole init\` in this app's folder to create or join its project.`,
|
|
170
|
+
code: "identity_mismatch",
|
|
171
|
+
identity: identityView(project),
|
|
172
|
+
}, 409);
|
|
173
|
+
}
|
|
174
|
+
const existing = await workspaceProjectWithIdentity(env, project.workspaceId, identity.hash);
|
|
175
|
+
if (existing && existing.id !== projectId) {
|
|
176
|
+
return response({
|
|
177
|
+
error: `${describeIdentity(identity)} is already project ${existing.id} in this workspace. Use that project instead.`,
|
|
178
|
+
code: "identity_exists",
|
|
179
|
+
projectId: existing.id,
|
|
180
|
+
}, 409);
|
|
181
|
+
}
|
|
182
|
+
const refusal = await identityInUse(env, principal, project.workspaceId, identity);
|
|
183
|
+
if (refusal) return refusal;
|
|
184
|
+
if (!(await assignIdentity(env, projectId, identity))) return response({ error: "The project's identity changed; try again", code: "identity_conflict" }, 409);
|
|
185
|
+
await auditProject(env, principal, projectId, { action: "project.identity_set", targetType: "project", targetId: projectId, data: { kind: identity.kind, identity: describeIdentity(identity) } }, request);
|
|
186
|
+
return response({ identity: identityView({ identityKind: identity.kind, identityKey: identity.key }), ...status });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
registerRoutes(async ({ request, env, principal, url }) => {
|
|
190
|
+
if (request.method !== "POST") return null;
|
|
191
|
+
const params = matchPath(url.pathname, "/api/projects/:projectId/identity");
|
|
192
|
+
if (!params) return null;
|
|
193
|
+
return setProjectIdentity(env, principal, params.projectId, request);
|
|
194
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Projects nobody uses are paused after the plan's inactiveProjectDays (Free:
|
|
2
|
+
// 30; paid plans: never). A paused project keeps everything and stays
|
|
3
|
+
// readable, but takes no runs, tasks or claims (409 project_inactive) and
|
|
4
|
+
// doesn't count toward the project limit until a member activates it again.
|
|
5
|
+
//
|
|
6
|
+
// Activity is the latest of: the project's creation or last activation, a
|
|
7
|
+
// run requested or finished, an agent task requested, worked on or finished,
|
|
8
|
+
// a published plan, a saved journey, a machine (`bugmole serve` or the MCP
|
|
9
|
+
// with an API key) checking for tasks, or any audited dashboard change.
|
|
10
|
+
//
|
|
11
|
+
// Everything follows the "entitlements" rollout: enforced, projects are
|
|
12
|
+
// paused and refused; in shadow, the job and the gates only log.
|
|
13
|
+
import type { Env, Principal } from "../core.js";
|
|
14
|
+
import { can, memberOfProject, response } from "../core.js";
|
|
15
|
+
import { checkProjectLimit, entitlementMode } from "../billing/enforcement.js";
|
|
16
|
+
import { getEntitlements } from "../billing/entitlements.js";
|
|
17
|
+
import { auditProject, recordAudit } from "../org/audit.js";
|
|
18
|
+
import { matchPath, registerRoutes } from "../routes/index.js";
|
|
19
|
+
import { registerJob } from "../jobs/index.js";
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 86_400_000;
|
|
22
|
+
/** No plan pauses sooner than this, so younger projects are never examined. */
|
|
23
|
+
const MIN_INACTIVE_DAYS = 30;
|
|
24
|
+
const MAX_PAUSED_PER_TICK = 500;
|
|
25
|
+
|
|
26
|
+
/** Where a member activates a paused project (the project's dashboard page). */
|
|
27
|
+
export function activateUrl(env: Env, projectId: string): string {
|
|
28
|
+
return `${(env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "")}/?project=${encodeURIComponent(projectId)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** D1's CURRENT_TIMESTAMP is "YYYY-MM-DD HH:MM:SS" (UTC); everything else is ISO. */
|
|
32
|
+
export function parseRegistryTime(value: string | null | undefined): number {
|
|
33
|
+
if (!value) return Number.NaN;
|
|
34
|
+
return Date.parse(/[zZ]|[+-]\d\d:\d\d$/.test(value) ? value : `${value.replace(" ", "T")}Z`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const LAST_ACTIVITY = `
|
|
38
|
+
SELECT p.id, p.workspace_id AS workspaceId, p.name,
|
|
39
|
+
p.created_at AS createdAt, p.local_worker_seen_at AS workerSeenAt, p.activated_at AS activatedAt,
|
|
40
|
+
(SELECT MAX(COALESCE(r.completed_at, r.requested_at)) FROM runs r WHERE r.project_id = p.id) AS runAt,
|
|
41
|
+
(SELECT MAX(COALESCE(t.completed_at, t.heartbeat_at, t.requested_at)) FROM agent_tasks t WHERE t.project_id = p.id) AS taskAt,
|
|
42
|
+
(SELECT MAX(d.updated_at) FROM plan_drafts d WHERE d.project_id = p.id) AS planAt,
|
|
43
|
+
(SELECT MAX(j.created_at) FROM journey_revisions j WHERE j.project_id = p.id) AS journeyAt,
|
|
44
|
+
(SELECT MAX(a.created_at) FROM audit_log a WHERE a.project_id = p.id AND a.actor_type <> 'system') AS auditAt
|
|
45
|
+
FROM projects p
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
type ActivityRow = {
|
|
49
|
+
id: string; workspaceId: string; name: string;
|
|
50
|
+
createdAt: string | null; workerSeenAt: string | null; activatedAt: string | null;
|
|
51
|
+
runAt: string | null; taskAt: string | null; planAt: string | null; journeyAt: string | null; auditAt: string | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** The latest activity on a project, in epoch ms (NaN when it has none on record). */
|
|
55
|
+
export function lastActivityOf(row: Omit<ActivityRow, "id" | "workspaceId" | "name">): number {
|
|
56
|
+
const times = [row.createdAt, row.workerSeenAt, row.activatedAt, row.runAt, row.taskAt, row.planAt, row.journeyAt, row.auditAt]
|
|
57
|
+
.map(parseRegistryTime)
|
|
58
|
+
.filter(Number.isFinite);
|
|
59
|
+
return times.length ? Math.max(...times) : Number.NaN;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function projectLastActivity(env: Env, projectId: string): Promise<number> {
|
|
63
|
+
const row = await env.DB.prepare(`${LAST_ACTIVITY} WHERE p.id = ?`).bind(projectId).first<ActivityRow>();
|
|
64
|
+
return row ? lastActivityOf(row) : Number.NaN;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Pauses projects idle for longer than their workspace's plan allows. */
|
|
68
|
+
export async function pauseInactiveProjects(env: Env, now = new Date()): Promise<{ paused: number; wouldPause: number }> {
|
|
69
|
+
const oldest = new Date(now.getTime() - MIN_INACTIVE_DAYS * DAY_MS).toISOString();
|
|
70
|
+
const workspaces = await env.DB.prepare(`
|
|
71
|
+
SELECT DISTINCT workspace_id AS workspaceId FROM projects
|
|
72
|
+
WHERE workspace_id IS NOT NULL AND deleted_at IS NULL AND inactive_at IS NULL
|
|
73
|
+
AND COALESCE(activated_at, created_at) < ?
|
|
74
|
+
`).bind(oldest).all<{ workspaceId: string }>();
|
|
75
|
+
let paused = 0;
|
|
76
|
+
let wouldPause = 0;
|
|
77
|
+
for (const { workspaceId } of workspaces.results) {
|
|
78
|
+
if (paused >= MAX_PAUSED_PER_TICK) break;
|
|
79
|
+
const { inactiveProjectDays } = await getEntitlements(env, workspaceId);
|
|
80
|
+
if (inactiveProjectDays === null) continue;
|
|
81
|
+
const mode = await entitlementMode(env, workspaceId);
|
|
82
|
+
if (mode === "off") continue;
|
|
83
|
+
const cutoff = now.getTime() - inactiveProjectDays * DAY_MS;
|
|
84
|
+
const rows = await env.DB.prepare(`${LAST_ACTIVITY} WHERE p.workspace_id = ? AND p.deleted_at IS NULL AND p.inactive_at IS NULL`)
|
|
85
|
+
.bind(workspaceId).all<ActivityRow>();
|
|
86
|
+
const idle = rows.results.filter((row) => {
|
|
87
|
+
const last = lastActivityOf(row);
|
|
88
|
+
return Number.isFinite(last) && last < cutoff;
|
|
89
|
+
});
|
|
90
|
+
if (!idle.length) continue;
|
|
91
|
+
if (mode === "shadow") {
|
|
92
|
+
wouldPause += idle.length;
|
|
93
|
+
console.warn(`[entitlements] shadow: would pause ${idle.length} project(s) idle for over ${inactiveProjectDays} days in ${workspaceId}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const stamp = now.toISOString();
|
|
97
|
+
for (const project of idle.slice(0, MAX_PAUSED_PER_TICK - paused)) {
|
|
98
|
+
await env.DB.prepare("UPDATE projects SET inactive_at = ? WHERE id = ? AND inactive_at IS NULL").bind(stamp, project.id).run();
|
|
99
|
+
await recordAudit(env, { type: "system", id: "inactivity" }, {
|
|
100
|
+
workspaceId, projectId: project.id, action: "project.paused", targetType: "project", targetId: project.id,
|
|
101
|
+
data: { inactiveProjectDays, lastActivity: new Date(lastActivityOf(project)).toISOString() },
|
|
102
|
+
});
|
|
103
|
+
paused += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { paused, wouldPause };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The refusal for work on a paused project: runs, tasks and claims. Null when
|
|
111
|
+
* the project is active, or when plan limits aren't enforced (shadow logs).
|
|
112
|
+
*/
|
|
113
|
+
export async function projectInactiveRefusal(env: Env, projectId: string): Promise<Response | null> {
|
|
114
|
+
const project = await env.DB.prepare("SELECT inactive_at AS inactiveAt, workspace_id AS workspaceId FROM projects WHERE id = ?")
|
|
115
|
+
.bind(projectId).first<{ inactiveAt: string | null; workspaceId: string | null }>();
|
|
116
|
+
if (!project?.inactiveAt) return null;
|
|
117
|
+
const mode = await entitlementMode(env, project.workspaceId);
|
|
118
|
+
if (mode === "off") return null;
|
|
119
|
+
if (mode === "shadow") {
|
|
120
|
+
console.warn(`[entitlements] shadow: would refuse work on paused project ${projectId}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const days = project.workspaceId ? (await getEntitlements(env, project.workspaceId)).inactiveProjectDays : null;
|
|
124
|
+
const url = activateUrl(env, projectId);
|
|
125
|
+
return response({
|
|
126
|
+
error: `This project is paused${days ? ` after ${days} days without activity` : ""}, so it can't run tests or tasks. A project member can activate it: ${url}`,
|
|
127
|
+
code: "project_inactive",
|
|
128
|
+
inactiveAt: project.inactiveAt,
|
|
129
|
+
activateUrl: url,
|
|
130
|
+
}, 409);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** POST /api/projects/:id/activate — a member with write access un-pauses a project, within the plan's project limit. */
|
|
134
|
+
async function activateProject(env: Env, principal: Principal, projectId: string, request: Request): Promise<Response> {
|
|
135
|
+
if (!can(principal, "projects:write") && !can(principal, "*")) return response({ error: "Project editing is not allowed" }, 403);
|
|
136
|
+
if (!(await memberOfProject(env, principal, projectId, "project.write"))) return response({ error: "Project scope is not allowed" }, 403);
|
|
137
|
+
const project = await env.DB.prepare("SELECT workspace_id AS workspaceId, inactive_at AS inactiveAt FROM projects WHERE id = ? AND deleted_at IS NULL")
|
|
138
|
+
.bind(projectId).first<{ workspaceId: string | null; inactiveAt: string | null }>();
|
|
139
|
+
if (!project) return response({ error: "Project not found" }, 404);
|
|
140
|
+
if (!project.inactiveAt) return response({ project: { id: projectId, inactiveAt: null }, alreadyActive: true });
|
|
141
|
+
// A paused project doesn't count toward the limit, so activating needs room for it.
|
|
142
|
+
if (project.workspaceId) {
|
|
143
|
+
const limit = await checkProjectLimit(env, project.workspaceId);
|
|
144
|
+
if (limit) return limit;
|
|
145
|
+
}
|
|
146
|
+
const now = new Date().toISOString();
|
|
147
|
+
await env.DB.prepare("UPDATE projects SET inactive_at = NULL, activated_at = ? WHERE id = ?").bind(now, projectId).run();
|
|
148
|
+
await auditProject(env, principal, projectId, { action: "project.activated", targetType: "project", targetId: projectId, data: { pausedAt: project.inactiveAt } }, request);
|
|
149
|
+
return response({ project: { id: projectId, inactiveAt: null, activatedAt: now }, activated: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
registerRoutes(async ({ request, env, principal, url }) => {
|
|
153
|
+
if (request.method !== "POST") return null;
|
|
154
|
+
const params = matchPath(url.pathname, "/api/projects/:projectId/activate");
|
|
155
|
+
if (!params) return null;
|
|
156
|
+
return activateProject(env, principal, params.projectId, request);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
registerJob("daily", "inactive-projects", async (env, _ctx, now) => {
|
|
160
|
+
await pauseInactiveProjects(env, now);
|
|
161
|
+
});
|
|
162
|
+
|