@bugmole/cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/package.json +5 -2
  2. package/scripts/bugmole-admin.mjs +194 -0
  3. package/scripts/bugmole-admin.test.mjs +62 -0
  4. package/scripts/bugmole.test.ts +94 -2
  5. package/scripts/bugmole.ts +233 -46
  6. package/scripts/sync-byok.d.mts +2 -0
  7. package/scripts/sync-byok.mjs +18 -0
  8. package/scripts/sync-plan-catalog.d.mts +3 -0
  9. package/scripts/sync-plan-catalog.mjs +16 -6
  10. package/spec/domain_rules.yaml +139 -0
  11. package/spec/roles.yaml +20 -0
  12. package/spec/test-case-results.schema.json +20 -4
  13. package/spec/test-cases.schema.json +131 -16
  14. package/src/billing/plan-catalog.test.ts +82 -2
  15. package/src/billing/plan-catalog.ts +212 -9
  16. package/src/integrations/jira.ts +264 -0
  17. package/src/mcp/roles-and-review.test.ts +111 -0
  18. package/src/mcp/server.ts +358 -22
  19. package/src/mcp/write-test-cases.test.ts +68 -0
  20. package/src/registry/control-plane-client.ts +121 -6
  21. package/src/registry/migrations/0036_task_approval.sql +11 -0
  22. package/src/registry/migrations/0037_spec_proposals.sql +27 -0
  23. package/src/registry/migrations/0038_local_worker_seen.sql +5 -0
  24. package/src/registry/migrations/0039_project_secrets.sql +37 -0
  25. package/src/registry/migrations/0040_roles_and_task_review.sql +31 -0
  26. package/src/registry/migrations/0041_jira_integration.sql +75 -0
  27. package/src/registry/migrations/0042_subscription_gaps.sql +10 -0
  28. package/src/registry/migrations/0043_workspace_feature_overrides.sql +17 -0
  29. package/src/registry/migrations/0044_project_identity.sql +31 -0
  30. package/src/registry/project-identity.test.ts +99 -0
  31. package/src/registry/project-identity.ts +206 -0
  32. package/src/registry/roles.test.ts +66 -0
  33. package/src/registry/roles.ts +201 -0
  34. package/src/registry/task-scheduling.test.ts +94 -0
  35. package/src/registry/task-scheduling.ts +199 -2
  36. package/src/registry/test-case-revisions.test.ts +57 -0
  37. package/src/registry/test-case-revisions.ts +132 -0
  38. package/src/registry-worker/ai/routes.ts +3 -3
  39. package/src/registry-worker/artifacts.ts +23 -4
  40. package/src/registry-worker/billing/billing-core.test.ts +1 -1
  41. package/src/registry-worker/billing/checkout-routes.ts +53 -4
  42. package/src/registry-worker/billing/enforcement.ts +67 -12
  43. package/src/registry-worker/billing/paypal/api.ts +14 -0
  44. package/src/registry-worker/billing/paypal/client.ts +9 -0
  45. package/src/registry-worker/billing/paypal/provider.ts +10 -1
  46. package/src/registry-worker/billing/paypal.test.ts +108 -1
  47. package/src/registry-worker/billing/plan-gaps.test.ts +216 -0
  48. package/src/registry-worker/billing/provider.ts +8 -0
  49. package/src/registry-worker/billing/routes.ts +2 -1
  50. package/src/registry-worker/billing/subscriptions.ts +114 -4
  51. package/src/registry-worker/core.ts +22 -0
  52. package/src/registry-worker/devices/policy.ts +4 -5
  53. package/src/registry-worker/devices/routes.ts +8 -8
  54. package/src/registry-worker/feature-access.ts +165 -0
  55. package/src/registry-worker/feature-flags/admin.ts +300 -0
  56. package/src/registry-worker/feature-flags/feature-flags.test.ts +270 -0
  57. package/src/registry-worker/feature-flags/routes.ts +102 -0
  58. package/src/registry-worker/features.ts +9 -0
  59. package/src/registry-worker/feedback/routes.ts +2 -2
  60. package/src/registry-worker/flags.ts +80 -18
  61. package/src/registry-worker/github/checks.ts +4 -4
  62. package/src/registry-worker/hooks.ts +9 -0
  63. package/src/registry-worker/identity/oidc.test.ts +103 -0
  64. package/src/registry-worker/identity/oidc.ts +176 -0
  65. package/src/registry-worker/index.ts +477 -174
  66. package/src/registry-worker/jira/connection.ts +111 -0
  67. package/src/registry-worker/jira/jira.test.ts +404 -0
  68. package/src/registry-worker/jira/routes.ts +432 -0
  69. package/src/registry-worker/jira/workflow.ts +577 -0
  70. package/src/registry-worker/jobs/retention.ts +21 -5
  71. package/src/registry-worker/mcp/tools.ts +2 -1
  72. package/src/registry-worker/notifications/alerts.ts +4 -4
  73. package/src/registry-worker/notifications/notifications.test.ts +11 -0
  74. package/src/registry-worker/notifications/routes.ts +9 -10
  75. package/src/registry-worker/notifications/teams.ts +6 -2
  76. package/src/registry-worker/org/routes.ts +4 -4
  77. package/src/registry-worker/projects/identity.ts +194 -0
  78. package/src/registry-worker/projects/inactivity.ts +162 -0
  79. package/src/registry-worker/projects/projects.test.ts +283 -0
  80. package/src/registry-worker/proposals/proposals.test.ts +80 -0
  81. package/src/registry-worker/proposals/routes.ts +183 -0
  82. package/src/registry-worker/roles/roles.test.ts +84 -0
  83. package/src/registry-worker/roles/routes.ts +141 -0
  84. package/src/registry-worker/runner/dispatch.ts +1 -0
  85. package/src/registry-worker/runner/routes.ts +22 -5
  86. package/src/registry-worker/runner/runner.test.ts +21 -0
  87. package/src/registry-worker/runner/tokens.ts +8 -0
  88. package/src/registry-worker/secrets/crypto.ts +135 -0
  89. package/src/registry-worker/secrets/routes.ts +296 -0
  90. package/src/registry-worker/secrets/secrets.test.ts +237 -0
  91. package/src/registry-worker/signup/policy.ts +2 -2
  92. package/src/registry-worker/signup/routes.ts +12 -3
  93. package/src/registry-worker/sso/membership.ts +4 -2
  94. package/src/registry-worker/sso/routes.ts +24 -8
  95. package/src/registry-worker/sso/sso.test.ts +3 -2
  96. package/src/registry-worker/task-approval.test.ts +67 -0
  97. package/src/registry-worker/task-resume.test.ts +196 -0
  98. package/src/registry-worker/task-review.test.ts +121 -0
  99. package/src/runtime/appium-driver.ts +1 -1
  100. package/src/runtime/apply-proposals.test.ts +74 -0
  101. package/src/runtime/apply-proposals.ts +41 -0
  102. package/src/runtime/cloud-secrets.test.ts +201 -0
  103. package/src/runtime/cloud-secrets.ts +210 -0
  104. package/src/runtime/config-validate.ts +12 -9
  105. package/src/runtime/cursor-driver-run.ts +14 -3
  106. package/src/runtime/discovery-task.test.ts +26 -1
  107. package/src/runtime/discovery-task.ts +67 -6
  108. package/src/runtime/executor.ts +18 -10
  109. package/src/runtime/explorer.test.ts +32 -0
  110. package/src/runtime/explorer.ts +48 -0
  111. package/src/runtime/flow-language.ts +22 -8
  112. package/src/runtime/init-wizard.ts +112 -6
  113. package/src/runtime/journey-editor.ts +39 -2
  114. package/src/runtime/journey-graph.test.ts +18 -0
  115. package/src/runtime/local-registry-stub.test.ts +64 -0
  116. package/src/runtime/local-registry-stub.ts +255 -7
  117. package/src/runtime/local-vault.ts +65 -0
  118. package/src/runtime/pipeline.test.ts +23 -0
  119. package/src/runtime/pipeline.ts +61 -20
  120. package/src/runtime/planner.test.ts +24 -1
  121. package/src/runtime/planner.ts +88 -23
  122. package/src/runtime/playwright-driver.test.ts +21 -2
  123. package/src/runtime/playwright-driver.ts +49 -7
  124. package/src/runtime/project-identity.test.ts +161 -0
  125. package/src/runtime/project-identity.ts +232 -0
  126. package/src/runtime/project-roles.test.ts +107 -0
  127. package/src/runtime/project-roles.ts +158 -0
  128. package/src/runtime/propose-cli.ts +66 -0
  129. package/src/runtime/record-run-verdicts.test.ts +58 -0
  130. package/src/runtime/record-run-verdicts.ts +38 -4
  131. package/src/runtime/reporter.ts +1 -1
  132. package/src/runtime/reset.ts +2 -0
  133. package/src/runtime/roles-cli.test.ts +47 -0
  134. package/src/runtime/roles-cli.ts +60 -0
  135. package/src/runtime/run-once.ts +68 -5
  136. package/src/runtime/scenario-matrix.test.ts +41 -0
  137. package/src/runtime/scenario-matrix.ts +98 -0
  138. package/src/runtime/secret-driver.ts +77 -0
  139. package/src/runtime/secret-redaction.ts +69 -0
  140. package/src/runtime/secret-sources.test.ts +442 -0
  141. package/src/runtime/secret-sources.ts +176 -0
  142. package/src/runtime/secrets-cli.ts +146 -0
  143. package/src/runtime/serve-worker.ts +64 -6
  144. package/src/runtime/site-discovery.test.ts +47 -4
  145. package/src/runtime/site-discovery.ts +79 -9
  146. package/src/runtime/vault-federation.test.ts +37 -0
  147. package/src/runtime/vault-federation.ts +128 -0
  148. package/src/runtime/web-suite.ts +5 -2
  149. package/src/storage/create-object-store.ts +5 -1
  150. package/src/storage/object-store.ts +12 -1
  151. package/src/storage/test-case-results.test.ts +64 -0
  152. package/src/storage/test-case-results.ts +49 -0
  153. package/src/vendor/byok.ts +371 -0
@@ -0,0 +1,371 @@
1
+ // VENDORED from theaiinc/arcana packages/byok/src/byok.ts by `node scripts/sync-byok.mjs` — change it there, then sync.
2
+ // Bring-your-own-vault secret resolution: read a secret by reference from
3
+ // the vault its owner already uses, without the Arcana app, a phone, or any
4
+ // third-party SDK.
5
+ //
6
+ // gcpsm://projects/P/secrets/S[/versions/V] Google Secret Manager
7
+ // awssm://REGION/SECRET_ID[#field] AWS Secrets Manager (#field reads one field of a JSON secret)
8
+ // keychain://SERVICE/ACCOUNT the machine's keychain (macOS Keychain, Linux Secret Service)
9
+ // env://VARIABLE an environment variable
10
+ //
11
+ // A reference is a pointer, not a credential, so it is safe to store and
12
+ // commit. A value is returned to the caller only, never logged or put in an
13
+ // error message.
14
+ //
15
+ // One file, no dependencies, erasable TypeScript only: Node runs it as is,
16
+ // and other projects (the Bugmole CLI) vendor it verbatim.
17
+
18
+ import { execFile } from "node:child_process";
19
+ import { createHash, createHmac, createSign } from "node:crypto";
20
+
21
+ export type SecretReference =
22
+ | { kind: "gcp"; project: string; secret: string; version: string }
23
+ | { kind: "aws"; region: string; secretId: string; field?: string }
24
+ | { kind: "keychain"; service: string; account: string }
25
+ | { kind: "env"; variable: string };
26
+
27
+ export type ByokDeps = {
28
+ env?: Record<string, string | undefined>;
29
+ fetch?: typeof fetch;
30
+ /** Runs a program and returns its stdout; replaceable in tests. */
31
+ exec?: (file: string, args: string[], options?: { input?: string }) => Promise<string>;
32
+ platform?: string;
33
+ /**
34
+ * Environment prefixes AWS keys are read from, in order. A host can put a
35
+ * tenant's own read-only keys first (Bugmole Cloud uses VAULT_AWS_).
36
+ */
37
+ awsPrefixes?: string[];
38
+ /** Name of an environment variable holding a Google service account's JSON key. */
39
+ googleKeyVariable?: string;
40
+ /** Allow the aws, gcloud, security and secret-tool CLIs (default true). */
41
+ allowCli?: boolean;
42
+ };
43
+
44
+ export function parseReference(value: unknown): SecretReference | null {
45
+ if (typeof value !== "string" || /[\r\n]/.test(value)) return null;
46
+ const text = value.trim();
47
+ let match = /^gcpsm:\/\/projects\/([^/]+)\/secrets\/([^/]+)(?:\/versions\/([^/]+))?$/.exec(text);
48
+ if (match) return { kind: "gcp", project: match[1], secret: match[2], version: match[3] ?? "latest" };
49
+ match = /^awssm:\/\/([a-z]{2}(?:-[a-z]+)+-\d)\/([^#]+?)(?:#([^#]+))?$/.exec(text);
50
+ if (match) return { kind: "aws", region: match[1], secretId: decodeURIComponent(match[2]), ...(match[3] ? { field: match[3] } : {}) };
51
+ match = /^keychain:\/\/([^/]+)\/([^/]+)$/.exec(text);
52
+ if (match) return { kind: "keychain", service: match[1], account: match[2] };
53
+ match = /^env:\/\/([A-Za-z_][A-Za-z0-9_]*)$/.exec(text);
54
+ if (match) return { kind: "env", variable: match[1] };
55
+ return null;
56
+ }
57
+
58
+ export function formatReference(reference: SecretReference): string {
59
+ switch (reference.kind) {
60
+ case "gcp": return `gcpsm://projects/${reference.project}/secrets/${reference.secret}/versions/${reference.version}`;
61
+ case "aws": return `awssm://${reference.region}/${reference.secretId}${reference.field ? `#${reference.field}` : ""}`;
62
+ case "keychain": return `keychain://${reference.service}/${reference.account}`;
63
+ case "env": return `env://${reference.variable}`;
64
+ }
65
+ }
66
+
67
+ export const SOURCE_NAME: Record<SecretReference["kind"], string> = {
68
+ gcp: "Google Secret Manager",
69
+ aws: "AWS Secrets Manager",
70
+ keychain: "this machine's keychain",
71
+ env: "an environment variable",
72
+ };
73
+
74
+ function defaultExec(file: string, args: string[], options?: { input?: string }): Promise<string> {
75
+ return new Promise((resolve, reject) => {
76
+ const child = execFile(file, args, { maxBuffer: 1024 * 1024 }, (error, stdout) => (error ? reject(error) : resolve(String(stdout))));
77
+ child.stdin?.end(options?.input);
78
+ });
79
+ }
80
+
81
+ function execOf(deps: ByokDeps): NonNullable<ByokDeps["exec"]> {
82
+ if (deps.allowCli === false) return async (file) => { throw new Error(`${file} isn't available here`); };
83
+ return deps.exec ?? defaultExec;
84
+ }
85
+
86
+ // ---------- the machine's keychain ----------
87
+
88
+ type Keychain = Extract<SecretReference, { kind: "keychain" }>;
89
+
90
+ const quoted = (text: string) => `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
91
+
92
+ async function readKeychain(reference: Keychain, deps: ByokDeps): Promise<string> {
93
+ const exec = execOf(deps);
94
+ const platform = deps.platform ?? process.platform;
95
+ if (platform !== "darwin" && platform !== "linux") {
96
+ throw new Error("This machine has no supported keychain (macOS Keychain, or secret-tool on Linux)");
97
+ }
98
+ try {
99
+ if (platform === "darwin") return (await exec("security", ["find-generic-password", "-s", reference.service, "-a", reference.account, "-w"])).replace(/\n$/, "");
100
+ return await exec("secret-tool", ["lookup", "service", reference.service, "account", reference.account]);
101
+ } catch {
102
+ throw new Error(`No keychain entry ${formatReference(reference)} on this machine`);
103
+ }
104
+ }
105
+
106
+ /** Stores a value in the machine's keychain, replacing any earlier one. The value goes in on stdin, never in a command's arguments. */
107
+ export async function writeKeychain(reference: Keychain, value: string, label: string, deps: ByokDeps = {}): Promise<void> {
108
+ const exec = execOf(deps);
109
+ const platform = deps.platform ?? process.platform;
110
+ if (platform === "darwin") {
111
+ // `security add-generic-password -w VALUE` would show the value in the
112
+ // process list; its interactive mode reads the same command from stdin.
113
+ await exec("security", ["-i"], {
114
+ input: `add-generic-password -U -s ${quoted(reference.service)} -a ${quoted(reference.account)} -l ${quoted(label)} -w ${quoted(value)}\n`,
115
+ });
116
+ return;
117
+ }
118
+ if (platform === "linux") {
119
+ await exec("secret-tool", ["store", "--label", label, "service", reference.service, "account", reference.account], { input: value });
120
+ return;
121
+ }
122
+ throw new Error("This machine has no supported keychain (macOS Keychain, or secret-tool on Linux)");
123
+ }
124
+
125
+ export async function deleteKeychain(reference: Keychain, deps: ByokDeps = {}): Promise<boolean> {
126
+ const exec = execOf(deps);
127
+ const platform = deps.platform ?? process.platform;
128
+ try {
129
+ if (platform === "darwin") await exec("security", ["delete-generic-password", "-s", reference.service, "-a", reference.account]);
130
+ else if (platform === "linux") await exec("secret-tool", ["clear", "service", reference.service, "account", reference.account]);
131
+ else return false;
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** Whether a keychain entry exists, without reading its value. */
139
+ export async function keychainHas(reference: Keychain, deps: ByokDeps = {}): Promise<boolean> {
140
+ const exec = execOf(deps);
141
+ const platform = deps.platform ?? process.platform;
142
+ try {
143
+ // Without -w macOS prints the entry's attributes, not its value.
144
+ if (platform === "darwin") await exec("security", ["find-generic-password", "-s", reference.service, "-a", reference.account]);
145
+ else if (platform === "linux") {
146
+ const found = await exec("secret-tool", ["search", "service", reference.service, "account", reference.account]);
147
+ if (!found.trim()) return false;
148
+ } else return false;
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // ---------- AWS Secrets Manager ----------
156
+
157
+ export type AwsCredentials = { accessKeyId: string; secretAccessKey: string; sessionToken?: string };
158
+
159
+ const sha256Hex = (value: string) => createHash("sha256").update(value).digest("hex");
160
+ const hmac = (key: string | Buffer, value: string) => createHmac("sha256", key).update(value).digest();
161
+
162
+ /** AWS Signature Version 4 for a JSON POST. */
163
+ export function signAws(params: { credentials: AwsCredentials; region: string; service: string; url: string; headers: Record<string, string>; body: string; now?: Date }): Record<string, string> {
164
+ const now = params.now ?? new Date();
165
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
166
+ const date = amzDate.slice(0, 8);
167
+ const url = new URL(params.url);
168
+ const headers: Record<string, string> = {
169
+ ...Object.fromEntries(Object.entries(params.headers).map(([key, value]) => [key.toLowerCase(), value.trim()])),
170
+ host: url.host,
171
+ "x-amz-date": amzDate,
172
+ ...(params.credentials.sessionToken ? { "x-amz-security-token": params.credentials.sessionToken } : {}),
173
+ };
174
+ const names = Object.keys(headers).sort();
175
+ const canonical = [
176
+ "POST",
177
+ url.pathname || "/",
178
+ url.searchParams.toString(),
179
+ names.map((name) => `${name}:${headers[name]}\n`).join(""),
180
+ names.join(";"),
181
+ sha256Hex(params.body),
182
+ ].join("\n");
183
+ const scope = `${date}/${params.region}/${params.service}/aws4_request`;
184
+ const toSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256Hex(canonical)].join("\n");
185
+ const key = hmac(hmac(hmac(hmac(`AWS4${params.credentials.secretAccessKey}`, date), params.region), params.service), "aws4_request");
186
+ const signature = createHmac("sha256", key).update(toSign).digest("hex");
187
+ const { host: _host, ...rest } = headers;
188
+ return {
189
+ ...rest,
190
+ authorization: `AWS4-HMAC-SHA256 Credential=${params.credentials.accessKeyId}/${scope}, SignedHeaders=${names.join(";")}, Signature=${signature}`,
191
+ };
192
+ }
193
+
194
+ /** AWS keys from the configured environment prefixes, then the AWS CLI (profiles, SSO). */
195
+ export async function awsCredentials(deps: ByokDeps = {}): Promise<AwsCredentials> {
196
+ const env = deps.env ?? process.env;
197
+ for (const prefix of deps.awsPrefixes ?? ["AWS_"]) {
198
+ const accessKeyId = env[`${prefix}ACCESS_KEY_ID`];
199
+ const secretAccessKey = env[`${prefix}SECRET_ACCESS_KEY`];
200
+ if (accessKeyId && secretAccessKey) {
201
+ const sessionToken = env[`${prefix}SESSION_TOKEN`];
202
+ return { accessKeyId, secretAccessKey, ...(sessionToken ? { sessionToken } : {}) };
203
+ }
204
+ }
205
+ try {
206
+ const parsed = JSON.parse(await execOf(deps)("aws", ["configure", "export-credentials", "--format", "process"])) as { AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string };
207
+ if (parsed.AccessKeyId && parsed.SecretAccessKey) {
208
+ return { accessKeyId: parsed.AccessKeyId, secretAccessKey: parsed.SecretAccessKey, ...(parsed.SessionToken ? { sessionToken: parsed.SessionToken } : {}) };
209
+ }
210
+ } catch {
211
+ // Reported below.
212
+ }
213
+ throw new Error("No AWS credentials: set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or sign in with the AWS CLI");
214
+ }
215
+
216
+ async function readAws(reference: Extract<SecretReference, { kind: "aws" }>, deps: ByokDeps): Promise<string> {
217
+ const credentials = await awsCredentials(deps);
218
+ const url = `https://secretsmanager.${reference.region}.amazonaws.com/`;
219
+ const body = JSON.stringify({ SecretId: reference.secretId });
220
+ const headers = signAws({
221
+ credentials,
222
+ region: reference.region,
223
+ service: "secretsmanager",
224
+ url,
225
+ headers: { "content-type": "application/x-amz-json-1.1", "x-amz-target": "secretsmanager.GetSecretValue" },
226
+ body,
227
+ });
228
+ const reply = await (deps.fetch ?? fetch)(url, { method: "POST", headers, body });
229
+ if (!reply.ok) throw new Error(`AWS Secrets Manager refused ${formatReference(reference)} (${reply.status}): ${await errorText(reply)}`);
230
+ const payload = await reply.json() as { SecretString?: string; SecretBinary?: string };
231
+ const text = payload.SecretString ?? (payload.SecretBinary ? Buffer.from(payload.SecretBinary, "base64").toString("utf8") : undefined);
232
+ if (text === undefined) throw new Error(`${formatReference(reference)} has no value`);
233
+ return reference.field ? jsonField(text, reference.field, formatReference(reference)) : text;
234
+ }
235
+
236
+ // ---------- Google Secret Manager ----------
237
+
238
+ type ServiceAccountKey = { client_email?: string; private_key?: string; token_uri?: string };
239
+
240
+ async function serviceAccountToken(key: ServiceAccountKey, doFetch: typeof fetch): Promise<string> {
241
+ if (!key.client_email || !key.private_key) throw new Error("The service account key needs client_email and private_key");
242
+ const now = Math.floor(Date.now() / 1000);
243
+ const tokenUri = key.token_uri ?? "https://oauth2.googleapis.com/token";
244
+ const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url");
245
+ const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
246
+ iss: key.client_email,
247
+ scope: "https://www.googleapis.com/auth/cloud-platform",
248
+ aud: tokenUri,
249
+ iat: now,
250
+ exp: now + 600,
251
+ })}`;
252
+ const signature = createSign("RSA-SHA256").update(unsigned).sign(key.private_key).toString("base64url");
253
+ const reply = await doFetch(tokenUri, {
254
+ method: "POST",
255
+ headers: { "content-type": "application/x-www-form-urlencoded" },
256
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: `${unsigned}.${signature}` }).toString(),
257
+ });
258
+ if (!reply.ok) throw new Error(`Google refused the service account (${reply.status}): ${await errorText(reply)}`);
259
+ const body = await reply.json() as { access_token?: string };
260
+ if (!body.access_token) throw new Error("Google returned no access token for the service account");
261
+ return body.access_token;
262
+ }
263
+
264
+ /** A Google access token: a service account key, GOOGLE_OAUTH_ACCESS_TOKEN, or the signed-in gcloud CLI. */
265
+ export async function googleAccessToken(deps: ByokDeps = {}): Promise<string> {
266
+ const env = deps.env ?? process.env;
267
+ const keyJson = deps.googleKeyVariable ? env[deps.googleKeyVariable] : undefined;
268
+ if (keyJson) {
269
+ let key: ServiceAccountKey;
270
+ try {
271
+ key = JSON.parse(keyJson) as ServiceAccountKey;
272
+ } catch {
273
+ throw new Error(`${deps.googleKeyVariable} isn't a service account key (JSON)`);
274
+ }
275
+ return serviceAccountToken(key, deps.fetch ?? fetch);
276
+ }
277
+ if (env.GOOGLE_OAUTH_ACCESS_TOKEN) return env.GOOGLE_OAUTH_ACCESS_TOKEN;
278
+ try {
279
+ const token = (await execOf(deps)("gcloud", ["auth", "print-access-token"])).trim();
280
+ if (token) return token;
281
+ } catch {
282
+ // Reported below.
283
+ }
284
+ throw new Error("No Google credentials: sign in with `gcloud auth login`, or set GOOGLE_OAUTH_ACCESS_TOKEN");
285
+ }
286
+
287
+ async function readGoogle(reference: Extract<SecretReference, { kind: "gcp" }>, deps: ByokDeps): Promise<string> {
288
+ const token = await googleAccessToken(deps);
289
+ const url = `https://secretmanager.googleapis.com/v1/projects/${encodeURIComponent(reference.project)}/secrets/${encodeURIComponent(reference.secret)}/versions/${encodeURIComponent(reference.version)}:access`;
290
+ const reply = await (deps.fetch ?? fetch)(url, { headers: { authorization: `Bearer ${token}` } });
291
+ if (!reply.ok) throw new Error(`Google Secret Manager refused ${formatReference(reference)} (${reply.status}): ${await errorText(reply)}`);
292
+ const body = await reply.json() as { payload?: { data?: string } };
293
+ if (!body.payload?.data) throw new Error(`${formatReference(reference)} has no value`);
294
+ return Buffer.from(body.payload.data, "base64").toString("utf8");
295
+ }
296
+
297
+ // ---------- shared ----------
298
+
299
+ async function errorText(reply: Response): Promise<string> {
300
+ const text = (await reply.text().catch(() => "")).slice(0, 300);
301
+ try {
302
+ const parsed = JSON.parse(text) as { message?: string; Message?: string; error?: { message?: string } };
303
+ return parsed.error?.message ?? parsed.message ?? parsed.Message ?? text;
304
+ } catch {
305
+ return text;
306
+ }
307
+ }
308
+
309
+ function jsonField(text: string, field: string, label: string): string {
310
+ let parsed: unknown;
311
+ try {
312
+ parsed = JSON.parse(text);
313
+ } catch {
314
+ throw new Error(`${label} isn't JSON, so it has no field "${field}"`);
315
+ }
316
+ const value = (parsed as Record<string, unknown> | null)?.[field];
317
+ if (typeof value !== "string" && typeof value !== "number") throw new Error(`${label} has no text field "${field}"`);
318
+ return String(value);
319
+ }
320
+
321
+ /** Reads the value a reference points at. Errors name the reference, never a value. */
322
+ export async function resolveReference(reference: SecretReference, deps: ByokDeps = {}): Promise<string> {
323
+ switch (reference.kind) {
324
+ case "env": {
325
+ const value = (deps.env ?? process.env)[reference.variable];
326
+ if (value === undefined || value === "") throw new Error(`${reference.variable} is not set`);
327
+ return value;
328
+ }
329
+ case "keychain": return readKeychain(reference, deps);
330
+ case "aws": return readAws(reference, deps);
331
+ case "gcp": return readGoogle(reference, deps);
332
+ }
333
+ }
334
+
335
+ export type Readiness = { ready: boolean; detail: string };
336
+
337
+ /**
338
+ * Whether a reference can probably be read here, without reading it: an env
339
+ * variable or keychain entry exists, or credentials for the cloud vault do.
340
+ */
341
+ export async function readiness(reference: SecretReference, deps: ByokDeps = {}): Promise<Readiness> {
342
+ const env = deps.env ?? process.env;
343
+ switch (reference.kind) {
344
+ case "env":
345
+ return env[reference.variable] ? { ready: true, detail: `Read from ${reference.variable}` } : { ready: false, detail: `${reference.variable} is not set` };
346
+ case "keychain":
347
+ return (await keychainHas(reference, deps))
348
+ ? { ready: true, detail: "In this machine's keychain" }
349
+ : { ready: false, detail: `No keychain entry ${formatReference(reference)}` };
350
+ case "aws":
351
+ try {
352
+ await awsCredentials({ ...deps, allowCli: false });
353
+ return { ready: true, detail: "AWS keys found; read when it's used" };
354
+ } catch {
355
+ // Checking the AWS CLI's profile could start a sign-in, so it isn't.
356
+ return deps.allowCli === false
357
+ ? { ready: false, detail: "No AWS keys" }
358
+ : { ready: true, detail: "No AWS keys in the environment; the AWS CLI's profile is tried when it's used" };
359
+ }
360
+ case "gcp": {
361
+ if ((deps.googleKeyVariable && env[deps.googleKeyVariable]) || env.GOOGLE_OAUTH_ACCESS_TOKEN) return { ready: true, detail: "Google credentials found; read when it's used" };
362
+ try {
363
+ const account = (await execOf(deps)("gcloud", ["auth", "list", "--filter=status:ACTIVE", "--format=value(account)"])).trim();
364
+ if (account) return { ready: true, detail: `Signed in to gcloud as ${account}; read when it's used` };
365
+ } catch {
366
+ // Reported below.
367
+ }
368
+ return { ready: false, detail: "No Google credentials: run gcloud auth login, or set GOOGLE_OAUTH_ACCESS_TOKEN" };
369
+ }
370
+ }
371
+ }