Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit611431dunknown_key

feat(BLOCK-J): J26 audit log CSV export

feat(BLOCK-J): J26 audit log CSV export

- src/lib/audit-csv.ts pure helpers: csvCell/csvRow/csvDocument/formatAuditCsv
  with RFC 4180 quoting + CSV-injection guard (prefixes leading =+-@\t\r)
- GET /settings/audit.csv + /:owner/:repo/settings/audit.csv endpoints
  (requireAuth, owner-only for repo variant, Content-Disposition attachment,
  private+no-store caching)
- Download CSV button on both audit HTML pages
- 41 new tests: cell quoting, injection guard, row/doc assembly, filename
  sanitisation, formatAuditCsv serialisation, route smoke tests
- Full suite: 1200 -> 1241 pass
Claude committed on April 15, 2026Parent: 9090df3
4 files changed+5903611431d88548361c7d381de27fadad82836361ef
4 changed files+590−3
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
147147| Issue/PR search query DSL | ✅ | J23 — GitHub-style search DSL on the issue list. `GET /:owner/:repo/issues?q=…` accepts `is:open\|closed`, `author:<user>`, `label:<name>` (repeatable, AND), `-label:<name>` (exclude), `no:label`, `milestone:"<title>"`, `sort:created-desc\|created-asc\|updated-desc\|updated-asc\|comments-desc`, and any other text → case-insensitive substring match against title+body. Pure `tokenise` / `parseIssueQuery` / `matchIssue` / `sortIssues` / `applyQuery` / `formatIssueQuery` in `src/lib/issue-query.ts` — never throws; unknown qualifiers fall back to text; `sort:` values not in the allow-list fall back to default. The issues route joins labels, applies the DSL in-memory, and renders an inline search bar + collapsible syntax cheat-sheet + live match count. Tab pill counts remain over all issues so filters don't collapse them. |
148148| Branch rename with cascades | ✅ | J24 — `GET/POST /:owner/:repo/settings/branches` (owner-only). Pure `validateBranchName` / `planRename` / `shouldRewriteProtectionPattern` in `src/lib/branch-rename.ts` enforce `git check-ref-format(1)` (rejects `..`, `@{`, bare `@`, leading `-`, leading/trailing `.` or `/`, `//`, `.lock` suffix, whitespace, `~^:?*[\`, control chars). Git plumbing `renameBranch` (creates `refs/heads/<to>` → deletes `refs/heads/<from>` with rollback on partial failure) + `setHeadBranch` live in `src/git/repository.ts`. After the ref move the route cascades to `repositories.default_branch`, `pull_requests.base_branch` / `head_branch`, `merge_queue_entries.base_branch`, and exact-match `branch_protection.pattern` rows (globs untouched), with every rename audited as `branch.rename`. History is preserved — only the ref name changes. |
149149| Time-to-first-response metric | ✅ | J25 — `GET /:owner/:repo/insights/response-time[?window=7\|30\|90\|365\|0]` renders p50/mean/p90/fastest/slowest response times, a four-bucket distribution (≤1h, 1h–1d, 1d–1w, >1w), and the oldest-first list of open issues still waiting for a reply. Pure `computeTimeToFirstResponse` (ignores comments by the issue author, clamps negative deltas, skips unparseable dates), `computeIssueStats` (window filter), `summariseResponseTimes` (inclusive-method percentiles), `bucketResponseTimes`, `formatDuration` (`ms`/`s`/`m`/`h m`/`d h`), `buildResponseReport` one-shot, `parseWindow` (allow-list `VALID_WINDOWS`) in `src/lib/response-time.ts`. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers; DB failure degrades to empty report — never 500. |
150| Audit log CSV export | ✅ | J26 — `GET /settings/audit.csv` + `GET /:owner/:repo/settings/audit.csv` stream the audit log as RFC 4180 CSV with `Content-Disposition: attachment`. Pure `csvCell` / `csvRow` / `csvDocument` / `formatAuditCsv` / `auditCsvFilename` helpers in `src/lib/audit-csv.ts` implement CRLF termination, `"…"` wrapping + `""` escaping when cells contain `,"\n\r`, and a CSV-injection guard that prefixes any cell starting with `=+-@\t\r` with a single-quote so spreadsheet formula engines don't evaluate attacker-supplied content. Header row is `id,when,actor,action,targetType,targetId,ip,userAgent,metadata`. Same visibility rules as the HTML pages (requireAuth + owner-only for repo variant). Filename is `audit-<scope>-<ISO>.csv`. `Cache-Control: private, no-store`. |
150151| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
151152| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
152153| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
536537- `src/git/repository.ts` (Block J24 additions) — adds `renameBranch(owner, name, from, to)` and `setHeadBranch(owner, name, branch)`. `renameBranch` resolves `refs/heads/<from>` to a SHA, writes `refs/heads/<to>` at that SHA, then deletes the old ref; on partial failure it best-effort-removes the newly-created ref so the repo never ends up with both refs pointing at the same SHA. Both helpers flush `gitCache` under `${owner}/${name}:` so branch lists + default-branch lookups see the new state.
537538- `src/lib/response-time.ts` (Block J25) — pure time-to-first-response helpers. `DEFAULT_WINDOW_DAYS=30`, `VALID_WINDOWS=[0,7,30,90,365]`, `parseWindow` (allow-list validator), `computeTimeToFirstResponse({issueCreatedAt, issueAuthorId, comments})` (returns earliest non-author delta in ms or null; ignores unparseable dates; clamps negative deltas to 0), `computeIssueStats(issues, windowDays, now)` (window filter is `>= now - windowDays*24h`; `windowDays=0` means all-time; drops unparseable `createdAt`), `summariseResponseTimes(stats)` (medianMs/meanMs/p90Ms via inclusive-method percentile interpolation + `unresponded` counts only open issues with null responseMs), `bucketResponseTimes(stats)` (≤1h / >1h ≤1d / >1d ≤1w / >1w; null responseMs ignored), `formatDuration(ms\|null)` (→ `ms`/`s`/`m`/`Xh Ym`/`Xd Yh`, em-dash for null, negatives clamp to `0s`), `buildResponseReport` one-shot emits `{windowDays, now, perIssue, summary, buckets, unrepliedIssueIds}` sorted oldest-first. `__internal` re-exports for tests.
538539- `src/routes/response-time.tsx` (Block J25) — serves `GET /:owner/:repo/insights/response-time[?window=…]`. softAuth; private repos 404 for non-owner viewers. Fetches up to 2000 issues + all their comments via two queries (`inArray`), runs the pure report, renders eight KPI cards (total / responded / unreplied / median / mean / p90 / fastest / slowest), four bucket cards, and the top 25 oldest-unreplied open issues with a "waiting" duration. `resolveRepo` wraps DB in try/catch → never 500. Insights page header now links to it alongside Pulse.
540- `src/lib/audit-csv.ts` (Block J26) — pure CSV export helpers. `csvCell(value)` handles null/undefined → empty, `Date` → ISO (invalid Date → empty), RFC 4180 quoting (`,"\n\r` triggers `"..."` wrap; internal `"` doubled to `""`), and a CSV-injection guard that prefixes a cell starting with `=+-@\t\r` with a single quote (before any wrapping). `csvRow(cells)` CRLF-terminates. `csvDocument(rows)` concatenates. `AUDIT_CSV_COLUMNS = [id, when, actor, action, targetType, targetId, ip, userAgent, metadata]`. `formatAuditCsv(rows)` emits header + data rows; `normaliseCreated` accepts `Date` or ISO string and falls back to the raw string when unparseable. `auditCsvFilename(scope, now?)` emits `audit-<sanitised-scope>-<ISO>.csv` (all non-alnum in scope → `-`, empty → `audit`). `__internal` re-exports for tests.
541- `src/routes/audit.tsx` (Block J26 integration) — adds `GET /settings/audit.csv` + `GET /:owner/:repo/settings/audit.csv`, both `requireAuth`. Same filter + `LIMIT = 200` as the HTML pages. Repo variant 404s when the repo doesn't exist and 403s (`text/plain`) when the viewer isn't the owner. Response is `text/csv; charset=utf-8` with `Content-Disposition: attachment; filename="…"` and `Cache-Control: private, no-store`. HTML pages now show a "Download CSV" button next to the heading.
539542
540543### 4.7 Views (locked contracts)
541544- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
570573```bash
571574bun install
572575bun dev # hot reload
573bun test # 1200 tests currently pass
576bun test # 1241 tests currently pass
574577bun run db:migrate
575578```
576579
Addedsrc/__tests__/audit-csv.test.ts+321−0View fileUnifiedSplit
1/**
2 * Block J26 — Audit log CSV export. Pure helpers + route smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 csvCell,
8 csvRow,
9 csvDocument,
10 formatAuditCsv,
11 auditCsvFilename,
12 AUDIT_CSV_COLUMNS,
13 CSV_INJECTION_CHARS,
14 __internal,
15 type AuditCsvRow,
16} from "../lib/audit-csv";
17
18describe("audit-csv — csvCell", () => {
19 it("emits empty string for null/undefined", () => {
20 expect(csvCell(null)).toBe("");
21 expect(csvCell(undefined)).toBe("");
22 });
23
24 it("passes through simple strings unchanged", () => {
25 expect(csvCell("hello")).toBe("hello");
26 expect(csvCell("with spaces")).toBe("with spaces");
27 expect(csvCell("a/b_c")).toBe("a/b_c");
28 });
29
30 it("coerces numbers and booleans via String()", () => {
31 expect(csvCell(42)).toBe("42");
32 expect(csvCell(0)).toBe("0");
33 expect(csvCell(true)).toBe("true");
34 expect(csvCell(false)).toBe("false");
35 });
36
37 it("ISO-serialises Date objects", () => {
38 const d = new Date("2025-04-01T12:34:56.789Z");
39 expect(csvCell(d)).toBe("2025-04-01T12:34:56.789Z");
40 });
41
42 it("treats invalid Date as empty", () => {
43 expect(csvCell(new Date("not-a-date"))).toBe("");
44 });
45
46 it("quotes cells that contain commas", () => {
47 expect(csvCell("a,b")).toBe('"a,b"');
48 });
49
50 it("quotes cells that contain newlines", () => {
51 expect(csvCell("a\nb")).toBe('"a\nb"');
52 expect(csvCell("a\r\nb")).toBe('"a\r\nb"');
53 });
54
55 it("quotes cells that contain a CR on its own (the injection char still leads, gets prefixed)", () => {
56 // `\r` is both an injection trigger AND a quote trigger — expected behaviour:
57 // prefix `'`, then quote the whole cell.
58 expect(csvCell("\rboom")).toBe(`"'\rboom"`);
59 });
60
61 it("escapes embedded double quotes", () => {
62 expect(csvCell('a"b')).toBe('"a""b"');
63 expect(csvCell('""')).toBe('""""""');
64 });
65
66 it("CSV-injection guard prefixes leading =", () => {
67 expect(csvCell("=SUM(A1)")).toBe("'=SUM(A1)");
68 });
69
70 it("CSV-injection guard + quote trigger combine correctly", () => {
71 // leading = AND an embedded comma → prefix lives inside the quotes
72 expect(csvCell("=1,2")).toBe(`"'=1,2"`);
73 });
74
75 it("CSV-injection guard prefixes leading +", () => {
76 expect(csvCell("+1234")).toBe("'+1234");
77 });
78
79 it("CSV-injection guard prefixes leading -", () => {
80 expect(csvCell("-5")).toBe("'-5");
81 });
82
83 it("CSV-injection guard prefixes leading @", () => {
84 expect(csvCell("@name")).toBe("'@name");
85 });
86
87 it("CSV-injection guard prefixes leading tab", () => {
88 expect(csvCell("\tdata")).toBe("'\tdata");
89 });
90
91 it("does NOT prefix for benign leading chars", () => {
92 expect(csvCell("!bang")).toBe("!bang");
93 expect(csvCell("#hash")).toBe("#hash");
94 expect(csvCell("a=1")).toBe("a=1"); // equals not at position 0
95 expect(csvCell(" leading-space")).toBe(" leading-space");
96 });
97
98 it("handles Unicode without mangling", () => {
99 expect(csvCell("héllo 🚀")).toBe("héllo 🚀");
100 });
101
102 it("empty string stays empty", () => {
103 expect(csvCell("")).toBe("");
104 });
105
106 it("exposes the trigger set for documentation/testing", () => {
107 expect(CSV_INJECTION_CHARS.has("=")).toBe(true);
108 expect(CSV_INJECTION_CHARS.has("+")).toBe(true);
109 expect(CSV_INJECTION_CHARS.has("-")).toBe(true);
110 expect(CSV_INJECTION_CHARS.has("@")).toBe(true);
111 expect(CSV_INJECTION_CHARS.has("\t")).toBe(true);
112 expect(CSV_INJECTION_CHARS.has("\r")).toBe(true);
113 expect(CSV_INJECTION_CHARS.has("a")).toBe(false);
114 expect(CSV_INJECTION_CHARS.has("=")).toBe(true);
115 });
116});
117
118describe("audit-csv — csvRow", () => {
119 it("joins cells with comma and terminates with CRLF", () => {
120 expect(csvRow(["a", "b", "c"])).toBe("a,b,c\r\n");
121 });
122
123 it("empty array yields just the CRLF", () => {
124 expect(csvRow([])).toBe("\r\n");
125 });
126
127 it("nulls become empty cells", () => {
128 expect(csvRow([null, "x", undefined])).toBe(",x,\r\n");
129 });
130
131 it("quotes are applied per-cell", () => {
132 expect(csvRow(["a,b", 'c"d', "e"])).toBe('"a,b","c""d",e\r\n');
133 });
134});
135
136describe("audit-csv — csvDocument", () => {
137 it("concatenates rows into a full document", () => {
138 expect(
139 csvDocument([
140 ["a", "b"],
141 ["1", "2"],
142 ])
143 ).toBe("a,b\r\n1,2\r\n");
144 });
145
146 it("empty input yields empty string", () => {
147 expect(csvDocument([])).toBe("");
148 });
149});
150
151describe("audit-csv — formatAuditCsv", () => {
152 const sampleRow: AuditCsvRow = {
153 id: "aud_1",
154 action: "branch.rename",
155 targetType: "branch",
156 targetId: "refs/heads/old",
157 ip: "203.0.113.1",
158 userAgent: "Mozilla/5.0",
159 metadata: '{"from":"old","to":"new"}',
160 createdAt: new Date("2025-04-01T12:00:00.000Z"),
161 actor: "alice",
162 };
163
164 it("writes header row exactly once", () => {
165 const csv = formatAuditCsv([sampleRow]);
166 const lines = csv.split("\r\n");
167 expect(lines[0]).toBe(
168 "id,when,actor,action,targetType,targetId,ip,userAgent,metadata"
169 );
170 });
171
172 it("writes a single data row after the header", () => {
173 const csv = formatAuditCsv([sampleRow]);
174 const lines = csv.split("\r\n").filter(Boolean);
175 expect(lines).toHaveLength(2);
176 expect(lines[1]).toBe(
177 'aud_1,2025-04-01T12:00:00.000Z,alice,branch.rename,branch,refs/heads/old,203.0.113.1,Mozilla/5.0,"{""from"":""old"",""to"":""new""}"'
178 );
179 });
180
181 it("empty rows array still writes the header", () => {
182 const csv = formatAuditCsv([]);
183 expect(csv).toBe(
184 "id,when,actor,action,targetType,targetId,ip,userAgent,metadata\r\n"
185 );
186 });
187
188 it("nullable fields become empty cells", () => {
189 const row: AuditCsvRow = {
190 id: "x",
191 action: "login",
192 targetType: null,
193 targetId: null,
194 ip: null,
195 userAgent: null,
196 metadata: null,
197 createdAt: new Date("2025-01-01T00:00:00Z"),
198 actor: null,
199 };
200 const csv = formatAuditCsv([row]);
201 const dataLine = csv.split("\r\n")[1]!;
202 expect(dataLine).toBe("x,2025-01-01T00:00:00.000Z,,login,,,,,");
203 });
204
205 it("accepts string dates and normalises to ISO", () => {
206 const row: AuditCsvRow = {
207 ...sampleRow,
208 createdAt: "2025-04-01T12:00:00Z",
209 };
210 const csv = formatAuditCsv([row]);
211 expect(csv).toContain("2025-04-01T12:00:00.000Z");
212 });
213
214 it("keeps unparseable string createdAt verbatim", () => {
215 const row: AuditCsvRow = {
216 ...sampleRow,
217 createdAt: "not-a-date",
218 };
219 const csv = formatAuditCsv([row]);
220 // Appears as a plain cell, not quoted (no commas/quotes/newlines).
221 const lines = csv.split("\r\n").filter(Boolean);
222 expect(lines[1]).toContain(",not-a-date,");
223 });
224
225 it("CSV-injection-guards formula-like actor names", () => {
226 const row: AuditCsvRow = { ...sampleRow, actor: "=cmd|evil" };
227 const csv = formatAuditCsv([row]);
228 // actor cell needs both quoting (pipe is fine — but quote is needed because
229 // of the prefix + `|`? No — `|` isn't a trigger). Just prefix `'`.
230 // However since actor contains no `,"` newline, we only see the prefix.
231 expect(csv).toContain(",'=cmd|evil,");
232 });
233
234 it("exposes canonical column list", () => {
235 expect(AUDIT_CSV_COLUMNS).toEqual([
236 "id",
237 "when",
238 "actor",
239 "action",
240 "targetType",
241 "targetId",
242 "ip",
243 "userAgent",
244 "metadata",
245 ]);
246 });
247
248 it("each row has exactly 9 cells (commas outside quoted regions)", () => {
249 const tricky: AuditCsvRow = {
250 id: "aud_tricky",
251 action: "x",
252 targetType: "t",
253 targetId: "id",
254 ip: "1.1.1.1",
255 userAgent: "mx, browser",
256 metadata: '{"a":"b,c"}',
257 createdAt: new Date("2025-04-01T00:00:00Z"),
258 actor: "name,with,commas",
259 };
260 const csv = formatAuditCsv([tricky]);
261 const dataLine = csv.split("\r\n")[1]!;
262 // Strip out quoted regions then count commas — must be exactly 8.
263 const stripped = dataLine.replace(/"[^"]*"/g, "X");
264 expect(stripped.match(/,/g)?.length).toBe(8);
265 });
266});
267
268describe("audit-csv — auditCsvFilename", () => {
269 it("slug + ISO timestamp form", () => {
270 const fn = auditCsvFilename("personal", new Date("2025-04-01T12:00:00Z"));
271 expect(fn).toBe("audit-personal-2025-04-01T12-00-00-000Z.csv");
272 });
273
274 it("sanitises arbitrary scope strings", () => {
275 const fn = auditCsvFilename(
276 "alice/repo?bad",
277 new Date("2025-04-01T00:00:00Z")
278 );
279 expect(fn).toMatch(/^audit-alice-repo-bad-2025-04-01T00-00-00-000Z\.csv$/);
280 });
281
282 it("falls back to 'audit' on empty or all-bad scope", () => {
283 const fn = auditCsvFilename("///", new Date("2025-04-01T00:00:00Z"));
284 expect(fn).toBe("audit-audit-2025-04-01T00-00-00-000Z.csv");
285 });
286
287 it("uses current time when `now` omitted", () => {
288 const fn = auditCsvFilename("personal");
289 expect(fn).toMatch(
290 /^audit-personal-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.csv$/
291 );
292 });
293});
294
295describe("audit-csv — routes", () => {
296 it("GET /settings/audit.csv returns 302 when unauthenticated", async () => {
297 const { default: app } = await import("../app");
298 const res = await app.request("/settings/audit.csv");
299 // requireAuth redirects to /login for unauthenticated web requests.
300 expect([302, 401, 403]).toContain(res.status);
301 });
302
303 it("GET /:owner/:repo/settings/audit.csv is guarded (never 500)", async () => {
304 const { default: app } = await import("../app");
305 const res = await app.request("/alice/repo/settings/audit.csv");
306 expect([302, 401, 403, 404]).toContain(res.status);
307 });
308});
309
310describe("audit-csv — __internal parity", () => {
311 it("re-exports helpers", () => {
312 expect(__internal.csvCell).toBe(csvCell);
313 expect(__internal.csvRow).toBe(csvRow);
314 expect(__internal.csvDocument).toBe(csvDocument);
315 expect(__internal.formatAuditCsv).toBe(formatAuditCsv);
316 expect(__internal.auditCsvFilename).toBe(auditCsvFilename);
317 expect(__internal.AUDIT_CSV_COLUMNS).toBe(AUDIT_CSV_COLUMNS);
318 expect(__internal.CSV_INJECTION_CHARS).toBe(CSV_INJECTION_CHARS);
319 expect(typeof __internal.normaliseCreated).toBe("function");
320 });
321});
Addedsrc/lib/audit-csv.ts+146−0View fileUnifiedSplit
1/**
2 * Block J26 — Audit log CSV export helpers.
3 *
4 * Pure, IO-free functions for serialising audit rows to RFC 4180-compliant CSV
5 * with CSV-injection mitigation on untrusted cell content.
6 *
7 * Injection mitigation: a cell whose first character is `=`, `+`, `-`, `@`,
8 * tab, or CR is prefixed with a single quote so spreadsheet formula engines
9 * won't evaluate it. The prefix is visible in the resulting cell but is
10 * preferable to RCE on double-click in Excel / Sheets.
11 *
12 * RFC 4180 rules implemented:
13 * - Rows are CRLF-terminated.
14 * - Cells containing `,`, `"`, `\n`, or `\r` are wrapped in double quotes.
15 * - Internal `"` is escaped by doubling: `"` → `""`.
16 * - A cell that needs injection-prefixing and also contains any quoting
17 * trigger is still quoted correctly (prefix goes INSIDE the quotes).
18 * - A leading BOM is NOT emitted — consumers who need Excel-compatibility
19 * can prepend `\uFEFF` themselves.
20 */
21export const CSV_INJECTION_CHARS = new Set(["=", "+", "-", "@", "\t", "\r"]);
22
23/**
24 * Quote a single cell value. Returns a string ready to be joined into a CSV
25 * row. `null`/`undefined` becomes an empty cell. Objects are `String(...)`'d
26 * (callers should pre-serialise JSON blobs themselves).
27 */
28export function csvCell(value: unknown): string {
29 if (value === null || value === undefined) return "";
30 let s: string;
31 if (value instanceof Date) {
32 s = Number.isNaN(value.getTime()) ? "" : value.toISOString();
33 } else if (typeof value === "string") {
34 s = value;
35 } else {
36 s = String(value);
37 }
38
39 // CSV injection guard — prefix with `'` when the cell starts with a
40 // spreadsheet-formula trigger. Must happen before quoting so the prefix
41 // lives inside the quoted region.
42 if (s.length > 0 && CSV_INJECTION_CHARS.has(s[0]!)) {
43 s = "'" + s;
44 }
45
46 const needsQuoting =
47 s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r");
48 if (!needsQuoting) return s;
49 return `"${s.replace(/"/g, '""')}"`;
50}
51
52/** Join cells with `,`, terminate with CRLF (RFC 4180). */
53export function csvRow(cells: readonly unknown[]): string {
54 return cells.map(csvCell).join(",") + "\r\n";
55}
56
57/** Assemble a full CSV document from an array of row arrays. */
58export function csvDocument(rows: readonly (readonly unknown[])[]): string {
59 let out = "";
60 for (const r of rows) out += csvRow(r);
61 return out;
62}
63
64/**
65 * Shape of an audit row as written by the live audit UI. Matches the select
66 * in `src/routes/audit.tsx`.
67 */
68export interface AuditCsvRow {
69 id: string;
70 action: string;
71 targetType: string | null;
72 targetId: string | null;
73 ip: string | null;
74 userAgent: string | null;
75 metadata: string | null;
76 createdAt: Date | string;
77 actor: string | null;
78}
79
80export const AUDIT_CSV_COLUMNS = [
81 "id",
82 "when",
83 "actor",
84 "action",
85 "targetType",
86 "targetId",
87 "ip",
88 "userAgent",
89 "metadata",
90] as const;
91
92function normaliseCreated(v: Date | string): string {
93 if (v instanceof Date) {
94 return Number.isNaN(v.getTime()) ? "" : v.toISOString();
95 }
96 if (typeof v === "string") {
97 const d = new Date(v);
98 return Number.isNaN(d.getTime()) ? v : d.toISOString();
99 }
100 return "";
101}
102
103/**
104 * Turn an array of audit rows into a CSV string with a header row. Metadata
105 * is written verbatim — callers that store JSON in `metadata` should keep
106 * doing so; the cell quoting handles embedded commas and quotes.
107 */
108export function formatAuditCsv(rows: readonly AuditCsvRow[]): string {
109 const body: unknown[][] = [AUDIT_CSV_COLUMNS.slice() as unknown[]];
110 for (const r of rows) {
111 body.push([
112 r.id,
113 normaliseCreated(r.createdAt),
114 r.actor ?? "",
115 r.action,
116 r.targetType ?? "",
117 r.targetId ?? "",
118 r.ip ?? "",
119 r.userAgent ?? "",
120 r.metadata ?? "",
121 ]);
122 }
123 return csvDocument(body);
124}
125
126/**
127 * Build a `Content-Disposition: attachment; filename="..."` value for an
128 * audit-log download. Scope is a short slug (`"personal"` or `"owner-repo"`).
129 */
130export function auditCsvFilename(scope: string, now: Date = new Date()): string {
131 const ts = now.toISOString().replace(/[:.]/g, "-");
132 const safeScope = scope.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
133 const slug = safeScope.length > 0 ? safeScope : "audit";
134 return `audit-${slug}-${ts}.csv`;
135}
136
137export const __internal = {
138 CSV_INJECTION_CHARS,
139 csvCell,
140 csvRow,
141 csvDocument,
142 formatAuditCsv,
143 auditCsvFilename,
144 AUDIT_CSV_COLUMNS,
145 normaliseCreated,
146};
Modifiedsrc/routes/audit.tsx+119−2View fileUnifiedSplit
1111import type { AuthEnv } from "../middleware/auth";
1212import { requireAuth } from "../middleware/auth";
1313import { Layout } from "../views/layout";
14import { formatAuditCsv, auditCsvFilename } from "../lib/audit-csv";
1415
1516const audit = new Hono<AuthEnv>();
1617
1718audit.use("/settings/audit", requireAuth);
19audit.use("/settings/audit.csv", requireAuth);
1820audit.use("/:owner/:repo/settings/audit", requireAuth);
21audit.use("/:owner/:repo/settings/audit.csv", requireAuth);
1922
2023const LIMIT = 200;
2124
144147 return c.html(
145148 <Layout title="Audit log" user={user}>
146149 <div class="settings-container" style="max-width: 1000px">
147 <h2>Audit log</h2>
150 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
151 <h2 style="margin: 0">Audit log</h2>
152 <a
153 href="/settings/audit.csv"
154 class="btn-secondary"
155 style="font-size: 13px"
156 >
157 Download CSV
158 </a>
159 </div>
148160 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
149161 The most recent {LIMIT} sensitive actions tied to your account — logins,
150162 token activity, merges, deploys, branch protection changes.
155167 );
156168});
157169
170// Personal audit CSV export — same filter, RFC 4180 output.
171audit.get("/settings/audit.csv", async (c) => {
172 const user = c.get("user")!;
173 let rows: AuditRow[] = [];
174 try {
175 const raw = await db
176 .select({
177 id: auditLog.id,
178 action: auditLog.action,
179 targetType: auditLog.targetType,
180 targetId: auditLog.targetId,
181 ip: auditLog.ip,
182 userAgent: auditLog.userAgent,
183 metadata: auditLog.metadata,
184 createdAt: auditLog.createdAt,
185 actor: users.username,
186 })
187 .from(auditLog)
188 .leftJoin(users, eq(users.id, auditLog.userId))
189 .where(eq(auditLog.userId, user.id))
190 .orderBy(desc(auditLog.createdAt))
191 .limit(LIMIT);
192 rows = raw as AuditRow[];
193 } catch (err) {
194 console.error("[audit] personal csv:", err);
195 }
196 const csv = formatAuditCsv(rows);
197 const filename = auditCsvFilename(`personal-${user.username}`);
198 return new Response(csv, {
199 status: 200,
200 headers: {
201 "Content-Type": "text/csv; charset=utf-8",
202 "Content-Disposition": `attachment; filename="${filename}"`,
203 "Cache-Control": "private, no-store",
204 },
205 });
206});
207
158208// Per-repo audit — events with repositoryId = this repo. Owner-only.
159209audit.get("/:owner/:repo/settings/audit", async (c) => {
160210 const user = c.get("user")!;
221271 <span>/</span>
222272 <span>audit</span>
223273 </div>
224 <h2>Audit log</h2>
274 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px">
275 <h2 style="margin: 0">Audit log</h2>
276 <a
277 href={`/${owner}/${repo}/settings/audit.csv`}
278 class="btn-secondary"
279 style="font-size: 13px"
280 >
281 Download CSV
282 </a>
283 </div>
225284 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
226285 Who did what in <code>{owner}/{repo}</code> — most recent {LIMIT} events.
227286 </p>
231290 );
232291});
233292
293// Per-repo audit CSV export — owner-only, same filter as the HTML page.
294audit.get("/:owner/:repo/settings/audit.csv", async (c) => {
295 const user = c.get("user")!;
296 const { owner, repo } = c.req.param();
297
298 let repoRow: { id: string; ownerId: string; name: string } | null = null;
299 try {
300 const [r] = await db
301 .select({ id: repositories.id, ownerId: repositories.ownerId, name: repositories.name })
302 .from(repositories)
303 .innerJoin(users, eq(users.id, repositories.ownerId))
304 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
305 .limit(1);
306 repoRow = (r as any) || null;
307 } catch (err) {
308 console.error("[audit] repo csv lookup:", err);
309 }
310 if (!repoRow) return c.notFound();
311 if (repoRow.ownerId !== user.id) {
312 return c.text("Forbidden", 403);
313 }
314
315 let rows: AuditRow[] = [];
316 try {
317 const raw = await db
318 .select({
319 id: auditLog.id,
320 action: auditLog.action,
321 targetType: auditLog.targetType,
322 targetId: auditLog.targetId,
323 ip: auditLog.ip,
324 userAgent: auditLog.userAgent,
325 metadata: auditLog.metadata,
326 createdAt: auditLog.createdAt,
327 actor: users.username,
328 })
329 .from(auditLog)
330 .leftJoin(users, eq(users.id, auditLog.userId))
331 .where(eq(auditLog.repositoryId, repoRow.id))
332 .orderBy(desc(auditLog.createdAt))
333 .limit(LIMIT);
334 rows = raw as AuditRow[];
335 } catch (err) {
336 console.error("[audit] repo csv:", err);
337 }
338
339 const csv = formatAuditCsv(rows);
340 const filename = auditCsvFilename(`${owner}-${repo}`);
341 return new Response(csv, {
342 status: 200,
343 headers: {
344 "Content-Type": "text/csv; charset=utf-8",
345 "Content-Disposition": `attachment; filename="${filename}"`,
346 "Cache-Control": "private, no-store",
347 },
348 });
349});
350
234351export default audit;
235352