@pithy-sh/audit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import { AuditAction, AuditActorType, AuditOutcome, AuditSeverity } from "@pithy-sh/core/src/audit/auditEvent";
5
+ import { MAX_PAGE_SIZE } from "@pithy-sh/core/src/data/cursor";
6
+ import { z } from "zod";
7
+
8
+ /**
9
+ * The request contracts audit's two routes declare (CLAUDE.md §HTTP).
10
+ *
11
+ * Every one of these is a **bound on something a caller chose**, which is the whole job here: a
12
+ * control-plane credential is verified, but verified is not trusted, and a management client with a
13
+ * bug can ask for a million rows off the largest table in the project as easily as a hostile one can.
14
+ *
15
+ * The free-text filters are bounded strings rather than enums, deliberately. `action` is a federated
16
+ * taxonomy — every capability declares its own codes and core holds no closed union — so the valid
17
+ * set is not knowable here, and the same is true of `resourceType`, `project`, and `worker`. Each is
18
+ * only ever compared, never interpreted, so a filter naming something this deployment has never
19
+ * recorded is an empty page rather than a 400. That is the better answer to the same question: a
20
+ * dashboard filtering by a capability the adopter removed should show nothing, not fail.
21
+ */
22
+
23
+ /** How long any single filter value may be. Generous for a name, far short of a payload. */
24
+ const MAX_FILTER_LENGTH = 256;
25
+
26
+ /** A bounded free-text filter value — compared against a column, never interpreted. */
27
+ const FilterValue = z.string().min(1).max(MAX_FILTER_LENGTH);
28
+
29
+ /**
30
+ * The tenant filter, where **an empty value is the null filter**: `?tenant=acme` is one tenant's trail,
31
+ * `?tenant=` is the events that belong to no tenant, and omitting it entirely filters nothing.
32
+ *
33
+ * Three states over a query string that natively has two, so the third needs an encoding. The empty
34
+ * string is the one value that cannot collide: `FilterValue` is `min(1)`, so no tenant id is ever
35
+ * empty, and no adopter can be locked out of filtering for a tenant whose id happens to be the sentinel
36
+ * — which is what `?tenant=none` or `?tenant=null` would have risked. It is decoded here rather than in
37
+ * the handler, so `AuditQuery`'s `string | null` is what reaches the query builder.
38
+ */
39
+ const TenantFilter = z.union([FilterValue, z.literal("").transform(() => null)]);
40
+
41
+ /**
42
+ * An inclusive time bound, as ISO-8601, decoded to a `Date` by the schema rather than by the handler.
43
+ *
44
+ * The conversion belongs here because a handler takes typed values: the route signature carries the
45
+ * contract, so `from` reaches the query as the `Date` the filter wants and no handler ever parses a
46
+ * caller's string.
47
+ */
48
+ const TimeBound = z
49
+ .string()
50
+ .datetime({ offset: true })
51
+ .transform((value) => new Date(value));
52
+
53
+ /** One event's id in the path. */
54
+ export const AuditEventIdParam = z
55
+ .object({
56
+ eventId: z
57
+ .string()
58
+ .uuid()
59
+ .describe(
60
+ "The event's `eventId` — the recorder's UUID idempotency key, not the internal autoincrement `id`. A param schema constrains the string; the handler still does the lookup.",
61
+ ),
62
+ })
63
+ .describe("The path parameters of the single-event route.");
64
+ export type AuditEventIdParam = z.infer<typeof AuditEventIdParam>;
65
+
66
+ /**
67
+ * The trail query: what to filter by, and where to resume.
68
+ *
69
+ * Field names match {@link AuditQuery} exactly, so the validated object is the filter — there is no
70
+ * hand-written mapping in the handler for a rename to fall out of sync with.
71
+ */
72
+ export const ListAuditEventsQuery = z
73
+ .object({
74
+ actorType: AuditActorType.optional().describe(
75
+ "Filter to one kind of principal. `control-plane` is the one that separates a management client's own actions from the adopter's users'.",
76
+ ),
77
+ actorId: FilterValue.optional().describe("Filter to one principal — a user id, a service name, a token subject."),
78
+ action: AuditAction.max(MAX_FILTER_LENGTH)
79
+ .optional()
80
+ .describe("Filter to one exact `domain/reason` action code. Exact, never a prefix — the taxonomy is federated."),
81
+ outcome: AuditOutcome.optional().describe(
82
+ "Filter to one outcome. `denied` is the one worth watching: a run of denials is what a credential being probed looks like.",
83
+ ),
84
+ severity: AuditSeverity.optional().describe("Filter to one severity."),
85
+ resourceType: FilterValue.optional().describe("Filter to the kind of thing acted on (`user`, `secret`)."),
86
+ resourceId: FilterValue.optional().describe("Filter to one target — everything that happened to this thing."),
87
+ project: FilterValue.optional().describe("Filter to one project, as the recorder stamped it."),
88
+ environment: FilterValue.optional().describe("Filter to one environment (`dev` | `staging` | `prod`)."),
89
+ worker: FilterValue.optional().describe(
90
+ "Filter to one `apps/<name>` Worker. Two Workers sharing a database share this table, so this is the only column that tells their events apart.",
91
+ ),
92
+ tenant: TenantFilter.optional().describe(
93
+ "Filter to one tenant — whose actions these were, as the emitter recorded them. Send it empty (`?tenant=`) for the events that belong to no tenant: a CLI-originated action, a fleet-wide one, or a row recorded before the column existed. Omit it to filter nothing.",
94
+ ),
95
+ from: TimeBound.optional().describe("Inclusive lower bound on when the event occurred, ISO-8601."),
96
+ to: TimeBound.optional().describe("Inclusive upper bound on when the event occurred, ISO-8601."),
97
+ cursor: z
98
+ .string()
99
+ .max(512)
100
+ .optional()
101
+ .describe("Where to resume, from the previous page's `nextCursor`. Opaque; a malformed one is a first page."),
102
+ limit: z.coerce
103
+ .number()
104
+ .int()
105
+ .min(1)
106
+ .max(MAX_PAGE_SIZE)
107
+ .optional()
108
+ .describe("How many events to return. Bounded, because a verified client can still have a bug."),
109
+ })
110
+ .describe("The audit trail query: what to filter the trail by, and where to resume reading it.");
111
+ export type ListAuditEventsQuery = z.infer<typeof ListAuditEventsQuery>;
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import type { AuditEventRow } from "../data/auditEvent";
5
+ import type { AuditEventDetailView, AuditEventView } from "./responses";
6
+
7
+ /**
8
+ * What a management client is shown of an audit event — decided here, once, rather than by whichever
9
+ * columns a query happened to select.
10
+ *
11
+ * **No route returns a raw row.** The trail is the most sensitive table Pithy ships: it is the record
12
+ * of every security-relevant action across every capability, and it carries personal data the adopter
13
+ * collected from their own users. Handing a `SELECT *` to a client means every column the schema ever
14
+ * gains is disclosed by default, which is exactly backwards.
15
+ *
16
+ * ## What is deliberately absent from both views
17
+ *
18
+ * `id`, the autoincrement surrogate. Its own schema says internal, never exposed, and that is right:
19
+ * it is monotonic, so publishing it tells a client how many events the project has recorded and lets
20
+ * it address rows by counting. Events are addressed by `eventId`, a UUID. The keyset cursor does
21
+ * carry `id` as a position, and that is a different thing — it is opaque, it is not a field a client
22
+ * can filter or address by, and it names only the row that client was just handed.
23
+ *
24
+ * ## What is in the detail view only, and why
25
+ *
26
+ * `ip`, `userAgent`, and `metadata`. These are the trail's personal data, and they are the reason
27
+ * {@link AUDIT_EVENT_DETAIL_READ_SCOPE} exists as a separate grant.
28
+ *
29
+ * - **`ip`** is a personal identifier under GDPR, and it is also the field a forensic read genuinely
30
+ * needs — "was this login from where this person usually is". So it is exposed, not dropped;
31
+ * dropping it would make the column pointless and the incident unanswerable. It is exposed one
32
+ * event at a time, behind its own scope, so reading it is a decision rather than a side effect of
33
+ * opening a dashboard.
34
+ * - **`userAgent`** is a device fingerprint. Same reasoning, same place.
35
+ * - **`metadata`** is a capability-specific bag whose contents nothing here can predict. `@pithy-sh/testers`
36
+ * writes an invited tester's email address into it, on purpose, and other capabilities write resource
37
+ * names and denial reasons. A page of a hundred events would therefore leak an arbitrary amount of
38
+ * whatever every capability decided to record — so it is not in the listing at any page size.
39
+ *
40
+ * `sessionId` **is** in the list view, and it is not the exception it looks like. Better Auth's session
41
+ * row id is not its session token: the credential is `session.token`, which the trail never holds. The
42
+ * id is a correlation key, and correlating a chain of actions to one sign-in is most of what an audit
43
+ * trail is read for.
44
+ */
45
+
46
+ /**
47
+ * ## The field list lives in `responses.ts`
48
+ *
49
+ * Both view types are `z.output` of the Zod objects there, so there is one declaration of what a
50
+ * client receives rather than an interface here and a mirror of it in every management client. A
51
+ * field added to one and not the other does not compile.
52
+ *
53
+ * Project one row for the listing.
54
+ */
55
+ export function auditEventView(row: AuditEventRow): AuditEventView {
56
+ return {
57
+ eventId: row.eventId,
58
+ occurredAt: row.occurredAt.toISOString(),
59
+ action: row.action,
60
+ outcome: row.outcome,
61
+ severity: row.severity,
62
+ actorType: row.actorType,
63
+ actorId: row.actorId,
64
+ sessionId: row.sessionId,
65
+ resourceType: row.resourceType,
66
+ resourceId: row.resourceId,
67
+ requestId: row.requestId,
68
+ project: row.project,
69
+ environment: row.environment,
70
+ worker: row.worker,
71
+ // The point of the column: it turns "this was revoked, by this subject" into "…against this exact
72
+ // build". A forensic view that omitted it would leave the reader guessing which code ran.
73
+ version: row.version,
74
+ // In the listing, not gated behind the detail scope. It is an opaque id the adopter already holds,
75
+ // not personal data — and a client that can filter by tenant but cannot see which tenant a row
76
+ // carries has to take the filter on trust, which is how a row ends up drawn under the wrong heading.
77
+ tenant: row.tenant,
78
+ };
79
+ }
80
+
81
+ /** Project one row for the single-event read, network identifiers and metadata included. */
82
+ export function auditEventDetailView(row: AuditEventRow): AuditEventDetailView {
83
+ return { ...auditEventView(row), ip: row.ip, userAgent: row.userAgent, metadata: row.metadata };
84
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ /**
5
+ * The package entrypoint — the surface `pithy add audit` wires into `pithy.config.ts`. Deliberately
6
+ * narrow: the capability factory, the recorder/query seams an app reads the trail with, the federated
7
+ * action-constant helper, and the CLI emitter + actor resolver. Every other module is imported by
8
+ * deep path (`@pithy-sh/audit/src/...`); this is the documented contract, not a barrel.
9
+ */
10
+
11
+ export { AuditTrailActions, defineAuditActions } from "./actions";
12
+ export { type AuditCapability, type AuditConfigInput, audit, isAuditCapability } from "./capability";
13
+ export { type CliAuditEvent, emitFromCLI } from "./cli/emitFromCLI";
14
+ export {
15
+ type CfAccountTokenActorSource,
16
+ type CfActorSource,
17
+ type CfUserActorSource,
18
+ createCachedActorResolver,
19
+ type ResolvedActor,
20
+ resolveActor,
21
+ } from "./cli/resolveActor";
22
+ export { AuditEventRow } from "./data/auditEvent";
23
+ export { type AuditDatabase, auditDatabase } from "./data/tables";
24
+ export {
25
+ AUDIT_CONTROL_PLANE_SCOPES,
26
+ AUDIT_EVENT_DETAIL_READ_SCOPE,
27
+ AUDIT_TRAIL_READ_SCOPE,
28
+ auditAdminRoutes,
29
+ } from "./http/guards";
30
+ export { type AuditEventPage, type AuditQuery, pageAuditEvents, queryAuditEvents, readAuditEvent } from "./query";
31
+ export { type AuditRecorderOptions, createAuditEmit, recordAuditEvent } from "./recorder";
@@ -0,0 +1,117 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import type { Kysely } from "kysely";
5
+ import type { Migration } from "kysely/migration";
6
+
7
+ /**
8
+ * Create the `pithy_audit_events` table in the configured audit database (the `DB` binding by
9
+ * default). The `pithy_audit_` prefix keeps it from clashing with an adopter's own tables.
10
+ *
11
+ * Identifiers are declared in **camelCase**: the runner installs `CamelCasePlugin`, which snake-cases
12
+ * every identifier in the emitted DDL (CLAUDE.md §Data layer). `pithyAuditEvents` becomes the SQL
13
+ * table `pithy_audit_events`; the column names match the Zod schema.
14
+ *
15
+ * The indexes serve the query shapes the trail is read by — time range (`occurredAt`), per-action
16
+ * (`action`), per-actor (`actorType`, `actorId`), per-resource (`resourceType`, `resourceId`), and
17
+ * per-tenant over a window (`tenant`, `occurredAt`).
18
+ * `down` is the tested inverse: drop the indexes, then the table (D1 has no transactional DDL).
19
+ *
20
+ * This is the whole audit schema, in one migration — see `CONTRIBUTING.md` §Migrations for why that is
21
+ * the shape while nothing is published, and what changes the day something is.
22
+ */
23
+ export const audit_0001_init: Migration = {
24
+ up: async (db: Kysely<unknown>): Promise<void> => {
25
+ await db.schema
26
+ .createTable("pithyAuditEvents")
27
+ .addColumn("id", "integer", (c) => c.primaryKey().autoIncrement())
28
+ .addColumn("eventId", "text", (c) => c.notNull())
29
+ .addColumn("occurredAt", "integer", (c) => c.notNull())
30
+ .addColumn("action", "text", (c) => c.notNull())
31
+ .addColumn("outcome", "text", (c) => c.notNull())
32
+ .addColumn("severity", "text", (c) => c.notNull().defaultTo("info"))
33
+ .addColumn("actorType", "text", (c) => c.notNull())
34
+ .addColumn("actorId", "text")
35
+ .addColumn("sessionId", "text")
36
+ .addColumn("resourceType", "text")
37
+ .addColumn("resourceId", "text")
38
+ .addColumn("ip", "text")
39
+ .addColumn("userAgent", "text")
40
+ .addColumn("requestId", "text")
41
+ .addColumn("metadata", "text")
42
+ // Where the event was recorded, stamped by the recorder from the Worker's own vars — never by an
43
+ // emitter. Nullable, and permanently so: a Worker scaffolded without the vars carries none of
44
+ // them, and a CLI-originated action came from no Worker at all. `null` means "not recorded",
45
+ // which is a true statement; a default would invent an origin and make the invention unqueryable.
46
+ // `version` joins them for the same reason and on the same terms: the Cloudflare build id the
47
+ // recorder read from `CF_VERSION_METADATA`. It is what turns "this was revoked" into "this was
48
+ // revoked, by this subject, against this exact build". Null for a CLI action, and for a Worker
49
+ // that does not declare the binding.
50
+ .addColumn("project", "text")
51
+ .addColumn("environment", "text")
52
+ .addColumn("worker", "text")
53
+ .addColumn("version", "text")
54
+ // Whose action it was. The four columns above say which deployment of *ours* wrote a row; in a
55
+ // multi-tenant application all four are constant across every row, so nothing on the event
56
+ // distinguished one customer's history from another's. `actorId` does not either: one person can
57
+ // administer two tenants, and every event they produce carries the same actor.
58
+ //
59
+ // Nullable, with no default, permanently — for the same reason the origin columns are. A
60
+ // single-tenant app has no such dimension and must not be made to invent one; a CLI-originated
61
+ // action and a fleet-wide operator action genuinely have no tenant. `null` means "not
62
+ // tenant-scoped", which is a true statement. The tenant of an action is a fact at the time of the
63
+ // action; a membership table only knows who belongs where *now*, so deriving one from the other
64
+ // would hand a year of one tenant's history to another the day somebody changes teams.
65
+ .addColumn("tenant", "text")
66
+ .execute();
67
+
68
+ // Unique on eventId — the recorder's idempotency key. A retried write reuses the same eventId, so
69
+ // this index turns a post-commit retry into a UNIQUE violation the retry wrapper treats as "already
70
+ // landed" rather than a duplicate row.
71
+ await db.schema
72
+ .createIndex("pithyAuditEventsEventIdIdx")
73
+ .on("pithyAuditEvents")
74
+ .column("eventId")
75
+ .unique()
76
+ .execute();
77
+ await db.schema.createIndex("pithyAuditEventsOccurredAtIdx").on("pithyAuditEvents").column("occurredAt").execute();
78
+ await db.schema.createIndex("pithyAuditEventsActionIdx").on("pithyAuditEvents").column("action").execute();
79
+ await db.schema
80
+ .createIndex("pithyAuditEventsActorIdx")
81
+ .on("pithyAuditEvents")
82
+ .columns(["actorType", "actorId"])
83
+ .execute();
84
+ await db.schema
85
+ .createIndex("pithyAuditEventsResourceIdx")
86
+ .on("pithyAuditEvents")
87
+ .columns(["resourceType", "resourceId"])
88
+ .execute();
89
+ // One composite index in the order a reader narrows by: a project owns environments, an environment
90
+ // holds Workers. SQLite uses a leading subset of a composite index, so this also serves `project`
91
+ // alone and `project + environment` without a second index. A `worker`-only filter is not a shape
92
+ // the trail is read by — a Worker name only means anything inside its project.
93
+ await db.schema
94
+ .createIndex("pithyAuditEventsOriginIdx")
95
+ .on("pithyAuditEvents")
96
+ .columns(["project", "environment", "worker"])
97
+ .execute();
98
+ // The tenant read is (tenant, time): one tenant's trail, newest first, usually over a window. So the
99
+ // index leads with `tenant` and carries `occurredAt` — a `tenant`-only index would still leave the
100
+ // sort to a scan of the largest table in most projects.
101
+ await db.schema
102
+ .createIndex("pithyAuditEventsTenantIdx")
103
+ .on("pithyAuditEvents")
104
+ .columns(["tenant", "occurredAt"])
105
+ .execute();
106
+ },
107
+ down: async (db: Kysely<unknown>): Promise<void> => {
108
+ await db.schema.dropIndex("pithyAuditEventsTenantIdx").execute();
109
+ await db.schema.dropIndex("pithyAuditEventsOriginIdx").execute();
110
+ await db.schema.dropIndex("pithyAuditEventsResourceIdx").execute();
111
+ await db.schema.dropIndex("pithyAuditEventsActorIdx").execute();
112
+ await db.schema.dropIndex("pithyAuditEventsActionIdx").execute();
113
+ await db.schema.dropIndex("pithyAuditEventsOccurredAtIdx").execute();
114
+ await db.schema.dropIndex("pithyAuditEventsEventIdIdx").execute();
115
+ await db.schema.dropTable("pithyAuditEvents").execute();
116
+ },
117
+ };
package/src/query.ts ADDED
@@ -0,0 +1,197 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: FSL-1.1-MIT
3
+
4
+ import type { AuditActorType, AuditOutcome, AuditSeverity } from "@pithy-sh/core/src/audit/auditEvent";
5
+ import { SQLiteDate } from "@pithy-sh/core/src/data/codecs";
6
+ import { decodeCursor, type PageCursor, pageLimit, toPage } from "@pithy-sh/core/src/data/cursor";
7
+ import { AuditEventRow } from "./data/auditEvent";
8
+ import type { AuditDatabase } from "./data/tables";
9
+
10
+ /**
11
+ * A filter over the audit trail. Every field is optional and ANDed together; an empty filter returns
12
+ * the whole trail (newest first). This is the typed read seam consumers use to read events by actor,
13
+ * action, time range, resource, outcome, severity, origin, and tenant.
14
+ *
15
+ * `tenant` is the only field where `null` is a value rather than an absence — see its own doc. Every
16
+ * other field is "match this or don't filter".
17
+ *
18
+ * It is a Kysely query first: `src/http/routes.ts` is the control-plane surface over it, and it is
19
+ * this package's own contribution behind `requireControlPlane(scope)` rather than something core
20
+ * reaches in and adds. `actorType: "control-plane"` is what separates a management client's actions
21
+ * from the adopter's own users', which is the question that surface is actually asked.
22
+ */
23
+ export interface AuditQuery {
24
+ /** Match the acting principal's kind. */
25
+ actorType?: AuditActorType;
26
+ /** Match the acting principal's id. */
27
+ actorId?: string;
28
+ /** Match an exact `domain/reason` action code. */
29
+ action?: string;
30
+ /** Match the outcome (`success` | `failure` | `denied`). */
31
+ outcome?: AuditOutcome;
32
+ /** Match the severity (`info` | `warning` | `critical`). */
33
+ severity?: AuditSeverity;
34
+ /** Match the targeted resource's type. */
35
+ resourceType?: string;
36
+ /** Match the targeted resource's id. */
37
+ resourceId?: string;
38
+ /** Match the project the event was recorded in. */
39
+ project?: string;
40
+ /** Match the environment the recording Worker served. */
41
+ environment?: string;
42
+ /** Match the recording Worker's `apps/<name>` directory name. */
43
+ worker?: string;
44
+ /**
45
+ * Match the tenant the action was taken for — the read this column exists to serve, usually with a
46
+ * time range.
47
+ *
48
+ * **`null` is a filter, not an absence.** Omitted (`undefined`) means "do not filter by tenant";
49
+ * `null` means "the events that belong to no tenant" — a CLI-originated action, a fleet-wide operator
50
+ * action, a row recorded before the column existed. Both are questions an adopter genuinely asks, and
51
+ * the second one is why this is `string | null` rather than `string`: without it, asking it means
52
+ * writing SQL against this capability's own table.
53
+ */
54
+ tenant?: string | null;
55
+ /** Inclusive lower bound on `occurredAt`. */
56
+ from?: Date;
57
+ /** Inclusive upper bound on `occurredAt`. */
58
+ to?: Date;
59
+ /**
60
+ * Cap the number of rows returned. {@link queryAuditEvents} uses it verbatim; {@link pageAuditEvents}
61
+ * clamps it into `[1, MAX_PAGE_SIZE]`, because that one answers a caller over HTTP.
62
+ */
63
+ limit?: number;
64
+ /**
65
+ * Where to resume, from the previous page's `nextCursor`. Read only by {@link pageAuditEvents}, and
66
+ * opaque — a malformed one is a first page, never an error (see `@pithy-sh/core/src/data/cursor`).
67
+ */
68
+ cursor?: string;
69
+ }
70
+
71
+ /**
72
+ * One page of the trail, and where the next one starts.
73
+ *
74
+ * `nextCursor` is null at the end of the list, and knowing that costs no `COUNT` — the query
75
+ * over-fetches by one row and {@link toPage} drops it. On the largest table in most projects, a count
76
+ * query per page is the difference between a page load and a table scan.
77
+ */
78
+ export interface AuditEventPage {
79
+ /** The page, newest first. */
80
+ events: AuditEventRow[];
81
+ /** Where the next page starts, or null at the end of the trail. */
82
+ nextCursor: string | null;
83
+ }
84
+
85
+ /**
86
+ * The filtered, ordered query both readers run — one builder, so a filter can never mean two things.
87
+ *
88
+ * The order is `(occurredAt desc, id desc)` and it is not an incidental choice: it is what the keyset
89
+ * cursor names a position in. `id` is the tiebreak, and without it two events recorded in the same
90
+ * millisecond straddle a page boundary and one of them is skipped or returned twice — which on an
91
+ * audit trail is a missing record, not a cosmetic glitch.
92
+ */
93
+ function auditEventQuery(db: AuditDatabase, filter: AuditQuery) {
94
+ let query = db.selectFrom("pithyAuditEvents").selectAll();
95
+
96
+ if (filter.actorType !== undefined) query = query.where("actorType", "=", filter.actorType);
97
+ if (filter.actorId !== undefined) query = query.where("actorId", "=", filter.actorId);
98
+ if (filter.action !== undefined) query = query.where("action", "=", filter.action);
99
+ if (filter.outcome !== undefined) query = query.where("outcome", "=", filter.outcome);
100
+ if (filter.severity !== undefined) query = query.where("severity", "=", filter.severity);
101
+ if (filter.resourceType !== undefined) query = query.where("resourceType", "=", filter.resourceType);
102
+ if (filter.resourceId !== undefined) query = query.where("resourceId", "=", filter.resourceId);
103
+ // The origin filters, in the composite index's column order — `project`, then `environment`, then
104
+ // `worker` — so a narrowing query uses a leading subset of `pithyAuditEventsOriginIdx` rather than
105
+ // scanning. Note these match a *recorded* origin: a row written before the columns existed has NULL
106
+ // and is excluded by any of them, which is correct — it has no origin to match.
107
+ if (filter.project !== undefined) query = query.where("project", "=", filter.project);
108
+ if (filter.environment !== undefined) query = query.where("environment", "=", filter.environment);
109
+ if (filter.worker !== undefined) query = query.where("worker", "=", filter.worker);
110
+ // The tenant filter, and the one place `null` is a value rather than "unset". `= null` matches
111
+ // nothing in SQL, so a null filter has to become `is null` — which is also the reason this reads
112
+ // `!== undefined` rather than a truthiness check. Placed before the time bounds so it leads
113
+ // `pithyAuditEventsTenantIdx`, whose second column is `occurredAt`: one tenant's trail over a window
114
+ // is the query, and it must not scan the largest table in the project.
115
+ if (filter.tenant !== undefined) {
116
+ query = filter.tenant === null ? query.where("tenant", "is", null) : query.where("tenant", "=", filter.tenant);
117
+ }
118
+ if (filter.from !== undefined) query = query.where("occurredAt", ">=", SQLiteDate.encode(filter.from));
119
+ if (filter.to !== undefined) query = query.where("occurredAt", "<=", SQLiteDate.encode(filter.to));
120
+
121
+ return query.orderBy("occurredAt", "desc").orderBy("id", "desc");
122
+ }
123
+
124
+ /**
125
+ * A decoded cursor, narrowed to the position this table's ordering can actually use.
126
+ *
127
+ * `occurredAt` is a **ms-epoch number** here, not an ISO string — Pithy's own tables store dates as
128
+ * numbers, and only Better Auth's store them as TEXT. A cursor carrying a string would compare
129
+ * lexically against an integer column and silently return the wrong page, so anything that is not a
130
+ * pair of numbers is treated as no cursor at all. A bad cursor is a first page, which is the same
131
+ * answer `decodeCursor` gives to a truncated or stale one.
132
+ */
133
+ function keysetPosition(cursor: PageCursor | undefined): { sort: number; id: number } | undefined {
134
+ if (!cursor) return undefined;
135
+ if (typeof cursor.sort !== "number" || typeof cursor.id !== "number") return undefined;
136
+ return { sort: cursor.sort, id: cursor.id };
137
+ }
138
+
139
+ /**
140
+ * Read audit events matching `filter`, newest first (`occurredAt` then `id`, both descending). Each
141
+ * row is decoded back through {@link AuditEventRow} — dates become `Date`s, `metadata` a parsed and
142
+ * validated object — so callers get the app shape, never raw SQLite columns.
143
+ */
144
+ export async function queryAuditEvents(db: AuditDatabase, filter: AuditQuery = {}): Promise<AuditEventRow[]> {
145
+ let query = auditEventQuery(db, filter);
146
+ if (filter.limit !== undefined) query = query.limit(filter.limit);
147
+ const rows = await query.execute();
148
+ return rows.map((row) => AuditEventRow.parse(row));
149
+ }
150
+
151
+ /**
152
+ * One page of the trail, resumable — the read the control-plane route serves.
153
+ *
154
+ * **Keyset, never offset.** The audit table is appended to constantly while somebody is reading it, so
155
+ * with `OFFSET` every event recorded during a read pushes one row from the page a client already has
156
+ * onto the page it is about to fetch: it sees that row twice, and any row that fell off the far end it
157
+ * never sees at all. On a security trail, "you may silently miss a record while paging" is not a
158
+ * usability defect. The cursor names the last row's exact position instead, so the next page starts
159
+ * where the previous ended whatever was written in between.
160
+ */
161
+ export async function pageAuditEvents(db: AuditDatabase, filter: AuditQuery = {}): Promise<AuditEventPage> {
162
+ const limit = pageLimit(filter.limit);
163
+ const position = keysetPosition(decodeCursor(filter.cursor));
164
+
165
+ let query = auditEventQuery(db, filter);
166
+ if (position) {
167
+ // The descending-keyset predicate: strictly older, or the same instant and a lower surrogate id.
168
+ query = query.where((eb) =>
169
+ eb.or([
170
+ eb("occurredAt", "<", position.sort),
171
+ eb.and([eb("occurredAt", "=", position.sort), eb("id", "<", position.id)]),
172
+ ]),
173
+ );
174
+ }
175
+
176
+ // One more than asked for: the extra row is how "is there another page" is answered without a count.
177
+ const rows = await query.limit(limit + 1).execute();
178
+ // Decoded before the split, so the cursor is derived from the app shape rather than from whichever
179
+ // union the row type allows. `getTime()` is the ms-epoch the column actually stores — this table is
180
+ // Pithy's own, so its dates are numbers, not the ISO-8601 TEXT Better Auth's tables hold.
181
+ const decoded = rows.map((row) => AuditEventRow.parse(row));
182
+ const page = toPage(decoded, limit, (row) => ({ sort: row.occurredAt.getTime(), id: row.id }));
183
+ return { events: page.items, nextCursor: page.nextCursor };
184
+ }
185
+
186
+ /**
187
+ * Read one event by its `eventId`, or null when there is none.
188
+ *
189
+ * Addressed by `eventId` — the recorder's UUID idempotency key, uniquely indexed — and never by the
190
+ * autoincrement `id`. `id` is a surrogate that leaks how many events a project has recorded and is
191
+ * guessable by counting; `eventId` is neither, and it is the value the trail's own idempotency
192
+ * guarantee is written against.
193
+ */
194
+ export async function readAuditEvent(db: AuditDatabase, eventId: string): Promise<AuditEventRow | null> {
195
+ const row = await db.selectFrom("pithyAuditEvents").selectAll().where("eventId", "=", eventId).executeTakeFirst();
196
+ return row ? AuditEventRow.parse(row) : null;
197
+ }