Skip to content
204 linesCodeBlameRaw
1/**
2 * The audit log, as the site shows and exports it: who may see what, the
3 * filters a page's address carries, and the CSV and JSON it downloads.
4 * Pure, so it can be tested; the server side is in audit.server.ts.
5 */
6
7import type {
8 ActorKind,
9 AuditEntry,
10 AuditOutcome,
11 AuditQuery,
12 AuditVisibility,
13 Role,
14} from "@g1t/contracts";
15
16/**
17 * How much of a workspace's log a viewer sees: an owner everything; a
18 * member what was done to the workspace's projects, and what they did or
19 * had done for them; anyone else nothing.
20 */
21export function visibilityFor(role: Role | null, username: string): AuditVisibility | null {
22 if (role === "owner") return { kind: "all" };
23 if (role === "member") return { kind: "projects", username };
24 return null;
25}
26
27/**
28 * The earliest time a workspace's log can be read from, given how many
29 * days are kept (7 free, 90 on the plan, or what staff set): the later of
30 * what was asked for and the start of the window.
31 */
32export function retainedSince(since: string | null | undefined, days: number, now = Date.now()): string {
33 const start = new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
34 return since && since > start ? since : start;
35}
36
37/** The filters a page's address can carry. */
38export type AuditFilters = {
39 actor: string;
40 action: string;
41 project: string;
42 outcome: "" | AuditOutcome;
43 kind: "" | ActorKind;
44 run: string;
45 /** `YYYY-MM-DD`, inclusive. */
46 from: string;
47 /** `YYYY-MM-DD`, inclusive. */
48 to: string;
49 before: string;
50};
51
52const OUTCOMES: AuditOutcome[] = ["allowed", "denied"];
53// `agent` rows (g1t's own agents, before they were removed) still render, but are no longer a filter.
54const KINDS: ActorKind[] = ["person", "workspace"];
55const DAY = /^\d{4}-\d{2}-\d{2}$/;
56
57function clean(value: string | null, max = 120): string {
58 return (value ?? "").trim().slice(0, max);
59}
60
61export function parseFilters(params: URLSearchParams): AuditFilters {
62 const outcome = clean(params.get("outcome"));
63 const kind = clean(params.get("kind"));
64 const day = (name: string) => {
65 const value = clean(params.get(name));
66 return DAY.test(value) ? value : "";
67 };
68 return {
69 actor: clean(params.get("actor")),
70 action: clean(params.get("action")),
71 project: clean(params.get("project")),
72 outcome: OUTCOMES.includes(outcome as AuditOutcome) ? (outcome as AuditOutcome) : "",
73 kind: KINDS.includes(kind as ActorKind) ? (kind as ActorKind) : "",
74 run: clean(params.get("run")),
75 from: day("from"),
76 to: day("to"),
77 before: clean(params.get("before")),
78 };
79}
80
81/** The day after `day`, for an inclusive end. */
82function nextDay(day: string): string {
83 const date = new Date(`${day}T00:00:00Z`);
84 date.setUTCDate(date.getUTCDate() + 1);
85 return date.toISOString().slice(0, 10);
86}
87
88/** What to ask the log for. `project` is a project's name in `workspace`, or `owner/name`. */
89export function toQuery(
90 workspace: string,
91 visibility: AuditVisibility,
92 filters: AuditFilters,
93 limit: number,
94): AuditQuery {
95 const project = filters.project.includes("/") ? filters.project : filters.project ? `${workspace}/${filters.project}` : null;
96 return {
97 workspace,
98 visibility,
99 actor: filters.actor || null,
100 agent: null,
101 action: filters.action || null,
102 repo: project,
103 outcome: filters.outcome || null,
104 actorKind: filters.kind || null,
105 runIds: filters.run ? [filters.run] : [],
106 since: filters.from ? `${filters.from}T00:00:00.000Z` : null,
107 until: filters.to ? `${nextDay(filters.to)}T00:00:00.000Z` : null,
108 before: filters.before || null,
109 limit,
110 };
111}
112
113/** The address of the same view with `changes` applied, for links. */
114export function filterHref(base: string, filters: AuditFilters, changes: Partial<AuditFilters> = {}): string {
115 const merged = { ...filters, ...changes };
116 const params = new URLSearchParams();
117 for (const [key, value] of Object.entries(merged)) {
118 if (value) params.set(key, value);
119 }
120 const search = params.toString();
121 return search ? `${base}?${search}` : base;
122}
123
124/** Who acted, as people read it: "g1t on behalf of syntaqx". */
125export function actorLabel(entry: Pick<AuditEntry, "actor" | "agent" | "onBehalfOf">): string {
126 return entry.onBehalfOf ? `${entry.agent ?? entry.actor} on behalf of ${entry.onBehalfOf}` : entry.actor;
127}
128
129/** What it was done to: `acme/rocket#12`, with a ref or path when there is one. */
130export function targetLabel(entry: Pick<AuditEntry, "workspace" | "repo" | "number" | "gitRef" | "path">): string {
131 const where = entry.repo ? `${entry.repo}${entry.number != null ? `#${entry.number}` : ""}` : entry.workspace;
132 const what = [entry.gitRef, entry.path].filter(Boolean).join(" ");
133 return what ? `${where} ${what}` : where;
134}
135
136/** An operation's name as a phrase: `create_issue` is "create issue". */
137export function actionLabel(action: string): string {
138 if (action === "git.push") return "git push";
139 if (action === "git.fetch") return "git fetch";
140 return action.replaceAll("_", " ");
141}
142
143export const CSV_COLUMNS = [
144 "id",
145 "time",
146 "workspace",
147 "actorKind",
148 "actor",
149 "agent",
150 "onBehalfOf",
151 "runId",
152 "runKind",
153 "credentialId",
154 "action",
155 "surface",
156 "repo",
157 "number",
158 "gitRef",
159 "path",
160 "outcome",
161 "rule",
162 "result",
163 "message",
164 "requestId",
165] as const satisfies readonly (keyof AuditEntry)[];
166
167function csvCell(value: unknown): string {
168 if (value == null) return "";
169 let text = String(value);
170 // A spreadsheet runs a cell that starts like a formula; keep it text.
171 if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
172 return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
173}
174
175/** RFC 4180 CSV, a header row and one row per entry. */
176export function toCsv(entries: AuditEntry[]): string {
177 const rows = [CSV_COLUMNS.join(",")];
178 for (const entry of entries) rows.push(CSV_COLUMNS.map((column) => csvCell(entry[column])).join(","));
179 return `${rows.join("\r\n")}\r\n`;
180}
181
182/** The download's file name: `acme-audit-2026-10-04.csv`. */
183export function exportName(workspace: string, format: "csv" | "json", now: Date): string {
184 return `${workspace}-audit-${now.toISOString().slice(0, 10)}.${format}`;
185}
186
187/** Plain words for the rule that decided an entry. */
188export function ruleLabel(rule: string): string {
189 if (rule === "never") return "never allowed for agents (an old entry)";
190 if (rule === "scope:operation") return "not in the run's scope";
191 if (rule === "scope:repository") return "outside the run's repository";
192 if (rule === "scope:pull") return "outside the run's pull request";
193 if (rule === "on-behalf-of:membership") return "the person it works for is not a member";
194 if (rule === "git:push") return "no push grant";
195 if (rule === "git:read") return "no read grant";
196 if (rule === "git:ref") return "branch not granted";
197 if (rule === "git:not-a-run") return "tools token used with git";
198 if (rule === "service" || rule === "repository") return "refused by the repository's rules";
199 if (rule === "person") return "the person's own access";
200 if (rule === "workspace-token") return "the workspace token's access";
201 const run = /^run:([a-z]+)\/(runner|tools)(?::(push|read))?$/.exec(rule);
202 if (run) return `${run[1]} run ${run[2] === "tools" ? "tools" : "runner"}${run[3] ? ` (${run[3]})` : ""}`;
203 return rule;
204}