CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
workflow-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.
| abfa9ad | 1 | /** |
| 2 | * Per-repo workflow secrets — settings UI for listing, creating, and deleting | |
| 3 | * encrypted secrets that are substituted into workflow steps at runtime. | |
| 4 | * | |
| 5 | * Shape mirrors `src/routes/tokens.tsx` + `src/routes/webhooks.tsx`: | |
| 6 | * - JSX page rendered through Layout + RepoHeader + RepoNav (active=settings). | |
| 7 | * - Flash state via `?added=NAME | ?deleted=1 | ?error=...` query params. | |
| 8 | * - Every mutating route is gated on `requireAuth` + `requireRepoAccess("admin")`. | |
| 9 | * | |
| 10 | * The UI never sees plaintext values after upsert — the `listRepoSecrets` | |
| 11 | * helper in `../lib/workflow-secrets` returns metadata only (id, name, | |
| 12 | * createdAt, createdBy). This file's sole job is to render and mutate. | |
| 13 | */ | |
| 14 | ||
| 15 | import { Hono } from "hono"; | |
| 16 | import { eq, inArray } from "drizzle-orm"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { users } from "../db/schema"; | |
| 19 | import { Layout } from "../views/layout"; | |
| 20 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 21 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 22 | import type { AuthEnv } from "../middleware/auth"; | |
| 23 | import { requireRepoAccess } from "../middleware/repo-access"; | |
| 24 | import { | |
| 25 | Container, | |
| 26 | Alert, | |
| 27 | EmptyState, | |
| 28 | Button, | |
| 29 | Text, | |
| 30 | formatRelative, | |
| 31 | } from "../views/ui"; | |
| 32 | import { | |
| 33 | listRepoSecrets, | |
| 34 | upsertRepoSecret, | |
| 35 | deleteRepoSecret, | |
| 36 | } from "../lib/workflow-secrets"; | |
| 37 | import { audit } from "../lib/notify"; | |
| 38 | ||
| 39 | const workflowSecretsRoutes = new Hono<AuthEnv>(); | |
| 40 | ||
| 41 | workflowSecretsRoutes.use("*", softAuth); | |
| 42 | ||
| 43 | const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; | |
| 44 | const MAX_NAME_LEN = 100; | |
| 45 | const MAX_VALUE_LEN = 32768; | |
| 46 | ||
| 47 | /** Sub-nav shown under the main RepoNav for repo settings pages. */ | |
| 48 | function SettingsSubNav({ | |
| 49 | owner, | |
| 50 | repo, | |
| 51 | active, | |
| 52 | }: { | |
| 53 | owner: string; | |
| 54 | repo: string; | |
| 55 | active: "general" | "collaborators" | "webhooks" | "secrets"; | |
| 56 | }) { | |
| 57 | const link = ( | |
| 58 | href: string, | |
| 59 | label: string, | |
| 60 | key: "general" | "collaborators" | "webhooks" | "secrets" | |
| 61 | ) => ( | |
| 62 | <a | |
| 63 | href={href} | |
| 64 | style={ | |
| 65 | "padding: 6px 12px; border-radius: 6px; text-decoration: none; " + | |
| 66 | (active === key | |
| 67 | ? "background: var(--bg-secondary); color: var(--text); font-weight: 600" | |
| 68 | : "color: var(--text-muted)") | |
| 69 | } | |
| 70 | > | |
| 71 | {label} | |
| 72 | </a> | |
| 73 | ); | |
| 74 | return ( | |
| 75 | <div | |
| 76 | style="display: flex; gap: 4px; margin: 12px 0 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border)" | |
| 77 | > | |
| 78 | {link(`/${owner}/${repo}/settings`, "General", "general")} | |
| 79 | {link( | |
| 80 | `/${owner}/${repo}/settings/collaborators`, | |
| 81 | "Collaborators", | |
| 82 | "collaborators" | |
| 83 | )} | |
| 84 | {link( | |
| 85 | `/${owner}/${repo}/settings/webhooks`, | |
| 86 | "Webhooks", | |
| 87 | "webhooks" | |
| 88 | )} | |
| 89 | {link( | |
| 90 | `/${owner}/${repo}/settings/secrets`, | |
| 91 | "Secrets", | |
| 92 | "secrets" | |
| 93 | )} | |
| 94 | </div> | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 98 | // ─── GET: List + add form ─────────────────────────────────────────────────── | |
| 99 | ||
| 100 | workflowSecretsRoutes.get( | |
| 101 | "/:owner/:repo/settings/secrets", | |
| 102 | requireAuth, | |
| 103 | requireRepoAccess("admin"), | |
| 104 | async (c) => { | |
| 105 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 106 | const user = c.get("user")!; | |
| 107 | const repo = c.get("repository") as { id: string } | undefined; | |
| 108 | ||
| 109 | const added = c.req.query("added"); | |
| 110 | const deleted = c.req.query("deleted"); | |
| 111 | const error = c.req.query("error"); | |
| 112 | ||
| 113 | if (!repo) { | |
| 114 | return c.notFound(); | |
| 115 | } | |
| 116 | ||
| 117 | const result = await listRepoSecrets(repo.id); | |
| 118 | const secrets = result.ok ? result.secrets : []; | |
| 119 | const loadError = result.ok ? null : result.error; | |
| 120 | ||
| 121 | // Resolve createdBy user ids -> usernames for display/linking. | |
| 122 | const creatorIds = Array.from( | |
| 123 | new Set(secrets.map((s) => s.createdBy).filter(Boolean) as string[]) | |
| 124 | ); | |
| 125 | const creatorRows = creatorIds.length | |
| 126 | ? await db | |
| 127 | .select({ id: users.id, username: users.username }) | |
| 128 | .from(users) | |
| 129 | .where(inArray(users.id, creatorIds)) | |
| 130 | : []; | |
| 131 | const creatorMap = new Map(creatorRows.map((r) => [r.id, r.username])); | |
| 132 | ||
| 133 | return c.html( | |
| 134 | <Layout title={`Secrets — ${ownerName}/${repoName}`} user={user}> | |
| 135 | <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} /> | |
| 136 | <RepoNav owner={ownerName} repo={repoName} active="settings" /> | |
| 137 | <Container maxWidth={760}> | |
| 138 | <SettingsSubNav | |
| 139 | owner={ownerName} | |
| 140 | repo={repoName} | |
| 141 | active="secrets" | |
| 142 | /> | |
| 143 | ||
| 144 | <h2 style="margin-bottom: 8px">Workflow secrets</h2> | |
| 145 | <Text size={14} muted style="display:block;margin-bottom:20px"> | |
| 146 | Secrets are encrypted at rest and only decrypted at workflow | |
| 147 | runtime. They are never printed to logs. Reference them in YAML | |
| 148 | as{" "} | |
| 149 | <code style="font-family: var(--font-mono); background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px"> | |
| 150 | {"${{ secrets.NAME }}"} | |
| 151 | </code> | |
| 152 | . Values are write-only — after saving, only the name is | |
| 153 | visible. | |
| 154 | </Text> | |
| 155 | ||
| 156 | {added && ( | |
| 157 | <Alert variant="success"> | |
| 158 | Secret <code>{decodeURIComponent(added)}</code> saved. | |
| 159 | </Alert> | |
| 160 | )} | |
| 161 | {deleted && <Alert variant="success">Secret deleted.</Alert>} | |
| 162 | {error && ( | |
| 163 | <Alert variant="error">{decodeURIComponent(error)}</Alert> | |
| 164 | )} | |
| 165 | {loadError && ( | |
| 166 | <Alert variant="error"> | |
| 167 | Could not load secrets: {loadError} | |
| 168 | </Alert> | |
| 169 | )} | |
| 170 | ||
| 171 | <div class="panel" style="margin-top: 16px; padding: 0; overflow: hidden"> | |
| 172 | {secrets.length === 0 ? ( | |
| 173 | <div style="padding: 24px"> | |
| 174 | <EmptyState> | |
| 175 | <Text muted>No secrets yet.</Text> | |
| 176 | </EmptyState> | |
| 177 | </div> | |
| 178 | ) : ( | |
| 179 | <table | |
| 180 | class="file-table" | |
| 181 | style="width: 100%; border-collapse: collapse" | |
| 182 | > | |
| 183 | <thead> | |
| 184 | <tr style="background: var(--bg-secondary); text-align: left"> | |
| 185 | <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)"> | |
| 186 | Name | |
| 187 | </th> | |
| 188 | <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)"> | |
| 189 | Added by | |
| 190 | </th> | |
| 191 | <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)"> | |
| 192 | Added | |
| 193 | </th> | |
| 194 | <th style="padding: 10px 14px" /> | |
| 195 | </tr> | |
| 196 | </thead> | |
| 197 | <tbody> | |
| 198 | {secrets.map((s) => { | |
| 199 | const creator = s.createdBy | |
| 200 | ? creatorMap.get(s.createdBy) | |
| 201 | : null; | |
| 202 | return ( | |
| 203 | <tr style="border-top: 1px solid var(--border)"> | |
| 204 | <td style="padding: 10px 14px"> | |
| 205 | <code | |
| 206 | style="font-family: var(--font-mono); font-size: 13px" | |
| 207 | > | |
| 208 | {s.name} | |
| 209 | </code> | |
| 210 | </td> | |
| 211 | <td style="padding: 10px 14px; font-size: 13px"> | |
| 212 | {creator ? ( | |
| 213 | <a href={`/${creator}`}>{creator}</a> | |
| 214 | ) : ( | |
| 215 | <span style="color: var(--text-muted)"> | |
| 216 | unknown | |
| 217 | </span> | |
| 218 | )} | |
| 219 | </td> | |
| 220 | <td | |
| 221 | style="padding: 10px 14px; font-size: 13px; color: var(--text-muted)" | |
| 222 | title={ | |
| 223 | s.createdAt | |
| 224 | ? new Date(s.createdAt).toISOString() | |
| 225 | : "" | |
| 226 | } | |
| 227 | > | |
| 228 | {s.createdAt | |
| 229 | ? formatRelative(s.createdAt) | |
| 230 | : "—"} | |
| 231 | </td> | |
| 232 | <td style="padding: 10px 14px; text-align: right"> | |
| 233 | <form | |
| 234 | method="post" | |
| 235 | action={`/${ownerName}/${repoName}/settings/secrets/${s.id}/delete`} | |
| 236 | style="display: inline" | |
| 237 | onsubmit={`return confirm('Delete secret ${s.name}?')`} | |
| 238 | > | |
| 239 | <Button | |
| 240 | type="submit" | |
| 241 | variant="danger" | |
| 242 | size="sm" | |
| 243 | > | |
| 244 | Delete | |
| 245 | </Button> | |
| 246 | </form> | |
| 247 | </td> | |
| 248 | </tr> | |
| 249 | ); | |
| 250 | })} | |
| 251 | </tbody> | |
| 252 | </table> | |
| 253 | )} | |
| 254 | </div> | |
| 255 | ||
| 256 | <h3 style="margin-top: 32px; margin-bottom: 4px"> | |
| 257 | Add a new secret | |
| 258 | </h3> | |
| 259 | <Text size={13} muted style="display:block;margin-bottom:12px"> | |
| 260 | Names must be uppercase letters, digits, and underscores, and | |
| 261 | cannot start with a digit. Adding a secret with an existing | |
| 262 | name replaces the stored value. | |
| 263 | </Text> | |
| 264 | <form | |
| 265 | method="post" | |
| 266 | action={`/${ownerName}/${repoName}/settings/secrets`} | |
| 267 | > | |
| 268 | <div class="form-group"> | |
| 269 | <label for="secret-name">Name</label> | |
| 270 | <input | |
| 271 | type="text" | |
| 272 | id="secret-name" | |
| 273 | name="name" | |
| 274 | required | |
| 275 | pattern="[A-Z_][A-Z0-9_]*" | |
| 276 | maxlength={MAX_NAME_LEN} | |
| 277 | placeholder="DEPLOY_TOKEN" | |
| 278 | autocomplete="off" | |
| 279 | style="font-family: var(--font-mono)" | |
| 280 | title="Uppercase letters, digits, and underscores; cannot start with a digit" | |
| 281 | /> | |
| 282 | </div> | |
| 283 | <div class="form-group"> | |
| 284 | <label for="secret-value">Value</label> | |
| 285 | <textarea | |
| 286 | id="secret-value" | |
| 287 | name="value" | |
| 288 | required | |
| 289 | rows={4} | |
| 290 | maxlength={MAX_VALUE_LEN} | |
| 291 | placeholder="Paste secret value" | |
| 292 | autocomplete="off" | |
| 293 | spellcheck={false} | |
| 294 | style="width: 100%; font-family: var(--font-mono); font-size: 13px" | |
| 295 | /> | |
| 296 | </div> | |
| 297 | <button type="submit" class="btn btn-primary"> | |
| 298 | Add secret | |
| 299 | </button> | |
| 300 | </form> | |
| 301 | </Container> | |
| 302 | </Layout> | |
| 303 | ); | |
| 304 | } | |
| 305 | ); | |
| 306 | ||
| 307 | // ─── POST: Create / update ────────────────────────────────────────────────── | |
| 308 | ||
| 309 | workflowSecretsRoutes.post( | |
| 310 | "/:owner/:repo/settings/secrets", | |
| 311 | requireAuth, | |
| 312 | requireRepoAccess("admin"), | |
| 313 | async (c) => { | |
| 314 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 315 | const user = c.get("user")!; | |
| 316 | const repo = c.get("repository") as { id: string } | undefined; | |
| 317 | if (!repo) return c.notFound(); | |
| 318 | ||
| 319 | const body = await c.req.parseBody(); | |
| 320 | const name = String(body.name || "").trim(); | |
| 321 | const value = typeof body.value === "string" ? body.value : ""; | |
| 322 | ||
| 323 | const base = `/${ownerName}/${repoName}/settings/secrets`; | |
| 324 | const fail = (msg: string) => | |
| 325 | c.redirect(`${base}?error=${encodeURIComponent(msg)}`); | |
| 326 | ||
| 327 | if (!name) return fail("Name is required"); | |
| 328 | if (name.length > MAX_NAME_LEN) | |
| 329 | return fail(`Name must be ≤ ${MAX_NAME_LEN} characters`); | |
| 330 | if (!SECRET_NAME_RE.test(name)) | |
| 331 | return fail( | |
| 332 | "Name must be uppercase letters, digits, and underscores, and cannot start with a digit" | |
| 333 | ); | |
| 334 | if (!value) return fail("Value is required"); | |
| 335 | if (value.length > MAX_VALUE_LEN) | |
| 336 | return fail(`Value must be ≤ ${MAX_VALUE_LEN} characters`); | |
| 337 | ||
| 338 | const result = await upsertRepoSecret({ | |
| 339 | repoId: repo.id, | |
| 340 | name, | |
| 341 | value, | |
| 342 | createdBy: user.id, | |
| 343 | }); | |
| 344 | ||
| 345 | if (!result.ok) return fail(result.error); | |
| 346 | ||
| 347 | // Best-effort audit — swallow any error so it never breaks the redirect. | |
| 348 | try { | |
| 349 | await audit({ | |
| 350 | userId: user.id, | |
| 351 | repositoryId: repo.id, | |
| 352 | action: "workflow.secret.create", | |
| 353 | targetType: "workflow_secret", | |
| 354 | targetId: result.id, | |
| 355 | metadata: { name }, | |
| 356 | }); | |
| 357 | } catch { | |
| 358 | // audit() already swallows errors, but guard anyway. | |
| 359 | } | |
| 360 | ||
| 361 | return c.redirect(`${base}?added=${encodeURIComponent(name)}`); | |
| 362 | } | |
| 363 | ); | |
| 364 | ||
| 365 | // ─── POST: Delete ─────────────────────────────────────────────────────────── | |
| 366 | ||
| 367 | workflowSecretsRoutes.post( | |
| 368 | "/:owner/:repo/settings/secrets/:secretId/delete", | |
| 369 | requireAuth, | |
| 370 | requireRepoAccess("admin"), | |
| 371 | async (c) => { | |
| 372 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 373 | const user = c.get("user")!; | |
| 374 | const repo = c.get("repository") as { id: string } | undefined; | |
| 375 | if (!repo) return c.notFound(); | |
| 376 | ||
| 377 | const secretId = c.req.param("secretId"); | |
| 378 | const base = `/${ownerName}/${repoName}/settings/secrets`; | |
| 379 | ||
| 380 | if (!secretId) { | |
| 381 | return c.redirect(`${base}?error=${encodeURIComponent("Missing secret id")}`); | |
| 382 | } | |
| 383 | ||
| 384 | const result = await deleteRepoSecret({ | |
| 385 | repoId: repo.id, | |
| 386 | secretId, | |
| 387 | }); | |
| 388 | ||
| 389 | if (!result.ok) { | |
| 390 | return c.redirect( | |
| 391 | `${base}?error=${encodeURIComponent(result.error)}` | |
| 392 | ); | |
| 393 | } | |
| 394 | ||
| 395 | try { | |
| 396 | await audit({ | |
| 397 | userId: user.id, | |
| 398 | repositoryId: repo.id, | |
| 399 | action: "workflow.secret.delete", | |
| 400 | targetType: "workflow_secret", | |
| 401 | targetId: secretId, | |
| 402 | }); | |
| 403 | } catch { | |
| 404 | // best-effort | |
| 405 | } | |
| 406 | ||
| 407 | return c.redirect(`${base}?deleted=1`); | |
| 408 | } | |
| 409 | ); | |
| 410 | ||
| 411 | export default workflowSecretsRoutes; |