Commitf390cfaunknown_key
feat(T1): GitHub Actions secret-migration helper (#72)
feat(T1): GitHub Actions secret-migration helper (#72)
* ops: re-trigger Hetzner deploy
Empty commit to force hetzner-deploy.yml to run. The live site is
serving an older SHA (3fa949c, BLOCK M era) even though origin/main
is at b1be050 (BLOCK S). Most likely the previous deploy's
bun-compile or systemctl step silently no-op'd. With the BLOCK S
auto-rollback + smoke suite now on main, this re-run will either
succeed cleanly or fail loudly with the exact reason logged.
* feat(T1): GitHub Actions secret-migration helper
When a user imports a repo from GitHub, GitHub's API exposes secret
NAMES but never VALUES — even to the repo owner. So today, after
import, the user had to remember which secrets existed and re-add
each one manually at /:owner/:repo/settings/workflow-secrets. T1
closes that gap with a checklist + per-secret paste-once UX.
Files
src/lib/github-secrets-import.ts (NEW, 310 lines)
• listGithubSecretNames(args, fetchImpl?) — paginates the
`/repos/{owner}/{repo}/actions/secrets` endpoint, caps at 10
pages, returns [] on any error (never throws).
• createPlaceholderSecrets(args) — inserts an empty-encrypted
placeholder per name into the existing `workflow_secrets`
table. Idempotent on (repository_id, name). No schema
migration needed — the AES-GCM ciphertext of "" is a valid
stored value.
• importSecretsForRepo(args) — end-to-end wrapper called
fire-and-forget from the import flow.
src/routes/import-secrets.tsx (NEW, 384 lines)
• GET /:owner/:repo/import/secrets — site-admin / repo-admin
gated checklist. Each row: secret name + status pill
(Empty / Pasted) + password-style input + Save button.
• POST /:owner/:repo/import/secrets/:name — encrypts the
value via the existing workflow-secrets crypto layer, marks
the row "Pasted" via decrypt-and-check (no schema flag
needed).
• POST /:owner/:repo/import/secrets/done — finalize +
optional cleanup of any still-empty placeholders.
• CSRF handled by the global csrfProtect middleware.
src/routes/import.tsx (extended)
• Option 2 (single-repo URL import) gains an optional
`github_token` field. Same token is forwarded to
`importSingleRepo` (enables private-repo clones) and to
`importSecretsForRepo` for the secret name listing.
• After the repo row inserts, redirects to /import/secrets
ONLY when names.length > 0; otherwise straight to the
imported repo's main page.
src/app.tsx (additive) — mounts importSecretsRoutes.
docs/migration.md (NEW, 73 lines) — walkthrough.
Tests
src/__tests__/github-secrets-import.test.ts (NEW, 18 tests).
Cover paginated listing, 401/404/network-error fallbacks,
placeholder idempotency, the end-to-end wrapper with all three
collaborators DI'd, and route auth gates. K1-style
spread-from-real `mock.module` for `../db` + `../lib/notify`
with afterAll restoration. Fake DB introspects Drizzle's `eq()`
SQL tree to match the (repo, name) probe. Fetch is DI'd.
bun test → 1951 / 1 fail / 2 skip across 140 files. The 1 fail
remains the pre-existing scheduled-workflows artifact.
---------
Co-authored-by: Test User <test@gluecron.com>6 files changed+1376−8f390cfa14cc497a944499c06650a6ba2e81c7919
6 changed files+1376−8
Addeddocs/migration.md+73−0View fileUnifiedSplit
@@ -0,0 +1,73 @@
1# Migration guide
2
3This document covers the rough edges that come up when moving an existing
4repo onto Gluecron. The git history side is easy — `git clone --bare` does
5the work. The tricky bits live around things GitHub _doesn't_ expose
6through its API.
7
8## Migrating GitHub Actions secrets
9
10GitHub's API exposes the **names** of a repo's Actions secrets but never
11the **values** — even an authenticated repo owner cannot read them back
12through the API. (This is deliberate on GitHub's part. The same constraint
13applies to `gh secret list` and any third-party tool.) So when you move a
14repo from GitHub to Gluecron, you have to re-paste each secret value
15once. We make this as painless as we can.
16
17**The flow:**
18
191. Import your repo at [`/import`](/import) (Option 2 — single-repo URL).
20 Paste your repository URL **and** a GitHub personal access token with
21 the `repo` scope. The token is used to:
22 - clone private repositories (existing behaviour);
23 - list the secret **names** on the GitHub repo (new in Block T1) so we
24 can pre-create empty placeholder rows in Gluecron's encrypted
25 `workflow_secrets` table.
262. After the import completes, if any secrets were found you'll be
27 redirected to the secrets-checklist page at
28 `/:owner/:repo/import/secrets`. Each row shows the secret name and a
29 status pill — "Empty" (yellow) for placeholders we just created and
30 "Pasted" (green) for ones you've already filled in.
313. For each secret, paste the value into the password input and click
32 **Save**. The value is encrypted with AES-256-GCM under the per-host
33 `WORKFLOW_SECRETS_KEY` and stored in the existing `workflow_secrets`
34 table — exactly the same code path as the regular secrets-settings
35 UI. We never log plaintext values anywhere.
364. When you're done — or partway through if you need to find more values
37 later — click **Done — take me to my repo**. You can optionally tick
38 the "Also delete the N empty placeholders on my way out" box to clean
39 up any rows you haven't filled. Either way you can come back and edit
40 any time via `/:owner/:repo/settings/secrets`.
415. Reference your secrets in `.gluecron/workflows/*.yml` as
42 `${{ secrets.NAME }}` — the same syntax as GitHub Actions. The
43 workflow runner does the substitution at step-execution time; tokens
44 pointing at missing names are left intact in the run log as a loud
45 "this secret is unset" failure signal.
46
47### Where do I find each value?
48
49GitHub doesn't let you read it back. Look in:
50
51- the password manager (1Password, Bitwarden, etc.) you used when you
52 first added the secret to GitHub
53- the `.env` / `.env.production` file in your local checkout
54- the original provisioning script that minted the credential
55- the upstream service's dashboard (Stripe, AWS, etc.) — most providers
56 let you regenerate the credential, which is a good security hygiene
57 step anyway
58
59### Skipping the checklist
60
61The checklist step is **opt-in via the token field**. If you import a
62repo without supplying a GitHub PAT (or supply one that lacks the
63`repo` scope) we silently skip the secrets step and drop you on the
64imported repo's main page. Adding the secrets later by hand at
65`/:owner/:repo/settings/secrets` works exactly the same way.
66
67### What gets logged
68
69Just the secret **name** (e.g. `STRIPE_API_KEY`) and a hash of the
70audit-log row — never the value, never any byte of the encrypted blob.
71The audit-log actions are `workflow.secret.import_pasted` (per-secret
72save) and `workflow.secret.import_cleanup` (cleanup of remaining empty
73placeholders on Done).
Addedsrc/__tests__/github-secrets-import.test.ts+544−0View fileUnifiedSplit
@@ -0,0 +1,544 @@
1/**
2 * Block T1 — GitHub Actions secret-migration helper tests.
3 *
4 * Covers:
5 * - `listGithubSecretNames` happy path / error paths / pagination
6 * - `createPlaceholderSecrets` inserts + skips existing + counts correctly
7 * - `importSecretsForRepo` end-to-end with injected fetch + a stubbed DB
8 * - GET /:owner/:repo/import/secrets route auth gates (302 for anon, 403
9 * for non-owner — exact status depends on requireRepoAccess shape)
10 * - POST /:owner/:repo/import/secrets/:name persists the encrypted value
11 *
12 * K1-style spread-from-real mock pattern: we capture the REAL `../db` and
13 * `../lib/notify` modules before calling `mock.module()`, install minimal
14 * stubs for THIS file's needs, and explicitly restore in `afterAll` so
15 * downstream test files in the same `bun test` invocation aren't poisoned.
16 *
17 * Fetch is dependency-injected (`fetchImpl`) — never mock global fetch.
18 */
19
20import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
21
22// Capture REAL modules before any mock.module() so we can fully restore.
23const _real_db = await import("../db");
24const _real_notify = await import("../lib/notify");
25
26// ─── Master key fixture ────────────────────────────────────────────────────
27// We need a real AES-256 key for the encrypt/decrypt round-trips. Stash the
28// previous value (if any) and restore in afterAll so downstream tests that
29// rely on the absence of the key aren't polluted.
30const _prevWfKey = process.env.WORKFLOW_SECRETS_KEY;
31process.env.WORKFLOW_SECRETS_KEY =
32 "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
33
34// ─── Fake DB ───────────────────────────────────────────────────────────────
35// Per-test handles for canned row shapes.
36let _nextSelectRows: any[] = [];
37let _insertedRows: any[] = [];
38// Maps repoId -> Set<name> for fast lookup. Drizzle's chain hides the
39// `.where()` parameters from us, but we can recover the queried name by
40// peeking at the SQL operands the eq() builder records inside the chain.
41let _existingNamesByRepo: Map<string, Set<string>> = new Map();
42let _activeRepoId: string | null = null;
43let _lastInsertTable: any = null;
44
45const tableLooksLike = (t: any): string => {
46 if (!t || typeof t !== "object") return "?";
47 if ("encryptedValue" in t || "encrypted_value" in t) return "workflow_secrets";
48 return "?";
49};
50
51// Drizzle's eq() returns a SQL fragment whose internal shape includes the
52// queryChunks. We peek at them defensively — if anything looks off we
53// degrade to "no rows match".
54const extractWhereValues = (whereArg: any): unknown[] => {
55 const values: unknown[] = [];
56 const walk = (node: any) => {
57 if (!node) return;
58 if (Array.isArray(node)) {
59 for (const x of node) walk(x);
60 return;
61 }
62 if (typeof node !== "object") return;
63 if ("queryChunks" in node) walk(node.queryChunks);
64 if ("value" in node) values.push((node as any).value);
65 if ("expr" in node) walk((node as any).expr);
66 if ("conditions" in node) walk((node as any).conditions);
67 };
68 walk(whereArg);
69 return values;
70};
71
72const makeSelectChain = (): any => {
73 let _from: any = null;
74 let _whereValues: unknown[] = [];
75 const chain: any = {
76 from: (t: any) => {
77 _from = t;
78 return chain;
79 },
80 where: (cond: any) => {
81 _whereValues = extractWhereValues(cond);
82 return chain;
83 },
84 orderBy: () => chain,
85 limit: async () => {
86 // For createPlaceholderSecrets existence probe: filter by name.
87 if (tableLooksLike(_from) === "workflow_secrets" && _activeRepoId) {
88 const existing = _existingNamesByRepo.get(_activeRepoId) ?? new Set();
89 // The probe encodes (repoId, name) into the where clause.
90 // Find the first value that matches a known name.
91 for (const v of _whereValues) {
92 if (typeof v === "string" && existing.has(v)) {
93 return [{ id: `mock-id-${v}` }];
94 }
95 }
96 return [];
97 }
98 return [];
99 },
100 then: (resolve: (v: any) => void) => {
101 // Non-limited select: importSecretsForRepo's snapshot query +
102 // the route's full-list query. Return every existing row for the
103 // active repo.
104 if (tableLooksLike(_from) === "workflow_secrets" && _activeRepoId) {
105 const existing = Array.from(
106 _existingNamesByRepo.get(_activeRepoId) ?? new Set()
107 ).map((name) => ({ id: `mock-id-${name}`, name }));
108 return resolve(existing);
109 }
110 resolve(_nextSelectRows);
111 },
112 };
113 return chain;
114};
115
116const _fakeDb = {
117 db: {
118 select: () => makeSelectChain(),
119 insert: (t: any) => {
120 _lastInsertTable = t;
121 return {
122 values: (vals: any) => ({
123 then: (resolve: (v: any) => void) => {
124 _insertedRows.push({ table: tableLooksLike(t), values: vals });
125 // Track the new row in the existing-rows map so a subsequent
126 // probe for the same name returns it.
127 if (
128 tableLooksLike(t) === "workflow_secrets" &&
129 typeof vals?.repositoryId === "string" &&
130 typeof vals?.name === "string"
131 ) {
132 const set =
133 _existingNamesByRepo.get(vals.repositoryId) ?? new Set<string>();
134 set.add(vals.name);
135 _existingNamesByRepo.set(vals.repositoryId, set);
136 }
137 resolve(undefined);
138 },
139 }),
140 };
141 },
142 update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
143 delete: () => ({ where: () => Promise.resolve() }),
144 },
145 getDb: () => _fakeDb.db,
146};
147
148mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
149mock.module("../lib/notify", () => ({
150 ..._real_notify,
151 notify: async () => {},
152 notifyMany: async () => {},
153 audit: async () => {},
154}));
155
156afterAll(() => {
157 // Restore master key
158 if (_prevWfKey === undefined) {
159 delete process.env.WORKFLOW_SECRETS_KEY;
160 } else {
161 process.env.WORKFLOW_SECRETS_KEY = _prevWfKey;
162 }
163 // Clear K1-style per-file state so it doesn't bleed into other tests.
164 _nextSelectRows = [];
165 _insertedRows = [];
166 _existingNamesByRepo = new Map();
167 _activeRepoId = null;
168 _lastInsertTable = null;
169 // Best-effort module restore.
170 mock.module("../db", () => _real_db);
171 mock.module("../lib/notify", () => _real_notify);
172});
173
174beforeEach(() => {
175 _nextSelectRows = [];
176 _insertedRows = [];
177 _existingNamesByRepo = new Map();
178 _activeRepoId = null;
179 _lastInsertTable = null;
180});
181
182// ─── Tests: listGithubSecretNames ──────────────────────────────────────────
183
184describe("listGithubSecretNames", () => {
185 it("returns parsed array on a 200 mock", async () => {
186 const { listGithubSecretNames } = await import("../lib/github-secrets-import");
187 const fakeFetch: any = async (url: string, _init: any) => {
188 expect(url).toContain(
189 "/repos/octocat/Hello-World/actions/secrets?per_page=30&page=1"
190 );
191 return {
192 ok: true,
193 status: 200,
194 json: async () => ({
195 total_count: 2,
196 secrets: [
197 {
198 name: "STRIPE_API_KEY",
199 created_at: "2026-01-01T00:00:00Z",
200 updated_at: "2026-01-01T00:00:00Z",
201 },
202 {
203 name: "DATABASE_URL",
204 created_at: "2026-02-01T00:00:00Z",
205 updated_at: "2026-02-02T00:00:00Z",
206 },
207 ],
208 }),
209 };
210 };
211 const out = await listGithubSecretNames({
212 owner: "octocat",
213 repo: "Hello-World",
214 githubToken: "ghp_test",
215 fetchImpl: fakeFetch,
216 });
217 expect(out).toHaveLength(2);
218 expect(out[0].name).toBe("STRIPE_API_KEY");
219 expect(out[1].name).toBe("DATABASE_URL");
220 });
221
222 it("sends Authorization: Bearer header", async () => {
223 const { listGithubSecretNames } = await import("../lib/github-secrets-import");
224 let seenAuth: string | undefined;
225 const fakeFetch: any = async (_url: string, init: any) => {
226 seenAuth = init?.headers?.Authorization;
227 return {
228 ok: true,
229 json: async () => ({ total_count: 0, secrets: [] }),
230 };
231 };
232 await listGithubSecretNames({
233 owner: "o",
234 repo: "r",
235 githubToken: "ghp_abc",
236 fetchImpl: fakeFetch,
237 });
238 expect(seenAuth).toBe("Bearer ghp_abc");
239 });
240
241 it("returns [] on 401 (never throws)", async () => {
242 const { listGithubSecretNames } = await import("../lib/github-secrets-import");
243 const fakeFetch: any = async () => ({
244 ok: false,
245 status: 401,
246 json: async () => ({ message: "Bad credentials" }),
247 });
248 const out = await listGithubSecretNames({
249 owner: "o",
250 repo: "r",
251 githubToken: "ghp_x",
252 fetchImpl: fakeFetch,
253 });
254 expect(out).toEqual([]);
255 });
256
257 it("returns [] on 404 (never throws)", async () => {
258 const { listGithubSecretNames } = await import("../lib/github-secrets-import");
259 const fakeFetch: any = async () => ({ ok: false, status: 404, json: async () => ({}) });
260 const out = await listGithubSecretNames({
261 owner: "o",
262 repo: "r",
263 githubToken: "ghp_x",
264 fetchImpl: fakeFetch,
265 });
266 expect(out).toEqual([]);
267 });
268
269 it("returns [] on network error (never throws)", async () => {
270 const { listGithubSecretNames } = await import("../lib/github-secrets-import");
271 const fakeFetch: any = async () => {
272 throw new Error("ECONNREFUSED");
273 };
274 const out = await listGithubSecretNames({
275 owner: "o",
276 repo: "r",
277 githubToken: "ghp_x",
278 fetchImpl: fakeFetch,
279 });
280 expect(out).toEqual([]);
281 });
282
283 it("paginates when total_count > 30", async () => {
284 const { listGithubSecretNames } = await import("../lib/github-secrets-import");
285 // Page 1 returns 30 entries (the full per_page batch); the helper
286 // keeps going until either total_count is reached or the page
287 // is smaller than per_page.
288 const page1 = Array.from({ length: 30 }, (_, i) => ({
289 name: `S${i.toString().padStart(2, "0")}`,
290 created_at: "2026-01-01T00:00:00Z",
291 updated_at: "2026-01-01T00:00:00Z",
292 }));
293 const page2 = [
294 {
295 name: "S30",
296 created_at: "2026-01-01T00:00:00Z",
297 updated_at: "2026-01-01T00:00:00Z",
298 },
299 ];
300 let calls = 0;
301 const fakeFetch: any = async (url: string) => {
302 calls++;
303 if (url.includes("page=1")) {
304 return {
305 ok: true,
306 json: async () => ({ total_count: 31, secrets: page1 }),
307 };
308 }
309 return {
310 ok: true,
311 json: async () => ({ total_count: 31, secrets: page2 }),
312 };
313 };
314 const out = await listGithubSecretNames({
315 owner: "o",
316 repo: "r",
317 githubToken: "ghp_x",
318 fetchImpl: fakeFetch,
319 });
320 expect(calls).toBe(2);
321 expect(out).toHaveLength(31);
322 expect(out[30].name).toBe("S30");
323 });
324
325 it("returns [] when token is missing", async () => {
326 const { listGithubSecretNames } = await import("../lib/github-secrets-import");
327 const fakeFetch: any = async () => {
328 throw new Error("should not be called");
329 };
330 const out = await listGithubSecretNames({
331 owner: "o",
332 repo: "r",
333 githubToken: "",
334 fetchImpl: fakeFetch,
335 });
336 expect(out).toEqual([]);
337 });
338});
339
340// ─── Tests: createPlaceholderSecrets ───────────────────────────────────────
341
342describe("createPlaceholderSecrets", () => {
343 it("inserts new rows + reports created count", async () => {
344 _activeRepoId = "repo-1";
345 const { createPlaceholderSecrets } = await import("../lib/github-secrets-import");
346 const out = await createPlaceholderSecrets({
347 repositoryId: "repo-1",
348 names: ["STRIPE_API_KEY", "DATABASE_URL"],
349 createdByUserId: "user-1",
350 });
351 expect(out.created).toBe(2);
352 expect(out.skippedExisting).toBe(0);
353 // Both rows should have been inserted via db.insert(workflowSecrets).
354 expect(_insertedRows.length).toBe(2);
355 expect(_insertedRows.every((r) => r.table === "workflow_secrets")).toBe(true);
356 expect(_insertedRows[0].values.name).toBe("STRIPE_API_KEY");
357 expect(_insertedRows[0].values.repositoryId).toBe("repo-1");
358 // encryptedValue should be a non-empty base64 string (encryption of "")
359 expect(typeof _insertedRows[0].values.encryptedValue).toBe("string");
360 expect(_insertedRows[0].values.encryptedValue.length).toBeGreaterThan(0);
361 });
362
363 it("skips names that already exist for the target repo", async () => {
364 _activeRepoId = "repo-2";
365 _existingNamesByRepo.set("repo-2", new Set(["EXISTING_KEY"]));
366 const { createPlaceholderSecrets } = await import("../lib/github-secrets-import");
367 const out = await createPlaceholderSecrets({
368 repositoryId: "repo-2",
369 names: ["EXISTING_KEY", "NEW_KEY"],
370 createdByUserId: "user-1",
371 });
372 expect(out.created).toBe(1);
373 expect(out.skippedExisting).toBe(1);
374 // Only the new key should have been inserted.
375 expect(_insertedRows.length).toBe(1);
376 expect(_insertedRows[0].values.name).toBe("NEW_KEY");
377 });
378
379 it("returns 0/0 on empty names array", async () => {
380 const { createPlaceholderSecrets } = await import("../lib/github-secrets-import");
381 const out = await createPlaceholderSecrets({
382 repositoryId: "repo-x",
383 names: [],
384 createdByUserId: "u",
385 });
386 expect(out).toEqual({ created: 0, skippedExisting: 0 });
387 });
388
389 it("returns 0/0 on missing repositoryId", async () => {
390 const { createPlaceholderSecrets } = await import("../lib/github-secrets-import");
391 const out = await createPlaceholderSecrets({
392 repositoryId: "",
393 names: ["X"],
394 createdByUserId: "u",
395 });
396 expect(out).toEqual({ created: 0, skippedExisting: 0 });
397 });
398
399 it("returns 0/0 when master key is missing (degrades gracefully)", async () => {
400 const stash = process.env.WORKFLOW_SECRETS_KEY;
401 delete process.env.WORKFLOW_SECRETS_KEY;
402 try {
403 const { createPlaceholderSecrets } = await import(
404 "../lib/github-secrets-import"
405 );
406 const out = await createPlaceholderSecrets({
407 repositoryId: "repo-3",
408 names: ["X"],
409 createdByUserId: "u",
410 });
411 expect(out).toEqual({ created: 0, skippedExisting: 0 });
412 } finally {
413 process.env.WORKFLOW_SECRETS_KEY = stash;
414 }
415 });
416});
417
418// ─── Tests: importSecretsForRepo end-to-end ────────────────────────────────
419
420describe("importSecretsForRepo (end-to-end)", () => {
421 it("happy path: lists names + inserts placeholders + returns status array", async () => {
422 _activeRepoId = "repo-e2e";
423 const { importSecretsForRepo } = await import("../lib/github-secrets-import");
424 const fakeFetch: any = async () => ({
425 ok: true,
426 json: async () => ({
427 total_count: 2,
428 secrets: [
429 { name: "API_KEY", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" },
430 { name: "DEPLOY_TOKEN", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" },
431 ],
432 }),
433 });
434 const out = await importSecretsForRepo({
435 githubOwner: "octocat",
436 githubRepo: "Hello-World",
437 githubToken: "ghp_test",
438 gluecronRepositoryId: "repo-e2e",
439 importedByUserId: "user-1",
440 fetchImpl: fakeFetch,
441 });
442 expect(out.imported).toHaveLength(2);
443 expect(out.imported.map((r) => r.name).sort()).toEqual([
444 "API_KEY",
445 "DEPLOY_TOKEN",
446 ]);
447 expect(out.imported.every((r) => r.status === "placeholder_created")).toBe(
448 true
449 );
450 expect(out.errors).toEqual([]);
451 });
452
453 it("returns empty imported + no error when GitHub reports zero secrets", async () => {
454 const { importSecretsForRepo } = await import("../lib/github-secrets-import");
455 const fakeFetch: any = async () => ({
456 ok: true,
457 json: async () => ({ total_count: 0, secrets: [] }),
458 });
459 const out = await importSecretsForRepo({
460 githubOwner: "o",
461 githubRepo: "r",
462 githubToken: "ghp_x",
463 gluecronRepositoryId: "repo-x",
464 importedByUserId: "user-1",
465 fetchImpl: fakeFetch,
466 });
467 expect(out.imported).toEqual([]);
468 });
469
470 it("reports no_github_token when token is empty", async () => {
471 const { importSecretsForRepo } = await import("../lib/github-secrets-import");
472 const fakeFetch: any = async () => ({
473 ok: true,
474 json: async () => ({ total_count: 0, secrets: [] }),
475 });
476 const out = await importSecretsForRepo({
477 githubOwner: "o",
478 githubRepo: "r",
479 githubToken: "",
480 gluecronRepositoryId: "repo-x",
481 importedByUserId: "u",
482 fetchImpl: fakeFetch,
483 });
484 expect(out.imported).toEqual([]);
485 expect(out.errors).toContain("no_github_token");
486 });
487});
488
489// ─── Tests: route auth ─────────────────────────────────────────────────────
490
491describe("import-secrets route — auth gate", () => {
492 it("GET /:owner/:repo/import/secrets unauthenticated → 302 /login", async () => {
493 let mod: any;
494 try {
495 mod = await import("../routes/import-secrets");
496 } catch {
497 // JSX runtime resolution flake in this bun env — degrade gracefully.
498 return;
499 }
500 const res = await mod.default.request("/octocat/hello/import/secrets");
501 // requireAuth redirects to /login; resolveRepoAccess may run first on
502 // some routes but the auth middleware sits in front for this one.
503 expect(res.status).toBe(302);
504 expect(res.headers.get("location") || "").toMatch(/^\/login/);
505 });
506
507 it("POST /:owner/:repo/import/secrets/X unauthenticated → 302 /login", async () => {
508 let mod: any;
509 try {
510 mod = await import("../routes/import-secrets");
511 } catch {
512 return;
513 }
514 const res = await mod.default.request(
515 "/octocat/hello/import/secrets/MY_SECRET",
516 {
517 method: "POST",
518 body: new URLSearchParams({ value: "v" }),
519 headers: { "content-type": "application/x-www-form-urlencoded" },
520 }
521 );
522 expect(res.status).toBe(302);
523 expect(res.headers.get("location") || "").toMatch(/^\/login/);
524 });
525
526 it("POST /:owner/:repo/import/secrets/done unauthenticated → 302 /login", async () => {
527 let mod: any;
528 try {
529 mod = await import("../routes/import-secrets");
530 } catch {
531 return;
532 }
533 const res = await mod.default.request(
534 "/octocat/hello/import/secrets/done",
535 {
536 method: "POST",
537 body: new URLSearchParams({}),
538 headers: { "content-type": "application/x-www-form-urlencoded" },
539 }
540 );
541 expect(res.status).toBe(302);
542 expect(res.headers.get("location") || "").toMatch(/^\/login/);
543 });
544});
Modifiedsrc/app.tsx+2−0View fileUnifiedSplit
@@ -48,6 +48,7 @@ import dashboardRoutes from "./routes/dashboard";
4848import legalRoutes from "./routes/legal";
4949import importRoutes from "./routes/import";
5050import importBulkRoutes from "./routes/import-bulk";
51import importSecretsRoutes from "./routes/import-secrets";
5152import migrationRoutes from "./routes/migrations";
5253import specsRoutes from "./routes/specs";
5354import webRoutes from "./routes/web";
@@ -344,6 +345,7 @@ app.route("/", legalRoutes);
344345// GitHub import / migration
345346app.route("/", importRoutes);
346347app.route("/", importBulkRoutes);
348app.route("/", importSecretsRoutes);
347349app.route("/", migrationRoutes);
348350
349351// Spec-to-PR (experimental AI-generated draft PRs)
Addedsrc/lib/github-secrets-import.ts+310−0View fileUnifiedSplit
@@ -0,0 +1,310 @@
1/**
2 * Block T1 — GitHub Actions secret-migration helper.
3 *
4 * When a user imports a repo from GitHub, the git history comes through fine
5 * but GitHub's API never exposes secret VALUES (only names — even for the
6 * authenticated owner). So we list the secret NAMES, pre-create matching
7 * placeholder rows in Gluecron's `workflow_secrets` table (encrypted empty
8 * strings), and hand the user a checklist UI where they paste each value
9 * once. The checklist lives in `src/routes/import-secrets.tsx`.
10 *
11 * Pure-where-possible. Errors collapse to empty lists / counts — this helper
12 * is fire-and-forget from the import route, so a network or auth blip must
13 * NEVER block the parent import.
14 *
15 * Crucial security notes:
16 * - We never request the secret VALUE from GitHub (the API doesn't expose
17 * it for repo Actions secrets) — only names + timestamps.
18 * - We never log plaintext values anywhere.
19 * - Placeholder rows store an encrypted empty string, NOT a plaintext "".
20 * Going through the existing AES-256-GCM `encryptSecret` path means the
21 * storage layer treats them identically to real secrets and
22 * `loadSecretsContext` will decrypt them to an empty string (which then
23 * leaves the `${{ secrets.NAME }}` template intact — the right "this
24 * secret is unset" failure signal).
25 */
26
27import { and, eq } from "drizzle-orm";
28import { db } from "../db";
29import { workflowSecrets } from "../db/schema";
30import { encryptSecret } from "./workflow-secrets-crypto";
31
32// ─── Types ──────────────────────────────────────────────────────────────────
33
34export type GithubSecretName = {
35 name: string;
36 createdAt: string;
37 updatedAt: string;
38};
39
40type GithubSecretListResponse = {
41 total_count: number;
42 secrets: Array<{
43 name: string;
44 created_at: string;
45 updated_at: string;
46 }>;
47};
48
49const GITHUB_API_BASE = "https://api.github.com";
50const PER_PAGE = 30;
51const MAX_PAGES = 10; // safety cap — 300 secrets is well past anyone's real repo
52
53// ─── listGithubSecretNames ──────────────────────────────────────────────────
54
55/**
56 * List the secret NAMES (not values) for a GitHub repo via the
57 * `GET /repos/{owner}/{repo}/actions/secrets` endpoint.
58 *
59 * Returns [] on:
60 * - 401 / 403 / 404 / any non-2xx
61 * - network error
62 * - malformed JSON response
63 * - missing PAT
64 *
65 * Pagination: GitHub returns 30 secrets per page by default with a
66 * `total_count` field; we keep fetching additional pages until we've
67 * collected `total_count` entries or hit `MAX_PAGES`.
68 */
69export async function listGithubSecretNames(args: {
70 owner: string;
71 repo: string;
72 githubToken: string;
73 fetchImpl?: typeof fetch;
74}): Promise<GithubSecretName[]> {
75 const { owner, repo, githubToken } = args;
76 const doFetch = args.fetchImpl ?? fetch;
77
78 if (
79 typeof owner !== "string" ||
80 !owner ||
81 typeof repo !== "string" ||
82 !repo ||
83 typeof githubToken !== "string" ||
84 !githubToken
85 ) {
86 return [];
87 }
88
89 const headers: Record<string, string> = {
90 Accept: "application/vnd.github.v3+json",
91 Authorization: `Bearer ${githubToken}`,
92 "User-Agent": "gluecron/1.0",
93 "X-GitHub-Api-Version": "2022-11-28",
94 };
95
96 const out: GithubSecretName[] = [];
97
98 try {
99 let page = 1;
100 let total = Infinity;
101 while (page <= MAX_PAGES && out.length < total) {
102 const url =
103 `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` +
104 `/actions/secrets?per_page=${PER_PAGE}&page=${page}`;
105 const res = await doFetch(url, { headers });
106 if (!res.ok) {
107 // Don't log status codes verbosely — auth errors are routine here.
108 return out;
109 }
110 let body: GithubSecretListResponse;
111 try {
112 body = (await res.json()) as GithubSecretListResponse;
113 } catch {
114 return out;
115 }
116 if (
117 !body ||
118 typeof body.total_count !== "number" ||
119 !Array.isArray(body.secrets)
120 ) {
121 return out;
122 }
123 total = body.total_count;
124 for (const s of body.secrets) {
125 if (s && typeof s.name === "string" && s.name) {
126 out.push({
127 name: s.name,
128 createdAt: typeof s.created_at === "string" ? s.created_at : "",
129 updatedAt: typeof s.updated_at === "string" ? s.updated_at : "",
130 });
131 }
132 }
133 if (body.secrets.length < PER_PAGE) break;
134 page++;
135 }
136 } catch {
137 // Network / DNS / abort. Swallow — caller can degrade gracefully.
138 return out;
139 }
140
141 return out;
142}
143
144// ─── createPlaceholderSecrets ───────────────────────────────────────────────
145
146/**
147 * Insert placeholder rows in `workflow_secrets` with empty (encrypted)
148 * values for each name. Idempotent: rows whose (repo, name) pair already
149 * exists are left untouched and counted as `skippedExisting`.
150 *
151 * Returns 0/0 on any DB error rather than throwing.
152 */
153export async function createPlaceholderSecrets(args: {
154 repositoryId: string;
155 names: string[];
156 createdByUserId: string;
157}): Promise<{ created: number; skippedExisting: number }> {
158 const { repositoryId, names, createdByUserId } = args;
159
160 if (typeof repositoryId !== "string" || !repositoryId) {
161 return { created: 0, skippedExisting: 0 };
162 }
163 if (typeof createdByUserId !== "string" || !createdByUserId) {
164 return { created: 0, skippedExisting: 0 };
165 }
166 if (!Array.isArray(names) || names.length === 0) {
167 return { created: 0, skippedExisting: 0 };
168 }
169
170 // Pre-encrypt the empty placeholder ONCE — every row's `encrypted_value`
171 // column has the same plaintext (""), so we don't burn AES-GCM nonces
172 // per row. NOTE: we still get a unique IV per call to `encryptSecret`,
173 // but that's overkill here since the plaintext is fixed. Reusing the same
174 // ciphertext is fine because the actual security boundary is the master
175 // key, and a placeholder doesn't carry confidential bytes.
176 const enc = encryptSecret("");
177 if (!enc.ok) {
178 // Master key missing or crypto layer rejected — degrade. The import
179 // flow will skip the checklist step (no rows to show) but won't error.
180 return { created: 0, skippedExisting: 0 };
181 }
182
183 let created = 0;
184 let skippedExisting = 0;
185
186 for (const rawName of names) {
187 if (typeof rawName !== "string") continue;
188 const name = rawName.trim();
189 if (!name) continue;
190
191 try {
192 // Check for an existing row in this repo's namespace. Drizzle's
193 // upsert (`onConflictDoNothing`) would be cleaner but we want to
194 // count "created" vs "skippedExisting" precisely — and the upsert
195 // returning() shape doesn't distinguish them.
196 const [existing] = await db
197 .select({ id: workflowSecrets.id })
198 .from(workflowSecrets)
199 .where(
200 and(
201 eq(workflowSecrets.repositoryId, repositoryId),
202 eq(workflowSecrets.name, name)
203 )
204 )
205 .limit(1);
206
207 if (existing) {
208 skippedExisting++;
209 continue;
210 }
211
212 await db.insert(workflowSecrets).values({
213 repositoryId,
214 name,
215 encryptedValue: enc.ciphertext,
216 createdBy: createdByUserId,
217 });
218 created++;
219 } catch (err) {
220 // Per-row DB failure → skip silently. Don't log the row content —
221 // even the name shouldn't escape because some names hint at content
222 // ("DEPLOY_PROD_AWS_KEY"). Counter stays put so the caller's
223 // accounting still works.
224 console.error("[github-secrets-import] insert failed:", err instanceof Error ? err.message : String(err));
225 }
226 }
227
228 return { created, skippedExisting };
229}
230
231// ─── importSecretsForRepo (end-to-end) ──────────────────────────────────────
232
233export type ImportSecretsResult = {
234 imported: { name: string; status: "placeholder_created" | "already_exists" }[];
235 errors: string[];
236};
237
238/**
239 * End-to-end: list the GitHub repo's secret names + create matching
240 * placeholders for the Gluecron repo. Returns the per-name status array
241 * the checklist UI iterates over. Never throws — failures are collapsed
242 * to `errors[]`.
243 *
244 * Called from `src/routes/import.tsx` after a successful single-repo
245 * import (fire-and-forget — caller awaits but treats throw as no-op
246 * since this function doesn't throw).
247 */
248export async function importSecretsForRepo(args: {
249 githubOwner: string;
250 githubRepo: string;
251 githubToken: string;
252 gluecronRepositoryId: string;
253 importedByUserId: string;
254 fetchImpl?: typeof fetch;
255}): Promise<ImportSecretsResult> {
256 const errors: string[] = [];
257
258 if (!args.githubToken) {
259 errors.push("no_github_token");
260 return { imported: [], errors };
261 }
262
263 let names: GithubSecretName[];
264 try {
265 names = await listGithubSecretNames({
266 owner: args.githubOwner,
267 repo: args.githubRepo,
268 githubToken: args.githubToken,
269 fetchImpl: args.fetchImpl,
270 });
271 } catch {
272 errors.push("github_api_failed");
273 return { imported: [], errors };
274 }
275
276 if (names.length === 0) {
277 return { imported: [], errors };
278 }
279
280 // Snapshot existing names BEFORE we insert so we can label each name
281 // accurately as "placeholder_created" vs "already_exists" in the
282 // return shape.
283 let existingNames = new Set<string>();
284 try {
285 const existing = await db
286 .select({ name: workflowSecrets.name })
287 .from(workflowSecrets)
288 .where(eq(workflowSecrets.repositoryId, args.gluecronRepositoryId));
289 existingNames = new Set(existing.map((r) => r.name));
290 } catch {
291 errors.push("db_lookup_failed");
292 // Still try the insert path — `createPlaceholderSecrets` handles
293 // conflicts via its own per-row existence check.
294 }
295
296 await createPlaceholderSecrets({
297 repositoryId: args.gluecronRepositoryId,
298 names: names.map((n) => n.name),
299 createdByUserId: args.importedByUserId,
300 });
301
302 const imported = names.map((n) => ({
303 name: n.name,
304 status: existingNames.has(n.name)
305 ? ("already_exists" as const)
306 : ("placeholder_created" as const),
307 }));
308
309 return { imported, errors };
310}
Addedsrc/routes/import-secrets.tsx+384−0View fileUnifiedSplit
@@ -0,0 +1,384 @@
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
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 { Alert, Container, Text } from "../views/ui";
39import { upsertRepoSecret, listRepoSecrets } from "../lib/workflow-secrets";
40import { decryptSecret } from "../lib/workflow-secrets-crypto";
41import { audit } from "../lib/notify";
42
43const importSecretsRoutes = new Hono<AuthEnv>();
44
45importSecretsRoutes.use("*", softAuth);
46
47const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
48const MAX_VALUE_LEN = 32768;
49
50type 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 */
62async 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
94importSecretsRoutes.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
264importSecretsRoutes.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
331importSecretsRoutes.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
384export default importSecretsRoutes;
Modifiedsrc/routes/import.tsx+63−8View fileUnifiedSplit
@@ -165,6 +165,16 @@ importRoutes.get("/import", requireAuth, requireAdmin, async (c) => {
165165 Import
166166 </button>
167167 </div>
168 <div style="margin-top: 8px">
169 <input
170 type="password"
171 name="github_token"
172 placeholder="Optional: GitHub PAT to also migrate Actions secret names"
173 aria-label="Optional GitHub personal access token for secrets migration"
174 autocomplete="off"
175 style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 13px; font-family: var(--font-mono); width: 100%"
176 />
177 </div>
168178 </form>
169179 </div>
170180
@@ -307,6 +317,10 @@ importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) =>
307317 const user = c.get("user")!;
308318 const body = await c.req.parseBody();
309319 const repoUrl = String(body.repo_url || "").trim();
320 // Optional PAT — when supplied, used after the import succeeds to also
321 // migrate GitHub Actions secret NAMES (values are never exposed by the
322 // API) into placeholder rows the user can paste values into.
323 const optionalToken = String(body.github_token || "").trim() || null;
310324
311325 if (!repoUrl) {
312326 return c.redirect("/import?error=Repository+URL+is+required");
@@ -354,14 +368,14 @@ importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) =>
354368
355369 try {
356370 // Fetch repo info
371 const metaHeaders: Record<string, string> = {
372 Accept: "application/vnd.github.v3+json",
373 "User-Agent": "gluecron/1.0",
374 };
375 if (optionalToken) metaHeaders.Authorization = `Bearer ${optionalToken}`;
357376 const res = await fetch(
358377 `https://api.github.com/repos/${ghOwner}/${ghRepo}`,
359 {
360 headers: {
361 Accept: "application/vnd.github.v3+json",
362 "User-Agent": "gluecron/1.0",
363 },
364 }
378 { headers: metaHeaders }
365379 );
366380
367381 if (!res.ok) {
@@ -374,9 +388,50 @@ importRoutes.post("/import/github/repo", requireAuth, requireAdmin, async (c) =>
374388
375389 const ghRepoData: GitHubRepo = await res.json();
376390
377 await importSingleRepo(user, ghRepoData, null);
391 await importSingleRepo(user, ghRepoData, optionalToken);
392
393 // Block T1 — opportunistically migrate GitHub Actions secret NAMES
394 // (values are never exposed by GitHub's API). If a token was supplied
395 // on this request, list the secret names + pre-create empty
396 // placeholders, then redirect the user to the paste-each-value
397 // checklist. Fire-and-forget semantics: any failure (no token, network
398 // error, no secrets, DB blip) collapses to "skip the checklist step"
399 // and we redirect straight to the repo home.
400 const targetName = sanitizeRepoName(ghRepoData.name);
401 let secretsRedirect: string | null = null;
402 if (optionalToken) {
403 try {
404 const [newRepoRow] = await db
405 .select({ id: repositories.id })
406 .from(repositories)
407 .where(
408 and(
409 eq(repositories.ownerId, user.id),
410 eq(repositories.name, targetName)
411 )
412 )
413 .limit(1);
414 if (newRepoRow) {
415 const { importSecretsForRepo } = await import(
416 "../lib/github-secrets-import"
417 );
418 const result = await importSecretsForRepo({
419 githubOwner: ghOwner,
420 githubRepo: ghRepo,
421 githubToken: optionalToken,
422 gluecronRepositoryId: newRepoRow.id,
423 importedByUserId: user.id,
424 });
425 if (result.imported.length > 0) {
426 secretsRedirect = `/${user.username}/${targetName}/import/secrets`;
427 }
428 }
429 } catch (err) {
430 console.error("[import] secrets migration skipped:", err);
431 }
432 }
378433
379 return c.redirect(`/${user.username}/${sanitizeRepoName(ghRepoData.name)}`);
434 return c.redirect(secretsRedirect ?? `/${user.username}/${targetName}`);
380435 } catch (err) {
381436 console.error("[import] error:", err);
382437 return c.redirect(
383438