feat(audit): append-only audit log for spaces
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s

Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.

The table is deliberately generic (action + target + JSON metadata) so a new
action needs no migration. Writes never throw: a kick must not fail because
its log entry could not be written, since the kick already happened.

Leaving is recorded as a different action from being removed. The same route
serves both, and a log that conflates them misleads exactly when it matters.

Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
This commit is contained in:
2026-08-31 13:22:52 -03:00
parent fb662bfe12
commit bbb190cbda
16 changed files with 4604 additions and 2 deletions
+15
View File
@@ -73,6 +73,7 @@ import type {
ReattachResponse,
Activity,
} from '@backspace/shared';
import type { AuditEvent } from '@backspace/shared/src/audit.js';
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
@@ -285,6 +286,10 @@ export class BackspaceApiClient {
removeFavorite: (id: string) => Promise<void>;
};
readonly audit: {
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
};
readonly spotify: {
status: () => Promise<{ configured: boolean; connected: boolean }>;
authorizeUrl: () => Promise<{ url: string }>;
@@ -699,6 +704,16 @@ export class BackspaceApiClient {
},
};
this.audit = {
log: (spaceId: string, before?: string) => {
const params = new URLSearchParams();
if (before) params.set('before', before);
const qs = params.toString();
return request<{ events: AuditEvent[]; hasMore: boolean }>(
'GET', `/spaces/${spaceId}/audit-log${qs ? `?${qs}` : ''}`);
},
};
this.spotify = {
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),