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,83 @@
import { BadRequestException, CanActivate, ExecutionContext, Inject, Injectable, SetMetadata, UnauthorizedException, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { verifySession, epochValid, type SessionPayload } from "../auth/session.js";
import { pool, withTenant } from "../db/client.js";
import { userCanUseConnection } from "../groups/groups.service.js";
import { isUuid } from "../http/validation.js";
export const PERMISSION_KEY = "required_permission";
export const GROUP_ACCESS_KEY = "required_group_access";
export const RequirePermission = (permission: string): MethodDecorator =>
SetMetadata(PERMISSION_KEY, permission) as MethodDecorator;
/**
* Additionally gate a route on group scope for its `:connectionId` param:
* Owner/Admin pass; anyone else must belong to a group the connection is
* assigned to. Fails closed if the route has no `:connectionId`.
*/
export const RequireGroupAccess = (source: "params" | "body" = "params"): MethodDecorator =>
SetMetadata(GROUP_ACCESS_KEY, source) as MethodDecorator;
export interface AuthenticatedRequest {
session?: SessionPayload;
}
/**
* RBAC guard: session signature -> workspace membership -> permission set.
* Epoch-fenced (finding #2): revocation bumps authz_epoch, session fails closed.
*/
@Injectable()
export class RbacGuard implements CanActivate {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.get<string>(PERMISSION_KEY, context.getHandler());
const http = context.switchToHttp();
const req = http.getRequest<{ headers: Record<string, string>; params?: Record<string, string>; body?: unknown } & AuthenticatedRequest>();
const auth = req.headers["authorization"];
if (auth === undefined || !auth.startsWith("Bearer ")) throw new UnauthorizedException();
const session = verifySession(auth.slice("Bearer ".length));
if (session === null) throw new UnauthorizedException();
const ws = await pool.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [session.workspaceId]);
// authz_epoch is bigint: node-postgres returns it as a string, so normalise
// before the strict comparison or every session reads as stale.
const rawEpoch = ws.rows[0]?.authz_epoch as string | number | undefined;
if (rawEpoch === undefined || !epochValid(session, Number(rawEpoch))) {
throw new UnauthorizedException("authorization epoch stale — re-authenticate");
}
if (required !== undefined) {
// memberships is FORCE RLS: the lookup must run inside the session's
// tenant context, otherwise sp_app sees zero rows and every check fails.
const allowed = await withTenant(session.workspaceId, (client) =>
client.query(
`SELECT 1 FROM role_permissions rp
JOIN memberships m ON m.role = rp.role AND m.workspace_id = $1 AND m.user_id = $2 AND m.status = 'active'
WHERE rp.permission = $3`,
[session.workspaceId, session.userId, required],
),
);
if (allowed.rowCount === 0) throw new ForbiddenException(`missing permission: ${required}`);
}
const groupSource = this.reflector.get<"params" | "body" | undefined>(GROUP_ACCESS_KEY, context.getHandler());
if (groupSource !== undefined) {
const body = req.body;
const connectionId = groupSource === "body"
? (typeof body === "object" && body !== null && "connectionId" in body ? body.connectionId : undefined)
: req.params?.connectionId;
if (connectionId === undefined || connectionId === "") {
throw new ForbiddenException("group access check requires a connectionId");
}
if (typeof connectionId !== "string" || !isUuid(connectionId)) throw new BadRequestException("connectionId must be a UUID");
const permitted = await withTenant(session.workspaceId, (client) =>
userCanUseConnection(client, session.workspaceId, session.userId, connectionId),
);
if (!permitted) throw new ForbiddenException("connection is not shared with any of your groups");
}
req.session = session;
return true;
}
}