[api] audit logging

This commit is contained in:
Devin Zuczek
2026-09-18 17:55:55 -04:00
parent ba428199c5
commit d3a1a30e62
10 changed files with 566 additions and 12 deletions
+40
View File
@@ -0,0 +1,40 @@
-- The audit log — one row per privileged action a staff account took. Owned by the `api`
-- worker; WRITTEN by `notify`, which records every call to its admin-gated `/internal/*`
-- endpoints here. Generated from packages/domain/src/audit-db.ts (AUDIT_LOG_SCHEMA_DDL) —
-- keep in sync.
--
-- This is not the worker log. `logger.info` lines are for reading during an incident: they
-- are sampled, they expire, and nothing can be asked of them later. The actions this table
-- exists for leave no other trace on the database at all — a maintenance broadcast is a
-- WebSocket frame and then nothing, and a coach message to every online player is
-- deliberately not stored (see 0019_notification.sql) — so without a row here there is no
-- answer at all to "who sent that, and when", a question that tends to arrive weeks later.
--
-- `player_id` is the ACTOR: the staff account that made the call, never whoever it was
-- aimed at. A target, where there is one, lives inside `data` with the rest of what was
-- asked for. The broadcasts have no target at all, so a column for one would be null on
-- half the table and could not be the thing every row is filed under.
--
-- `data` is JSON as TEXT, and deliberately unstructured: each action records what it was
-- asked to do, and those shapes have nothing in common beyond being worth keeping. It is
-- nullable because an action whose whole content is its name has nothing to put there, and
-- null says that where `{}` would only look like a payload that went missing. Capped at
-- 4096 characters by the writer, since a request body's size is the caller's to choose;
-- over that the row keeps the head of the JSON and says `truncated`.
--
-- `audit_log_id` is a plain rowid alias, not AUTOINCREMENT: the id never leaves the server
-- (unlike `notification`'s, which is handed to the client) and nothing deletes from this
-- table, so there are no freed rowids to be reused. Reads order by it rather than by
-- `date`, which ties when two actions land in the same millisecond.
CREATE TABLE IF NOT EXISTS audit_log (
audit_log_id INTEGER PRIMARY KEY,
player_id INTEGER NOT NULL,
action TEXT NOT NULL,
data TEXT,
date TEXT NOT NULL
);
-- The two ways anyone asks this table anything: everything one staffer did, and every time
-- a given action was taken. Both newest-first, which is the order an audit trail is read in.
CREATE INDEX IF NOT EXISTS idx_audit_log_player ON audit_log (player_id, audit_log_id DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, audit_log_id DESC);
+53
View File
@@ -21,6 +21,7 @@ import {
subRoomDataBlob,
updateAccount,
verifyPassword,
writeAuditLog,
} from '@repo/domain'
import {
intVar,
@@ -1121,6 +1122,58 @@ const app = new Hono<App>()
// grant_type=refresh_token). A refresh grant thus rotates its token.
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
// A STAFF sign-in is an audit event in its own right. What these accounts then do is
// recorded against them on the `audit_log` table (`notify` files every `/internal/*`
// call there); this is the row saying the session those actions came from began — and
// from what device, build and IP, which is the first thing anyone asks when an action
// further down the log looks wrong. The flags themselves are the trigger, so an account
// granted the role between logins starts being recorded the next time it signs in.
//
// A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS,
// and recording those would bury the logins under hourly repeats that say nothing new
// about who came in or from where.
//
// Written after the token is minted, so the row means a login that actually happened,
// and caught rather than awaited into the response: a failed audit insert must not turn
// a staffer's successful sign-in into a 500. It leaves a loud line in the worker log
// instead — the one thing a gap in an audit trail should never do quietly.
const staffRoles =
roleAccount && grantType !== 'refresh_token'
? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role))
: []
if (staffRoles.length > 0) {
try {
await writeAuditLog(c.env.DB, {
playerId: Number(accountId),
action: 'staff_login',
// The IP leads: it is the first thing anyone asks of a staff sign-in, and the one
// field here that says WHERE the session came from rather than what it could do.
// Empty outside the Cloudflare edge — recorded as the empty string it is rather
// than omitted, so a row can't be read as "we didn't record one".
//
// Then: which powers the session carries, HOW they signed in, and the same
// unverified-but-recorded device/build context the account row keeps — kept here
// too so the row stands on its own months later, after the account's
// `lastLoginIp` and device have been overwritten by every login since.
data: {
ip: clientIp,
roles: staffRoles,
grantType,
username: roleAccount?.username ?? null,
deviceId,
deviceClass,
version: version ?? null,
},
})
} catch (err) {
logger.error('could not write a staff_login audit log row', {
accountId,
grantType,
error: err instanceof Error ? err.message : String(err),
})
}
}
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
+121
View File
@@ -5,6 +5,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../auth.app'
import {
AUDIT_LOG_SCHEMA_DDL,
GAME_VERSION,
getAccountsByDeviceId,
hashPassword,
@@ -70,6 +71,9 @@ beforeAll(async () => {
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The audit trail a staff sign-in is recorded on. Owned by the `api` worker, whose
// migrations don't run here, so build it from the mirrored DDL.
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
const hash = await hashPassword(LOGIN_PASSWORD)
@@ -1780,3 +1784,120 @@ describe('ban evasion at the token endpoint', () => {
}
})
})
// A staff sign-in is recorded on the `audit_log` table — the same table `notify` files
// every `/internal/*` call on. What those accounts then do is attributed to them; this is
// the row that says the session it came from began, and from what device, build and IP.
describe('staff login audit', () => {
interface AuditRow {
player_id: number
action: string
data: string | null
date: string
}
const staffLogins = async (accountId: number): Promise<AuditRow[]> => {
const { results } = await env.DB.prepare(
`SELECT player_id, action, data, date FROM audit_log
WHERE player_id = ?1 AND action = 'staff_login' ORDER BY audit_log_id`
)
.bind(accountId)
.all<AuditRow>()
return results
}
/** Seed an account with the given flags and a known password. */
const seedAccount = async (accountId: number, data: Record<string, unknown>) =>
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
username: `Staff${accountId}`,
passwordHash: await hashPassword(LOGIN_PASSWORD),
...data,
})
)
.run()
test('a login by an isModerator account is recorded, with the context of the sign-in', async () => {
await seedAccount(9601, { username: 'ModPlayer', isModerator: true })
const res = await postToken(
`grant_type=password&account_id=9601&password=${LOGIN_PASSWORD}` +
'&device_id=dev-mod&device_class=2&ver=20230414',
'203.0.113.9'
)
expect(res.status).toBe(200)
const rows = await staffLogins(9601)
expect(rows).toHaveLength(1)
expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false)
const data = JSON.parse(rows[0].data!) as Record<string, unknown>
expect(data).toMatchObject({
ip: '203.0.113.9',
roles: ['moderator'],
grantType: 'password',
username: 'ModPlayer',
deviceId: 'dev-mod',
deviceClass: 2,
version: '20230414',
})
// The IP LEADS the payload: it is the first thing anyone asks of a staff sign-in, and
// the reader of a raw row should not have to hunt for it.
expect(Object.keys(data)[0]).toBe('ip')
})
// Both flags on one account record both — the row says which powers the session carries,
// not merely that it carries some.
test('an account holding both flags records both roles', async () => {
await seedAccount(9602, { isDeveloper: true, isModerator: true })
expect((await postToken(`account_id=9602&password=${LOGIN_PASSWORD}`)).status).toBe(200)
const rows = await staffLogins(9602)
expect(rows).toHaveLength(1)
expect((JSON.parse(rows[0].data!) as { roles: string[] }).roles).toEqual([
'developer',
'moderator',
])
})
// The flags are the whole trigger. An ordinary player signing in is not an audit event,
// and recording every login would bury the ones that are.
test('an ordinary login records nothing', async () => {
await seedAccount(9603, {})
expect((await postToken(`account_id=9603&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9603)).toHaveLength(0)
})
// A refresh renews the SAME session every TOKEN_TTL_SECONDS. Recording those would bury
// the actual sign-ins under hourly repeats saying nothing new about who came in or from
// where — so the login is recorded once, and the refreshes after it are not.
test('a refresh_token grant is not a login and is not recorded', async () => {
await seedAccount(9604, { isDeveloper: true })
const first = await postToken(`account_id=9604&password=${LOGIN_PASSWORD}`)
expect(first.status).toBe(200)
expect(await staffLogins(9604)).toHaveLength(1)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}`
)
expect(refreshed.status).toBe(200)
expect(await staffLogins(9604)).toHaveLength(1)
})
// The flag is read at LOGIN, exactly as `rn.plus` and the token's `role` claim are, so
// granting the role starts the trail at their next sign-in rather than retroactively.
test('an account granted the role starts being recorded on its next login', async () => {
await seedAccount(9605, {})
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9605)).toHaveLength(0)
await env.DB.prepare(
`UPDATE account SET data = json_set(data, '$.isModerator', json('true')) WHERE account_id = ?1`
)
.bind(9605)
.run()
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
expect(await staffLogins(9605)).toHaveLength(1)
})
})
+5
View File
@@ -9,6 +9,11 @@
// history separate from the rooms worker's migrations on the same database. The
// "local" placeholder is replaced with the real id from RECFLARE_D1 (see .env) at
// deploy time.
//
// It also WRITES one table it doesn't own: a sign-in by an account holding the
// isDeveloper/isModerator flag is recorded on `audit_log` as a `staff_login`
// (apps/api/migrations/0024_audit_log.sql, owned by the `api` worker). That is the row
// saying the session every later audited action came from began, and from what IP.
"d1_databases": [
{
"binding": "DB",
+9
View File
@@ -23,6 +23,15 @@ instance holds the shared hub state (one process across all connections).
`MessageReceived` to every connected client (online-only; not persisted). Same
admin-role gate.
Every `/internal/*` call — including one the role gate refuses, and one that 404s — is
recorded on the shared database's `audit_log` table, against the account that made it:
`player_id` is the caller, `action` is the path as a snake_case verb (`coach_message`),
and `data` carries the request body or query string plus the response status. The write
happens in the admin gate, so a new `/internal/*` endpoint is audited the day it is added.
A token carrying an admin role but no usable `sub` is refused (401) rather than let through
unattributable. A failed audit write never fails the call; it leaves an error in the worker
log. The table is owned by the `api` worker (`apps/api/migrations/0024_audit_log.sql`).
## Hub protocol
1. Client `POST /hub/v1/negotiate`, then opens a WebSocket to `/hub/v1?id=<token>`.
+87 -3
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { createNotification } from '@repo/domain'
import { createNotification, writeAuditLog } from '@repo/domain'
import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
@@ -119,16 +119,100 @@ async function connectionOwner(c: Context<App>): Promise<number | null> {
)
}
/**
* The `action` an `/internal/<path>` call is recorded under — the path after the prefix as a
* stable snake_case verb, so `/internal/coach-message` files under `coach_message`.
*
* Derived rather than mapped, so the audit trail can't fall behind the routes: a new
* `/internal/*` endpoint is logged the day it is added, with no second list to keep in sync.
* The cost is that the value comes from the request, so it is squeezed down to `[a-z0-9_]`
* and capped — this middleware also runs for `/internal/` paths that match no route at all
* (they 404 just after it), and an admin token reaching for one is still worth a row.
*/
function auditAction(path: string): string {
const slug = path
.replace(/^\/internal\/?/, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64)
return slug === '' ? 'unknown' : slug
}
/**
* Record one `/internal/*` call against the account that made it.
*
* What is kept is what was ASKED FOR plus how it was answered: the request body (or the
* query string, for the reads) is the substance of the action — a coach message's text and
* recipient exist nowhere else once the frame has gone out, and a broadcast is a WebSocket
* frame and then nothing — and the status says whether it happened. Reading the body here
* costs nothing: Hono caches a parsed body on the request, so this is the same object the
* handler parsed rather than a second read of a consumed stream.
*
* Never throws. By the time this runs the action has usually already taken place, so a
* failed insert must not turn a delivered coach message into an error for the caller — it
* leaves a loud line in the worker log instead, which is the one thing a gap in an audit
* trail should never do quietly.
*/
async function recordInternalCall(c: Context<App>, playerId: number, status: number) {
const action = auditAction(c.req.path)
const data: Record<string, unknown> = { method: c.req.method, path: c.req.path, status }
// A GET carries no body by definition; everything else may, and a request whose body
// isn't JSON (or is empty, as the DELETE's is) simply records none.
const body = c.req.method === 'GET' ? null : await c.req.json().catch(() => null)
if (body !== null) data.body = body
const query = c.req.query()
if (Object.keys(query).length > 0) data.query = query
try {
await writeAuditLog(c.env.DB, { playerId, action, data })
} catch (err) {
logger.error('could not write an audit log row', {
action,
playerId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Gates the `/internal/*` endpoints on a valid Bearer token that carries one of the
* {@link ADMIN_ROLES} in its `role` claim. 401 for a missing/invalid token, 403 for a
* valid token that lacks an admin role.
*
* Also where every call through here is AUDITED (see {@link recordInternalCall}). It sits in
* the gate rather than in each handler for the same reason the gate does: these endpoints
* are the operator's remote control over every connected client, and a record of who used
* it should not be something a new endpoint can forget to add.
*/
const requireAdmin: MiddlewareHandler<App> = async (c, next) => {
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
const secret = await c.env.JWT_SECRET.get()
const roles = await validateAndGetRoles(c.req.raw, secret)
if (roles === null) return c.json({ error: 'Unauthorized' }, 401)
if (!roles.some((role) => ADMIN_ROLES.has(role))) return c.json({ error: 'Forbidden' }, 403)
// Every call is filed under the account that made it, so a token with an admin role but
// no usable `sub` is refused rather than let through unattributable. One minted by `auth`
// always has one, so this costs a real caller nothing.
const accountId = await validateAndGetAccountId(c.req.raw, secret)
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
if (!roles.some((role) => ADMIN_ROLES.has(role))) {
// Recorded like any other call: a signed-in account reaching for an admin endpoint is
// exactly what an audit trail is kept for, and unlike the 401 above we know who it was.
await recordInternalCall(c, accountId, 403)
return c.json({ error: 'Forbidden' }, 403)
}
// Recorded AFTER the handler so the row can carry the outcome, and from a `finally` so a
// handler that throws is recorded too — as the 500 `onError` is about to answer with.
let status = 500
try {
await next()
status = c.res.status
} finally {
await recordInternalCall(c, accountId, status)
}
}
const app = new Hono<App>()
+120 -1
View File
@@ -2,7 +2,7 @@ import { adminSecretsStore, env, runInDurableObject } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import { NOTIFICATION_SCHEMA_DDL } from '@repo/domain'
import { AUDIT_LOG_SCHEMA_DDL, NOTIFICATION_SCHEMA_DDL } from '@repo/domain'
import '../../notify.app'
@@ -55,6 +55,9 @@ beforeAll(async () => {
// The notification store a targeted coach message writes to. Owned by the `api` worker's
// migrations, which don't run here, so build it from the mirrored DDL.
for (const ddl of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(ddl).run()
// The audit trail every /internal/* call is filed on. Owned by `api` too, so likewise
// built from the mirrored DDL.
for (const ddl of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(ddl).run()
})
/** Who `connect` is by default — kept clear of the player ids the tests notify. */
@@ -801,3 +804,119 @@ describe('CORS', () => {
expect(res.headers.get('access-control-allow-origin')).toBe('*')
})
})
// Every /internal/* call is recorded on the `audit_log` table, against the account that
// made it. These endpoints are the operator's remote control over every connected client
// and most of what they do leaves no other trace on the database — a maintenance broadcast
// is a WebSocket frame and then nothing — so the row is the only lasting answer to "who
// sent that".
describe('audit log', () => {
interface AuditRow {
player_id: number
action: string
data: string | null
date: string
}
/** Everything recorded against one actor, oldest first. */
const auditRows = async (playerId: number): Promise<AuditRow[]> => {
const { results } = await env.DB.prepare(
'SELECT player_id, action, data, date FROM audit_log WHERE player_id = ?1 ORDER BY audit_log_id'
)
.bind(playerId)
.all<AuditRow>()
return results
}
const auditData = (row: AuditRow): Record<string, unknown> =>
JSON.parse(row.data ?? '{}') as Record<string, unknown>
// A distinct actor per test: the rest of the file drives these endpoints as account 1,
// so filtering on the caller is what keeps each assertion about its own call.
test('records a successful call against the caller, with what was asked for', async () => {
const actor = 4242
const res = await post(
'/internal/coach-message',
{ playerId: 9500, messageContent: 'audited' },
await bearer(String(actor), ['moderator'])
)
expect(res.status).toBe(200)
const rows = await auditRows(actor)
expect(rows).toHaveLength(1)
// The action is the path after `/internal/`, as a snake_case verb.
expect(rows[0].action).toBe('coach_message')
expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false)
expect(auditData(rows[0])).toMatchObject({
method: 'POST',
path: '/internal/coach-message',
status: 200,
// The message and its recipient exist nowhere else once the frame has gone out.
body: { playerId: 9500, messageContent: 'audited' },
})
})
// A refused call is still a call someone made, and here — unlike the 401 below — we know
// who made it. A signed-in account reaching for an admin endpoint is close to the whole
// point of keeping the trail.
test('records a call the role gate refused, with the status that refused it', async () => {
const actor = 4243
const res = await post(
'/internal/broadcast',
{ notificationType: 1 },
await bearer(String(actor), ['gameClient'])
)
expect(res.status).toBe(403)
const rows = await auditRows(actor)
expect(rows).toHaveLength(1)
expect(rows[0].action).toBe('broadcast')
expect(auditData(rows[0]).status).toBe(403)
})
test('records a rejected body as the attempt it was', async () => {
const actor = 4244
const res = await post(
'/internal/coach-message',
{ playerId: 9501 },
await bearer(String(actor), ['moderator'])
)
expect(res.status).toBe(400)
const rows = await auditRows(actor)
expect(rows).toHaveLength(1)
expect(auditData(rows[0])).toMatchObject({ status: 400, body: { playerId: 9501 } })
})
// The reads are recorded too, and a call that carries its arguments in the query string
// rather than a body records those instead.
test('records a read, with its query string', async () => {
const actor = 4245
const res = await exports.default.fetch(`${ORIGIN}/internal/hub-state/pending?all=true`, {
method: 'DELETE',
headers: await bearer(String(actor), ['developer']),
})
expect(res.status).toBe(200)
const rows = await auditRows(actor)
expect(rows).toHaveLength(1)
expect(rows[0].action).toBe('hub_state_pending')
expect(auditData(rows[0])).toMatchObject({ method: 'DELETE', query: { all: 'true' } })
})
// Nothing to file it under: `player_id` is the actor, and a request with no valid token
// names nobody. The 401 is the record of that one, in the worker log.
test('records nothing for a call with no valid token', async () => {
const count = async () =>
(await env.DB.prepare('SELECT COUNT(*) AS n FROM audit_log').first<{ n: number }>())!.n
const before = await count()
const res = await exports.default.fetch(`${ORIGIN}/internal/broadcast`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notificationType: 1 }),
})
expect(res.status).toBe(401)
expect(await count()).toBe(before)
})
})
+14 -7
View File
@@ -8,14 +8,21 @@
"bindings": [{ "name": "RECFLARE_NOTIFICATIONS_HUB", "class_name": "NotificationsHub" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["NotificationsHub"] }],
// The shared `recflare` D1 database, for the message store behind
// `GET /api/messages/v2/get`: a coach message to ONE player is a durable message and is
// written here before it is pushed. No `migrations_dir` — the `api` worker owns this
// schema (apps/api/migrations/0019_message.sql); notify only writes rows.
// The shared `recflare` D1 database. Two tables, both owned (and migrated) by the `api`
// worker — no `migrations_dir` here; notify only writes rows:
//
// The coach BROADCAST is deliberately not stored: it reaches everyone online at once, so
// persisting it would write a row per player — thousands per maintenance notice, for
// something nobody needs to re-read.
// - `notification` (apps/api/migrations/0019_notification.sql), the message store behind
// `GET /api/messages/v2/get`: a coach message to ONE player is a durable message and is
// written there before it is pushed.
// - `audit_log` (apps/api/migrations/0024_audit_log.sql), where EVERY `/internal/*` call
// is recorded against the staff account that made it. These endpoints are the
// operator's remote control over every connected client and most of what they do leaves
// no other trace, so the row is the only lasting answer to "who sent that".
//
// The coach BROADCAST is deliberately not stored as a notification: it reaches everyone
// online at once, so persisting it would write a row per player — thousands per
// maintenance notice, for something nobody needs to re-read. Its audit row is one row
// either way, which is part of why that trail is worth keeping.
"d1_databases": [
{
"binding": "DB",
+115
View File
@@ -0,0 +1,115 @@
/**
* The audit log on the shared `recflare` D1 database — one row per privileged action a
* staff account took, written by whoever performs the action.
*
* This is not the worker log. `logger.info` lines are for reading during an incident: they
* are sampled, they expire, and nothing can be asked of them later. An audit row is the
* durable answer to "who sent that, and when" — the kind of question that arrives weeks
* after the fact, about an action (a coach message, a maintenance broadcast) that left no
* other trace on the database at all.
*
* `player_id` is the ACTOR — the staff account that made the call — not whoever the action
* was aimed at. A target, where there is one, lives inside `data` along with the rest of
* what was asked for; the broadcasts have no target at all, so a column for one would be
* null on half the table and could not be the thing every row is filed under.
*
* `data` is JSON as TEXT, and deliberately unstructured: each action records what it was
* asked to do, and those shapes have nothing in common beyond being worth keeping. Read it
* with `JSON.parse`; nothing queries INTO it (D1 has `json_extract` if that ever changes).
*
* The `api` worker owns this schema/migration (`apps/api/migrations/0024_audit_log.sql`,
* applied under its own `migrations_table` so it doesn't clash with the other workers'
* migrations that share the database). {@link AUDIT_LOG_SCHEMA_DDL} mirrors that migration
* so a worker that writes the table but doesn't migrate it — `notify` — can build it
* directly in its tests.
*/
/**
* How much serialized `data` one row keeps.
*
* A body arrives from a caller, so its size is theirs to choose, and an audit row that
* carries a multi-megabyte payload costs the same storage whether or not anyone ever reads
* it. Over the cap the row keeps the head of the JSON and says so (see {@link writeAuditLog})
* rather than dropping `data` altogether: a truncated record still answers what was done,
* and a row that silently lost its payload does not.
*/
export const MAX_AUDIT_DATA_CHARS = 4096
/** Schema DDL (mirror of apps/api/migrations/0024_audit_log.sql). */
export const AUDIT_LOG_SCHEMA_DDL: string[] = [
// `audit_log_id` is a plain rowid alias, not AUTOINCREMENT: the id never leaves the
// server (unlike `notification`'s, which is handed to the client), and nothing deletes
// from this table, so there are no freed rowids to be reused.
//
// `date` is an ISO-8601 UTC string, which is fixed-width and so orders correctly under
// SQLite's plain string comparison — though reads order by `audit_log_id`, which is
// monotonic and doesn't tie when two actions land in the same millisecond.
//
// `data` is nullable: an action whose whole content is its name (and its actor, and its
// time) has nothing to put there, and null says that where `{}` would only look like a
// payload that went missing.
`CREATE TABLE IF NOT EXISTS audit_log (
audit_log_id INTEGER PRIMARY KEY,
player_id INTEGER NOT NULL,
action TEXT NOT NULL,
data TEXT,
date TEXT NOT NULL
)`,
// The two ways anyone asks this table anything: everything one staffer did, and every
// time a given action was taken. Both newest-first, which is the order an audit trail is
// read in.
`CREATE INDEX IF NOT EXISTS idx_audit_log_player ON audit_log (player_id, audit_log_id DESC)`,
`CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, audit_log_id DESC)`,
]
/** What a caller records. The id and the timestamp are minted here. */
export interface NewAuditLogEntry {
/** The staff account that took the action — the actor, never the target. */
playerId: number
/**
* What they did, as a stable `snake_case` verb: `coach_message`, `broadcast`. It is what
* the log is filtered on, so it names the ACTION and not the outcome — a call that was
* refused is the same action as one that went through, distinguished by what `data` says
* about it.
*/
action: string
/** Whatever describes this particular action. Serialized to JSON; omit where there is none. */
data?: unknown
}
/**
* Record one action.
*
* Callers should not let a failure here fail the action itself: by the time this runs the
* thing has usually already happened, so throwing would report a coach message as refused
* after it was delivered. Catch it and log loudly instead — a gap in the audit trail is
* worth an error line, but it is not worth lying to the caller about what took place.
*/
export async function writeAuditLog(db: D1Database, entry: NewAuditLogEntry): Promise<void> {
await db
.prepare(`INSERT INTO audit_log (player_id, action, data, date) VALUES (?1, ?2, ?3, ?4)`)
.bind(entry.playerId, entry.action, serializeAuditData(entry.data), new Date().toISOString())
.run()
}
/**
* `data` as it goes into the column: JSON text, null when there is nothing to say, and a
* self-describing stand-in when what there is exceeds {@link MAX_AUDIT_DATA_CHARS}.
*
* The stand-in is itself valid JSON, so every non-null `data` in the table parses — a reader
* never has to guess whether a row was truncated or simply malformed.
*/
function serializeAuditData(data: unknown): string | null {
if (data === undefined || data === null) return null
const json = JSON.stringify(data)
// `JSON.stringify` answers undefined for a value that isn't representable (a bare
// function, a symbol). Nothing here passes one, but the column would take the string
// "undefined" if something ever did.
if (json === undefined) return null
if (json.length <= MAX_AUDIT_DATA_CHARS) return json
return JSON.stringify({
truncated: true,
chars: json.length,
preview: json.slice(0, MAX_AUDIT_DATA_CHARS),
})
}
+1
View File
@@ -7,6 +7,7 @@ export {
MatchmakingErrorCode,
} from './enums'
export * from './accounts-db'
export * from './audit-db'
export * from './clubs-db'
export * from './d1-binds'
export * from './images-db'