@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,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
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// GET /api/workspaces/:id/features — what the dashboard may show a member:
|
|
2
|
+
// every feature, whether it is on for this workspace, why, and the plan that
|
|
3
|
+
// would unlock it. Read-only; changes go through the platform routes in admin.ts.
|
|
4
|
+
import { PLANS } from "../../billing/plan-catalog.js";
|
|
5
|
+
import { memberOfWorkspace, response } from "../core.js";
|
|
6
|
+
import type { Env } from "../core.js";
|
|
7
|
+
import { entitlementMode } from "../billing/enforcement.js";
|
|
8
|
+
import { usageSummary } from "../billing/ledger.js";
|
|
9
|
+
import {
|
|
10
|
+
type EffectiveFeatures,
|
|
11
|
+
type FeatureState,
|
|
12
|
+
type LimitKey,
|
|
13
|
+
type LimitState,
|
|
14
|
+
effectiveFeatures,
|
|
15
|
+
limitsFor,
|
|
16
|
+
planSource,
|
|
17
|
+
} from "../feature-access.js";
|
|
18
|
+
import { FEATURES, FEATURE_DEFS, type Feature } from "../flags.js";
|
|
19
|
+
import { type AuthedRouteContext, matchPath, registerRoutes } from "../routes/index.js";
|
|
20
|
+
|
|
21
|
+
export type FeatureView = {
|
|
22
|
+
feature: Feature;
|
|
23
|
+
label: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
reason: FeatureState["reason"];
|
|
26
|
+
/** Cheapest plan that includes it; null when it isn't plan-gated. */
|
|
27
|
+
requiredPlan: string | null;
|
|
28
|
+
/** The plan-catalog key (as in GET /usage limits.features), when plan-gated. */
|
|
29
|
+
planFeature: string | null;
|
|
30
|
+
/** Set when a workspace override decided and it lapses. */
|
|
31
|
+
expiresAt: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function featureView(state: FeatureState): FeatureView {
|
|
35
|
+
const def = FEATURE_DEFS[state.feature];
|
|
36
|
+
return {
|
|
37
|
+
feature: state.feature,
|
|
38
|
+
label: def.label,
|
|
39
|
+
enabled: state.enabled,
|
|
40
|
+
reason: state.reason,
|
|
41
|
+
requiredPlan: state.requiredPlan ?? null,
|
|
42
|
+
planFeature: def.planFeature ?? null,
|
|
43
|
+
expiresAt: state.reason === "workspace_override" ? state.overrideExpiresAt ?? null : null,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type LimitUsage = Partial<Record<LimitKey, number>>;
|
|
48
|
+
|
|
49
|
+
export function featuresBody(workspaceId: string, evaluated: EffectiveFeatures, used: LimitUsage = {}, limitsEnforced: "enforce" | "shadow" | "off" = "enforce") {
|
|
50
|
+
const features = Object.fromEntries(FEATURES.map((feature) => [feature, featureView(evaluated.features[feature])])) as Record<Feature, FeatureView>;
|
|
51
|
+
const planFeatures = Object.fromEntries(
|
|
52
|
+
Object.values(features).filter((view) => view.planFeature).map((view) => [view.planFeature, view]),
|
|
53
|
+
) as Record<string, FeatureView>;
|
|
54
|
+
return {
|
|
55
|
+
workspaceId,
|
|
56
|
+
plan: { id: evaluated.plan.plan, name: PLANS[evaluated.plan.plan].name, status: evaluated.plan.status },
|
|
57
|
+
source: planSource(evaluated.plan),
|
|
58
|
+
features,
|
|
59
|
+
planFeatures,
|
|
60
|
+
limits: Object.fromEntries(Object.entries(limitsFor(evaluated.plan)).map(([key, state]) => {
|
|
61
|
+
const usedValue = used[key as LimitKey];
|
|
62
|
+
return [key, usedValue === undefined ? state : { ...state, used: usedValue }];
|
|
63
|
+
})) as Record<LimitKey, LimitState>,
|
|
64
|
+
/** "enforce" unless FEATURE_ENTITLEMENTS (or an override) has limits in shadow mode or off. */
|
|
65
|
+
limitsEnforced,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Current usage against the limits that have a meter: projects, cloud minutes and AI credits this period. */
|
|
70
|
+
export async function limitUsage(env: Env, workspaceId: string): Promise<LimitUsage> {
|
|
71
|
+
const [{ summary }, projects] = await Promise.all([
|
|
72
|
+
usageSummary(env, workspaceId),
|
|
73
|
+
// Paused (inactive) projects don't count toward the limit.
|
|
74
|
+
env.DB.prepare("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ? AND deleted_at IS NULL AND inactive_at IS NULL")
|
|
75
|
+
.bind(workspaceId).first<{ count: number }>(),
|
|
76
|
+
]);
|
|
77
|
+
return {
|
|
78
|
+
projects: projects?.count ?? 0,
|
|
79
|
+
cloudMinutes: summary.lines.browser_minute.used,
|
|
80
|
+
aiCredits: summary.lines.ai_credit.used,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function workspaceFeatures(env: Env, workspaceId: string) {
|
|
85
|
+
const [evaluated, used, mode] = await Promise.all([
|
|
86
|
+
effectiveFeatures(env, workspaceId),
|
|
87
|
+
limitUsage(env, workspaceId),
|
|
88
|
+
entitlementMode(env, workspaceId),
|
|
89
|
+
]);
|
|
90
|
+
return featuresBody(workspaceId, evaluated, used, mode);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function getFeatures({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
94
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
95
|
+
return response(await workspaceFeatures(env, workspaceId));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
registerRoutes(async (context) => {
|
|
99
|
+
const params = matchPath(context.url.pathname, "/api/workspaces/:workspaceId/features");
|
|
100
|
+
if (params && context.request.method === "GET") return getFeatures(context, params.workspaceId);
|
|
101
|
+
return null;
|
|
102
|
+
});
|
|
@@ -5,7 +5,9 @@ import "./billing/routes.js";
|
|
|
5
5
|
import "./jobs/retention.js";
|
|
6
6
|
import "./ai/routes.js";
|
|
7
7
|
import "./runner/routes.js";
|
|
8
|
+
import "./proposals/routes.js";
|
|
8
9
|
import "./signup/routes.js";
|
|
10
|
+
import "./signup/attribution.js";
|
|
9
11
|
import "./billing/checkout-routes.js";
|
|
10
12
|
import "./notifications/outbox.js";
|
|
11
13
|
import "./notifications/alerts.js";
|
|
@@ -13,8 +15,17 @@ import "./notifications/routes.js";
|
|
|
13
15
|
import "./github/checks.js";
|
|
14
16
|
import "./gitlab/checks.js";
|
|
15
17
|
import "./personas/routes.js";
|
|
18
|
+
import "./roles/routes.js";
|
|
19
|
+
import "./secrets/routes.js";
|
|
20
|
+
import "./identity/oidc.js";
|
|
16
21
|
import "./feedback/routes.js";
|
|
22
|
+
import "./feedback/website.js";
|
|
17
23
|
import "./devices/routes.js";
|
|
18
24
|
import "./domains/routes.js";
|
|
19
25
|
import "./sso/routes.js";
|
|
20
26
|
import "./mcp/routes.js";
|
|
27
|
+
import "./jira/routes.js";
|
|
28
|
+
import "./feature-flags/routes.js";
|
|
29
|
+
import "./feature-flags/admin.js";
|
|
30
|
+
import "./projects/identity.js";
|
|
31
|
+
import "./projects/inactivity.js";
|
|
@@ -228,3 +228,50 @@ test("a missing GitHub setup keeps the report and stops retrying", async () => {
|
|
|
228
228
|
assert.equal((db.db.prepare("SELECT status FROM outbox").get() as any).status, "dead");
|
|
229
229
|
assert.equal((db.db.prepare("SELECT status FROM feedback_reports WHERE id = ?").get(sent.json.id) as any).status, "new");
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
// ── The public website's "Questions?" panel ──
|
|
233
|
+
|
|
234
|
+
const SITE = { origin: "https://bugmole.com", "cf-connecting-ip": "203.0.113.9" };
|
|
235
|
+
|
|
236
|
+
test("website feedback is stored for triage, never filed on GitHub", async () => {
|
|
237
|
+
const gh = fakeGithub();
|
|
238
|
+
const { env, db } = feedbackEnv(gh);
|
|
239
|
+
const preflight = await call(env, "OPTIONS", "/api/public/feedback", { headers: { origin: "https://bugmole.com" } });
|
|
240
|
+
assert.equal(preflight.status, 204);
|
|
241
|
+
assert.equal(preflight.headers.get("access-control-allow-origin"), "https://bugmole.com");
|
|
242
|
+
const sent = await call(env, "POST", "/api/public/feedback", {
|
|
243
|
+
headers: SITE,
|
|
244
|
+
body: { topic: "question", message: "Does Bugmole test Flutter apps?", email: "ann@example.com", page: "https://bugmole.com/pricing/?token=abc" },
|
|
245
|
+
});
|
|
246
|
+
assert.equal(sent.status, 201);
|
|
247
|
+
assert.equal(sent.headers.get("access-control-allow-origin"), "https://bugmole.com");
|
|
248
|
+
const row = db.db.prepare("SELECT aegis_id, kind, category, contact, page, ip, github_issue_number FROM feedback_reports WHERE id = ?").get(sent.json.id) as any;
|
|
249
|
+
assert.deepEqual({ ...row }, { aegis_id: "website", kind: "feedback", category: "website:question", contact: "ann@example.com", page: redactUrl("https://bugmole.com/pricing/?token=abc"), ip: "203.0.113.9", github_issue_number: null });
|
|
250
|
+
await drainOutbox(env);
|
|
251
|
+
assert.equal(gh.state.issues.length, 0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("website feedback only from bugmole.com, and not from bots", async () => {
|
|
255
|
+
const { env, db } = feedbackEnv(fakeGithub());
|
|
256
|
+
const other = await call(env, "POST", "/api/public/feedback", { headers: { origin: "https://evil.example" }, body: { topic: "bug", message: "hello there" } });
|
|
257
|
+
assert.equal(other.status, 403);
|
|
258
|
+
const noOrigin = await call(env, "POST", "/api/public/feedback", { body: { topic: "bug", message: "hello there" } });
|
|
259
|
+
assert.equal(noOrigin.status, 403);
|
|
260
|
+
const bot = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "bug", message: "cheap pills", website: "http://spam" } });
|
|
261
|
+
assert.equal(bot.status, 201);
|
|
262
|
+
const bad = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "bug", message: "x" } });
|
|
263
|
+
assert.equal(bad.status, 422);
|
|
264
|
+
assert.equal((db.db.prepare("SELECT count(*) AS n FROM feedback_reports").get() as any).n, 0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("website feedback is rate limited per visitor", async () => {
|
|
268
|
+
const { env } = feedbackEnv(fakeGithub());
|
|
269
|
+
for (let i = 0; i < 5; i++) {
|
|
270
|
+
const ok = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "idea", message: `idea number ${i}` } });
|
|
271
|
+
assert.equal(ok.status, 201);
|
|
272
|
+
}
|
|
273
|
+
const limited = await call(env, "POST", "/api/public/feedback", { headers: SITE, body: { topic: "idea", message: "one more idea" } });
|
|
274
|
+
assert.equal(limited.status, 429);
|
|
275
|
+
const someoneElse = await call(env, "POST", "/api/public/feedback", { headers: { ...SITE, "cf-connecting-ip": "198.51.100.4" }, body: { topic: "idea", message: "a different person" } });
|
|
276
|
+
assert.equal(someoneElse.status, 201);
|
|
277
|
+
});
|
|
@@ -6,7 +6,7 @@ import { githubAppConfigured, githubRequest, GithubAppError, type GithubAppEnv }
|
|
|
6
6
|
import type { Env, Principal } from "../core.js";
|
|
7
7
|
import { constantTimeEqual, newId, requestJson, response, sha256 } from "../core.js";
|
|
8
8
|
import { getWorkspacePlan } from "../billing/entitlements.js";
|
|
9
|
-
import {
|
|
9
|
+
import { featureEnabled } from "../feature-access.js";
|
|
10
10
|
import { recordAudit } from "../org/audit.js";
|
|
11
11
|
import { drainSoon, enqueue, PermanentDeliveryError, registerOutboxHandler } from "../notifications/outbox.js";
|
|
12
12
|
import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
|
|
@@ -174,7 +174,7 @@ async function submit({ env, principal, request, ctx }: AuthedRouteContext): Pro
|
|
|
174
174
|
await recordAudit(env, { type: "user", id: principal.aegisId }, { workspaceId: workspace.id, projectId: project?.id ?? null, action: "feedback.submitted", targetType: "feedback", targetId: id, data: { kind } }, request);
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
if (await
|
|
177
|
+
if (await featureEnabled(env, workspace?.id ?? null, "feedback")) {
|
|
178
178
|
const since = new Date(Date.now() - DUPLICATE_WINDOW_MS).toISOString().replace("T", " ").slice(0, 19);
|
|
179
179
|
const original = await env.DB.prepare(`
|
|
180
180
|
SELECT id, github_issue_number AS issue FROM feedback_reports
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Questions and feedback from the public website (bugmole.com's "Questions?"
|
|
2
|
+
// panel), from people who aren't signed in. They land in the same triage list
|
|
3
|
+
// as dashboard reports (aegis_id "website"), but are never filed as GitHub
|
|
4
|
+
// issues automatically: anyone on the internet can send one.
|
|
5
|
+
import type { Env } from "../core.js";
|
|
6
|
+
import { newId, response } from "../core.js";
|
|
7
|
+
import { registerPublicRoutes } from "../routes/index.js";
|
|
8
|
+
import { fingerprintOf, redactSecrets, redactUrl } from "./format.js";
|
|
9
|
+
|
|
10
|
+
export const WEBSITE_AEGIS_ID = "website";
|
|
11
|
+
/** Per visitor (IP) and for the whole site, per hour. */
|
|
12
|
+
export const WEBSITE_IP_LIMIT_PER_HOUR = 5;
|
|
13
|
+
export const WEBSITE_LIMIT_PER_HOUR = 100;
|
|
14
|
+
const ALLOWED_ORIGINS = new Set(["https://bugmole.com"]);
|
|
15
|
+
const KINDS = { question: "feedback", idea: "feedback", bug: "bug" } as const;
|
|
16
|
+
|
|
17
|
+
function cors(origin: string | null): Record<string, string> {
|
|
18
|
+
return origin && ALLOWED_ORIGINS.has(origin)
|
|
19
|
+
? { "access-control-allow-origin": origin, "access-control-allow-methods": "POST", "access-control-allow-headers": "content-type", "access-control-max-age": "86400", vary: "Origin" }
|
|
20
|
+
: {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function withCors(res: Response, origin: string | null): Response {
|
|
24
|
+
const headers = new Headers(res.headers);
|
|
25
|
+
for (const [k, v] of Object.entries(cors(origin))) headers.set(k, v);
|
|
26
|
+
return new Response(res.body, { status: res.status, headers });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const text = (value: unknown, max: number) => (typeof value === "string" ? value.trim().slice(0, max) : "");
|
|
30
|
+
const validEmail = (s: string) => /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/.test(s);
|
|
31
|
+
|
|
32
|
+
export async function submitWebsiteFeedback(request: Request, env: Env): Promise<Response> {
|
|
33
|
+
const origin = request.headers.get("origin");
|
|
34
|
+
if (!origin || !ALLOWED_ORIGINS.has(origin)) return response({ error: "Not allowed" }, 403);
|
|
35
|
+
const body = await request.json().catch(() => null) as Record<string, unknown> | null;
|
|
36
|
+
if (!body) return withCors(response({ error: "Send JSON" }, 400), origin);
|
|
37
|
+
// A field people never see: bots fill it in. Pretend it worked.
|
|
38
|
+
if (text(body.website, 200)) return withCors(response({ ok: true }, 201), origin);
|
|
39
|
+
const topic = text(body.topic, 20) as keyof typeof KINDS;
|
|
40
|
+
if (!(topic in KINDS)) return withCors(response({ error: "topic is question, idea or bug" }, 422), origin);
|
|
41
|
+
const message = text(body.message, 2_000);
|
|
42
|
+
if (message.length < 3) return withCors(response({ error: "Write a few words first" }, 422), origin);
|
|
43
|
+
const email = text(body.email, 200);
|
|
44
|
+
if (email && !validEmail(email)) return withCors(response({ error: "That email address doesn't look right" }, 422), origin);
|
|
45
|
+
|
|
46
|
+
const ip = request.headers.get("cf-connecting-ip");
|
|
47
|
+
const hourAgo = new Date(Date.now() - 3_600_000).toISOString().replace("T", " ").slice(0, 19);
|
|
48
|
+
const recent = await env.DB.prepare(`
|
|
49
|
+
SELECT count(*) AS total, SUM(CASE WHEN ip IS NOT NULL AND ip = ? THEN 1 ELSE 0 END) AS fromIp
|
|
50
|
+
FROM feedback_reports WHERE aegis_id = ? AND created_at > ?
|
|
51
|
+
`).bind(ip, WEBSITE_AEGIS_ID, hourAgo).first<{ total: number | null; fromIp: number | null }>();
|
|
52
|
+
if ((recent?.total ?? 0) >= WEBSITE_LIMIT_PER_HOUR || (ip && (recent?.fromIp ?? 0) >= WEBSITE_IP_LIMIT_PER_HOUR)) {
|
|
53
|
+
return withCors(response({ error: "Thanks, we've had a lot of messages just now. Please try again later, or ask on Discord." }, 429), origin);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const kind = KINDS[topic];
|
|
57
|
+
const page = text(body.page, 500) ? redactUrl(text(body.page, 500)) : null;
|
|
58
|
+
const clean = redactSecrets(message);
|
|
59
|
+
const id = newId("fb");
|
|
60
|
+
await env.DB.prepare(`
|
|
61
|
+
INSERT INTO feedback_reports (id, aegis_id, kind, category, message, page, contact, data_json, fingerprint, ip)
|
|
62
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
63
|
+
`).bind(
|
|
64
|
+
id, WEBSITE_AEGIS_ID, kind, `website:${topic}`, clean, page, email || null,
|
|
65
|
+
JSON.stringify({ source: "website", userAgent: text(request.headers.get("user-agent"), 300) || undefined }),
|
|
66
|
+
await fingerprintOf(kind, clean, page, []), ip,
|
|
67
|
+
).run();
|
|
68
|
+
return withCors(response({ ok: true, id }, 201), origin);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
registerPublicRoutes(async ({ request, env, url }) => {
|
|
72
|
+
if (url.pathname !== "/api/public/feedback") return null;
|
|
73
|
+
const origin = request.headers.get("origin");
|
|
74
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors(origin) });
|
|
75
|
+
if (request.method !== "POST") return withCors(response({ error: "Not found" }, 404), origin);
|
|
76
|
+
return submitWebsiteFeedback(request, env);
|
|
77
|
+
});
|