@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
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
//
|
|
2
|
-
// for everyone ("on"),
|
|
3
|
-
//
|
|
1
|
+
// The feature catalog and its global rollout switches. A FEATURE_<NAME> var
|
|
2
|
+
// turns a feature on for everyone ("on"), leaves it to per-workspace opt-in
|
|
3
|
+
// ("beta"), or kills it everywhere ("off"; "shadow" also counts as off for
|
|
4
|
+
// access, and only changes how the entitlements feature enforces limits).
|
|
5
|
+
//
|
|
6
|
+
// Whether a feature is usable by a workspace is decided in one place,
|
|
7
|
+
// feature-access.ts: kill switch → beta opt-in → workspace override → plan.
|
|
8
|
+
// See docs/feature-flags.md.
|
|
9
|
+
import type { PlanFeatures } from "../billing/plan-catalog.js";
|
|
4
10
|
|
|
5
11
|
export const FEATURES = [
|
|
12
|
+
// Rollout flags from the gap-closing launch. Unset, they default to "beta".
|
|
6
13
|
"entitlements",
|
|
7
14
|
"cloud_runners",
|
|
8
15
|
"billing",
|
|
@@ -13,27 +20,82 @@ export const FEATURES = [
|
|
|
13
20
|
"sso",
|
|
14
21
|
"feedback",
|
|
15
22
|
"mcp",
|
|
23
|
+
// Plan features from the pricing matrix. Unset, they default to "on": the
|
|
24
|
+
// plan decides, and the var exists only as a kill switch.
|
|
25
|
+
"ai_fixes",
|
|
26
|
+
"scim",
|
|
27
|
+
"audit_export",
|
|
28
|
+
"priority_support",
|
|
29
|
+
"self_hosted_workers",
|
|
30
|
+
// Included in every plan (the pricing page's all-plans rows). Always on
|
|
31
|
+
// unless killed; no gate reads them yet beyond the evaluator.
|
|
32
|
+
"unlimited_users",
|
|
33
|
+
"unlimited_local_runs",
|
|
34
|
+
"desktop_browsers",
|
|
35
|
+
"emulated_mobile",
|
|
36
|
+
"ai_failure_analysis",
|
|
16
37
|
] as const;
|
|
17
38
|
export type Feature = (typeof FEATURES)[number];
|
|
18
39
|
|
|
19
|
-
export type
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
export type FlagMode = "on" | "beta" | "off" | "shadow";
|
|
41
|
+
|
|
42
|
+
export type FeatureDef = {
|
|
43
|
+
label: string;
|
|
44
|
+
/** Mode when FEATURE_<NAME> is unset. */
|
|
45
|
+
defaultMode: FlagMode;
|
|
46
|
+
/** The plan-catalog feature that gates it; features without one are on every plan. */
|
|
47
|
+
planFeature?: keyof PlanFeatures;
|
|
22
48
|
};
|
|
23
49
|
|
|
24
|
-
export
|
|
50
|
+
export const FEATURE_DEFS: Record<Feature, FeatureDef> = {
|
|
51
|
+
entitlements: { label: "Plan limits", defaultMode: "beta" },
|
|
52
|
+
cloud_runners: { label: "Bugmole Cloud runs", defaultMode: "beta" },
|
|
53
|
+
billing: { label: "Self-serve billing", defaultMode: "beta" },
|
|
54
|
+
pr_checks: { label: "Pull and merge request checks", defaultMode: "beta", planFeature: "prChecks" },
|
|
55
|
+
slack: { label: "Slack alerts", defaultMode: "beta", planFeature: "slack" },
|
|
56
|
+
teams: { label: "Microsoft Teams alerts", defaultMode: "beta", planFeature: "teams" },
|
|
57
|
+
device_clouds: { label: "Real devices and device clouds", defaultMode: "beta", planFeature: "deviceClouds" },
|
|
58
|
+
sso: { label: "Single sign-on", defaultMode: "beta", planFeature: "sso" },
|
|
59
|
+
feedback: { label: "Feedback to GitHub issues", defaultMode: "beta" },
|
|
60
|
+
mcp: { label: "Hosted MCP", defaultMode: "beta" },
|
|
61
|
+
ai_fixes: { label: "AI test repair", defaultMode: "on", planFeature: "aiFixes" },
|
|
62
|
+
scim: { label: "SCIM provisioning", defaultMode: "on", planFeature: "scim" },
|
|
63
|
+
audit_export: { label: "Audit log export and full history", defaultMode: "on", planFeature: "auditLogExport" },
|
|
64
|
+
priority_support: { label: "Priority support", defaultMode: "on", planFeature: "prioritySupport" },
|
|
65
|
+
self_hosted_workers: { label: "Self-hosted cloud workers", defaultMode: "on", planFeature: "selfHostedWorkers" },
|
|
66
|
+
unlimited_users: { label: "Unlimited users", defaultMode: "on", planFeature: "unlimitedUsers" },
|
|
67
|
+
unlimited_local_runs: { label: "Unlimited local & CI runs", defaultMode: "on", planFeature: "unlimitedLocalRuns" },
|
|
68
|
+
desktop_browsers: { label: "Chrome, Firefox, Safari & Edge", defaultMode: "on", planFeature: "desktopBrowsers" },
|
|
69
|
+
emulated_mobile: { label: "Emulated iPhone & Android", defaultMode: "on", planFeature: "emulatedMobile" },
|
|
70
|
+
ai_failure_analysis: { label: "AI failure analysis", defaultMode: "on", planFeature: "aiFailureAnalysis" },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Operational flags: rollout and platform switches with no pricing-table row.
|
|
75
|
+
* The catalog's `extras.*` (Enterprise contract terms) are descriptive and
|
|
76
|
+
* never enforced, so they have no flags.
|
|
77
|
+
* Every other feature maps 1:1 to a plan-catalog `features.*` key through
|
|
78
|
+
* FEATURE_DEFS[feature].planFeature — this table is the one mapping between
|
|
79
|
+
* flag names and catalog keys (flags.test.ts fails if either side drifts).
|
|
80
|
+
*/
|
|
81
|
+
export const OPERATIONAL_FEATURES = ["entitlements", "cloud_runners", "billing", "feedback", "mcp"] as const satisfies readonly Feature[];
|
|
25
82
|
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
return raw === "on" || raw === "off" || raw === "shadow" ? raw : "beta";
|
|
83
|
+
export function isFeature(value: unknown): value is Feature {
|
|
84
|
+
return typeof value === "string" && (FEATURES as readonly string[]).includes(value);
|
|
29
85
|
}
|
|
30
86
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
87
|
+
/** The feature a plan-catalog key belongs to (e.g. "auditLogExport" → "audit_export"). */
|
|
88
|
+
export function featureForPlanKey(key: string): Feature | null {
|
|
89
|
+
return FEATURES.find((feature) => FEATURE_DEFS[feature].planFeature === key) ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type FlagEnv = {
|
|
93
|
+
DB: D1Database;
|
|
94
|
+
[key: `FEATURE_${string}`]: string | undefined;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export function flagMode(env: Omit<FlagEnv, "DB">, feature: Feature): FlagMode {
|
|
98
|
+
const raw = env[`FEATURE_${feature.toUpperCase()}`]?.trim().toLowerCase();
|
|
99
|
+
if (raw === "on" || raw === "off" || raw === "shadow" || raw === "beta") return raw;
|
|
100
|
+
return FEATURE_DEFS[feature].defaultMode;
|
|
39
101
|
}
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
import { createCheckRun, githubAppConfigured, GithubAppError, updateCheckRun, type CheckRunUpdate, type GithubAppEnv } from "../../integrations/github-app.js";
|
|
8
8
|
import type { Env, Principal } from "../core.js";
|
|
9
9
|
import { memberOfProject, newId, requestJson, response } from "../core.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { workspaceOfProject } from "../billing/entitlements.js";
|
|
11
|
+
import { featureEnabled } from "../feature-access.js";
|
|
12
12
|
import { runChangedHooks, runService, type InternalRunFields } from "../hooks.js";
|
|
13
13
|
import { auditProject } from "../org/audit.js";
|
|
14
14
|
import { enqueue, PermanentDeliveryError, registerOutboxHandler } from "../notifications/outbox.js";
|
|
@@ -52,8 +52,8 @@ export { previewUrl };
|
|
|
52
52
|
/** GitLab's merge request checks share this gate — one PR/MR checks feature regardless of provider. */
|
|
53
53
|
export async function prChecksOn(env: Env, projectId: string): Promise<boolean> {
|
|
54
54
|
const workspaceId = await workspaceOfProject(env, projectId);
|
|
55
|
-
if (!workspaceId
|
|
56
|
-
return (
|
|
55
|
+
if (!workspaceId) return false;
|
|
56
|
+
return featureEnabled(env, workspaceId, "pr_checks");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** The binding's installation must still be connected to its project and cover the repo. */
|
|
@@ -45,7 +45,16 @@ export type InternalRunFields = {
|
|
|
45
45
|
prNumber?: number;
|
|
46
46
|
githubInstallationId?: number;
|
|
47
47
|
gitlabBindingId?: string;
|
|
48
|
+
/** A retest limited to some cases of one case set (a Jira ticket's case). */
|
|
49
|
+
caseScope?: RunCaseScope;
|
|
48
50
|
};
|
|
51
|
+
|
|
52
|
+
/** Which cases a run judges; absent for a run of the whole flow. */
|
|
53
|
+
export type RunCaseScope = { caseSetId: string; caseIds: string[]; jiraIssueKey?: string };
|
|
54
|
+
|
|
55
|
+
/** A test case proposal was accepted, rejected, or applied to storage. */
|
|
56
|
+
export type ProposalDecided = { projectId: string; proposalId: string; status: string; applied: boolean };
|
|
57
|
+
export const proposalDecidedHooks: Array<(env: Env, event: ProposalDecided) => Promise<void>> = [];
|
|
49
58
|
export type RunCreator = (request: Request, env: Env, principal: import("./core.js").Principal, projectId: string, ctx: ExecutionContext, internal?: InternalRunFields) => Promise<Response>;
|
|
50
59
|
/** Set by index.ts, so integrations can create runs through the same checks as the API. */
|
|
51
60
|
export const runService: { create?: RunCreator } = {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createPublicKey, createVerify, generateKeyPairSync } from "node:crypto";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { call, createTestEnv, seedPlan, seedProject, seedVerifiedDomain } from "../testing/harness.js";
|
|
5
|
+
import { runnerLauncher, type LaunchRequest } from "../runner/dispatch.js";
|
|
6
|
+
import { runnerMayCall } from "../runner/tokens.js";
|
|
7
|
+
import { validAudience } from "./oidc.js";
|
|
8
|
+
|
|
9
|
+
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
10
|
+
const PEM = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
11
|
+
const BASE = { RUNNER_TOKEN_SECRET: "runner-test-secret", FEATURE_CLOUD_RUNNERS: "on", REGISTRY_PUBLIC_URL: "https://registry.test" };
|
|
12
|
+
const PROVIDER = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/bugmole/providers/bugmole";
|
|
13
|
+
|
|
14
|
+
async function exploringProject(overrides: Record<string, string>) {
|
|
15
|
+
const launches: LaunchRequest[] = [];
|
|
16
|
+
const original = runnerLauncher.start;
|
|
17
|
+
runnerLauncher.start = async (_env, launch) => { launches.push(launch); };
|
|
18
|
+
const setup = createTestEnv({ ...BASE, ...overrides });
|
|
19
|
+
const project = await seedProject(setup.env, { storageProvider: "bugmole" });
|
|
20
|
+
await seedPlan(setup.env, project.projectId, project.owner);
|
|
21
|
+
seedVerifiedDomain(setup.db, project.workspaceId, "shop.test");
|
|
22
|
+
const created = await call(setup.env, "POST", `/api/projects/${project.projectId}/tasks`, {
|
|
23
|
+
as: project.owner,
|
|
24
|
+
body: { title: "Explore https://shop.test", description: "Explore https://shop.test", taskType: "discovery", idempotencyKey: "d1", executionMode: "cloud" },
|
|
25
|
+
});
|
|
26
|
+
assert.equal(created.status, 201);
|
|
27
|
+
return { ...setup, ...project, taskId: created.json.task.id as string, token: launches[0].token, restore: () => { runnerLauncher.start = original; } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const decode = (part: string) => JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
31
|
+
|
|
32
|
+
test("Bugmole publishes OIDC discovery and its signing key, and nothing when no key is set", async () => {
|
|
33
|
+
const { env } = createTestEnv({ ...BASE, OIDC_SIGNING_KEY: PEM });
|
|
34
|
+
const discovery = await call(env, "GET", "/.well-known/openid-configuration");
|
|
35
|
+
assert.equal(discovery.status, 200);
|
|
36
|
+
assert.equal(discovery.json.issuer, "https://api.bugmole.com");
|
|
37
|
+
assert.equal(discovery.json.jwks_uri, "https://api.bugmole.com/.well-known/jwks.json");
|
|
38
|
+
assert.deepEqual(discovery.json.id_token_signing_alg_values_supported, ["RS256"]);
|
|
39
|
+
const jwks = await call(env, "GET", "/.well-known/jwks.json");
|
|
40
|
+
assert.equal(jwks.json.keys.length, 1);
|
|
41
|
+
const [key] = jwks.json.keys;
|
|
42
|
+
assert.deepEqual(Object.keys(key).sort(), ["alg", "e", "kid", "kty", "n", "use"], "the public half only");
|
|
43
|
+
assert.equal(key.alg, "RS256");
|
|
44
|
+
|
|
45
|
+
const bare = createTestEnv(BASE).env;
|
|
46
|
+
assert.notEqual((await call(bare, "GET", "/.well-known/jwks.json")).status, 200);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a cloud runner gets a token its project's cloud can verify, for its own job only", async () => {
|
|
50
|
+
const { env, projectId, workspaceId, taskId, token, restore } = await exploringProject({ OIDC_SIGNING_KEY: PEM });
|
|
51
|
+
try {
|
|
52
|
+
const issued = await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=${encodeURIComponent(PROVIDER)}`, { token });
|
|
53
|
+
assert.equal(issued.status, 200, JSON.stringify(issued.json));
|
|
54
|
+
const [header, payload, signature] = issued.json.token.split(".");
|
|
55
|
+
const claims = decode(payload);
|
|
56
|
+
assert.equal(claims.iss, "https://api.bugmole.com");
|
|
57
|
+
assert.equal(claims.sub, `project:${projectId}`);
|
|
58
|
+
assert.equal(claims.aud, PROVIDER);
|
|
59
|
+
assert.equal(claims.project_id, projectId);
|
|
60
|
+
assert.equal(claims.workspace_id, workspaceId);
|
|
61
|
+
assert.equal(claims.job_kind, "task");
|
|
62
|
+
assert.equal(claims.job_id, taskId);
|
|
63
|
+
assert.equal(claims.exp - claims.iat, 600, "ten minutes");
|
|
64
|
+
|
|
65
|
+
// Verified the way Google and AWS do: with the published JWKS, by kid.
|
|
66
|
+
const jwks = (await call(env, "GET", "/.well-known/jwks.json")).json.keys;
|
|
67
|
+
const jwk = jwks.find((key: { kid: string }) => key.kid === decode(header).kid);
|
|
68
|
+
assert.ok(jwk, "the token's kid is published");
|
|
69
|
+
const verifier = createVerify("RSA-SHA256").update(`${header}.${payload}`);
|
|
70
|
+
assert.equal(verifier.verify(createPublicKey({ key: jwk, format: "jwk" }), Buffer.from(signature, "base64url")), true);
|
|
71
|
+
|
|
72
|
+
assert.equal((await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=sts.amazonaws.com`, { token })).status, 200);
|
|
73
|
+
assert.equal((await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=ftp://nope`, { token })).status, 422);
|
|
74
|
+
assert.equal((await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token`, { token })).status, 422);
|
|
75
|
+
assert.equal((await call(env, "GET", `/api/runner/tasks/other/identity-token?audience=sts.amazonaws.com`, { token })).status, 403);
|
|
76
|
+
} finally {
|
|
77
|
+
restore();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("no signing key: the token route says so instead of failing", async () => {
|
|
82
|
+
const { env, taskId, token, restore } = await exploringProject({});
|
|
83
|
+
try {
|
|
84
|
+
const reply = await call(env, "GET", `/api/runner/tasks/${taskId}/identity-token?audience=sts.amazonaws.com`, { token });
|
|
85
|
+
assert.equal(reply.status, 503);
|
|
86
|
+
assert.equal(reply.json.code, "oidc_unconfigured");
|
|
87
|
+
} finally {
|
|
88
|
+
restore();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("only the runner's own job may ask, and only the audiences a cloud uses", () => {
|
|
93
|
+
const run = { kind: "run" as const, id: "run_1", projectId: "shop", workspaceId: "acme" };
|
|
94
|
+
assert.equal(runnerMayCall(run, "GET", new URL("https://r/api/runner/runs/run_1/identity-token?audience=x")), true);
|
|
95
|
+
assert.equal(runnerMayCall(run, "GET", new URL("https://r/api/runner/runs/run_2/identity-token?audience=x")), false);
|
|
96
|
+
assert.equal(runnerMayCall(run, "POST", new URL("https://r/api/runner/runs/run_1/identity-token")), false);
|
|
97
|
+
assert.equal(validAudience(PROVIDER), true);
|
|
98
|
+
assert.equal(validAudience("sts.amazonaws.com"), true);
|
|
99
|
+
assert.equal(validAudience("https://vault.example.com"), true);
|
|
100
|
+
assert.equal(validAudience("//iam.googleapis.com/projects/abc/locations/global/workloadIdentityPools/x/providers/y"), false);
|
|
101
|
+
assert.equal(validAudience("http://insecure.example.com"), false);
|
|
102
|
+
assert.equal(validAudience(""), false);
|
|
103
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Bugmole Cloud as an OpenID Connect identity provider, so a cloud runner can
|
|
2
|
+
// read a customer's own vault without anyone storing a key for it.
|
|
3
|
+
//
|
|
4
|
+
// The registry signs a short-lived token (RS256, ten minutes) for the one run
|
|
5
|
+
// or exploration a runner holds: subject `project:<projectId>`, with the
|
|
6
|
+
// audience the customer's cloud expects. The customer trusts this issuer once
|
|
7
|
+
// (a Google Workload Identity pool, or an AWS IAM OIDC provider and role) and
|
|
8
|
+
// the runner trades the token for credentials scoped to their secrets:
|
|
9
|
+
//
|
|
10
|
+
// GET /.well-known/openid-configuration discovery (public)
|
|
11
|
+
// GET /.well-known/jwks.json the signing key's public half (public)
|
|
12
|
+
// GET /api/runner/{runs|tasks}/:id/identity-token?audience=… the runner's token
|
|
13
|
+
//
|
|
14
|
+
// The private key is OIDC_SIGNING_KEY (an RSA PKCS#8 PEM, a Worker secret).
|
|
15
|
+
// The key id is its RFC 7638 thumbprint, so rotating the key needs nothing
|
|
16
|
+
// else: clouds fetch the JWKS again when they meet an unknown kid.
|
|
17
|
+
import type { Env } from "../core.js";
|
|
18
|
+
import { response } from "../core.js";
|
|
19
|
+
import { auditProject } from "../org/audit.js";
|
|
20
|
+
import { matchPath, registerPublicRoutes, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
|
|
21
|
+
import { cloudWorkerId } from "../runner/jobs.js";
|
|
22
|
+
|
|
23
|
+
export const IDENTITY_TOKEN_TTL_SECONDS = 600;
|
|
24
|
+
const DEFAULT_ISSUER = "https://api.bugmole.com";
|
|
25
|
+
|
|
26
|
+
type OidcEnv = Env;
|
|
27
|
+
|
|
28
|
+
export function oidcIssuer(env: OidcEnv): string {
|
|
29
|
+
return (env.OIDC_ISSUER || DEFAULT_ISSUER).replace(/\/+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
|
|
34
|
+
function base64Url(bytes: Uint8Array | ArrayBuffer): string {
|
|
35
|
+
let binary = "";
|
|
36
|
+
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte);
|
|
37
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pemBody(pem: string): Uint8Array {
|
|
41
|
+
const body = pem.replace(/-----(BEGIN|END) [A-Z ]+-----/g, "").replace(/\s+/g, "");
|
|
42
|
+
return Uint8Array.from(atob(body), (char) => char.charCodeAt(0));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type SigningKey = { privateKey: CryptoKey; publicJwk: JsonWebKey & { kid: string; use: string; alg: string } };
|
|
46
|
+
|
|
47
|
+
let cached: { pem: string; key: Promise<SigningKey> } | null = null;
|
|
48
|
+
|
|
49
|
+
/** The signing key and its public JWK, from OIDC_SIGNING_KEY; null when it isn't configured. */
|
|
50
|
+
export function signingKey(env: OidcEnv): Promise<SigningKey> | null {
|
|
51
|
+
const pem = env.OIDC_SIGNING_KEY;
|
|
52
|
+
if (!pem) return null;
|
|
53
|
+
if (cached?.pem === pem) return cached.key;
|
|
54
|
+
const key = (async () => {
|
|
55
|
+
const algorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
|
|
56
|
+
// Imported once extractable, only to publish its public half.
|
|
57
|
+
const extractable = await crypto.subtle.importKey("pkcs8", pemBody(pem), algorithm, true, ["sign"]);
|
|
58
|
+
const jwk = await crypto.subtle.exportKey("jwk", extractable) as JsonWebKey;
|
|
59
|
+
const { n, e } = jwk;
|
|
60
|
+
if (!n || !e) throw new Error("OIDC_SIGNING_KEY isn't an RSA key");
|
|
61
|
+
// RFC 7638 thumbprint: SHA-256 of the required members in lexical order.
|
|
62
|
+
const thumbprint = await crypto.subtle.digest("SHA-256", encoder.encode(JSON.stringify({ e, kty: "RSA", n })));
|
|
63
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pemBody(pem), algorithm, false, ["sign"]);
|
|
64
|
+
return { privateKey, publicJwk: { kty: "RSA", n, e, kid: base64Url(thumbprint), use: "sig", alg: "RS256" } };
|
|
65
|
+
})();
|
|
66
|
+
cached = { pem, key };
|
|
67
|
+
return key;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type IdentityClaims = {
|
|
71
|
+
sub: string;
|
|
72
|
+
aud: string;
|
|
73
|
+
project_id: string;
|
|
74
|
+
workspace_id: string | null;
|
|
75
|
+
job_kind: "run" | "task";
|
|
76
|
+
job_id: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Signs an identity token for a runner's job. */
|
|
80
|
+
export async function signIdentityToken(env: OidcEnv, claims: IdentityClaims, now = Date.now()): Promise<{ token: string; expiresAt: string }> {
|
|
81
|
+
const key = signingKey(env);
|
|
82
|
+
if (!key) throw new Error("OIDC_SIGNING_KEY is not configured");
|
|
83
|
+
const { privateKey, publicJwk } = await key;
|
|
84
|
+
const iat = Math.floor(now / 1000);
|
|
85
|
+
const header = { alg: "RS256", typ: "JWT", kid: publicJwk.kid };
|
|
86
|
+
const payload = { iss: oidcIssuer(env), iat, nbf: iat - 30, exp: iat + IDENTITY_TOKEN_TTL_SECONDS, jti: crypto.randomUUID(), ...claims };
|
|
87
|
+
const unsigned = `${base64Url(encoder.encode(JSON.stringify(header)))}.${base64Url(encoder.encode(JSON.stringify(payload)))}`;
|
|
88
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, encoder.encode(unsigned));
|
|
89
|
+
return { token: `${unsigned}.${base64Url(signature)}`, expiresAt: new Date((iat + IDENTITY_TOKEN_TTL_SECONDS) * 1000).toISOString() };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Audiences a cloud accepts: Google's Workload Identity provider
|
|
94
|
+
* (//iam.googleapis.com/projects/…/providers/…), AWS STS
|
|
95
|
+
* (sts.amazonaws.com), or an https URL of the customer's choosing.
|
|
96
|
+
*/
|
|
97
|
+
export function validAudience(audience: string): boolean {
|
|
98
|
+
if (!audience || audience.length > 512 || /\s/.test(audience)) return false;
|
|
99
|
+
if (audience === "sts.amazonaws.com") return true;
|
|
100
|
+
if (/^\/\/iam\.googleapis\.com\/projects\/\d+\/locations\/global\/workloadIdentityPools\/[a-z0-9-]+\/providers\/[a-z0-9-]+$/.test(audience)) return true;
|
|
101
|
+
try {
|
|
102
|
+
return new URL(audience).protocol === "https:";
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const json = (body: unknown, cacheSeconds: number) =>
|
|
109
|
+
new Response(JSON.stringify(body), {
|
|
110
|
+
headers: { "content-type": "application/json", "cache-control": `public, max-age=${cacheSeconds}`, "access-control-allow-origin": "*" },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
registerPublicRoutes(async ({ request, env, url }) => {
|
|
114
|
+
if (request.method !== "GET") return null;
|
|
115
|
+
const issuer = oidcIssuer(env as OidcEnv);
|
|
116
|
+
if (url.pathname === "/.well-known/openid-configuration") {
|
|
117
|
+
if (!signingKey(env as OidcEnv)) return null;
|
|
118
|
+
return json({
|
|
119
|
+
issuer,
|
|
120
|
+
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
|
121
|
+
response_types_supported: ["id_token"],
|
|
122
|
+
subject_types_supported: ["public"],
|
|
123
|
+
id_token_signing_alg_values_supported: ["RS256"],
|
|
124
|
+
claims_supported: ["iss", "sub", "aud", "iat", "nbf", "exp", "jti", "project_id", "workspace_id", "job_kind", "job_id"],
|
|
125
|
+
}, 3600);
|
|
126
|
+
}
|
|
127
|
+
if (url.pathname === "/.well-known/jwks.json") {
|
|
128
|
+
const key = signingKey(env as OidcEnv);
|
|
129
|
+
if (!key) return null;
|
|
130
|
+
return json({ keys: [(await key).publicJwk] }, 300);
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** A token for the one job a cloud runner holds, while it holds it. */
|
|
136
|
+
async function issueToRunner({ env, principal, request, url }: AuthedRouteContext, kind: "run" | "task", jobId: string): Promise<Response> {
|
|
137
|
+
const runner = principal.runner;
|
|
138
|
+
if (!runner || runner.kind !== kind || runner.id !== jobId) return response({ error: "Not found" }, 404);
|
|
139
|
+
const audience = url.searchParams.get("audience") ?? "";
|
|
140
|
+
if (!validAudience(audience)) {
|
|
141
|
+
return response({ error: "audience must be sts.amazonaws.com, a Google Workload Identity provider (//iam.googleapis.com/projects/…/providers/…), or an https URL" }, 422);
|
|
142
|
+
}
|
|
143
|
+
const table = kind === "run" ? "runs" : "agent_tasks";
|
|
144
|
+
const job = await env.DB.prepare(`SELECT project_id AS projectId, status, worker_id AS workerId FROM ${table} WHERE id = ?`)
|
|
145
|
+
.bind(jobId).first<{ projectId: string; status: string; workerId: string | null }>();
|
|
146
|
+
if (!job || job.projectId !== runner.projectId) return response({ error: "Not found" }, 404);
|
|
147
|
+
if (job.status !== "running" || job.workerId !== cloudWorkerId({ kind, id: jobId })) {
|
|
148
|
+
return response({ error: `This ${kind} isn't held by a cloud runner`, status: job.status }, 409);
|
|
149
|
+
}
|
|
150
|
+
if (!signingKey(env as OidcEnv)) return response({ error: "Identity tokens aren't configured on this registry", code: "oidc_unconfigured" }, 503);
|
|
151
|
+
const issued = await signIdentityToken(env as OidcEnv, {
|
|
152
|
+
sub: `project:${runner.projectId}`,
|
|
153
|
+
aud: audience,
|
|
154
|
+
project_id: runner.projectId,
|
|
155
|
+
workspace_id: runner.workspaceId,
|
|
156
|
+
job_kind: kind,
|
|
157
|
+
job_id: jobId,
|
|
158
|
+
});
|
|
159
|
+
await auditProject(env, principal, runner.projectId, {
|
|
160
|
+
action: "identity_token.issued",
|
|
161
|
+
targetType: kind,
|
|
162
|
+
targetId: jobId,
|
|
163
|
+
data: { audience },
|
|
164
|
+
}, request);
|
|
165
|
+
return response(issued);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
registerRoutes(async (context) => {
|
|
169
|
+
const { request, url } = context;
|
|
170
|
+
if (request.method !== "GET") return null;
|
|
171
|
+
let params = matchPath(url.pathname, "/api/runner/runs/:runId/identity-token");
|
|
172
|
+
if (params) return issueToRunner(context, "run", params.runId);
|
|
173
|
+
params = matchPath(url.pathname, "/api/runner/tasks/:taskId/identity-token");
|
|
174
|
+
if (params) return issueToRunner(context, "task", params.taskId);
|
|
175
|
+
return null;
|
|
176
|
+
});
|