CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflow-secrets.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| abfa9ad | 1 | /** |
| 2 | * Workflow secrets — DB-backed CRUD + runtime context loader. | |
| 3 | * | |
| 4 | * Sibling to `workflow-secrets-crypto.ts`, which owns the pure AES-256-GCM | |
| 5 | * primitives. This file is the *only* place outside the runner that touches | |
| 6 | * `workflowSecrets` rows. All public fns follow the project-wide | |
| 7 | * `{ok:true,...}` / `{ok:false,error}` contract and never throw — DB errors | |
| 8 | * are caught and collapsed to a terse error string. | |
| 9 | * | |
| 10 | * Callers: | |
| 11 | * - Agent 7 (settings UI) uses `listRepoSecrets`, `upsertRepoSecret`, | |
| 12 | * `deleteRepoSecret` to render + mutate the per-repo secrets table. | |
| 13 | * - Agent 5 (workflow runner) calls `loadSecretsContext` once at run start | |
| 14 | * to build the `{ NAME: plaintext }` map that feeds | |
| 15 | * `substituteSecrets(template, secrets)` for every step's `run:` / `env:`. | |
| 16 | * | |
| 17 | * Security notes: | |
| 18 | * - `listRepoSecrets` never returns plaintext or ciphertext — UI only sees | |
| 19 | * metadata (name, createdAt, createdBy). | |
| 20 | * - `deleteRepoSecret` is scoped on both (repoId, secretId) to prevent a | |
| 21 | * caller with one repo's context from deleting another repo's secret by | |
| 22 | * guessing a UUID. | |
| 23 | * - `loadSecretsContext` silently omits secrets whose decryption fails so | |
| 24 | * the `${{ secrets.NAME }}` template left intact in logs — that's a louder | |
| 25 | * failure signal than substituting empty string. | |
| 26 | */ | |
| 27 | ||
| 28 | import { and, asc, eq } from "drizzle-orm"; | |
| 29 | import { db } from "../db"; | |
| 30 | import { workflowSecrets } from "../db/schema"; | |
| 31 | import { decryptSecret, encryptSecret, getMasterKey } from "./workflow-secrets-crypto"; | |
| 32 | ||
| 33 | /** Matches GitHub Actions secret-name rules: uppercase + digits + underscore, | |
| 34 | * must not start with a digit, max 100 chars. Case is enforced strictly. */ | |
| 35 | const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; | |
| 36 | const MAX_NAME_LEN = 100; | |
| 37 | ||
| 38 | export type SecretMetadata = { | |
| 39 | id: string; | |
| 40 | name: string; | |
| 41 | createdAt: Date; | |
| 42 | createdBy: string | null; | |
| 43 | }; | |
| 44 | ||
| 45 | /** | |
| 46 | * List a repository's secrets — metadata only. | |
| 47 | * | |
| 48 | * Returns rows ordered by `name` ascending. Plaintext and ciphertext are | |
| 49 | * deliberately excluded so this fn is safe to call from a web handler that | |
| 50 | * renders the settings page. | |
| 51 | */ | |
| 52 | export async function listRepoSecrets( | |
| 53 | repoId: string | |
| 54 | ): Promise< | |
| 55 | | { ok: true; secrets: SecretMetadata[] } | |
| 56 | | { ok: false; error: string } | |
| 57 | > { | |
| 58 | if (typeof repoId !== "string" || repoId.length === 0) { | |
| 59 | return { ok: false, error: "repoId is required" }; | |
| 60 | } | |
| 61 | try { | |
| 62 | const rows = await db | |
| 63 | .select({ | |
| 64 | id: workflowSecrets.id, | |
| 65 | name: workflowSecrets.name, | |
| 66 | createdAt: workflowSecrets.createdAt, | |
| 67 | createdBy: workflowSecrets.createdBy, | |
| 68 | }) | |
| 69 | .from(workflowSecrets) | |
| 70 | .where(eq(workflowSecrets.repositoryId, repoId)) | |
| 71 | .orderBy(asc(workflowSecrets.name)); | |
| 72 | return { ok: true, secrets: rows }; | |
| 73 | } catch (err) { | |
| 74 | console.error("[workflow-secrets] listRepoSecrets:", err); | |
| 75 | return { ok: false, error: "db query failed" }; | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | /** | |
| 80 | * Create or update a repository secret. Name must match `[A-Z_][A-Z0-9_]*` | |
| 81 | * (max 100 chars) and value is encrypted under the master key before being | |
| 82 | * written. On conflict (repo+name already exists) the existing row is | |
| 83 | * replaced and its `updated_at` bumped; `created_at` and `created_by` stay | |
| 84 | * pinned to the original insert for audit history. | |
| 85 | */ | |
| 86 | export async function upsertRepoSecret(args: { | |
| 87 | repoId: string; | |
| 88 | name: string; | |
| 89 | value: string; | |
| 90 | createdBy: string; | |
| 91 | }): Promise<{ ok: true; id: string } | { ok: false; error: string }> { | |
| 92 | const { repoId, name, value, createdBy } = args; | |
| 93 | if (typeof repoId !== "string" || repoId.length === 0) { | |
| 94 | return { ok: false, error: "repoId is required" }; | |
| 95 | } | |
| 96 | if (typeof createdBy !== "string" || createdBy.length === 0) { | |
| 97 | return { ok: false, error: "createdBy is required" }; | |
| 98 | } | |
| 99 | if (typeof name !== "string" || name.length === 0) { | |
| 100 | return { ok: false, error: "name is required" }; | |
| 101 | } | |
| 102 | if (name.length > MAX_NAME_LEN) { | |
| 103 | return { ok: false, error: `name must be <= ${MAX_NAME_LEN} chars` }; | |
| 104 | } | |
| 105 | if (!SECRET_NAME_RE.test(name)) { | |
| 106 | return { | |
| 107 | ok: false, | |
| 108 | error: "name must match /^[A-Z_][A-Z0-9_]*$/ (uppercase, digits, underscore; not leading digit)", | |
| 109 | }; | |
| 110 | } | |
| 111 | if (typeof value !== "string") { | |
| 112 | return { ok: false, error: "value must be a string" }; | |
| 113 | } | |
| 114 | ||
| 115 | const enc = encryptSecret(value); | |
| 116 | if (!enc.ok) return { ok: false, error: enc.error }; | |
| 117 | ||
| 118 | try { | |
| 119 | const rows = await db | |
| 120 | .insert(workflowSecrets) | |
| 121 | .values({ | |
| 122 | repositoryId: repoId, | |
| 123 | name, | |
| 124 | encryptedValue: enc.ciphertext, | |
| 125 | createdBy, | |
| 126 | }) | |
| 127 | .onConflictDoUpdate({ | |
| 128 | target: [workflowSecrets.repositoryId, workflowSecrets.name], | |
| 129 | set: { | |
| 130 | encryptedValue: enc.ciphertext, | |
| 131 | updatedAt: new Date(), | |
| 132 | }, | |
| 133 | }) | |
| 134 | .returning({ id: workflowSecrets.id }); | |
| 135 | const id = rows[0]?.id; | |
| 136 | if (!id) return { ok: false, error: "insert returned no row" }; | |
| 137 | return { ok: true, id }; | |
| 138 | } catch (err) { | |
| 139 | console.error("[workflow-secrets] upsertRepoSecret:", err); | |
| 140 | return { | |
| 141 | ok: false, | |
| 142 | error: `db write failed: ${err instanceof Error ? err.message : String(err)}`, | |
| 143 | }; | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | /** | |
| 148 | * Delete a single secret by id, scoped to its repository. Both filters must | |
| 149 | * match — this is defence-in-depth against a caller that has a secretId but | |
| 150 | * the wrong repo context (e.g. path-param manipulation). | |
| 151 | * | |
| 152 | * Returns `{ok:true}` even if no row matched; the caller can't distinguish | |
| 153 | * "deleted" from "never existed" and that's fine for an idempotent DELETE. | |
| 154 | */ | |
| 155 | export async function deleteRepoSecret(args: { | |
| 156 | repoId: string; | |
| 157 | secretId: string; | |
| 158 | }): Promise<{ ok: true } | { ok: false; error: string }> { | |
| 159 | const { repoId, secretId } = args; | |
| 160 | if (typeof repoId !== "string" || repoId.length === 0) { | |
| 161 | return { ok: false, error: "repoId is required" }; | |
| 162 | } | |
| 163 | if (typeof secretId !== "string" || secretId.length === 0) { | |
| 164 | return { ok: false, error: "secretId is required" }; | |
| 165 | } | |
| 166 | try { | |
| 167 | await db | |
| 168 | .delete(workflowSecrets) | |
| 169 | .where( | |
| 170 | and( | |
| 171 | eq(workflowSecrets.id, secretId), | |
| 172 | eq(workflowSecrets.repositoryId, repoId) | |
| 173 | ) | |
| 174 | ); | |
| 175 | return { ok: true }; | |
| 176 | } catch (err) { | |
| 177 | console.error("[workflow-secrets] deleteRepoSecret:", err); | |
| 178 | return { | |
| 179 | ok: false, | |
| 180 | error: `db delete failed: ${err instanceof Error ? err.message : String(err)}`, | |
| 181 | }; | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 18b210d | 185 | /** |
| 186 | * Substitute `${{ secrets.NAME }}` references inside a template string with | |
| 187 | * the corresponding plaintext from `secrets`. Pure helper — no DB, no | |
| 188 | * side effects. Designed to feed the workflow runner's per-step `run:` | |
| 189 | * value before handing it to `bash -c`. | |
| 190 | * | |
| 191 | * Behaviour: | |
| 192 | * - Whitespace inside the `{{ ... }}` is tolerated: `${{secrets.X}}`, | |
| 193 | * `${{ secrets.X }}`, `${{ secrets . X }}`. | |
| 194 | * - Names that don't appear in `secrets` are left intact (so the | |
| 195 | * unsubstituted token shows up in logs and surfaces the misconfig | |
| 196 | * loudly — matches the loadSecretsContext docstring contract). | |
| 197 | * - Names that don't match the GitHub-Actions secret-name grammar | |
| 198 | * (`[A-Z_][A-Z0-9_]*`) are also left intact, defence-in-depth | |
| 199 | * against malformed YAML producing weird tokens. | |
| 200 | * - The function never throws on bad input; non-string templates | |
| 201 | * return `""`. | |
| 202 | * | |
| 203 | * Pure regex; no exec-on-string allocations beyond the single replace. | |
| 204 | */ | |
| 205 | export function substituteSecrets( | |
| 206 | template: string, | |
| 207 | secrets: Record<string, string> | |
| 208 | ): string { | |
| 209 | if (typeof template !== "string") return ""; | |
| 210 | if (!template || !secrets) return template || ""; | |
| 211 | // ${{ <ws>* secrets <ws>* . <ws>* NAME <ws>* }} | |
| 212 | // We split the regex on the dot so we can tolerate whitespace either | |
| 213 | // side of it; the NAME group is captured with the strict grammar so a | |
| 214 | // malformed identifier won't accidentally substitute. | |
| 215 | return template.replace( | |
| 216 | /\$\{\{\s*secrets\s*\.\s*([A-Z_][A-Z0-9_]*)\s*\}\}/g, | |
| 217 | (match, name: string) => { | |
| 218 | if (Object.prototype.hasOwnProperty.call(secrets, name)) { | |
| 219 | return secrets[name]; | |
| 220 | } | |
| 221 | return match; | |
| 222 | } | |
| 223 | ); | |
| 224 | } | |
| abfa9ad | 225 | /** |
| 226 | * Build the `{ NAME: plaintext }` map consumed by the runner's | |
| 227 | * `substituteSecrets(template, secrets)` calls. | |
| 228 | * | |
| 229 | * Graceful-degrade semantics (intentional — the runner MUST NOT crash here): | |
| 230 | * - Master key missing → returns `{}`. A warning is logged. Every | |
| 231 | * `${{ secrets.X }}` token will pass through untouched, making the | |
| 232 | * misconfiguration visible in job logs. | |
| 233 | * - Individual decryption failure (tampered row, key rotated, etc.) → that | |
| 234 | * secret is skipped; others still load. | |
| 235 | * - DB error → returns `{}`. | |
| 236 | * | |
| 237 | * Note: this returns the raw map, not a `{ok,...}` tuple, because the runner | |
| 238 | * wants a hot path with no branching at call sites. | |
| 239 | */ | |
| 240 | export async function loadSecretsContext( | |
| 241 | repoId: string | |
| 242 | ): Promise<Record<string, string>> { | |
| 243 | if (typeof repoId !== "string" || repoId.length === 0) { | |
| 244 | return {}; | |
| 245 | } | |
| 246 | if (!getMasterKey()) { | |
| 247 | console.error( | |
| 248 | "[workflow-secrets] loadSecretsContext: WORKFLOW_SECRETS_KEY missing; secrets will not be substituted" | |
| 249 | ); | |
| 250 | return {}; | |
| 251 | } | |
| 252 | ||
| 253 | let rows: { name: string; encryptedValue: string }[]; | |
| 254 | try { | |
| 255 | rows = await db | |
| 256 | .select({ | |
| 257 | name: workflowSecrets.name, | |
| 258 | encryptedValue: workflowSecrets.encryptedValue, | |
| 259 | }) | |
| 260 | .from(workflowSecrets) | |
| 261 | .where(eq(workflowSecrets.repositoryId, repoId)); | |
| 262 | } catch (err) { | |
| 263 | console.error("[workflow-secrets] loadSecretsContext db error:", err); | |
| 264 | return {}; | |
| 265 | } | |
| 266 | ||
| 267 | const out: Record<string, string> = {}; | |
| 268 | for (const row of rows) { | |
| 269 | const dec = decryptSecret(row.encryptedValue); | |
| 270 | if (!dec.ok) { | |
| 271 | console.error( | |
| 272 | `[workflow-secrets] decrypt failed for secret "${row.name}": ${dec.error}` | |
| 273 | ); | |
| 274 | continue; | |
| 275 | } | |
| 276 | out[row.name] = dec.plaintext; | |
| 277 | } | |
| 278 | return out; | |
| 279 | } |