Tactical Media Broadcast Hub — initial release

Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,35 @@
import { BadRequestException, Controller, Get, Inject, Query, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { isUuid, parseLimit, requireSession } from "../http/validation.js";
import { AuditQueryService, type AuditPage } from "./audit.query.js";
@Controller("audit")
@UseGuards(RbacGuard)
export class AuditController {
constructor(@Inject(AuditQueryService) private readonly audit: AuditQueryService) {}
@Get()
@RequirePermission("audit.view")
async list(
@Req() request: Request & AuthenticatedRequest,
@Query("action") action: string | undefined,
@Query("actor") actor: string | undefined,
@Query("before") before: string | undefined,
@Query("limit") limit: string | undefined,
): Promise<AuditPage> {
const session = requireSession(request);
if (actor !== undefined && actor !== "" && actor !== "system" && !isUuid(actor)) {
throw new BadRequestException("actor must be a user UUID or \"system\"");
}
if (before !== undefined && before !== "" && !/^\d{1,18}$/.test(before)) {
throw new BadRequestException("before must be a sequence cursor");
}
return this.audit.page(session.workspaceId, {
...(action === undefined || action === "" ? {} : { action }),
...(actor === undefined || actor === "" ? {} : { actor }),
...(before === undefined || before === "" ? {} : { before }),
limit: parseLimit(limit, 50, 200),
});
}
}

View file

@ -0,0 +1,113 @@
import { Injectable } from "@nestjs/common";
import { withTenant } from "../db/client.js";
export interface AuditEntryDto {
id: string;
seq: string;
at: string;
actorUserId: string | null;
actorEmail: string | null;
actorRole: string | null;
action: string;
targetType: string;
targetId: string;
diff: Record<string, unknown> | null;
}
export interface AuditActorFacet {
/** null = system / service actor. */
id: string | null;
email: string | null;
}
export interface AuditPage {
entries: AuditEntryDto[];
/** Pass back as `before` to fetch the next (older) page; null when exhausted. */
nextCursor: string | null;
facets: { actions: string[]; actors: AuditActorFacet[] };
}
export interface AuditFilter {
action?: string;
/** User UUID, or "system" for entries without a human actor. */
actor?: string;
before?: string;
limit: number;
}
interface AuditRow {
id: string;
seq: string;
at: Date;
actor_user_id: string | null;
actor_email: string | null;
actor_role: string | null;
action: string;
target_type: string;
target_id: string;
diff: Record<string, unknown> | null;
}
/** Read side of the tenant audit chain. RLS (tenant_audit policy) scopes rows to the session's workspace. */
@Injectable()
export class AuditQueryService {
async page(workspaceId: string, filter: AuditFilter): Promise<AuditPage> {
return withTenant(workspaceId, async (client) => {
const actorIsSystem = filter.actor === "system";
const rows = await client.query(
`SELECT a.id, a.seq::text AS seq, a.at, a.actor_user_id, u.email AS actor_email, a.actor_role,
a.action, a.target_type, a.target_id, a.diff
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.workspace_id = $1
AND ($2::text IS NULL OR a.action = $2)
AND ($3::uuid IS NULL OR a.actor_user_id = $3)
AND (NOT $4::boolean OR a.actor_user_id IS NULL)
AND ($5::bigint IS NULL OR a.seq < $5)
ORDER BY a.seq DESC
LIMIT $6`,
[
workspaceId,
filter.action ?? null,
actorIsSystem ? null : (filter.actor ?? null),
actorIsSystem,
filter.before ?? null,
filter.limit + 1,
],
);
const all = rows.rows as AuditRow[];
const hasMore = all.length > filter.limit;
const entries = all.slice(0, filter.limit).map((r) => ({
id: r.id,
seq: r.seq,
at: new Date(r.at).toISOString(),
actorUserId: r.actor_user_id,
actorEmail: r.actor_email,
actorRole: r.actor_role,
action: r.action,
targetType: r.target_type,
targetId: r.target_id,
diff: r.diff,
}));
const actions = await client.query(
"SELECT DISTINCT action FROM audit_log WHERE workspace_id = $1 ORDER BY action",
[workspaceId],
);
const actors = await client.query(
`SELECT DISTINCT a.actor_user_id AS id, u.email
FROM audit_log a LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.workspace_id = $1 ORDER BY u.email NULLS LAST`,
[workspaceId],
);
return {
entries,
nextCursor: hasMore ? (entries[entries.length - 1]?.seq ?? null) : null,
facets: {
actions: (actions.rows as Array<{ action: string }>).map((r) => r.action),
actors: actors.rows as AuditActorFacet[],
},
};
});
}
}

View file

@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { scrub } from "./audit.service.js";
describe("audit diff scrubbing", () => {
it("redacts secret-shaped keys (spec: secrets never enter audit payloads)", () => {
const clean = scrub({ password: "hunter2", stream_key: "sk-1", note: "hello", count: 3 });
expect(clean).toEqual({ password: "[REDACTED]", stream_key: "[REDACTED]", note: "hello", count: 3 });
});
it("passes null through", () => {
expect(scrub(null)).toBeNull();
});
});

View file

@ -0,0 +1,38 @@
import { withTenant } from "../db/client.js";
/**
* Audit service: hash-chained, secrets never enter payloads.
* Branch selection (findings #1/#6): tenant context present => tenant chain
* (audit_append), even for service roles; no tenant context => system chain.
*/
const SECRET_KEYS = /(password|secret|token|key|credential|stream_key|refresh)/i;
export function scrub(diff: Record<string, unknown> | null): Record<string, unknown> | null {
if (diff === null) return null;
const clean: Record<string, unknown> = {};
for (const [k, v] of Object.entries(diff)) {
clean[k] = SECRET_KEYS.test(k) ? "[REDACTED]" : v;
}
return clean;
}
export interface AuditEvent {
actorUserId: string | null;
actorRole: string;
action: string;
targetType: string;
targetId: string;
diff?: Record<string, unknown> | null;
}
export async function appendTenantAudit(workspaceId: string, event: AuditEvent): Promise<string> {
const res = await withTenant(workspaceId, async (client) => {
const result = await client.query(
"SELECT audit_append($1, $2, $3, $4, $5, $6, $7) AS id",
[workspaceId, event.actorUserId, event.actorRole, event.action, event.targetType, event.targetId, scrub(event.diff ?? null)],
);
return result.rows[0]?.id as string;
});
return res;
}