Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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