@bugmole/cli 0.4.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/LICENSE +18 -4
- package/package.json +7 -4
- 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 +290 -43
- package/scripts/ensure-playwright.cjs +87 -0
- package/scripts/ensure-playwright.test.ts +36 -0
- package/scripts/sync-byok.d.mts +2 -0
- package/scripts/sync-byok.mjs +18 -0
- package/scripts/sync-launcher-version.cjs +24 -0
- package/scripts/sync-launcher-version.test.ts +28 -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/0034_signup_attribution.sql +19 -0
- package/src/registry/migrations/0035_domain_verify_file.sql +6 -0
- 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 +24 -0
- package/src/registry-worker/devices/policy.ts +4 -5
- package/src/registry-worker/devices/routes.ts +8 -8
- package/src/registry-worker/domains/domains.test.ts +64 -1
- package/src/registry-worker/domains/routes.ts +32 -15
- package/src/registry-worker/domains.ts +40 -2
- 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 +11 -0
- package/src/registry-worker/feedback/feedback.test.ts +47 -0
- package/src/registry-worker/feedback/routes.ts +2 -2
- package/src/registry-worker/feedback/website.ts +77 -0
- 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 +494 -173
- 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.test.ts +24 -1
- package/src/registry-worker/org/routes.ts +9 -8
- 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 +59 -0
- package/src/registry-worker/runner/tokens.ts +12 -1
- 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/attribution.test.ts +147 -0
- package/src/registry-worker/signup/attribution.ts +194 -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/ai-exploration.test.ts +34 -1
- package/src/runtime/ai-exploration.ts +97 -3
- package/src/runtime/appium-driver.ts +7 -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 +87 -7
- 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 +43 -8
- package/src/runtime/init-wizard.ts +112 -6
- package/src/runtime/journey-editor.ts +78 -2
- package/src/runtime/journey-graph.test.ts +60 -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 +164 -0
- package/src/runtime/playwright-driver.ts +286 -15
- 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-job.test.ts +21 -0
- package/src/runtime/run-job.ts +8 -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 +181 -4
- package/src/runtime/site-discovery.ts +248 -29
- package/src/runtime/vault-federation.test.ts +37 -0
- package/src/runtime/vault-federation.ts +128 -0
- package/src/runtime/web-suite.test.ts +65 -0
- package/src/runtime/web-suite.ts +8 -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,165 @@
|
|
|
1
|
+
// The one answer to "may this workspace use feature X?". Every layer is
|
|
2
|
+
// evaluated in a fixed order, and the first one that decides wins:
|
|
3
|
+
//
|
|
4
|
+
// 1. Global kill switch FEATURE_<NAME>=off (or shadow) → off, "flag_off"
|
|
5
|
+
// 2. Beta rollout FEATURE_<NAME>=beta needs a workspace_flags opt-in,
|
|
6
|
+
// otherwise off, "beta_not_enabled"
|
|
7
|
+
// 3. Workspace override an unexpired workspace_feature_overrides row forces
|
|
8
|
+
// it on or off (trials, comps, incidents), "workspace_override"
|
|
9
|
+
// 4. Plan the plan's catalog features, "plan"; changed by the
|
|
10
|
+
// contract overrides on workspace_plans, "enterprise_override"
|
|
11
|
+
//
|
|
12
|
+
// Features with no plan gate are on for every plan once the rollout allows
|
|
13
|
+
// them (reason "plan"). See docs/feature-flags.md.
|
|
14
|
+
import { LIMIT_KEYS, PLANS, PLAN_IDS, entitlementsFor, type LimitKey, type PlanId } from "../billing/plan-catalog.js";
|
|
15
|
+
import type { Env } from "./core.js";
|
|
16
|
+
import { FEATURES, FEATURE_DEFS, flagMode, type Feature, type FlagMode } from "./flags.js";
|
|
17
|
+
import { freePlan, getWorkspacePlan, type WorkspacePlan } from "./billing/entitlements.js";
|
|
18
|
+
|
|
19
|
+
export type FeatureReason = "plan" | "flag_off" | "beta_not_enabled" | "workspace_override" | "enterprise_override";
|
|
20
|
+
|
|
21
|
+
export type FeatureState = {
|
|
22
|
+
feature: Feature;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
reason: FeatureReason;
|
|
25
|
+
/** The cheapest plan that includes it, for plan-gated features (null when no self-serve plan does). */
|
|
26
|
+
requiredPlan?: PlanId;
|
|
27
|
+
/** The global rollout mode it was evaluated under. */
|
|
28
|
+
mode: FlagMode;
|
|
29
|
+
/** When a workspace override decided, when it lapses (null: until cleared). */
|
|
30
|
+
overrideExpiresAt?: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type FeatureOverride = { feature: Feature; enabled: boolean; expiresAt: string | null; reason: string | null; setBy: string; updatedAt: string };
|
|
34
|
+
|
|
35
|
+
export type FeatureInputs = {
|
|
36
|
+
env: Omit<Env, "DB"> | Record<string, unknown>;
|
|
37
|
+
plan: WorkspacePlan;
|
|
38
|
+
/** Features this workspace opted into while they are in beta. */
|
|
39
|
+
betaOptIns: ReadonlySet<Feature>;
|
|
40
|
+
overrides: readonly FeatureOverride[];
|
|
41
|
+
now?: Date;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The cheapest plan whose catalog features include this one. */
|
|
45
|
+
export function requiredPlanFor(feature: Feature): PlanId | undefined {
|
|
46
|
+
const key = FEATURE_DEFS[feature].planFeature;
|
|
47
|
+
if (!key) return undefined;
|
|
48
|
+
return PLAN_IDS.find((plan) => PLANS[plan].features[key]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function overrideActive(override: Pick<FeatureOverride, "expiresAt">, now = new Date()): boolean {
|
|
52
|
+
return !override.expiresAt || Date.parse(override.expiresAt) > now.getTime();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Pure evaluation of every feature; the async loaders below feed it. */
|
|
56
|
+
export function evaluateFeatures(inputs: FeatureInputs): Record<Feature, FeatureState> {
|
|
57
|
+
const now = inputs.now ?? new Date();
|
|
58
|
+
const entitlements = entitlementsFor(inputs.plan.plan, inputs.plan.overrides);
|
|
59
|
+
const contract = inputs.plan.overrides.features ?? {};
|
|
60
|
+
const result = {} as Record<Feature, FeatureState>;
|
|
61
|
+
for (const feature of FEATURES) {
|
|
62
|
+
const def = FEATURE_DEFS[feature];
|
|
63
|
+
const mode = flagMode(inputs.env as Parameters<typeof flagMode>[0], feature);
|
|
64
|
+
const requiredPlan = requiredPlanFor(feature);
|
|
65
|
+
const base = { feature, mode, ...(def.planFeature ? { requiredPlan } : {}) } as Pick<FeatureState, "feature" | "mode" | "requiredPlan">;
|
|
66
|
+
if (mode === "off" || mode === "shadow") {
|
|
67
|
+
result[feature] = { ...base, enabled: false, reason: "flag_off" };
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (mode === "beta" && !inputs.betaOptIns.has(feature)) {
|
|
71
|
+
result[feature] = { ...base, enabled: false, reason: "beta_not_enabled" };
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const override = inputs.overrides.find((row) => row.feature === feature && overrideActive(row, now));
|
|
75
|
+
if (override) {
|
|
76
|
+
result[feature] = { ...base, enabled: override.enabled, reason: "workspace_override", overrideExpiresAt: override.expiresAt };
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!def.planFeature) {
|
|
80
|
+
result[feature] = { ...base, enabled: true, reason: "plan" };
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const contractSet = contract[def.planFeature] !== undefined;
|
|
84
|
+
result[feature] = { ...base, enabled: entitlements.features[def.planFeature], reason: contractSet ? "enterprise_override" : "plan" };
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function betaOptIns(env: Env, workspaceId: string): Promise<Set<Feature>> {
|
|
90
|
+
const rows = await env.DB.prepare("SELECT flag FROM workspace_flags WHERE workspace_id = ? AND enabled = 1")
|
|
91
|
+
.bind(workspaceId).all<{ flag: string }>();
|
|
92
|
+
return new Set(rows.results.map((row) => row.flag).filter((flag): flag is Feature => (FEATURES as readonly string[]).includes(flag)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Override rows for a workspace, expired ones included. Tolerates a registry not yet migrated. */
|
|
96
|
+
export async function loadOverrides(env: Env, workspaceId: string): Promise<FeatureOverride[]> {
|
|
97
|
+
try {
|
|
98
|
+
const rows = await env.DB.prepare(`
|
|
99
|
+
SELECT feature, enabled, expires_at AS expiresAt, reason, set_by AS setBy, updated_at AS updatedAt
|
|
100
|
+
FROM workspace_feature_overrides WHERE workspace_id = ?
|
|
101
|
+
`).bind(workspaceId).all<{ feature: string; enabled: number; expiresAt: string | null; reason: string | null; setBy: string; updatedAt: string }>();
|
|
102
|
+
return rows.results
|
|
103
|
+
.filter((row) => (FEATURES as readonly string[]).includes(row.feature))
|
|
104
|
+
.map((row) => ({ ...row, feature: row.feature as Feature, enabled: row.enabled === 1 }));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error("[features] workspace_feature_overrides unavailable; is migration 0043 applied?", error);
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type EffectiveFeatures = {
|
|
112
|
+
plan: WorkspacePlan;
|
|
113
|
+
features: Record<Feature, FeatureState>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Every feature for one workspace (three small reads). A null workspace is a Free workspace with no opt-ins. */
|
|
117
|
+
export async function effectiveFeatures(env: Env, workspaceId: string | null | undefined, now = new Date()): Promise<EffectiveFeatures> {
|
|
118
|
+
if (!workspaceId) {
|
|
119
|
+
const plan = freePlan("");
|
|
120
|
+
return { plan, features: evaluateFeatures({ env, plan, betaOptIns: new Set(), overrides: [], now }) };
|
|
121
|
+
}
|
|
122
|
+
const [plan, optIns, overrides] = await Promise.all([
|
|
123
|
+
getWorkspacePlan(env, workspaceId),
|
|
124
|
+
betaOptIns(env, workspaceId),
|
|
125
|
+
loadOverrides(env, workspaceId),
|
|
126
|
+
]);
|
|
127
|
+
return { plan, features: evaluateFeatures({ env, plan, betaOptIns: optIns, overrides, now }) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function featureState(env: Env, workspaceId: string | null | undefined, feature: Feature): Promise<FeatureState> {
|
|
131
|
+
return (await effectiveFeatures(env, workspaceId)).features[feature];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function featureEnabled(env: Env, workspaceId: string | null | undefined, feature: Feature): Promise<boolean> {
|
|
135
|
+
return (await featureState(env, workspaceId, feature)).enabled;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Why a feature is off, in the terms gates answer with: "rollout" (not
|
|
140
|
+
* available to this workspace yet, or switched off for it) or "plan" (an
|
|
141
|
+
* upgrade would unlock it, so a 402). Null when it is on.
|
|
142
|
+
*/
|
|
143
|
+
export function blockedBy(state: FeatureState): "rollout" | "plan" | null {
|
|
144
|
+
if (state.enabled) return null;
|
|
145
|
+
return state.reason === "plan" || state.reason === "enterprise_override" ? "plan" : "rollout";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type { LimitKey };
|
|
149
|
+
/** A catalog limit (LIMIT_KEYS). Enforcement reads the same entitlementsFor() values. */
|
|
150
|
+
export type LimitState = { limit: number | null; unlimited: boolean; used?: number };
|
|
151
|
+
|
|
152
|
+
/** A workspace's limits: its plan's catalog limits with contract overrides applied. */
|
|
153
|
+
export function limitsFor(plan: WorkspacePlan): Record<LimitKey, LimitState> {
|
|
154
|
+
const entitlements = entitlementsFor(plan.plan, plan.overrides);
|
|
155
|
+
return Object.fromEntries(LIMIT_KEYS.map((key) => {
|
|
156
|
+
const limit = entitlements[key];
|
|
157
|
+
return [key, { limit, unlimited: limit === null }];
|
|
158
|
+
})) as Record<LimitKey, LimitState>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Where a workspace's plan comes from, for ops and the dashboard. */
|
|
162
|
+
export function planSource(plan: WorkspacePlan): "default" | "subscription" | "contract" {
|
|
163
|
+
if (plan.provider) return "subscription";
|
|
164
|
+
return plan.plan === "free" ? "default" : "contract";
|
|
165
|
+
}
|
|
@@ -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);
|