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:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
57
apps/api/src/connections/pending.ts
Normal file
57
apps/api/src/connections/pending.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
import type { AuthorizationRequest } from "@tactical-media-broadcast-hub/connector-sdk";
|
||||
import type { SessionPayload } from "../auth/session.js";
|
||||
import { RespControlRedis, type ControlRedis } from "../media-control/redis.js";
|
||||
import { vault, type Vault } from "../vault/vault.js";
|
||||
|
||||
export const AUTH_TTL_MS = 10 * 60_000;
|
||||
export const stateCookieName = (state: string): string => `ap_connect_${state}`;
|
||||
export const validState = (state: string): boolean => /^[A-Za-z0-9_-]{43}$/.test(state);
|
||||
export interface PendingAuthorization {
|
||||
session: SessionPayload;
|
||||
provider: string;
|
||||
request: AuthorizationRequest;
|
||||
secrets: Record<string, string>;
|
||||
reconnect: boolean;
|
||||
expiresAt: number;
|
||||
browserHash: string;
|
||||
}
|
||||
export interface PendingStore {
|
||||
put(state: string, pending: PendingAuthorization): Promise<void>;
|
||||
consume(state: string, browserBinding: string): Promise<PendingAuthorization>;
|
||||
}
|
||||
export function browserHash(binding: string): string {
|
||||
return createHash("sha256").update(binding).digest("hex");
|
||||
}
|
||||
export const randomState = (): string => randomBytes(32).toString("base64url");
|
||||
|
||||
/** GET + compare-and-delete makes callbacks one-use across API replicas. */
|
||||
export class RedisPendingStore implements PendingStore {
|
||||
constructor(
|
||||
private readonly redis: Pick<ControlRedis, "get" | "set" | "eval"> = new RespControlRedis(process.env.REDIS_URL ?? "redis://localhost:6379"),
|
||||
private readonly encryption: Vault = vault,
|
||||
) {}
|
||||
async put(state: string, pending: PendingAuthorization): Promise<void> {
|
||||
const sealed = await this.encryption.seal(pending.session.workspaceId, state, Buffer.from(JSON.stringify(pending)));
|
||||
const value = JSON.stringify({ workspaceId: pending.session.workspaceId, ciphertext: sealed.ciphertext.toString("base64") });
|
||||
if (!await this.redis.set(`oauth:pending:${state}`, value, { nx: true, pxMs: AUTH_TTL_MS })) throw new Error("state collision");
|
||||
}
|
||||
async consume(state: string, binding: string): Promise<PendingAuthorization> {
|
||||
if (!validState(state) || binding.length > 200) throw new BadRequestException("Invalid authorization state");
|
||||
const key = `oauth:pending:${state}`;
|
||||
const raw = await this.redis.get(key);
|
||||
if (!raw) throw new BadRequestException("Authorization expired or already used");
|
||||
const envelope = JSON.parse(raw) as { workspaceId: string; ciphertext: string };
|
||||
const opened = await this.encryption.open(envelope.workspaceId, state, { keyVersion: 1, ciphertext: Buffer.from(envelope.ciphertext, "base64") });
|
||||
const pending = JSON.parse(opened.toString()) as PendingAuthorization;
|
||||
const actual = Buffer.from(browserHash(binding));
|
||||
const expected = Buffer.from(pending.browserHash);
|
||||
if (pending.expiresAt <= Date.now() || pending.session.workspaceId !== envelope.workspaceId || pending.request.workspaceId !== envelope.workspaceId || !binding.startsWith(`${envelope.workspaceId}.`) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
||||
throw new BadRequestException("Authorization browser or workspace mismatch");
|
||||
}
|
||||
const consumed = await this.redis.eval("if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[1]); return 1 end; return 0", [key], [raw]);
|
||||
if (consumed !== 1) throw new BadRequestException("Authorization already used");
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue