CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
import-secrets.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| f390cfa | 1 | /** |
| 2 | * Block T1 — GitHub Actions secret-migration checklist UI. | |
| 3 | * | |
| 4 | * After a user imports a GitHub repo (see `src/routes/import.tsx`), if their | |
| 5 | * GitHub repo had any Actions secrets we pre-create matching placeholder | |
| 6 | * rows in `workflow_secrets` (via `src/lib/github-secrets-import.ts`) and | |
| 7 | * redirect to `GET /:owner/:repo/import/secrets`. This route renders the | |
| 8 | * one-shot checklist — name + status pill + a password input per row + a | |
| 9 | * "Save" button per row. The Done button always works, even with empty | |
| 10 | * placeholders remaining, because users can come back later via the | |
| 11 | * regular `/settings/secrets` page. | |
| 12 | * | |
| 13 | * Why a separate route from `workflow-secrets.tsx`? | |
| 14 | * - Different UX shape (vertical checklist vs add-form-on-top table) | |
| 15 | * - Different copy ("we found N secrets from your GitHub repo") | |
| 16 | * - Different one-shot lifecycle (cleanup empty placeholders on Done) | |
| 17 | * | |
| 18 | * Empty-vs-filled detection: we decrypt each row and check if plaintext | |
| 19 | * is the empty string. Cheap, accurate, and avoids leaking the "is this | |
| 20 | * a placeholder?" boolean as a database column (which would also force | |
| 21 | * a migration for a feature we shipped behaviourally). | |
| 22 | * | |
| 23 | * CSRF: every POST is guarded by the global `csrfProtect` middleware | |
| 24 | * (see `src/middleware/csrf.ts`); same-origin Origin/Referer check is the | |
| 25 | * primary defence. Each value submission still goes through the existing | |
| 26 | * `upsertRepoSecret` encryption layer — no plaintext storage. | |
| 27 | */ | |
| 28 | ||
| 29 | import { Hono } from "hono"; | |
| 30 | import { and, eq } from "drizzle-orm"; | |
| 31 | import { db } from "../db"; | |
| 32 | import { workflowSecrets } from "../db/schema"; | |
| 33 | import { Layout } from "../views/layout"; | |
| 34 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 35 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 36 | import type { AuthEnv } from "../middleware/auth"; | |
| 37 | import { requireRepoAccess } from "../middleware/repo-access"; | |
| 38 | import { Alert, Container, Text } from "../views/ui"; | |
| 39 | import { upsertRepoSecret, listRepoSecrets } from "../lib/workflow-secrets"; | |
| 40 | import { decryptSecret } from "../lib/workflow-secrets-crypto"; | |
| 41 | import { audit } from "../lib/notify"; | |
| 42 | ||
| 43 | const importSecretsRoutes = new Hono<AuthEnv>(); | |
| 44 | ||
| 45 | importSecretsRoutes.use("*", softAuth); | |
| 46 | ||
| 47 | const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; | |
| 48 | const MAX_VALUE_LEN = 32768; | |
| 49 | ||
| 50 | type ChecklistRow = { | |
| 51 | id: string; | |
| 52 | name: string; | |
| 53 | status: "pasted" | "empty"; | |
| 54 | }; | |
| 55 | ||
| 56 | /** | |
| 57 | * Load each secret row for the repo and decrypt it to determine empty | |
| 58 | * (placeholder) vs filled. Returns an empty array on any DB or decrypt | |
| 59 | * failure — the route handler degrades to "no rows" rather than erroring, | |
| 60 | * because this UI is an optional post-import polish step. | |
| 61 | */ | |
| 62 | async function loadChecklist(repoId: string): Promise<ChecklistRow[]> { | |
| 63 | let rows: { id: string; name: string; encryptedValue: string }[]; | |
| 64 | try { | |
| 65 | rows = await db | |
| 66 | .select({ | |
| 67 | id: workflowSecrets.id, | |
| 68 | name: workflowSecrets.name, | |
| 69 | encryptedValue: workflowSecrets.encryptedValue, | |
| 70 | }) | |
| 71 | .from(workflowSecrets) | |
| 72 | .where(eq(workflowSecrets.repositoryId, repoId)); | |
| 73 | } catch { | |
| 74 | return []; | |
| 75 | } | |
| 76 | ||
| 77 | // Sort: empty placeholders first (so the user works through them), then | |
| 78 | // filled rows alphabetically. Stable within each group. | |
| 79 | const out: ChecklistRow[] = rows.map((r) => { | |
| 80 | const dec = decryptSecret(r.encryptedValue); | |
| 81 | const status: "pasted" | "empty" = | |
| 82 | dec.ok && dec.plaintext === "" ? "empty" : "pasted"; | |
| 83 | return { id: r.id, name: r.name, status }; | |
| 84 | }); | |
| 85 | out.sort((a, b) => { | |
| 86 | if (a.status !== b.status) return a.status === "empty" ? -1 : 1; | |
| 87 | return a.name.localeCompare(b.name); | |
| 88 | }); | |
| 89 | return out; | |
| 90 | } | |
| 91 | ||
| 92 | // ─── GET: Checklist ───────────────────────────────────────────────────────── | |
| 93 | ||
| 94 | importSecretsRoutes.get( | |
| 95 | "/:owner/:repo/import/secrets", | |
| 96 | requireAuth, | |
| 97 | requireRepoAccess("admin"), | |
| 98 | async (c) => { | |
| 99 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 100 | const user = c.get("user")!; | |
| 101 | const repo = c.get("repository") as { id: string } | undefined; | |
| 102 | if (!repo) return c.notFound(); | |
| 103 | ||
| 104 | const saved = c.req.query("saved"); | |
| 105 | const error = c.req.query("error"); | |
| 106 | ||
| 107 | const rows = await loadChecklist(repo.id); | |
| 108 | const emptyCount = rows.filter((r) => r.status === "empty").length; | |
| 109 | const pastedCount = rows.length - emptyCount; | |
| 110 | ||
| 111 | // If the repo has zero placeholders at all (e.g. the user came back to | |
| 112 | // this URL after finishing earlier), short-circuit to the regular | |
| 113 | // settings page so they're not stuck on a screen showing no rows. | |
| 114 | if (rows.length === 0) { | |
| 115 | return c.redirect(`/${ownerName}/${repoName}`); | |
| 116 | } | |
| 117 | ||
| 118 | return c.html( | |
| 119 | <Layout | |
| 120 | title={`Import secrets — ${ownerName}/${repoName}`} | |
| 121 | user={user} | |
| 122 | > | |
| 123 | <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} /> | |
| 124 | <RepoNav owner={ownerName} repo={repoName} active="settings" /> | |
| 125 | <Container maxWidth={780}> | |
| 126 | <div | |
| 127 | style="position: sticky; top: 0; background: var(--bg); padding: 20px 0 16px; border-bottom: 1px solid var(--border); margin-bottom: 20px; z-index: 5" | |
| 128 | > | |
| 129 | <h2 style="margin-bottom: 6px"> | |
| 130 | Migrate {rows.length} secret{rows.length === 1 ? "" : "s"} from GitHub | |
| 131 | </h2> | |
| 132 | <Text size={14} muted style="display:block;margin-bottom:8px"> | |
| 133 | We found {rows.length} Actions secret{rows.length === 1 ? "" : "s"} on | |
| 134 | your GitHub repo. Paste each value below to migrate them.{" "} | |
| 135 | <strong> | |
| 136 | {pastedCount} pasted · {emptyCount} still empty | |
| 137 | </strong> | |
| 138 | . | |
| 139 | </Text> | |
| 140 | <Text size={13} muted style="display:block"> | |
| 141 | Need the value? Find it in{" "} | |
| 142 | <code style="font-family: var(--font-mono); background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px"> | |
| 143 | github.com/{ownerName}/{repoName}/settings/secrets/actions | |
| 144 | </code> | |
| 145 | {" "}— each value is opaque so you can't read it from GitHub, you'll | |
| 146 | need to copy from wherever you originally created it | |
| 147 | (1Password, .env file, etc.). | |
| 148 | </Text> | |
| 149 | </div> | |
| 150 | ||
| 151 | {saved && ( | |
| 152 | <Alert variant="success"> | |
| 153 | Saved <code>{decodeURIComponent(saved)}</code>. | |
| 154 | </Alert> | |
| 155 | )} | |
| 156 | {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>} | |
| 157 | ||
| 158 | <div | |
| 159 | class="panel" | |
| 160 | style="padding: 0; overflow: hidden; margin-top: 8px" | |
| 161 | > | |
| 162 | <table | |
| 163 | class="file-table" | |
| 164 | style="width: 100%; border-collapse: collapse" | |
| 165 | > | |
| 166 | <thead> | |
| 167 | <tr style="background: var(--bg-secondary); text-align: left"> | |
| 168 | <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); width: 40%"> | |
| 169 | Secret name | |
| 170 | </th> | |
| 171 | <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); width: 15%"> | |
| 172 | Status | |
| 173 | </th> | |
| 174 | <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)"> | |
| 175 | Value | |
| 176 | </th> | |
| 177 | </tr> | |
| 178 | </thead> | |
| 179 | <tbody> | |
| 180 | {rows.map((row) => { | |
| 181 | const action = `/${ownerName}/${repoName}/import/secrets/${encodeURIComponent(row.name)}`; | |
| 182 | const pillStyle = | |
| 183 | row.status === "pasted" | |
| 184 | ? "background: var(--green-dim, #1f3d2a); color: var(--green, #3fb950); border: 1px solid var(--green, #3fb950)" | |
| 185 | : "background: var(--yellow-dim, #3d3520); color: var(--yellow, #d29922); border: 1px solid var(--yellow, #d29922)"; | |
| 186 | return ( | |
| 187 | <tr | |
| 188 | data-secret-row={row.name} | |
| 189 | style="border-top: 1px solid var(--border); vertical-align: middle" | |
| 190 | > | |
| 191 | <td style="padding: 10px 14px"> | |
| 192 | <code style="font-family: var(--font-mono); font-size: 13px"> | |
| 193 | {row.name} | |
| 194 | </code> | |
| 195 | </td> | |
| 196 | <td style="padding: 10px 14px"> | |
| 197 | <span | |
| 198 | data-status-pill | |
| 199 | style={ | |
| 200 | "display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; " + | |
| 201 | pillStyle | |
| 202 | } | |
| 203 | > | |
| 204 | {row.status === "pasted" ? "Pasted" : "Empty"} | |
| 205 | </span> | |
| 206 | </td> | |
| 207 | <td style="padding: 10px 14px"> | |
| 208 | <form method="post" action={action} style="display: flex; gap: 8px"> | |
| 209 | <input | |
| 210 | type="password" | |
| 211 | name="value" | |
| 212 | required | |
| 213 | maxlength={MAX_VALUE_LEN} | |
| 214 | placeholder={ | |
| 215 | row.status === "pasted" | |
| 216 | ? "Already saved — paste new value to overwrite" | |
| 217 | : "Paste value" | |
| 218 | } | |
| 219 | autocomplete="off" | |
| 220 | spellcheck={false} | |
| 221 | style="flex: 1; font-family: var(--font-mono); font-size: 13px; padding: 6px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)" | |
| 222 | /> | |
| 223 | <button | |
| 224 | type="submit" | |
| 225 | class="btn btn-primary" | |
| 226 | style="padding: 6px 14px" | |
| 227 | > | |
| 228 | Save | |
| 229 | </button> | |
| 230 | </form> | |
| 231 | </td> | |
| 232 | </tr> | |
| 233 | ); | |
| 234 | })} | |
| 235 | </tbody> | |
| 236 | </table> | |
| 237 | </div> | |
| 238 | ||
| 239 | <form | |
| 240 | method="post" | |
| 241 | action={`/${ownerName}/${repoName}/import/secrets/done`} | |
| 242 | style="margin-top: 24px; display: flex; gap: 12px; align-items: center" | |
| 243 | > | |
| 244 | <button type="submit" class="btn btn-primary"> | |
| 245 | Done — take me to my repo | |
| 246 | </button> | |
| 247 | <label style="font-size: 13px; color: var(--text-muted); display: flex; align-items: center; gap: 6px"> | |
| 248 | <input | |
| 249 | type="checkbox" | |
| 250 | name="cleanup_empty" | |
| 251 | value="1" | |
| 252 | /> | |
| 253 | Also delete the {emptyCount} empty placeholder{emptyCount === 1 ? "" : "s"} on my way out | |
| 254 | </label> | |
| 255 | </form> | |
| 256 | </Container> | |
| 257 | </Layout> | |
| 258 | ); | |
| 259 | } | |
| 260 | ); | |
| 261 | ||
| 262 | // ─── POST: Save one value ─────────────────────────────────────────────────── | |
| 263 | ||
| 264 | importSecretsRoutes.post( | |
| 265 | "/:owner/:repo/import/secrets/done", | |
| 266 | requireAuth, | |
| 267 | requireRepoAccess("admin"), | |
| 268 | async (c) => { | |
| 269 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 270 | const user = c.get("user")!; | |
| 271 | const repo = c.get("repository") as { id: string } | undefined; | |
| 272 | if (!repo) return c.notFound(); | |
| 273 | ||
| 274 | const body = await c.req.parseBody(); | |
| 275 | const cleanup = String(body.cleanup_empty || "") === "1"; | |
| 276 | ||
| 277 | if (cleanup) { | |
| 278 | try { | |
| 279 | const meta = await listRepoSecrets(repo.id); | |
| 280 | if (meta.ok) { | |
| 281 | // Decrypt each row to find empty placeholders, then delete them | |
| 282 | // by id+repo (defensive scope, same shape as deleteRepoSecret). | |
| 283 | const rows = await db | |
| 284 | .select({ | |
| 285 | id: workflowSecrets.id, | |
| 286 | name: workflowSecrets.name, | |
| 287 | encryptedValue: workflowSecrets.encryptedValue, | |
| 288 | }) | |
| 289 | .from(workflowSecrets) | |
| 290 | .where(eq(workflowSecrets.repositoryId, repo.id)); | |
| 291 | let deleted = 0; | |
| 292 | for (const r of rows) { | |
| 293 | const dec = decryptSecret(r.encryptedValue); | |
| 294 | if (dec.ok && dec.plaintext === "") { | |
| 295 | await db | |
| 296 | .delete(workflowSecrets) | |
| 297 | .where( | |
| 298 | and( | |
| 299 | eq(workflowSecrets.id, r.id), | |
| 300 | eq(workflowSecrets.repositoryId, repo.id) | |
| 301 | ) | |
| 302 | ); | |
| 303 | deleted++; | |
| 304 | } | |
| 305 | } | |
| 306 | if (deleted > 0) { | |
| 307 | try { | |
| 308 | await audit({ | |
| 309 | userId: user.id, | |
| 310 | repositoryId: repo.id, | |
| 311 | action: "workflow.secret.import_cleanup", | |
| 312 | targetType: "repository", | |
| 313 | targetId: repo.id, | |
| 314 | metadata: { deleted }, | |
| 315 | }); | |
| 316 | } catch { | |
| 317 | // best-effort | |
| 318 | } | |
| 319 | } | |
| 320 | } | |
| 321 | } catch { | |
| 322 | // Cleanup errors never block the redirect — user can re-run via | |
| 323 | // the regular /settings/secrets UI. | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | return c.redirect(`/${ownerName}/${repoName}`); | |
| 328 | } | |
| 329 | ); | |
| 330 | ||
| 331 | importSecretsRoutes.post( | |
| 332 | "/:owner/:repo/import/secrets/:name", | |
| 333 | requireAuth, | |
| 334 | requireRepoAccess("admin"), | |
| 335 | async (c) => { | |
| 336 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 337 | const rawName = c.req.param("name"); | |
| 338 | const user = c.get("user")!; | |
| 339 | const repo = c.get("repository") as { id: string } | undefined; | |
| 340 | if (!repo) return c.notFound(); | |
| 341 | ||
| 342 | const base = `/${ownerName}/${repoName}/import/secrets`; | |
| 343 | const fail = (msg: string) => | |
| 344 | c.redirect(`${base}?error=${encodeURIComponent(msg)}`); | |
| 345 | ||
| 346 | const name = (rawName || "").trim(); | |
| 347 | if (!name || !SECRET_NAME_RE.test(name)) { | |
| 348 | return fail("Invalid secret name"); | |
| 349 | } | |
| 350 | ||
| 351 | const body = await c.req.parseBody(); | |
| 352 | const value = typeof body.value === "string" ? body.value : ""; | |
| 353 | ||
| 354 | if (!value) return fail("Value is required"); | |
| 355 | if (value.length > MAX_VALUE_LEN) | |
| 356 | return fail(`Value must be <= ${MAX_VALUE_LEN} characters`); | |
| 357 | ||
| 358 | const result = await upsertRepoSecret({ | |
| 359 | repoId: repo.id, | |
| 360 | name, | |
| 361 | value, | |
| 362 | createdBy: user.id, | |
| 363 | }); | |
| 364 | ||
| 365 | if (!result.ok) return fail(result.error); | |
| 366 | ||
| 367 | try { | |
| 368 | await audit({ | |
| 369 | userId: user.id, | |
| 370 | repositoryId: repo.id, | |
| 371 | action: "workflow.secret.import_pasted", | |
| 372 | targetType: "workflow_secret", | |
| 373 | targetId: result.id, | |
| 374 | metadata: { name }, | |
| 375 | }); | |
| 376 | } catch { | |
| 377 | // best-effort | |
| 378 | } | |
| 379 | ||
| 380 | return c.redirect(`${base}?saved=${encodeURIComponent(name)}`); | |
| 381 | } | |
| 382 | ); | |
| 383 | ||
| 384 | export default importSecretsRoutes; |