Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

import-secrets.tsxBlame703 lines · 2 contributors
f390cfaCC LABS App1/**
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
29import { Hono } from "hono";
30import { and, eq } from "drizzle-orm";
31import { db } from "../db";
32import { workflowSecrets } from "../db/schema";
33import { Layout } from "../views/layout";
34import { RepoHeader, RepoNav } from "../views/components";
35import { softAuth, requireAuth } from "../middleware/auth";
36import type { AuthEnv } from "../middleware/auth";
37import { requireRepoAccess } from "../middleware/repo-access";
38import { upsertRepoSecret, listRepoSecrets } from "../lib/workflow-secrets";
39import { decryptSecret } from "../lib/workflow-secrets-crypto";
40import { audit } from "../lib/notify";
41
42const importSecretsRoutes = new Hono<AuthEnv>();
43
44importSecretsRoutes.use("*", softAuth);
45
46const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
47const MAX_VALUE_LEN = 32768;
48
54eab24Claude49// ─── Scoped CSS ─────────────────────────────────────────────────────────────
50// Every class prefixed `.is-checklist-` so this surface cannot bleed into
51// neighbouring repo pages. Mirrors the 2026 hero + numbered-step pattern
52// from `src/routes/connect-claude.tsx` and `src/routes/admin-integrations.tsx`.
53const checklistStyles = `
54 .is-checklist-wrap {
55 max-width: 820px;
56 margin: 0 auto;
57 padding: var(--space-6) var(--space-4);
58 }
59
60 /* ─── Hero ─── */
61 .is-checklist-hero {
62 position: relative;
63 margin-bottom: var(--space-5);
64 padding: var(--space-5) var(--space-6);
65 background: var(--bg-elevated);
66 border: 1px solid var(--border);
67 border-radius: 16px;
68 overflow: hidden;
69 }
70 .is-checklist-hero::before {
71 content: '';
72 position: absolute;
73 top: 0; left: 0; right: 0;
74 height: 2px;
6fd5915Claude75 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
54eab24Claude76 opacity: 0.75;
77 pointer-events: none;
78 }
79 .is-checklist-hero-orb {
80 position: absolute;
81 inset: -22% -10% auto auto;
82 width: 380px; height: 380px;
6fd5915Claude83 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
54eab24Claude84 filter: blur(80px);
85 opacity: 0.7;
86 pointer-events: none;
87 z-index: 0;
88 }
89 .is-checklist-hero-inner { position: relative; z-index: 1; }
90 .is-checklist-eyebrow {
91 display: inline-flex;
92 align-items: center;
93 gap: 8px;
94 font-family: var(--font-mono);
95 font-size: 11.5px;
96 text-transform: uppercase;
97 letter-spacing: 0.14em;
98 color: var(--text-muted);
99 font-weight: 600;
100 margin-bottom: 14px;
101 }
102 .is-checklist-eyebrow-pill {
103 display: inline-flex;
104 align-items: center;
105 justify-content: center;
106 width: 18px; height: 18px;
107 border-radius: 6px;
6fd5915Claude108 background: rgba(91,110,232,0.14);
109 color: #5b6ee8;
110 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
54eab24Claude111 }
112 .is-checklist-title {
113 font-size: clamp(26px, 3.6vw, 36px);
114 font-family: var(--font-display);
115 font-weight: 800;
116 letter-spacing: -0.028em;
117 line-height: 1.05;
118 margin: 0 0 var(--space-2);
119 color: var(--text-strong);
120 }
121 .is-checklist-title-grad {
6fd5915Claude122 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
54eab24Claude123 -webkit-background-clip: text;
124 background-clip: text;
125 -webkit-text-fill-color: transparent;
126 color: transparent;
127 }
128 .is-checklist-sub {
129 font-size: 15px;
130 color: var(--text-muted);
131 margin: 0;
132 line-height: 1.55;
133 }
134 .is-checklist-sub code {
135 font-family: var(--font-mono);
136 font-size: 12.5px;
137 background: var(--bg-tertiary);
138 padding: 1px 6px;
139 border-radius: 4px;
140 color: var(--text);
141 }
142 .is-checklist-counts {
143 display: inline-flex;
144 align-items: center;
145 gap: 10px;
146 margin-top: var(--space-3);
147 padding: 6px 12px;
148 background: var(--bg-secondary);
149 border: 1px solid var(--border-subtle);
150 border-radius: 9999px;
151 font-size: 12.5px;
152 color: var(--text);
153 font-family: var(--font-mono);
154 }
155 .is-checklist-counts .num { color: var(--accent); font-weight: 700; }
156 .is-checklist-counts .sep { color: var(--text-faint); }
157
158 /* ─── Banners ─── */
159 .is-checklist-banner {
160 margin-bottom: var(--space-4);
161 padding: 10px 14px;
162 border-radius: 10px;
163 font-size: 13.5px;
164 border: 1px solid var(--border);
165 background: rgba(255,255,255,0.025);
166 color: var(--text);
167 }
168 .is-checklist-banner.is-ok {
169 border-color: rgba(52,211,153,0.40);
170 background: rgba(52,211,153,0.08);
171 color: #bbf7d0;
172 }
173 .is-checklist-banner.is-error {
174 border-color: rgba(248,113,113,0.40);
175 background: rgba(248,113,113,0.08);
176 color: #fecaca;
177 }
178 .is-checklist-banner code {
179 font-family: var(--font-mono);
180 background: rgba(0,0,0,0.18);
181 padding: 1px 6px;
182 border-radius: 4px;
183 }
184
185 /* ─── Numbered checklist cards ─── */
186 .is-checklist-list {
187 display: flex;
188 flex-direction: column;
189 gap: var(--space-3);
190 margin-top: var(--space-4);
191 }
192 .is-checklist-card {
193 position: relative;
194 padding: var(--space-4) var(--space-5);
195 background: var(--bg-elevated);
196 border: 1px solid var(--border);
197 border-radius: 14px;
198 transition: border-color 150ms ease, transform 150ms ease;
199 }
200 .is-checklist-card:hover { border-color: var(--border-strong); }
201 .is-checklist-card.is-pasted { border-color: rgba(52,211,153,0.30); }
202
203 .is-checklist-card-head {
204 display: flex;
205 align-items: center;
206 gap: 12px;
207 margin-bottom: 10px;
208 }
209 .is-checklist-num {
210 display: inline-flex;
211 align-items: center;
212 justify-content: center;
213 width: 26px; height: 26px;
214 border-radius: 50%;
6fd5915Claude215 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
54eab24Claude216 color: #c5b3ff;
6fd5915Claude217 border: 1px solid rgba(91,110,232,0.40);
54eab24Claude218 font-family: var(--font-display);
219 font-weight: 700;
220 font-size: 13px;
221 flex-shrink: 0;
222 }
223 .is-checklist-card-name {
224 font-family: var(--font-mono);
225 font-size: 14px;
226 color: var(--text-strong);
227 font-weight: 600;
228 word-break: break-all;
229 flex: 1;
230 }
231
232 .is-checklist-pill {
233 display: inline-flex;
234 align-items: center;
235 gap: 6px;
236 padding: 2px 9px;
237 border-radius: 9999px;
238 font-size: 11px;
239 font-weight: 600;
240 letter-spacing: 0.04em;
241 text-transform: uppercase;
242 flex-shrink: 0;
243 }
244 .is-checklist-pill .dot {
245 width: 6px; height: 6px;
246 border-radius: 50%;
247 background: currentColor;
248 }
249 .is-checklist-pill.is-saved {
250 color: #6ee7b7;
251 background: rgba(52,211,153,0.14);
252 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
253 }
254 .is-checklist-pill.is-empty {
255 color: #fde68a;
256 background: rgba(251,191,36,0.10);
257 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
258 }
259
260 .is-checklist-form {
261 display: flex;
262 gap: 8px;
263 align-items: stretch;
264 flex-wrap: wrap;
265 }
266 .is-checklist-input {
267 flex: 1;
268 min-width: 220px;
269 padding: 9px 12px;
270 font-size: 13.5px;
271 color: var(--text);
272 background: var(--bg);
273 border: 1px solid var(--border-strong);
274 border-radius: 8px;
275 outline: none;
276 font-family: var(--font-mono);
277 transition: border-color 120ms ease, box-shadow 120ms ease;
278 box-sizing: border-box;
279 }
280 .is-checklist-input:focus {
281 border-color: var(--border-focus);
6fd5915Claude282 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
54eab24Claude283 }
284 .is-checklist-save {
285 appearance: none;
286 border: 1px solid var(--border-strong);
287 background: var(--bg-secondary);
288 color: var(--text);
289 padding: 9px 16px;
290 border-radius: 8px;
291 font-family: inherit;
292 font-size: 13.5px;
293 font-weight: 600;
294 cursor: pointer;
295 transition: border-color 150ms ease, background 150ms ease;
296 }
297 .is-checklist-save:hover {
298 border-color: var(--border-focus);
299 background: rgba(255,255,255,0.03);
300 }
301
302 /* ─── Done bar ─── */
303 .is-checklist-done {
304 margin-top: var(--space-6);
305 padding: var(--space-4) var(--space-5);
306 background: var(--bg-elevated);
307 border: 1px solid var(--border);
308 border-radius: 14px;
309 display: flex;
310 align-items: center;
311 gap: var(--space-4);
312 flex-wrap: wrap;
313 }
314 .is-checklist-done-text {
315 flex: 1;
316 min-width: 220px;
317 font-size: 13.5px;
318 color: var(--text-muted);
319 line-height: 1.5;
320 }
321 .is-checklist-done-text strong {
322 color: var(--text-strong);
323 font-weight: 600;
324 }
325 .is-checklist-done-actions {
326 display: flex;
327 align-items: center;
328 gap: 12px;
329 flex-wrap: wrap;
330 }
331 .is-checklist-done-cleanup {
332 display: inline-flex;
333 align-items: center;
334 gap: 8px;
335 font-size: 12.5px;
336 color: var(--text-muted);
337 cursor: pointer;
338 user-select: none;
339 }
340 .is-checklist-done-cleanup input[type="checkbox"] {
341 width: 15px; height: 15px;
342 accent-color: var(--accent);
343 cursor: pointer;
344 }
345 .is-checklist-done-btn {
346 appearance: none;
347 padding: 11px 20px;
348 border-radius: 10px;
349 font-family: inherit;
350 font-size: 14px;
351 font-weight: 700;
352 letter-spacing: -0.005em;
353 cursor: pointer;
354 color: #fff;
6fd5915Claude355 border: 1px solid rgba(91,110,232,0.55);
356 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
357 box-shadow: 0 4px 14px rgba(91,110,232,0.32);
54eab24Claude358 transition: transform 150ms ease, box-shadow 150ms ease, filter 150ms ease;
359 }
360 .is-checklist-done-btn:hover {
361 transform: translateY(-1px);
6fd5915Claude362 box-shadow: 0 6px 18px rgba(91,110,232,0.42);
54eab24Claude363 filter: brightness(1.06);
364 }
365 .is-checklist-done-btn:active { transform: translateY(0); }
366`;
367
f390cfaCC LABS App368type ChecklistRow = {
369 id: string;
370 name: string;
371 status: "pasted" | "empty";
372};
373
374/**
375 * Load each secret row for the repo and decrypt it to determine empty
376 * (placeholder) vs filled. Returns an empty array on any DB or decrypt
377 * failure — the route handler degrades to "no rows" rather than erroring,
378 * because this UI is an optional post-import polish step.
379 */
380async function loadChecklist(repoId: string): Promise<ChecklistRow[]> {
381 let rows: { id: string; name: string; encryptedValue: string }[];
382 try {
383 rows = await db
384 .select({
385 id: workflowSecrets.id,
386 name: workflowSecrets.name,
387 encryptedValue: workflowSecrets.encryptedValue,
388 })
389 .from(workflowSecrets)
390 .where(eq(workflowSecrets.repositoryId, repoId));
391 } catch {
392 return [];
393 }
394
395 // Sort: empty placeholders first (so the user works through them), then
396 // filled rows alphabetically. Stable within each group.
397 const out: ChecklistRow[] = rows.map((r) => {
398 const dec = decryptSecret(r.encryptedValue);
399 const status: "pasted" | "empty" =
400 dec.ok && dec.plaintext === "" ? "empty" : "pasted";
401 return { id: r.id, name: r.name, status };
402 });
403 out.sort((a, b) => {
404 if (a.status !== b.status) return a.status === "empty" ? -1 : 1;
405 return a.name.localeCompare(b.name);
406 });
407 return out;
408}
409
410// ─── GET: Checklist ─────────────────────────────────────────────────────────
411
412importSecretsRoutes.get(
413 "/:owner/:repo/import/secrets",
414 requireAuth,
415 requireRepoAccess("admin"),
416 async (c) => {
417 const { owner: ownerName, repo: repoName } = c.req.param();
418 const user = c.get("user")!;
419 const repo = c.get("repository") as { id: string } | undefined;
420 if (!repo) return c.notFound();
421
422 const saved = c.req.query("saved");
423 const error = c.req.query("error");
424
425 const rows = await loadChecklist(repo.id);
426 const emptyCount = rows.filter((r) => r.status === "empty").length;
427 const pastedCount = rows.length - emptyCount;
428
429 // If the repo has zero placeholders at all (e.g. the user came back to
609a27aClaude430 // this URL after finishing earlier, or the secrets migration didn't
431 // actually find any rows — empty repo, no token, decrypt failure),
432 // redirect back to /import with a success banner explaining what
433 // happened. Previously this redirected silently to the repo home with
434 // no feedback, so users didn't know whether secrets had migrated or
435 // not.
f390cfaCC LABS App436 if (rows.length === 0) {
609a27aClaude437 return c.redirect(
438 `/import?success=${encodeURIComponent(
439 `Import of ${ownerName}/${repoName} succeeded. No secrets were migrated — either the GitHub repo has none, the token lacks the 'actions:read' scope, or you already pasted values in a previous session. Open the repo at /${ownerName}/${repoName} to start using it.`
440 )}`
441 );
f390cfaCC LABS App442 }
443
444 return c.html(
445 <Layout
446 title={`Import secrets — ${ownerName}/${repoName}`}
447 user={user}
448 >
449 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
450 <RepoNav owner={ownerName} repo={repoName} active="settings" />
54eab24Claude451 <style dangerouslySetInnerHTML={{ __html: checklistStyles }} />
452 <div class="is-checklist-wrap">
453 {/* ─── Hero ─── */}
454 <section class="is-checklist-hero">
455 <div class="is-checklist-hero-orb" aria-hidden="true" />
456 <div class="is-checklist-hero-inner">
457 <div class="is-checklist-eyebrow">
458 <span class="is-checklist-eyebrow-pill" aria-hidden="true">
459 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
460 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
461 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
462 </svg>
463 </span>
464 Post-import · {ownerName}/{repoName}
465 </div>
466 <h1 class="is-checklist-title">
467 Migrate{" "}
468 <span class="is-checklist-title-grad">
469 {rows.length} secret{rows.length === 1 ? "" : "s"}
470 </span>{" "}
471 from GitHub.
472 </h1>
473 <p class="is-checklist-sub">
474 We found {rows.length} Actions secret{rows.length === 1 ? "" : "s"} on your
475 GitHub repo. Paste each value below to migrate. GitHub stores values
476 opaquely — copy from wherever you originally created them
477 (1Password, .env file, etc.).
478 </p>
479 <div class="is-checklist-counts" aria-label="Migration progress">
480 <span><span class="num">{pastedCount}</span> pasted</span>
481 <span class="sep">·</span>
482 <span><span class="num">{emptyCount}</span> still empty</span>
483 </div>
484 </div>
485 </section>
f390cfaCC LABS App486
487 {saved && (
54eab24Claude488 <div class="is-checklist-banner is-ok" role="status">
f390cfaCC LABS App489 Saved <code>{decodeURIComponent(saved)}</code>.
54eab24Claude490 </div>
491 )}
492 {error && (
493 <div class="is-checklist-banner is-error" role="alert">
494 {decodeURIComponent(error)}
495 </div>
f390cfaCC LABS App496 )}
497
54eab24Claude498 {/* ─── Numbered checklist cards ─── */}
499 <div class="is-checklist-list">
500 {rows.map((row, idx) => {
501 const action = `/${ownerName}/${repoName}/import/secrets/${encodeURIComponent(row.name)}`;
502 const pasted = row.status === "pasted";
503 return (
504 <div
505 class={"is-checklist-card " + (pasted ? "is-pasted" : "is-empty")}
506 data-secret-row={row.name}
507 >
508 <div class="is-checklist-card-head">
509 <span class="is-checklist-num" aria-hidden="true">
510 {idx + 1}
511 </span>
512 <span class="is-checklist-card-name">{row.name}</span>
513 <span
514 data-status-pill
515 class={"is-checklist-pill " + (pasted ? "is-saved" : "is-empty")}
f390cfaCC LABS App516 >
54eab24Claude517 <span class="dot" aria-hidden="true" />
518 {pasted ? "Saved" : "Empty"}
519 </span>
520 </div>
521 <form method="post" action={action} class="is-checklist-form">
522 <input
523 type="password"
524 name="value"
525 required
526 maxlength={MAX_VALUE_LEN}
527 placeholder={
528 pasted
529 ? "Already saved — paste new value to overwrite"
530 : "Paste value"
531 }
532 autocomplete="off"
533 spellcheck={false}
534 class="is-checklist-input"
535 aria-label={`Value for ${row.name}`}
536 />
537 <button type="submit" class="is-checklist-save">
538 Save
539 </button>
540 </form>
541 </div>
542 );
543 })}
f390cfaCC LABS App544 </div>
545
54eab24Claude546 {/* ─── Done bar ─── */}
f390cfaCC LABS App547 <form
548 method="post"
549 action={`/${ownerName}/${repoName}/import/secrets/done`}
54eab24Claude550 class="is-checklist-done"
f390cfaCC LABS App551 >
54eab24Claude552 <div class="is-checklist-done-text">
553 {emptyCount > 0 ? (
554 <>
555 <strong>{emptyCount}</strong> placeholder{emptyCount === 1 ? "" : "s"} still empty —
556 finish later via{" "}
557 <code>/{ownerName}/{repoName}/settings/secrets</code>.
558 </>
559 ) : (
560 <>All secrets migrated. <strong>Ship it.</strong></>
561 )}
562 </div>
563 <div class="is-checklist-done-actions">
564 {emptyCount > 0 && (
565 <label class="is-checklist-done-cleanup">
566 <input type="checkbox" name="cleanup_empty" value="1" />
567 Delete the {emptyCount} empty placeholder{emptyCount === 1 ? "" : "s"}
568 </label>
569 )}
570 <button type="submit" class="is-checklist-done-btn">
571 Done — go to repo
572 </button>
573 </div>
f390cfaCC LABS App574 </form>
54eab24Claude575 </div>
f390cfaCC LABS App576 </Layout>
577 );
578 }
579);
580
581// ─── POST: Save one value ───────────────────────────────────────────────────
582
583importSecretsRoutes.post(
584 "/:owner/:repo/import/secrets/done",
585 requireAuth,
586 requireRepoAccess("admin"),
587 async (c) => {
588 const { owner: ownerName, repo: repoName } = c.req.param();
589 const user = c.get("user")!;
590 const repo = c.get("repository") as { id: string } | undefined;
591 if (!repo) return c.notFound();
592
593 const body = await c.req.parseBody();
594 const cleanup = String(body.cleanup_empty || "") === "1";
595
596 if (cleanup) {
597 try {
598 const meta = await listRepoSecrets(repo.id);
599 if (meta.ok) {
600 // Decrypt each row to find empty placeholders, then delete them
601 // by id+repo (defensive scope, same shape as deleteRepoSecret).
602 const rows = await db
603 .select({
604 id: workflowSecrets.id,
605 name: workflowSecrets.name,
606 encryptedValue: workflowSecrets.encryptedValue,
607 })
608 .from(workflowSecrets)
609 .where(eq(workflowSecrets.repositoryId, repo.id));
610 let deleted = 0;
611 for (const r of rows) {
612 const dec = decryptSecret(r.encryptedValue);
613 if (dec.ok && dec.plaintext === "") {
614 await db
615 .delete(workflowSecrets)
616 .where(
617 and(
618 eq(workflowSecrets.id, r.id),
619 eq(workflowSecrets.repositoryId, repo.id)
620 )
621 );
622 deleted++;
623 }
624 }
625 if (deleted > 0) {
626 try {
627 await audit({
628 userId: user.id,
629 repositoryId: repo.id,
630 action: "workflow.secret.import_cleanup",
631 targetType: "repository",
632 targetId: repo.id,
633 metadata: { deleted },
634 });
635 } catch {
636 // best-effort
637 }
638 }
639 }
640 } catch {
641 // Cleanup errors never block the redirect — user can re-run via
642 // the regular /settings/secrets UI.
643 }
644 }
645
646 return c.redirect(`/${ownerName}/${repoName}`);
647 }
648);
649
650importSecretsRoutes.post(
651 "/:owner/:repo/import/secrets/:name",
652 requireAuth,
653 requireRepoAccess("admin"),
654 async (c) => {
655 const { owner: ownerName, repo: repoName } = c.req.param();
656 const rawName = c.req.param("name");
657 const user = c.get("user")!;
658 const repo = c.get("repository") as { id: string } | undefined;
659 if (!repo) return c.notFound();
660
661 const base = `/${ownerName}/${repoName}/import/secrets`;
662 const fail = (msg: string) =>
663 c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
664
665 const name = (rawName || "").trim();
666 if (!name || !SECRET_NAME_RE.test(name)) {
667 return fail("Invalid secret name");
668 }
669
670 const body = await c.req.parseBody();
671 const value = typeof body.value === "string" ? body.value : "";
672
673 if (!value) return fail("Value is required");
674 if (value.length > MAX_VALUE_LEN)
675 return fail(`Value must be <= ${MAX_VALUE_LEN} characters`);
676
677 const result = await upsertRepoSecret({
678 repoId: repo.id,
679 name,
680 value,
681 createdBy: user.id,
682 });
683
684 if (!result.ok) return fail(result.error);
685
686 try {
687 await audit({
688 userId: user.id,
689 repositoryId: repo.id,
690 action: "workflow.secret.import_pasted",
691 targetType: "workflow_secret",
692 targetId: result.id,
693 metadata: { name },
694 });
695 } catch {
696 // best-effort
697 }
698
699 return c.redirect(`${base}?saved=${encodeURIComponent(name)}`);
700 }
701);
702
703export default importSecretsRoutes;