CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
org-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.
| 05ab9b1 | 1 | /** |
| 2 | * Org-level secrets — DB CRUD + runtime loader (Block M1). | |
| 3 | * | |
| 4 | * Stores AES-256-GCM ciphertext produced by `workflow-secrets-crypto.ts`. | |
| 5 | * The table is defined inline here because `src/db/schema.ts` is locked — | |
| 6 | * no new tables may be added there. | |
| 7 | * | |
| 8 | * Public API: | |
| 9 | * listOrgSecrets(orgId) — metadata only, never plaintext | |
| 10 | * upsertOrgSecret(orgId, name, …) — create or replace by (org_id, name) | |
| 11 | * deleteOrgSecret(orgId, secretId) — scoped delete, prevents cross-org deletes | |
| 12 | * loadOrgSecretsForRepo(orgId) — { NAME: plaintext } map for runner | |
| 13 | */ | |
| 14 | ||
| 15 | import { and, asc, eq } from "drizzle-orm"; | |
| 16 | import { | |
| 17 | pgTable, | |
| 18 | uuid, | |
| 19 | text, | |
| 20 | timestamp, | |
| 21 | uniqueIndex, | |
| 22 | index, | |
| 23 | } from "drizzle-orm/pg-core"; | |
| 24 | import { db } from "../db"; | |
| 25 | import { encryptSecret, decryptSecret } from "./workflow-secrets-crypto"; | |
| 26 | ||
| 27 | // ── Inline Drizzle table definition ──────────────────────────────────────── | |
| 28 | ||
| 29 | export const orgSecrets = pgTable( | |
| 30 | "org_secrets", | |
| 31 | { | |
| 32 | id: uuid("id").primaryKey().defaultRandom(), | |
| 33 | orgId: uuid("org_id").notNull(), | |
| 34 | name: text("name").notNull(), | |
| 35 | encryptedValue: text("encrypted_value").notNull(), | |
| 36 | iv: text("iv").notNull(), | |
| 37 | keyHint: text("key_hint"), | |
| 38 | createdBy: uuid("created_by"), | |
| 39 | createdAt: timestamp("created_at").defaultNow().notNull(), | |
| 40 | updatedAt: timestamp("updated_at").defaultNow().notNull(), | |
| 41 | }, | |
| 42 | (table) => [ | |
| 43 | uniqueIndex("org_secrets_org_name_uq").on(table.orgId, table.name), | |
| 44 | index("org_secrets_org_idx").on(table.orgId), | |
| 45 | ] | |
| 46 | ); | |
| 47 | ||
| 48 | export type OrgSecret = typeof orgSecrets.$inferSelect; | |
| 49 | ||
| 50 | // ── Validation ────────────────────────────────────────────────────────────── | |
| 51 | ||
| 52 | const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; | |
| 53 | const MAX_NAME_LEN = 100; | |
| 54 | ||
| 55 | // ── Public API ────────────────────────────────────────────────────────────── | |
| 56 | ||
| 57 | /** | |
| 58 | * List all secrets for an org — metadata only (no plaintext, no ciphertext). | |
| 59 | * Ordered alphabetically by name. | |
| 60 | */ | |
| 61 | export async function listOrgSecrets( | |
| 62 | orgId: string | |
| 63 | ): Promise< | |
| 64 | Array<{ | |
| 65 | id: string; | |
| 66 | name: string; | |
| 67 | keyHint: string | null; | |
| 68 | createdAt: Date; | |
| 69 | updatedAt: Date; | |
| 70 | }> | |
| 71 | > { | |
| 72 | try { | |
| 73 | const rows = await db | |
| 74 | .select({ | |
| 75 | id: orgSecrets.id, | |
| 76 | name: orgSecrets.name, | |
| 77 | keyHint: orgSecrets.keyHint, | |
| 78 | createdAt: orgSecrets.createdAt, | |
| 79 | updatedAt: orgSecrets.updatedAt, | |
| 80 | }) | |
| 81 | .from(orgSecrets) | |
| 82 | .where(eq(orgSecrets.orgId, orgId)) | |
| 83 | .orderBy(asc(orgSecrets.name)); | |
| 84 | return rows; | |
| 85 | } catch (err) { | |
| 86 | console.error("[org-secrets] listOrgSecrets:", err); | |
| 87 | return []; | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * Create or replace a secret. Name must be `[A-Z_][A-Z0-9_]*`. | |
| 93 | * Uses delete-then-insert to handle the unique constraint on (org_id, name). | |
| 94 | */ | |
| 95 | export async function upsertOrgSecret( | |
| 96 | orgId: string, | |
| 97 | name: string, | |
| 98 | plaintext: string, | |
| 99 | createdBy: string | |
| 100 | ): Promise<void> { | |
| 101 | if (!SECRET_NAME_RE.test(name) || name.length > MAX_NAME_LEN) { | |
| 102 | throw new Error( | |
| 103 | `Secret name must match /^[A-Z_][A-Z0-9_]*$/ and be at most ${MAX_NAME_LEN} chars.` | |
| 104 | ); | |
| 105 | } | |
| 106 | ||
| 107 | const result = encryptSecret(plaintext); | |
| 108 | if (!result.ok) { | |
| 109 | throw new Error(`Encryption failed: ${result.error}`); | |
| 110 | } | |
| 111 | ||
| 112 | // keyHint: last 4 chars of the plaintext, displayed in the UI | |
| 113 | const keyHint = | |
| 114 | plaintext.length >= 4 ? plaintext.slice(-4) : "*".repeat(plaintext.length); | |
| 115 | ||
| 116 | // The encrypted blob from encryptSecret is a single base64 string that | |
| 117 | // already contains [iv(12) || tag(16) || ciphertext]. We store it in | |
| 118 | // `encrypted_value` and leave `iv` as an empty string to satisfy the | |
| 119 | // NOT NULL constraint (the column was part of the original schema spec | |
| 120 | // but the crypto module packs iv into the ciphertext blob). | |
| 121 | const now = new Date(); | |
| 122 | ||
| 123 | // Delete existing row for this (orgId, name) if present, then insert. | |
| 124 | await db | |
| 125 | .delete(orgSecrets) | |
| 126 | .where(and(eq(orgSecrets.orgId, orgId), eq(orgSecrets.name, name))); | |
| 127 | ||
| 128 | await db.insert(orgSecrets).values({ | |
| 129 | orgId, | |
| 130 | name, | |
| 131 | encryptedValue: result.ciphertext, | |
| 132 | iv: "", // iv is packed into encryptedValue by encryptSecret | |
| 133 | keyHint, | |
| 134 | createdBy, | |
| 135 | createdAt: now, | |
| 136 | updatedAt: now, | |
| 137 | }); | |
| 138 | } | |
| 139 | ||
| 140 | /** | |
| 141 | * Delete a specific secret, scoped by both orgId and secretId so a caller | |
| 142 | * with one org's context cannot delete another org's secret by guessing a UUID. | |
| 143 | */ | |
| 144 | export async function deleteOrgSecret( | |
| 145 | orgId: string, | |
| 146 | secretId: string | |
| 147 | ): Promise<void> { | |
| 148 | await db | |
| 149 | .delete(orgSecrets) | |
| 150 | .where(and(eq(orgSecrets.orgId, orgId), eq(orgSecrets.id, secretId))); | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * Load all org secrets as a `{ NAME: plaintext }` map. | |
| 155 | * Called by the workflow runner to merge org-level secrets with repo-level ones | |
| 156 | * (repo-level secrets of the same name take precedence — callers apply that | |
| 157 | * merge logic themselves via `{ ...orgSecrets, ...repoSecrets }`). | |
| 158 | * | |
| 159 | * Swallows all errors and returns `{}` on failure so a misconfigured master | |
| 160 | * key or a transient DB outage never aborts a workflow run. | |
| 161 | */ | |
| 162 | export async function loadOrgSecretsForRepo( | |
| 163 | orgId: string | |
| 164 | ): Promise<Record<string, string>> { | |
| 165 | try { | |
| 166 | const rows = await db | |
| 167 | .select({ | |
| 168 | name: orgSecrets.name, | |
| 169 | encryptedValue: orgSecrets.encryptedValue, | |
| 170 | }) | |
| 171 | .from(orgSecrets) | |
| 172 | .where(eq(orgSecrets.orgId, orgId)); | |
| 173 | ||
| 174 | const map: Record<string, string> = {}; | |
| 175 | for (const row of rows) { | |
| 176 | const dec = decryptSecret(row.encryptedValue); | |
| 177 | if (dec.ok) { | |
| 178 | map[row.name] = dec.plaintext; | |
| 179 | } | |
| 180 | } | |
| 181 | return map; | |
| 182 | } catch (err) { | |
| 183 | console.error("[org-secrets] loadOrgSecretsForRepo:", err); | |
| 184 | return {}; | |
| 185 | } | |
| 186 | } |