@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
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Platform-operator routes: per-workspace feature overrides, beta opt-ins and
|
|
2
|
+
// contract (Enterprise) plans. They are for Bugmole staff only, called with
|
|
3
|
+
// `scripts/bugmole-admin.mjs`, and never proxied by the dashboard.
|
|
4
|
+
//
|
|
5
|
+
// Auth is a single bearer secret, PLATFORM_ADMIN_TOKEN (wrangler secret put).
|
|
6
|
+
// Unset, every route answers 503; a missing or wrong token answers 401. Every
|
|
7
|
+
// change is written to the workspace's audit log as actor system:platform_admin
|
|
8
|
+
// with the operator named in x-bugmole-operator.
|
|
9
|
+
import {
|
|
10
|
+
type PlanId,
|
|
11
|
+
type PlanOverrides,
|
|
12
|
+
entitlementsFor,
|
|
13
|
+
isPlanId,
|
|
14
|
+
} from "../../billing/plan-catalog.js";
|
|
15
|
+
import { type Env, constantTimeEqual, requestJson, response } from "../core.js";
|
|
16
|
+
import { type LimitKey, loadOverrides, overrideActive } from "../feature-access.js";
|
|
17
|
+
import { FEATURE_DEFS, featureForPlanKey, isFeature, type Feature } from "../flags.js";
|
|
18
|
+
import { planChangedHooks } from "../hooks.js";
|
|
19
|
+
import { applyProjectLimit } from "../billing/subscriptions.js";
|
|
20
|
+
import { getWorkspacePlan } from "../billing/entitlements.js";
|
|
21
|
+
import { recordAudit, type AuditActor } from "../org/audit.js";
|
|
22
|
+
import { matchPath, registerPublicRoutes, type RouteContext } from "../routes/index.js";
|
|
23
|
+
import { workspaceFeatures } from "./routes.js";
|
|
24
|
+
|
|
25
|
+
const PREFIX = "/api/platform/";
|
|
26
|
+
const ACTOR: AuditActor = { type: "system", id: "platform_admin" };
|
|
27
|
+
/** Plans an operator may put a workspace on; Team and Business come from checkout. */
|
|
28
|
+
const CONTRACT_PLANS: PlanId[] = ["enterprise", "free"];
|
|
29
|
+
const MAX_LIMIT = 100_000_000;
|
|
30
|
+
|
|
31
|
+
async function digest(value: string): Promise<string> {
|
|
32
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)));
|
|
33
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Null when the caller holds the platform token; otherwise the response to return. */
|
|
37
|
+
export async function platformAuth(request: Request, env: Env): Promise<Response | null> {
|
|
38
|
+
const expected = env.PLATFORM_ADMIN_TOKEN?.trim();
|
|
39
|
+
if (!expected) return response({ error: "Platform administration is not configured" }, 503);
|
|
40
|
+
const header = request.headers.get("authorization") ?? "";
|
|
41
|
+
const presented = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
|
|
42
|
+
// Hashing first makes the comparison constant-time regardless of length.
|
|
43
|
+
if (!presented || !constantTimeEqual(await digest(presented), await digest(expected))) {
|
|
44
|
+
return response({ error: "Platform token required" }, 401);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function operatorOf(request: Request): string {
|
|
50
|
+
const raw = request.headers.get("x-bugmole-operator") ?? "";
|
|
51
|
+
const cleaned = raw.replace(/[^\w.@+:-]/g, "").slice(0, 120);
|
|
52
|
+
return cleaned || "unknown";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function workspaceExists(env: Env, workspaceId: string): Promise<boolean> {
|
|
56
|
+
return Boolean(await env.DB.prepare("SELECT id FROM workspaces WHERE id = ?").bind(workspaceId).first());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseExpiry(value: unknown, now = new Date()): { expiresAt: string | null } | { error: string } {
|
|
60
|
+
if (value === undefined || value === null || value === "") return { expiresAt: null };
|
|
61
|
+
if (typeof value !== "string") return { error: "expiresAt must be an ISO date or date-time" };
|
|
62
|
+
// A bare date means the end of that day, UTC.
|
|
63
|
+
const iso = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T23:59:59.999Z` : value;
|
|
64
|
+
const at = Date.parse(iso);
|
|
65
|
+
if (Number.isNaN(at)) return { error: "expiresAt must be an ISO date or date-time" };
|
|
66
|
+
if (at <= now.getTime()) return { error: "expiresAt must be in the future" };
|
|
67
|
+
return { expiresAt: new Date(at).toISOString() };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function noteOf(value: unknown): string | null {
|
|
71
|
+
return typeof value === "string" && value.trim() ? value.trim().slice(0, 500) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function workspaceDetail(env: Env, workspaceId: string): Promise<Response> {
|
|
75
|
+
const body = await workspaceFeatures(env, workspaceId);
|
|
76
|
+
const plan = await getWorkspacePlan(env, workspaceId);
|
|
77
|
+
const betaRows = await env.DB.prepare("SELECT flag, enabled, updated_at AS updatedAt FROM workspace_flags WHERE workspace_id = ? ORDER BY flag")
|
|
78
|
+
.bind(workspaceId).all<{ flag: string; enabled: number; updatedAt: string }>();
|
|
79
|
+
const now = new Date();
|
|
80
|
+
return response({
|
|
81
|
+
...body,
|
|
82
|
+
contract: { plan: plan.plan, overrides: plan.overrides, provider: plan.provider, periodEnd: plan.periodEnd },
|
|
83
|
+
overrides: (await loadOverrides(env, workspaceId)).map((row) => ({ ...row, active: overrideActive(row, now) })),
|
|
84
|
+
betaOptIns: betaRows.results.map((row) => ({ feature: row.flag, enabled: row.enabled === 1, updatedAt: row.updatedAt })),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function listOverrides(env: Env): Promise<Response> {
|
|
89
|
+
const rows = await env.DB.prepare(`
|
|
90
|
+
SELECT workspace_id AS workspaceId, feature, enabled, expires_at AS expiresAt, reason, set_by AS setBy, updated_at AS updatedAt
|
|
91
|
+
FROM workspace_feature_overrides ORDER BY workspace_id, feature
|
|
92
|
+
`).all<{ workspaceId: string; feature: string; enabled: number; expiresAt: string | null; reason: string | null; setBy: string; updatedAt: string }>();
|
|
93
|
+
const now = new Date();
|
|
94
|
+
return response({
|
|
95
|
+
overrides: rows.results.map((row) => ({ ...row, enabled: row.enabled === 1, active: overrideActive(row, now) })),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function setOverride(context: RouteContext, workspaceId: string, feature: Feature): Promise<Response> {
|
|
100
|
+
const { env, request } = context;
|
|
101
|
+
const body = await requestJson(request);
|
|
102
|
+
if (!body || typeof body.enabled !== "boolean") return response({ error: "enabled must be true or false" }, 422);
|
|
103
|
+
const expiry = parseExpiry(body.expiresAt);
|
|
104
|
+
if ("error" in expiry) return response({ error: expiry.error }, 422);
|
|
105
|
+
const reason = noteOf(body.reason);
|
|
106
|
+
const operator = operatorOf(request);
|
|
107
|
+
const before = (await loadOverrides(env, workspaceId)).find((row) => row.feature === feature) ?? null;
|
|
108
|
+
await env.DB.prepare(`
|
|
109
|
+
INSERT INTO workspace_feature_overrides (workspace_id, feature, enabled, expires_at, reason, set_by)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
111
|
+
ON CONFLICT(workspace_id, feature) DO UPDATE SET
|
|
112
|
+
enabled = excluded.enabled, expires_at = excluded.expires_at, reason = excluded.reason,
|
|
113
|
+
set_by = excluded.set_by, updated_at = CURRENT_TIMESTAMP
|
|
114
|
+
`).bind(workspaceId, feature, body.enabled ? 1 : 0, expiry.expiresAt, reason, operator).run();
|
|
115
|
+
await recordAudit(env, ACTOR, {
|
|
116
|
+
workspaceId,
|
|
117
|
+
action: "platform.feature_override_set",
|
|
118
|
+
targetType: "feature",
|
|
119
|
+
targetId: feature,
|
|
120
|
+
data: {
|
|
121
|
+
operator,
|
|
122
|
+
enabled: body.enabled,
|
|
123
|
+
expiresAt: expiry.expiresAt,
|
|
124
|
+
reason,
|
|
125
|
+
previous: before ? { enabled: before.enabled, expiresAt: before.expiresAt } : null,
|
|
126
|
+
},
|
|
127
|
+
}, request);
|
|
128
|
+
return workspaceDetail(env, workspaceId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function clearOverride(context: RouteContext, workspaceId: string, feature: Feature): Promise<Response> {
|
|
132
|
+
const { env, request } = context;
|
|
133
|
+
const deleted = await env.DB.prepare("DELETE FROM workspace_feature_overrides WHERE workspace_id = ? AND feature = ?")
|
|
134
|
+
.bind(workspaceId, feature).run();
|
|
135
|
+
if ((deleted.meta?.changes ?? 0) === 0) return response({ error: "No override for that feature" }, 404);
|
|
136
|
+
await recordAudit(env, ACTOR, {
|
|
137
|
+
workspaceId, action: "platform.feature_override_cleared", targetType: "feature", targetId: feature, data: { operator: operatorOf(request) },
|
|
138
|
+
}, request);
|
|
139
|
+
return workspaceDetail(env, workspaceId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function setBeta(context: RouteContext, workspaceId: string, feature: Feature): Promise<Response> {
|
|
143
|
+
const { env, request } = context;
|
|
144
|
+
let enabled = false;
|
|
145
|
+
if (request.method === "PUT") {
|
|
146
|
+
const body = await requestJson(request);
|
|
147
|
+
if (!body || typeof body.enabled !== "boolean") return response({ error: "enabled must be true or false" }, 422);
|
|
148
|
+
enabled = body.enabled;
|
|
149
|
+
await env.DB.prepare(`
|
|
150
|
+
INSERT INTO workspace_flags (workspace_id, flag, enabled) VALUES (?, ?, ?)
|
|
151
|
+
ON CONFLICT(workspace_id, flag) DO UPDATE SET enabled = excluded.enabled, updated_at = CURRENT_TIMESTAMP
|
|
152
|
+
`).bind(workspaceId, feature, enabled ? 1 : 0).run();
|
|
153
|
+
} else {
|
|
154
|
+
await env.DB.prepare("DELETE FROM workspace_flags WHERE workspace_id = ? AND flag = ?").bind(workspaceId, feature).run();
|
|
155
|
+
}
|
|
156
|
+
await recordAudit(env, ACTOR, {
|
|
157
|
+
workspaceId,
|
|
158
|
+
action: request.method === "PUT" ? "platform.beta_opt_in_set" : "platform.beta_opt_in_cleared",
|
|
159
|
+
targetType: "feature",
|
|
160
|
+
targetId: feature,
|
|
161
|
+
data: { operator: operatorOf(request), ...(request.method === "PUT" ? { enabled } : {}) },
|
|
162
|
+
}, request);
|
|
163
|
+
return workspaceDetail(env, workspaceId);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const LIMIT_FIELDS: Array<[string, LimitKey]> = [
|
|
167
|
+
["projects", "projects"],
|
|
168
|
+
["historyDays", "historyDays"],
|
|
169
|
+
["cloudMinutes", "cloudMinutes"],
|
|
170
|
+
["aiCredits", "aiCredits"],
|
|
171
|
+
["parallel", "parallel"],
|
|
172
|
+
["retentionDays", "historyDays"],
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/** Validates contract limits and features into the PlanOverrides stored on workspace_plans. */
|
|
176
|
+
export function parseContract(body: Record<string, unknown>): { overrides: PlanOverrides } | { error: string } {
|
|
177
|
+
const overrides: PlanOverrides = {};
|
|
178
|
+
const limits = body.limits ?? {};
|
|
179
|
+
if (typeof limits !== "object" || limits === null || Array.isArray(limits)) return { error: "limits must be an object" };
|
|
180
|
+
for (const key of Object.keys(limits)) {
|
|
181
|
+
if (!LIMIT_FIELDS.some(([name]) => name === key)) return { error: `Unknown limit: ${key}` };
|
|
182
|
+
}
|
|
183
|
+
for (const [name, key] of LIMIT_FIELDS) {
|
|
184
|
+
if (!(name in limits)) continue;
|
|
185
|
+
const value = (limits as Record<string, unknown>)[name];
|
|
186
|
+
// null means no fixed limit.
|
|
187
|
+
if (value !== null && (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_LIMIT)) {
|
|
188
|
+
return { error: `${name} must be a whole number from 0 to ${MAX_LIMIT}, or null for unlimited` };
|
|
189
|
+
}
|
|
190
|
+
overrides[key] = value as number | null;
|
|
191
|
+
}
|
|
192
|
+
const features = body.features ?? {};
|
|
193
|
+
if (typeof features !== "object" || features === null || Array.isArray(features)) return { error: "features must be an object" };
|
|
194
|
+
for (const [key, value] of Object.entries(features)) {
|
|
195
|
+
const feature = isFeature(key) ? key : featureForPlanKey(key);
|
|
196
|
+
const planKey = feature ? FEATURE_DEFS[feature].planFeature : undefined;
|
|
197
|
+
if (!planKey) return { error: `${key} isn't a plan feature` };
|
|
198
|
+
if (typeof value !== "boolean") return { error: `features.${key} must be true or false` };
|
|
199
|
+
overrides.features = { ...overrides.features, [planKey]: value };
|
|
200
|
+
}
|
|
201
|
+
return { overrides };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function setPlan(context: RouteContext, workspaceId: string): Promise<Response> {
|
|
205
|
+
const { env, request } = context;
|
|
206
|
+
const body = await requestJson(request);
|
|
207
|
+
if (!body) return response({ error: "Expected a JSON body" }, 422);
|
|
208
|
+
if (!isPlanId(body.plan) || !CONTRACT_PLANS.includes(body.plan)) {
|
|
209
|
+
return response({ error: `plan must be one of ${CONTRACT_PLANS.join(", ")}; Team and Business are bought through checkout` }, 422);
|
|
210
|
+
}
|
|
211
|
+
const plan = body.plan;
|
|
212
|
+
const parsed = plan === "free" ? { overrides: {} } : parseContract(body);
|
|
213
|
+
if ("error" in parsed) return response({ error: parsed.error }, 422);
|
|
214
|
+
const periodEnd = parseExpiry(body.periodEnd);
|
|
215
|
+
if ("error" in periodEnd) return response({ error: periodEnd.error.replace("expiresAt", "periodEnd") }, 422);
|
|
216
|
+
const note = noteOf(body.note);
|
|
217
|
+
|
|
218
|
+
const existing = await env.DB.prepare("SELECT plan, status, provider, overrides_json AS overrides FROM workspace_plans WHERE workspace_id = ?")
|
|
219
|
+
.bind(workspaceId).first<{ plan: string; status: string; provider: string | null; overrides: string | null }>();
|
|
220
|
+
if (existing?.provider && existing.status !== "canceled") {
|
|
221
|
+
return response({
|
|
222
|
+
error: `This workspace pays through ${existing.provider}. Cancel that subscription before putting it on a contract plan.`,
|
|
223
|
+
code: "subscription_active",
|
|
224
|
+
}, 409);
|
|
225
|
+
}
|
|
226
|
+
const from = (await getWorkspacePlan(env, workspaceId)).plan;
|
|
227
|
+
const overridesJson = Object.keys(parsed.overrides).length ? JSON.stringify(parsed.overrides) : null;
|
|
228
|
+
const now = new Date().toISOString();
|
|
229
|
+
await env.DB.prepare(`
|
|
230
|
+
INSERT INTO workspace_plans (workspace_id, plan, status, overrides_json, current_period_start, current_period_end)
|
|
231
|
+
VALUES (?, ?, 'active', ?, ?, ?)
|
|
232
|
+
ON CONFLICT(workspace_id) DO UPDATE SET
|
|
233
|
+
plan = excluded.plan, status = 'active', overrides_json = excluded.overrides_json,
|
|
234
|
+
billing_interval = NULL, provider = NULL, provider_subscription_id = NULL,
|
|
235
|
+
pending_plan = NULL, grace_until = NULL,
|
|
236
|
+
current_period_start = excluded.current_period_start, current_period_end = excluded.current_period_end,
|
|
237
|
+
updated_at = CURRENT_TIMESTAMP
|
|
238
|
+
`).bind(workspaceId, plan, overridesJson, plan === "free" ? null : now, periodEnd.expiresAt).run();
|
|
239
|
+
const { locked } = await applyProjectLimit(env, workspaceId, entitlementsFor(plan, parsed.overrides));
|
|
240
|
+
await recordAudit(env, ACTOR, {
|
|
241
|
+
workspaceId,
|
|
242
|
+
action: "platform.plan_set",
|
|
243
|
+
targetType: "workspace",
|
|
244
|
+
targetId: workspaceId,
|
|
245
|
+
data: {
|
|
246
|
+
operator: operatorOf(request),
|
|
247
|
+
from,
|
|
248
|
+
to: plan,
|
|
249
|
+
overrides: parsed.overrides,
|
|
250
|
+
previousOverrides: existing?.overrides ? JSON.parse(existing.overrides) : null,
|
|
251
|
+
periodEnd: periodEnd.expiresAt,
|
|
252
|
+
note,
|
|
253
|
+
lockedProjects: locked,
|
|
254
|
+
},
|
|
255
|
+
}, request);
|
|
256
|
+
// Always, even Enterprise → Enterprise: contract features (SSO, SCIM) may have changed.
|
|
257
|
+
for (const hook of planChangedHooks) {
|
|
258
|
+
try {
|
|
259
|
+
await hook(env, { workspaceId, from, to: plan });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.error("[platform] plan change hook failed", error);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return workspaceDetail(env, workspaceId);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function route(context: RouteContext): Promise<Response | null> {
|
|
268
|
+
const { request, env, url } = context;
|
|
269
|
+
if (!url.pathname.startsWith(PREFIX)) return null;
|
|
270
|
+
const denied = await platformAuth(request, env);
|
|
271
|
+
if (denied) return denied;
|
|
272
|
+
const path = url.pathname;
|
|
273
|
+
const method = request.method;
|
|
274
|
+
|
|
275
|
+
if (path === "/api/platform/feature-overrides" && method === "GET") return listOverrides(env);
|
|
276
|
+
|
|
277
|
+
let params = matchPath(path, "/api/platform/workspaces/:workspaceId/features");
|
|
278
|
+
if (params && method === "GET") {
|
|
279
|
+
if (!(await workspaceExists(env, params.workspaceId))) return response({ error: "Workspace not found" }, 404);
|
|
280
|
+
return workspaceDetail(env, params.workspaceId);
|
|
281
|
+
}
|
|
282
|
+
params = matchPath(path, "/api/platform/workspaces/:workspaceId/features/:feature")
|
|
283
|
+
?? matchPath(path, "/api/platform/workspaces/:workspaceId/beta/:feature");
|
|
284
|
+
if (params && (method === "PUT" || method === "DELETE")) {
|
|
285
|
+
if (!isFeature(params.feature)) return response({ error: `Unknown feature: ${params.feature}` }, 404);
|
|
286
|
+
if (!(await workspaceExists(env, params.workspaceId))) return response({ error: "Workspace not found" }, 404);
|
|
287
|
+
if (path.includes("/beta/")) return setBeta(context, params.workspaceId, params.feature);
|
|
288
|
+
return method === "PUT"
|
|
289
|
+
? setOverride(context, params.workspaceId, params.feature)
|
|
290
|
+
: clearOverride(context, params.workspaceId, params.feature);
|
|
291
|
+
}
|
|
292
|
+
params = matchPath(path, "/api/platform/workspaces/:workspaceId/plan");
|
|
293
|
+
if (params && method === "PUT") {
|
|
294
|
+
if (!(await workspaceExists(env, params.workspaceId))) return response({ error: "Workspace not found" }, 404);
|
|
295
|
+
return setPlan(context, params.workspaceId);
|
|
296
|
+
}
|
|
297
|
+
return response({ error: "Not found" }, 404);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
registerPublicRoutes(route);
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { PLANS, type PlanFeatures } from "../../billing/plan-catalog.js";
|
|
4
|
+
import { freePlan, type WorkspacePlan } from "../billing/entitlements.js";
|
|
5
|
+
import { blockedBy, evaluateFeatures, featureState, requiredPlanFor, type FeatureOverride } from "../feature-access.js";
|
|
6
|
+
import { FEATURES, FEATURE_DEFS, OPERATIONAL_FEATURES, type Feature } from "../flags.js";
|
|
7
|
+
import { call, createTestEnv, seedProject } from "../testing/harness.js";
|
|
8
|
+
|
|
9
|
+
const ADMIN_TOKEN = "platform-test-token";
|
|
10
|
+
const ALL_ON = Object.fromEntries(FEATURES.map((feature) => [`FEATURE_${feature.toUpperCase()}`, "on"]));
|
|
11
|
+
|
|
12
|
+
function planOf(plan: WorkspacePlan["plan"], overrides: WorkspacePlan["overrides"] = {}): WorkspacePlan {
|
|
13
|
+
return { ...freePlan("acme"), plan, overrides };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function override(feature: Feature, enabled: boolean, expiresAt: string | null = null): FeatureOverride {
|
|
17
|
+
return { feature, enabled, expiresAt, reason: null, setBy: "ops", updatedAt: "2026-09-30T00:00:00Z" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NOW = new Date("2026-09-30T12:00:00Z");
|
|
21
|
+
|
|
22
|
+
test("every catalog feature has exactly one flag, and every flag is a catalog feature or listed as operational", () => {
|
|
23
|
+
const catalogKeys = Object.keys(PLANS.free.features) as Array<keyof PlanFeatures>;
|
|
24
|
+
const mapped = FEATURES.map((feature) => FEATURE_DEFS[feature].planFeature).filter(Boolean);
|
|
25
|
+
for (const key of catalogKeys) {
|
|
26
|
+
assert.equal(mapped.filter((value) => value === key).length, 1, `catalog feature ${key} needs exactly one FEATURE_DEFS entry`);
|
|
27
|
+
}
|
|
28
|
+
for (const feature of FEATURES) {
|
|
29
|
+
const key = FEATURE_DEFS[feature].planFeature;
|
|
30
|
+
if (key) assert.ok(catalogKeys.includes(key), `${feature} maps to ${key}, which isn't in the catalog`);
|
|
31
|
+
else assert.ok((OPERATIONAL_FEATURES as readonly string[]).includes(feature), `${feature} has no catalog key and isn't listed as operational`);
|
|
32
|
+
}
|
|
33
|
+
for (const feature of OPERATIONAL_FEATURES) assert.equal(FEATURE_DEFS[feature].planFeature, undefined);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the required plan is the cheapest plan that includes a feature", () => {
|
|
37
|
+
assert.equal(requiredPlanFor("pr_checks"), "team");
|
|
38
|
+
assert.equal(requiredPlanFor("device_clouds"), "business");
|
|
39
|
+
assert.equal(requiredPlanFor("scim"), "enterprise");
|
|
40
|
+
assert.equal(requiredPlanFor("self_hosted_workers"), "enterprise");
|
|
41
|
+
assert.equal(requiredPlanFor("feedback"), undefined);
|
|
42
|
+
assert.equal(requiredPlanFor("ai_failure_analysis"), "free", "all-plans features are on for Free");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("precedence: kill switch, then beta opt-in, then workspace override, then plan", () => {
|
|
46
|
+
const evaluate = (env: Record<string, string>, options: { plan?: WorkspacePlan; beta?: Feature[]; overrides?: FeatureOverride[] } = {}) =>
|
|
47
|
+
evaluateFeatures({
|
|
48
|
+
env,
|
|
49
|
+
plan: options.plan ?? planOf("free"),
|
|
50
|
+
betaOptIns: new Set(options.beta ?? []),
|
|
51
|
+
overrides: options.overrides ?? [],
|
|
52
|
+
now: NOW,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Plan only.
|
|
56
|
+
let result = evaluate(ALL_ON, { plan: planOf("team") });
|
|
57
|
+
assert.deepEqual([result.pr_checks.enabled, result.pr_checks.reason], [true, "plan"]);
|
|
58
|
+
assert.deepEqual([result.sso.enabled, result.sso.reason, result.sso.requiredPlan], [false, "plan", "business"]);
|
|
59
|
+
assert.deepEqual([result.feedback.enabled, result.feedback.reason, result.feedback.requiredPlan], [true, "plan", undefined]);
|
|
60
|
+
|
|
61
|
+
// A workspace override beats the plan both ways.
|
|
62
|
+
result = evaluate(ALL_ON, { plan: planOf("team"), overrides: [override("sso", true), override("pr_checks", false)] });
|
|
63
|
+
assert.deepEqual([result.sso.enabled, result.sso.reason], [true, "workspace_override"]);
|
|
64
|
+
assert.deepEqual([result.pr_checks.enabled, result.pr_checks.reason], [false, "workspace_override"]);
|
|
65
|
+
|
|
66
|
+
// The kill switch beats an override and the plan; shadow counts as off for access.
|
|
67
|
+
result = evaluate({ ...ALL_ON, FEATURE_SSO: "off", FEATURE_PR_CHECKS: "shadow" }, { plan: planOf("business"), overrides: [override("sso", true)] });
|
|
68
|
+
assert.deepEqual([result.sso.enabled, result.sso.reason], [false, "flag_off"]);
|
|
69
|
+
assert.deepEqual([result.pr_checks.enabled, result.pr_checks.reason], [false, "flag_off"]);
|
|
70
|
+
|
|
71
|
+
// Beta needs an opt-in before an override or the plan is consulted.
|
|
72
|
+
result = evaluate({ ...ALL_ON, FEATURE_SLACK: "beta" }, { plan: planOf("business"), overrides: [override("slack", true)] });
|
|
73
|
+
assert.deepEqual([result.slack.enabled, result.slack.reason], [false, "beta_not_enabled"]);
|
|
74
|
+
result = evaluate({ ...ALL_ON, FEATURE_SLACK: "beta" }, { plan: planOf("business"), beta: ["slack"], overrides: [override("slack", false)] });
|
|
75
|
+
assert.deepEqual([result.slack.enabled, result.slack.reason], [false, "workspace_override"]);
|
|
76
|
+
result = evaluate({ ...ALL_ON, FEATURE_SLACK: "beta" }, { plan: planOf("free"), beta: ["slack"] });
|
|
77
|
+
assert.deepEqual([result.slack.enabled, result.slack.reason, result.slack.requiredPlan], [false, "plan", "team"]);
|
|
78
|
+
|
|
79
|
+
// Blocked-by classifies what a gate should answer.
|
|
80
|
+
assert.equal(blockedBy(result.slack), "plan");
|
|
81
|
+
assert.equal(blockedBy(evaluate({ FEATURE_SLACK: "off" }).slack), "rollout");
|
|
82
|
+
assert.equal(blockedBy(evaluate(ALL_ON, { plan: planOf("team"), overrides: [override("pr_checks", false)] }).pr_checks), "rollout");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("unset vars: launch flags default to beta, plan features default to on", () => {
|
|
86
|
+
const result = evaluateFeatures({ env: {}, plan: planOf("enterprise"), betaOptIns: new Set(["mcp"]), overrides: [], now: NOW });
|
|
87
|
+
assert.equal(result.sso.reason, "beta_not_enabled");
|
|
88
|
+
assert.equal(result.mcp.enabled, true);
|
|
89
|
+
assert.deepEqual([result.scim.enabled, result.scim.mode], [true, "on"]);
|
|
90
|
+
assert.deepEqual([result.ai_fixes.enabled, result.audit_export.enabled], [true, true]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("an expired override stops counting", () => {
|
|
94
|
+
const overrides = [override("device_clouds", true, "2026-09-30T11:59:59Z"), override("sso", true, "2026-10-01T00:00:00Z")];
|
|
95
|
+
const result = evaluateFeatures({ env: ALL_ON, plan: planOf("free"), betaOptIns: new Set(), overrides, now: NOW });
|
|
96
|
+
assert.deepEqual([result.device_clouds.enabled, result.device_clouds.reason], [false, "plan"]);
|
|
97
|
+
assert.deepEqual([result.sso.enabled, result.sso.reason, result.sso.overrideExpiresAt], [true, "workspace_override", "2026-10-01T00:00:00Z"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("contract overrides on an Enterprise plan report enterprise_override", () => {
|
|
101
|
+
const plan = planOf("enterprise", { projects: 200, features: { scim: false, selfHostedWorkers: true } });
|
|
102
|
+
const result = evaluateFeatures({ env: ALL_ON, plan, betaOptIns: new Set(), overrides: [], now: NOW });
|
|
103
|
+
assert.deepEqual([result.scim.enabled, result.scim.reason], [false, "enterprise_override"]);
|
|
104
|
+
assert.deepEqual([result.self_hosted_workers.enabled, result.self_hosted_workers.reason], [true, "enterprise_override"]);
|
|
105
|
+
assert.deepEqual([result.sso.enabled, result.sso.reason], [true, "plan"]);
|
|
106
|
+
assert.equal(blockedBy(result.scim), "plan");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("beta opt-ins come from workspace_flags", async () => {
|
|
110
|
+
const { env, db } = createTestEnv({ ...ALL_ON, FEATURE_CLOUD_RUNNERS: "beta" });
|
|
111
|
+
const { workspaceId } = await seedProject(env);
|
|
112
|
+
assert.equal((await featureState(env, workspaceId, "cloud_runners")).reason, "beta_not_enabled");
|
|
113
|
+
db.db.prepare("INSERT INTO workspace_flags (workspace_id, flag, enabled) VALUES (?, 'cloud_runners', 0)").run(workspaceId);
|
|
114
|
+
assert.equal((await featureState(env, workspaceId, "cloud_runners")).enabled, false, "a disabled row isn't an opt-in");
|
|
115
|
+
db.db.prepare("UPDATE workspace_flags SET enabled = 1 WHERE workspace_id = ?").run(workspaceId);
|
|
116
|
+
assert.equal((await featureState(env, workspaceId, "cloud_runners")).enabled, true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("members read the evaluated features and limits; strangers can't", async () => {
|
|
120
|
+
const { env } = createTestEnv(ALL_ON);
|
|
121
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
122
|
+
const read = await call(env, "GET", `/api/workspaces/${workspaceId}/features`, { as: owner });
|
|
123
|
+
assert.equal(read.status, 200);
|
|
124
|
+
assert.deepEqual(read.json.plan, { id: "free", name: "Free", status: "active" });
|
|
125
|
+
assert.equal(read.json.source, "default");
|
|
126
|
+
assert.equal(read.json.features.pr_checks.enabled, false);
|
|
127
|
+
assert.equal(read.json.features.pr_checks.requiredPlan, "team");
|
|
128
|
+
assert.equal(read.json.features.pr_checks.planFeature, "prChecks");
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
{ enabled: read.json.planFeatures.deviceClouds.enabled, requiredPlan: read.json.planFeatures.deviceClouds.requiredPlan },
|
|
131
|
+
{ enabled: false, requiredPlan: "business" },
|
|
132
|
+
);
|
|
133
|
+
assert.equal(read.json.features.feedback.requiredPlan, null);
|
|
134
|
+
assert.deepEqual(read.json.limits.projects, { limit: 1, unlimited: false, used: 1 });
|
|
135
|
+
assert.deepEqual(read.json.limits.cloudMinutes, { limit: 300, unlimited: false, used: 0 });
|
|
136
|
+
assert.deepEqual(read.json.limits.parallel, { limit: 2, unlimited: false });
|
|
137
|
+
assert.equal(read.json.limitsEnforced, "enforce");
|
|
138
|
+
assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/features`, { as: "user_stranger" })).status, 403);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("platform routes: 503 without the secret, 401 without or with a wrong token", async () => {
|
|
142
|
+
const unset = createTestEnv(ALL_ON);
|
|
143
|
+
await seedProject(unset.env);
|
|
144
|
+
assert.equal((await call(unset.env, "GET", "/api/platform/workspaces/acme/features", { token: "anything" })).status, 503);
|
|
145
|
+
|
|
146
|
+
const { env } = createTestEnv({ ...ALL_ON, PLATFORM_ADMIN_TOKEN: ADMIN_TOKEN });
|
|
147
|
+
const { owner } = await seedProject(env);
|
|
148
|
+
assert.equal((await call(env, "GET", "/api/platform/workspaces/acme/features")).status, 401);
|
|
149
|
+
assert.equal((await call(env, "GET", "/api/platform/workspaces/acme/features", { token: "wrong" })).status, 401);
|
|
150
|
+
assert.equal((await call(env, "GET", "/api/platform/workspaces/acme/features", { token: `${ADMIN_TOKEN}x` })).status, 401);
|
|
151
|
+
assert.equal((await call(env, "GET", "/api/platform/workspaces/acme/features", { as: owner })).status, 401, "a dashboard session isn't a platform token");
|
|
152
|
+
assert.equal((await call(env, "PUT", "/api/platform/workspaces/acme/plan", { as: owner, body: { plan: "enterprise" } })).status, 401);
|
|
153
|
+
assert.equal((await call(env, "GET", "/api/platform/workspaces/acme/features", { token: ADMIN_TOKEN })).status, 200);
|
|
154
|
+
assert.equal((await call(env, "GET", "/api/platform/workspaces/nope/features", { token: ADMIN_TOKEN })).status, 404);
|
|
155
|
+
assert.equal((await call(env, "PUT", "/api/platform/workspaces/acme/features/bogus", { token: ADMIN_TOKEN, body: { enabled: true } })).status, 404);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("an operator override turns a plan feature on for a trial, is audited, and lapses", async () => {
|
|
159
|
+
const { env, db } = createTestEnv({ ...ALL_ON, PLATFORM_ADMIN_TOKEN: ADMIN_TOKEN });
|
|
160
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
161
|
+
const admin = { token: ADMIN_TOKEN, headers: { "x-bugmole-operator": "ops@bugmole.test" } };
|
|
162
|
+
|
|
163
|
+
assert.equal((await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/features/sso`, { ...admin, body: { enabled: true, expiresAt: "2020-01-01" } })).status, 422);
|
|
164
|
+
assert.equal((await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/features/sso`, { ...admin, body: { enabled: "yes" } })).status, 422);
|
|
165
|
+
|
|
166
|
+
const set = await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/features/device_clouds`, {
|
|
167
|
+
...admin, body: { enabled: true, expiresAt: "2099-01-31", reason: "Trial for Acme" },
|
|
168
|
+
});
|
|
169
|
+
assert.equal(set.status, 200);
|
|
170
|
+
assert.equal(set.json.features.device_clouds.enabled, true);
|
|
171
|
+
assert.equal(set.json.features.device_clouds.reason, "workspace_override");
|
|
172
|
+
assert.equal(set.json.features.device_clouds.expiresAt, "2099-01-31T23:59:59.999Z");
|
|
173
|
+
assert.equal(set.json.overrides[0].setBy, "ops@bugmole.test");
|
|
174
|
+
|
|
175
|
+
// The member endpoint and the server gate agree.
|
|
176
|
+
const member = await call(env, "GET", `/api/workspaces/${workspaceId}/features`, { as: owner });
|
|
177
|
+
assert.equal(member.json.features.device_clouds.enabled, true);
|
|
178
|
+
assert.equal(member.json.features.device_clouds.expiresAt, "2099-01-31T23:59:59.999Z");
|
|
179
|
+
const clouds = await call(env, "GET", `/api/workspaces/${workspaceId}/device-clouds`, { as: owner });
|
|
180
|
+
assert.equal(clouds.json.available, true);
|
|
181
|
+
|
|
182
|
+
const audit = db.db.prepare("SELECT actor_type AS actorType, actor_id AS actorId, action, target_id AS target, data_json AS data FROM audit_log WHERE action LIKE 'platform.%'").all() as Array<Record<string, string>>;
|
|
183
|
+
assert.equal(audit.length, 1);
|
|
184
|
+
assert.deepEqual([audit[0].actorType, audit[0].actorId, audit[0].action, audit[0].target], ["system", "platform_admin", "platform.feature_override_set", "device_clouds"]);
|
|
185
|
+
assert.equal(JSON.parse(audit[0].data).operator, "ops@bugmole.test");
|
|
186
|
+
assert.equal(JSON.parse(audit[0].data).reason, "Trial for Acme");
|
|
187
|
+
|
|
188
|
+
// Past its expiry, the plan decides again.
|
|
189
|
+
db.db.prepare("UPDATE workspace_feature_overrides SET expires_at = ? WHERE workspace_id = ?").run("2026-01-01T00:00:00.000Z", workspaceId);
|
|
190
|
+
assert.equal((await call(env, "GET", `/api/workspaces/${workspaceId}/device-clouds`, { as: owner })).json.available, false);
|
|
191
|
+
|
|
192
|
+
// Clearing it is audited too; clearing twice is a 404.
|
|
193
|
+
assert.equal((await call(env, "DELETE", `/api/platform/workspaces/${workspaceId}/features/device_clouds`, admin)).status, 200);
|
|
194
|
+
assert.equal((await call(env, "DELETE", `/api/platform/workspaces/${workspaceId}/features/device_clouds`, admin)).status, 404);
|
|
195
|
+
const list = await call(env, "GET", "/api/platform/feature-overrides", admin);
|
|
196
|
+
assert.deepEqual(list.json.overrides, []);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("an incident override turns a feature off even on a plan that includes it", async () => {
|
|
200
|
+
const { env, db } = createTestEnv({ ...ALL_ON, PLATFORM_ADMIN_TOKEN: ADMIN_TOKEN });
|
|
201
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
202
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan) VALUES (?, 'team')").run(workspaceId);
|
|
203
|
+
await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/features/ai_fixes`, { token: ADMIN_TOKEN, body: { enabled: false } });
|
|
204
|
+
const state = await featureState(env, workspaceId, "ai_fixes");
|
|
205
|
+
assert.deepEqual([state.enabled, state.reason], [false, "workspace_override"]);
|
|
206
|
+
const read = await call(env, "GET", `/api/workspaces/${workspaceId}/features`, { as: owner });
|
|
207
|
+
assert.equal(read.json.planFeatures.aiFixes.enabled, false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("beta opt-ins are set through the platform routes", async () => {
|
|
211
|
+
const { env } = createTestEnv({ ...ALL_ON, FEATURE_CLOUD_RUNNERS: "beta", PLATFORM_ADMIN_TOKEN: ADMIN_TOKEN });
|
|
212
|
+
const { workspaceId } = await seedProject(env);
|
|
213
|
+
const on = await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/beta/cloud_runners`, { token: ADMIN_TOKEN, body: { enabled: true } });
|
|
214
|
+
assert.equal(on.status, 200);
|
|
215
|
+
assert.equal(on.json.features.cloud_runners.enabled, true);
|
|
216
|
+
assert.deepEqual(on.json.betaOptIns.map((row: { feature: string; enabled: boolean }) => [row.feature, row.enabled]), [["cloud_runners", true]]);
|
|
217
|
+
const off = await call(env, "DELETE", `/api/platform/workspaces/${workspaceId}/beta/cloud_runners`, { token: ADMIN_TOKEN });
|
|
218
|
+
assert.equal(off.json.features.cloud_runners.reason, "beta_not_enabled");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("an operator puts a workspace on an Enterprise contract and back to Free", async () => {
|
|
222
|
+
const { env, db } = createTestEnv({ ...ALL_ON, PLATFORM_ADMIN_TOKEN: ADMIN_TOKEN });
|
|
223
|
+
const { workspaceId, owner } = await seedProject(env);
|
|
224
|
+
const admin = { token: ADMIN_TOKEN, headers: { "x-bugmole-operator": "ops" } };
|
|
225
|
+
|
|
226
|
+
assert.equal((await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, { ...admin, body: { plan: "team" } })).status, 422);
|
|
227
|
+
assert.equal((await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, { ...admin, body: { plan: "enterprise", limits: { projects: -1 } } })).status, 422);
|
|
228
|
+
assert.equal((await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, { ...admin, body: { plan: "enterprise", limits: { seats: 3 } } })).status, 422);
|
|
229
|
+
assert.equal((await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, { ...admin, body: { plan: "enterprise", features: { teleport: true } } })).status, 422);
|
|
230
|
+
assert.equal((await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, { ...admin, body: { plan: "enterprise", features: { feedback: true } } })).status, 422, "operational flags aren't contract features");
|
|
231
|
+
|
|
232
|
+
const set = await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, {
|
|
233
|
+
...admin,
|
|
234
|
+
body: {
|
|
235
|
+
plan: "enterprise",
|
|
236
|
+
limits: { projects: 40, cloudMinutes: 50_000, aiCredits: 20_000, parallel: 60, retentionDays: 400 },
|
|
237
|
+
features: { scim: false, self_hosted_workers: true },
|
|
238
|
+
note: "Contract C-17",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
assert.equal(set.status, 200);
|
|
242
|
+
assert.equal(set.json.plan.id, "enterprise");
|
|
243
|
+
assert.equal(set.json.source, "contract");
|
|
244
|
+
assert.deepEqual(set.json.limits.projects, { limit: 40, unlimited: false, used: 1 });
|
|
245
|
+
assert.equal(set.json.limits.historyDays.limit, 400);
|
|
246
|
+
assert.equal(set.json.features.scim.reason, "enterprise_override");
|
|
247
|
+
assert.equal(set.json.features.scim.enabled, false);
|
|
248
|
+
assert.equal(set.json.features.sso.enabled, true);
|
|
249
|
+
|
|
250
|
+
const usage = await call(env, "GET", `/api/workspaces/${workspaceId}/usage`, { as: owner });
|
|
251
|
+
assert.equal(usage.json.usage.lines.browser_minute.included, 50_000, "enforcement reads the same contract");
|
|
252
|
+
assert.equal(usage.json.limits.parallel, 60);
|
|
253
|
+
|
|
254
|
+
const [audit] = db.db.prepare("SELECT data_json AS data FROM audit_log WHERE action = 'platform.plan_set'").all() as Array<{ data: string }>;
|
|
255
|
+
const data = JSON.parse(audit.data);
|
|
256
|
+
assert.deepEqual([data.from, data.to, data.note, data.overrides.historyDays], ["free", "enterprise", "Contract C-17", 400]);
|
|
257
|
+
|
|
258
|
+
const back = await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, { ...admin, body: { plan: "free" } });
|
|
259
|
+
assert.equal(back.json.plan.id, "free");
|
|
260
|
+
assert.equal(back.json.limits.projects.limit, 1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("a workspace paying through PayPal can't be put on a contract", async () => {
|
|
264
|
+
const { env, db } = createTestEnv({ ...ALL_ON, PLATFORM_ADMIN_TOKEN: ADMIN_TOKEN });
|
|
265
|
+
const { workspaceId } = await seedProject(env);
|
|
266
|
+
db.db.prepare("INSERT INTO workspace_plans (workspace_id, plan, provider, provider_subscription_id) VALUES (?, 'business', 'paypal', 'I-1')").run(workspaceId);
|
|
267
|
+
const refused = await call(env, "PUT", `/api/platform/workspaces/${workspaceId}/plan`, { token: ADMIN_TOKEN, body: { plan: "enterprise" } });
|
|
268
|
+
assert.equal(refused.status, 409);
|
|
269
|
+
assert.equal(refused.json.code, "subscription_active");
|
|
270
|
+
});
|