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,76 @@
import { randomUUID } from "node:crypto";
import type { PoolClient } from "pg";
import { describe, expect, it } from "vitest";
import type { ConnectorConnection } from "@tactical-media-broadcast-hub/core";
import type { SessionPayload } from "../auth/session.js";
import { Vault } from "../vault/vault.js";
import { PgConnectionStore } from "./connection-store.js";
const session: SessionPayload = { workspaceId: randomUUID(), userId: randomUUID(), role: "Owner", authzEpoch: 3, issuedAt: 0, expiresAt: Math.floor(Date.now() / 1000) + 600 };
const connection: ConnectorConnection = { id: randomUUID(), workspaceId: session.workspaceId, provider: "mastodon", channelId: "remote-account", credentialId: { kind: "credential-ref", id: "opaque" }, fields: { instanceUrl: "https://social.example" }, createdAt: new Date() };
function fixture(allowed = true, existingCredential = false) {
const queries: Array<{ sql: string; params: unknown[] }> = [];
const credentialId = randomUUID();
const client = {
async query(sql: string, params: unknown[] = []) {
queries.push({ sql, params });
if (sql.includes("FROM memberships")) return { rowCount: allowed ? 1 : 0, rows: [] };
if (sql.startsWith("UPDATE scheduled_publications")) return { rowCount: 1, rows: [{ id: connection.id, draft_id: connection.id }] };
if (sql.includes("SELECT id FROM credentials")) return { rows: existingCredential ? [{ id: credentialId }] : [] };
if (sql.includes("SELECT c.*, cr.ciphertext")) return { rows: [{ id: connection.id, provider: connection.provider, channel_id: connection.channelId, fields: connection.fields, created_at: connection.createdAt, ciphertext: null, key_version: 1 }] };
return { rowCount: 1, rows: [{ id: connection.id }] };
},
} as unknown as PoolClient;
const store = new PgConnectionStore(async (workspaceId, callback) => {
expect(workspaceId).toBe(session.workspaceId);
return callback(client);
});
return { store, queries, credentialId };
}
describe("Postgres connection writes", () => {
it("saves only a vault envelope and identifier-only audit in the same tenant transaction", async () => {
const { store, queries } = fixture();
const sealed = await new Vault().seal(session.workspaceId, connection.id, Buffer.from('{"access_token":"never-in-db-plaintext"}'));
await store.save(session, connection, { provider: connection.provider, remoteId: "remote-account", handle: "@alice" }, sealed, false);
const credential = queries.find(({ sql }) => sql.startsWith("INSERT INTO credentials"));
expect(credential?.params).toContain(sealed.ciphertext);
expect(queries[0]?.sql).toContain("FOR UPDATE");
expect(JSON.stringify(queries)).not.toContain("never-in-db-plaintext");
const audit = queries.find(({ sql }) => sql.includes("audit_append"));
expect(audit?.params.at(-1)).toEqual({ provider: connection.provider });
});
it("rotates the credential row in place and blocks a revoked authorization before writes", async () => {
const { store, queries, credentialId } = fixture(true, true);
const sealed = await new Vault().seal(session.workspaceId, connection.id, Buffer.from("rotated-secret"));
await store.save(session, connection, { provider: connection.provider, remoteId: "remote-account", handle: "@alice" }, sealed, true);
expect(queries.find(({ sql }) => sql.startsWith("UPDATE credentials"))?.params[0]).toBe(credentialId);
expect(queries.some(({ sql }) => sql.startsWith("INSERT INTO connections"))).toBe(false);
const denied = fixture(false);
await expect(denied.store.save(session, connection, { provider: connection.provider, remoteId: "remote-account", handle: "@alice" }, sealed, true)).rejects.toThrow();
expect(denied.queries.some(({ sql }) => /^(INSERT|UPDATE)/.test(sql))).toBe(false);
});
it("locks and revokes locally before provider I/O while preserving publishing history", async () => {
const { store, queries } = fixture();
await store.remove(session, connection.id, async (stored) => {
expect(stored.connection.id).toBe(connection.id);
expect(queries.some(({ sql }) => sql.includes("FOR UPDATE"))).toBe(true);
expect(queries.some(({ sql }) => sql.startsWith("DELETE"))).toBe(false);
});
expect(queries.filter(({ sql }) => sql.startsWith("DELETE"))).toHaveLength(0);
expect(queries.some(({ sql }) => sql.startsWith("UPDATE credentials SET revoked_at"))).toBe(true);
expect(queries.some(({ sql }) => sql.startsWith("UPDATE connections SET disconnected_at"))).toBe(true);
expect(queries.some(({ sql }) => sql.includes("needs_reauthorization"))).toBe(true);
expect(queries.some(({ sql }) => sql.startsWith("UPDATE publishing_attempts") && sql.includes("abandoned"))).toBe(true);
expect(queries.some(({ sql }) => sql.startsWith("UPDATE drafts") && sql.includes("approved_revision = revision"))).toBe(true);
expect(queries.at(-1)?.sql).toContain("audit_append");
});
it("retains revoked local evidence and audits provider revocation failure", async () => {
const { store, queries } = fixture();
await expect(store.remove(session, connection.id, async () => { throw new Error("provider offline"); })).resolves.toBeUndefined();
expect(queries.some(({ sql }) => sql.startsWith("UPDATE credentials SET revoked_at"))).toBe(true);
expect(queries.at(-1)?.sql).toContain("connection.revoke_failed");
expect(queries.some(({ sql }) => sql.startsWith("DELETE"))).toBe(false);
});
});

View file

@ -0,0 +1,117 @@
import { NotFoundException, ForbiddenException } from "@nestjs/common";
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
import type { SessionPayload } from "../auth/session.js";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import type { SealedCredential } from "../vault/vault.js";
import type { ConnectionSummary } from "./connections.service.js";
import { scrub } from "../audit/audit.service.js";
export interface StoredConnection { connection: ConnectorConnection; credential: SealedCredential | null }
export interface ConnectionStore {
list(workspaceId: string): Promise<ConnectionSummary[]>;
get(workspaceId: string, id: string): Promise<StoredConnection>;
assertAuthorized(session: SessionPayload): Promise<void>;
save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void>;
rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void>;
remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void>;
}
export class PgConnectionStore implements ConnectionStore {
constructor(private readonly transaction: typeof withTenant = withTenant) {}
async assertAuthorized(session: SessionPayload): Promise<void> {
if (session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException("Session expired");
const result = await this.transaction(session.workspaceId, (client) => client.query(
`SELECT 1 FROM workspaces w JOIN memberships m ON m.workspace_id = w.id
JOIN role_permissions rp ON rp.role = m.role
WHERE w.id = $1 AND w.authz_epoch = $2 AND m.user_id = $3 AND m.status = 'active' AND rp.permission = 'connection.manage'`,
[session.workspaceId, session.authzEpoch, session.userId],
));
if (result.rowCount !== 1) throw new ForbiddenException("Connection authorization no longer valid");
}
async list(workspaceId: string): Promise<ConnectionSummary[]> {
const res = await this.transaction(workspaceId, (client) => client.query(
`SELECT c.*, cr.ciphertext, coalesce(cr.rotated_at, cr.created_at) AS rotated_at,
(SELECT array_agg(p.published_at ORDER BY p.published_at DESC) FROM publications p
WHERE p.connection_id = c.id AND p.published_at > now() - interval '24 hours') AS recent
FROM connections c LEFT JOIN LATERAL (SELECT * FROM credentials WHERE connection_id = c.id AND kind = 'connector-oauth' AND revoked_at IS NULL LIMIT 1) cr ON true WHERE c.disconnected_at IS NULL ORDER BY c.created_at`,
));
return res.rows.map((row: { id: string; provider: string; channel_id: string; fields: Record<string, unknown>; ciphertext: Buffer | null; created_at: Date; rotated_at: Date | null; recent: Date[] | null }) => ({
id: row.id, provider: row.provider, channelId: row.channel_id, fields: scrub(row.fields) ?? {},
health: row.ciphertext ? "unavailable" : "needs_credentials", credentialRotatedAt: row.rotated_at?.toISOString() ?? null,
recentPublishes: (row.recent ?? []).map((date) => date.toISOString()), createdAt: row.created_at.toISOString(),
}));
}
async get(workspaceId: string, id: string): Promise<StoredConnection> {
return this.transaction(workspaceId, (client) => this.read(client, workspaceId, id));
}
private async read(client: PoolClient, workspaceId: string, id: string): Promise<StoredConnection> {
const result = await client.query(
`SELECT c.*, cr.ciphertext, cr.key_version FROM connections c LEFT JOIN LATERAL
(SELECT * FROM credentials WHERE connection_id = c.id AND kind = 'connector-oauth' AND revoked_at IS NULL LIMIT 1) cr ON true WHERE c.id = $1`, [id],
);
const row = result.rows[0] as { id: string; provider: string; channel_id: string; fields: Record<string, string>; created_at: Date; ciphertext: Buffer | null; key_version: number } | undefined;
if (!row) throw new NotFoundException("Connection not found");
return {
connection: { id: row.id, workspaceId, provider: row.provider, channelId: row.channel_id, fields: row.fields, createdAt: row.created_at, credentialId: { kind: "credential-ref", id: row.id } },
credential: row.ciphertext ? { ciphertext: row.ciphertext, keyVersion: row.key_version } : null,
};
}
async save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void> {
await this.transaction(session.workspaceId, async (client) => {
// Serialize with membership revocation; re-check at commit, not only before provider I/O.
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'connection.manage'`, [session.userId, session.authzEpoch]);
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const fields = { ...connection.fields, handle: identity.handle, displayName: identity.displayName ?? identity.handle, remoteId: identity.remoteId };
if (reconnect) {
const updated = await client.query("UPDATE connections SET fields = $2, disconnected_at = NULL WHERE id = $1 AND provider = $3 RETURNING id", [connection.id, fields, connection.provider]);
if (!updated.rowCount) throw new NotFoundException("Connection removed during authorization");
} else {
await client.query("INSERT INTO connections (id, workspace_id, provider, channel_id, fields) VALUES ($1,$2,$3,$4,$5)", [connection.id, session.workspaceId, connection.provider, connection.channelId, fields]);
}
const existing = await client.query("SELECT id FROM credentials WHERE connection_id = $1 AND kind = 'connector-oauth' FOR UPDATE", [connection.id]);
if (existing.rows[0]) {
await client.query("UPDATE credentials SET ciphertext = $2, key_version = $3, rotated_at = now(), revoked_at = NULL WHERE id = $1", [existing.rows[0].id, credential.ciphertext, credential.keyVersion]);
} else {
await client.query("INSERT INTO credentials (workspace_id, connection_id, kind, ciphertext, key_version) VALUES ($1,$2,'connector-oauth',$3,$4)", [session.workspaceId, connection.id, credential.ciphertext, credential.keyVersion]);
}
await client.query("SELECT audit_append($1,$2,$3,$4,'connection',$5,$6)", [session.workspaceId, session.userId, session.role, reconnect ? "connection.reconnect" : "connection.connect", connection.id, { provider: connection.provider }]);
});
}
async remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void> {
let stored: StoredConnection | undefined;
await this.transaction(session.workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [session.workspaceId]);
const allowed = await client.query(`SELECT 1 FROM memberships m JOIN workspaces w ON w.id = m.workspace_id JOIN role_permissions rp ON rp.role = m.role WHERE m.user_id = $1 AND m.status = 'active' AND w.authz_epoch = $2 AND rp.permission = 'connection.manage'`, [session.userId, session.authzEpoch]);
if (!allowed.rowCount || session.expiresAt <= Math.floor(Date.now() / 1000)) throw new ForbiddenException();
const locked = await client.query("SELECT id FROM connections WHERE id = $1 FOR UPDATE", [id]);
if (!locked.rowCount) throw new NotFoundException("Connection not found");
stored = await this.read(client, session.workspaceId, id);
await client.query("UPDATE credentials SET revoked_at = now() WHERE connection_id = $1 AND revoked_at IS NULL", [id]);
await client.query("UPDATE connections SET disconnected_at = COALESCE(disconnected_at, now()) WHERE id = $1", [id]);
// Undispatched intent pauses. Dispatched or unknown work retains all evidence.
const paused = await client.query<{ id: string; draft_id: string }>(`UPDATE scheduled_publications s SET state = 'needs_reauthorization', error_code = 'CREDENTIAL_CHANGED'
WHERE connection_id = $1 AND state IN ('pending', 'in_flight') AND NOT EXISTS
(SELECT 1 FROM publishing_attempts a WHERE a.schedule_id = s.id AND (a.status = 'unknown' OR (a.dispatch_started_at IS NOT NULL AND a.status NOT IN ('completed', 'failed'))))
RETURNING s.id, s.draft_id`, [id]);
const scheduleIds = paused.rows.map((row) => row.id);
const draftIds = paused.rows.map((row) => row.draft_id);
await client.query("UPDATE publishing_attempts SET status = 'abandoned', owner_token = NULL, lease_until = NULL, error_code = 'CREDENTIAL_CHANGED' WHERE schedule_id = ANY($1::uuid[]) AND status IN ('ready', 'in_progress')", [scheduleIds]);
await client.query("UPDATE drafts SET state = CASE WHEN approved_revision = revision AND approved_hash IS NOT NULL THEN 'approved' ELSE 'editing' END, updated_at = now() WHERE id = ANY($1::uuid[]) AND state = 'scheduled'", [draftIds]);
await client.query("SELECT audit_append($1,$2,$3,'connection.remove','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id]);
});
if (stored) {
try { await revoke(stored); }
catch { await this.transaction(session.workspaceId, (client) => client.query("SELECT audit_append($1,$2,$3,'connection.revoke_failed','connection',$4,NULL)", [session.workspaceId, session.userId, session.role, id])); }
}
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
await this.transaction(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
// A health refresh must never overwrite a concurrent explicit reconnect.
const updated = await client.query("UPDATE credentials SET ciphertext = $3, key_version = $4, rotated_at = now() WHERE connection_id = $1 AND ciphertext = $2 AND kind = 'connector-oauth' AND revoked_at IS NULL RETURNING id", [id, previous.ciphertext, credential.ciphertext, credential.keyVersion]);
if (updated.rowCount) await client.query("SELECT audit_append($1,NULL,'Service','credential.refresh','connection',$2,NULL)", [workspaceId, id]);
});
}
}

View file

@ -0,0 +1,84 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, Query, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
import { ConnectionsService, type ConnectionSummary } from "./connections.service.js";
import { AUTH_TTL_MS, stateCookieName, validState } from "./pending.js";
@Controller("connections")
export class ConnectionsController {
constructor(@Inject(ConnectionsService) private readonly connections: ConnectionsService) {}
@Get()
@UseGuards(RbacGuard)
@RequirePermission("connection.view")
async list(@Req() request: Request & AuthenticatedRequest): Promise<ConnectionSummary[]> {
return this.connections.list(requireSession(request).workspaceId);
}
@Get("providers")
@UseGuards(RbacGuard)
@RequirePermission("connection.view")
providers() { return this.connections.providers(); }
private redirectResponse(response: Response, authorization: { redirectUrl: string; state: string; binding: string }) {
response.setHeader("Cache-Control", "no-store");
response.cookie(stateCookieName(authorization.state), authorization.binding, {
httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax",
path: "/connections/callback", maxAge: AUTH_TTL_MS,
});
return { redirectUrl: authorization.redirectUrl };
}
@Post("authorize")
@UseGuards(RbacGuard)
@RequirePermission("connection.manage")
async authorize(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown, @Res({ passthrough: true }) response: Response) {
const input = requireObject(body, "authorization");
const authorization = await this.connections.authorize(requireSession(request), requireString(input.provider, "provider"), optionalString(input.instanceUrl, "instanceUrl"));
return this.redirectResponse(response, authorization);
}
/** Deliberately outside RbacGuard: the provider's browser redirect carries no bearer header. */
@Get("callback")
async callback(@Req() request: Request, @Query() query: Record<string, unknown>, @Res() response: Response): Promise<void> {
const state = typeof query.state === "string" ? query.state : "";
const name = validState(state) ? stateCookieName(state) : "";
const cookie = name ? (request.headers.cookie ?? "").split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) : undefined;
let binding = "";
try { binding = decodeURIComponent(cookie ?? ""); } catch { /* Malformed cookies fail state validation. */ }
const location = await this.connections.callback(state, binding, typeof query.code === "string" ? query.code : undefined, typeof query.error === "string" ? query.error : undefined);
if (name) response.clearCookie(name, { path: "/connections/callback", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax" });
response.setHeader("Cache-Control", "no-store");
response.setHeader("Referrer-Policy", "no-referrer");
response.redirect(303, location);
}
// Public endpoint spelling is API routing, not provider behavior branching.
// eslint-disable-next-line no-restricted-syntax
@Post("telegram/bot-token")
@UseGuards(RbacGuard)
@RequirePermission("connection.manage")
async botToken(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<{ connected: true; token: string }> {
const input = requireObject(body, "bot token");
const id = optionalString(input.connectionId, "connectionId");
const channelId = optionalString(input.channelId, "channelId");
const token = await this.connections.botToken(requireSession(request), requireString(input.token, "token"), channelId ? { channelId } : {}, id ? parseId(id) : undefined);
return { connected: true, token };
}
@Post(":connectionId/reconnect")
@UseGuards(RbacGuard)
@RequirePermission("connection.manage")
async reconnect(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string, @Res({ passthrough: true }) response: Response) {
return this.redirectResponse(response, await this.connections.reconnect(requireSession(request), parseId(id)));
}
@Delete(":connectionId")
@UseGuards(RbacGuard)
@RequirePermission("connection.manage")
async remove(@Req() request: Request & AuthenticatedRequest, @Param("connectionId") id: string): Promise<{ removed: true; token: string }> {
const token = await this.connections.remove(requireSession(request), parseId(id));
return { removed: true, token };
}
}

View file

@ -0,0 +1,345 @@
import "reflect-metadata";
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import { createHash, randomUUID } from "node:crypto";
import { Module, UnauthorizedException, type ExecutionContext, type INestApplication } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
import { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import { createMastodonConnector } from "../../../../packages/connector-mastodon/src/index.js";
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
import { Vault, type SealedCredential } from "../vault/vault.js";
import type { SessionPayload } from "../auth/session.js";
import { RbacGuard } from "../rbac/rbac.guard.js";
import { logger } from "../telemetry/logger.js";
import { ConnectionsController } from "./connections.controller.js";
import { CONNECTION_DEPENDENCIES, ConnectionsService, type ConnectionDependencies, type ConnectionSummary } from "./connections.service.js";
import type { ConnectionStore, StoredConnection } from "./connection-store.js";
import { RedisPendingStore } from "./pending.js";
import { MemoryPendingRedis } from "./testing.js";
import { injectHttp } from "./http-test-transport.js";
class MemoryConnections implements ConnectionStore {
readonly rows = new Map<string, StoredConnection>();
readonly audit: Array<Record<string, string>> = [];
authorized = true;
async assertAuthorized(session: SessionPayload): Promise<void> {
if (!this.authorized || session.workspaceId !== sessionFixture.workspaceId) throw new Error("membership revoked");
}
async list(workspaceId: string): Promise<ConnectionSummary[]> {
return [...this.rows.values()].filter(({ connection }) => connection.workspaceId === workspaceId).map(({ connection, credential }) => ({
id: connection.id, provider: connection.provider, channelId: connection.channelId, fields: { ...connection.fields },
health: credential ? "unavailable" : "needs_credentials", credentialRotatedAt: null, recentPublishes: [], createdAt: connection.createdAt.toISOString(),
}));
}
async get(workspaceId: string, id: string): Promise<StoredConnection> {
const row = this.rows.get(id);
if (!row || row.connection.workspaceId !== workspaceId) throw new Error("connection not found");
return row;
}
async save(session: SessionPayload, connection: ConnectorConnection, identity: NormalizedIdentity, credential: SealedCredential, reconnect: boolean): Promise<void> {
await this.assertAuthorized(session);
if (reconnect && !this.rows.has(connection.id)) throw new Error("connection removed");
this.rows.set(connection.id, { connection: { ...connection, fields: { ...connection.fields, remoteId: identity.remoteId, handle: identity.handle } }, credential });
this.audit.push({ action: reconnect ? "connection.reconnect" : "connection.connect", provider: connection.provider });
}
async remove(session: SessionPayload, id: string, revoke: (stored: StoredConnection) => Promise<void>): Promise<void> {
await this.assertAuthorized(session);
await revoke(await this.get(session.workspaceId, id));
this.rows.delete(id);
this.audit.push({ action: "connection.remove" });
}
async rotateCredential(workspaceId: string, id: string, previous: SealedCredential, credential: SealedCredential): Promise<void> {
const row = await this.get(workspaceId, id);
if (row.credential?.ciphertext.equals(previous.ciphertext)) {
row.credential = credential;
this.audit.push({ action: "credential.refresh" });
}
}
}
const sessionFixture: SessionPayload = {
userId: randomUUID(), workspaceId: randomUUID(), role: "Owner", authzEpoch: 1,
issuedAt: Math.floor(Date.now() / 1000), expiresAt: Math.floor(Date.now() / 1000) + 3600,
};
@Module({})
class TestModule {}
async function form(request: IncomingMessage): Promise<URLSearchParams> {
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array));
return new URLSearchParams(Buffer.concat(chunks).toString());
}
function json(response: ServerResponse, value: unknown, status = 200): void {
response.writeHead(status, { "content-type": "application/json" }); response.end(JSON.stringify(value));
}
describe("HTTP OAuth connect with real connectors and mock authorization server", () => {
let mock: Server;
let origin: string;
let apiOrigin: string;
let app: INestApplication;
const store = new MemoryConnections();
const redis = new MemoryPendingRedis();
const encryption = new Vault();
const activeTokens = new Set<string>();
const grants = new Map<string, { challenge: string; redirectUri: string }>();
const revokedTokens: string[] = [];
const guard = vi.spyOn(RbacGuard.prototype, "canActivate").mockImplementation(async (context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<{ headers: Record<string, string>; session?: SessionPayload }>();
if (request.headers.authorization !== "Bearer test-session") throw new UnauthorizedException();
request.session = sessionFixture; return true;
});
const warn = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
const info = vi.spyOn(logger, "info").mockImplementation(() => undefined);
let nextAccount = "account-1";
let issued = 0;
let failExchange = false;
let bridgeConfigured = false;
const botToken = "12345:bot_token_secret";
beforeEach(() => {
store.rows.clear(); store.audit.length = 0; store.authorized = true;
redis.entries.clear(); activeTokens.clear(); grants.clear(); revokedTokens.length = 0;
nextAccount = "account-1"; issued = 0; failExchange = false; bridgeConfigured = false;
warn.mockClear(); info.mockClear();
});
beforeAll(async () => {
mock = createServer((request, response) => { void (async () => {
const url = new URL(request.url ?? "/", origin);
if (url.pathname === "/api/v1/apps") {
const body = await form(request);
expect(body.get("redirect_uris")).toBe(`${apiOrigin}/connections/callback`);
json(response, { client_id: "client-id", client_secret: "registered-client-secret" });
} else if (url.pathname === "/oauth/authorize") {
const code = randomUUID();
grants.set(code, { challenge: url.searchParams.get("code_challenge") ?? "", redirectUri: url.searchParams.get("redirect_uri") ?? "" });
const redirect = new URL(url.searchParams.get("redirect_uri") ?? "");
redirect.searchParams.set("state", url.searchParams.get("state") ?? "");
redirect.searchParams.set("code", code);
response.writeHead(302, { location: redirect.toString() }); response.end();
} else if (url.pathname === "/oauth/token") {
const body = await form(request);
const code = body.get("code") ?? "";
const grant = grants.get(code);
grants.delete(code);
if (failExchange || !grant || grant.redirectUri !== body.get("redirect_uri") || grant.challenge !== createHash("sha256").update(body.get("code_verifier") ?? "").digest("base64url") || body.get("client_secret") !== "registered-client-secret") {
json(response, { error: "secret echoed: registered-client-secret token-secret-leak" }, 400); return;
}
const token = `access-token-secret-${++issued}`;
activeTokens.add(token);
json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}`, token_type: "Bearer", scope: "read write:statuses write:media", created_at: Date.now() / 1000 });
} else if (url.pathname === "/api/v1/accounts/verify_credentials") {
const token = (request.headers.authorization ?? "").slice(7);
if (!activeTokens.has(token)) { json(response, { error: `revoked ${token}` }, 401); return; }
json(response, { id: nextAccount, username: "alice", acct: "alice", display_name: "Alice", avatar: "", url: `${origin}/@alice` });
} else if (url.pathname === "/api/v1/oauth-clients/local") {
json(response, { client_id: "client-id", client_secret: "registered-client-secret" });
} else if (url.pathname === "/api/v1/users/me") {
const token = (request.headers.authorization ?? "").slice(7);
if (!activeTokens.has(token)) { json(response, { error: "invalid token" }, 401); return; }
json(response, { id: 1, username: "alice", account: { id: 1, name: "alice", host: "127.0.0.1", displayName: "Alice" } });
} else if (url.pathname === "/api/v1/users/token") {
const body = await form(request);
if (body.get("grant_type") !== "refresh_token" || body.get("client_secret") !== "registered-client-secret") { json(response, { error: "invalid refresh" }, 401); return; }
const token = `access-token-secret-${++issued}`;
activeTokens.add(token);
json(response, { access_token: token, refresh_token: `refresh-token-secret-${issued}` });
} else if (url.pathname === "/api/v1/users/revoke-token") {
const token = (request.headers.authorization ?? "").slice(7);
revokedTokens.push(token); activeTokens.delete(token); json(response, {});
} else if (url.pathname === "/oauth/revoke") {
const body = await form(request);
const token = body.get("token") ?? "";
revokedTokens.push(token); activeTokens.delete(token); json(response, {});
} else if (url.pathname.startsWith("/bot")) {
if (!url.pathname.startsWith(`/bot${botToken}/`)) { json(response, { ok: false, error_code: 401, description: `unauthorized ${url.pathname}` }); return; }
json(response, { ok: true, result: { id: 12345, is_bot: true, username: "test_bot", first_name: "Test bot" } });
} else { json(response, { error: "not found" }, 404); }
})().catch(() => json(response, { error: "mock failure" }, 500)); });
origin = "http://127.0.0.1:4500";
const deps: ConnectionDependencies = {
renewSession: async () => "replacement-session",
store, pending: new RedisPendingStore(redis, encryption), encryption,
appUrl: "http://localhost:3001", callbackUrl: "",
registry: (credentials) => {
const registry = new ConnectorRegistry();
registry.register(createMastodonConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000 }));
registry.register(createPeerTubeConnector({ credentials, fetchImpl: mockFetch, requestsPerSecond: 100_000,
env: bridgeConfigured ? { PEERTUBE_AUTHORIZE_PATH: "/oauth/authorize", PEERTUBE_TOKEN_PATH: "/oauth/token" } : {},
}));
const botFetch: typeof fetch = (input, init) => fetch(String(input).replace("https://api.telegram.org", origin), init);
registry.register(createTelegramConnector({ credentials, fetchImpl: botFetch, requestsPerSecond: 100_000 }));
return registry;
},
};
app = await NestFactory.create({ module: TestModule, controllers: [ConnectionsController], providers: [
ConnectionsService, { provide: CONNECTION_DEPENDENCIES, useValue: deps },
] }, { logger: false });
await app.init();
apiOrigin = "http://api.local";
deps.callbackUrl = `${apiOrigin}/connections/callback`;
});
afterAll(async () => {
await app?.close();
warn.mockRestore(); info.mockRestore(); guard.mockRestore();
});
const mockFetch: typeof globalThis.fetch = (input, init) => injectHttp((request, response) => { mock.emit("request", request, response); }, input, init);
const fetch: typeof globalThis.fetch = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.origin === origin) return mockFetch(input, init);
if (url.origin !== apiOrigin) throw new Error("Unexpected test destination");
const adapter = app.getHttpAdapter();
return injectHttp((request, response) => { adapter.getInstance().handle(request, response); }, input, init);
};
async function api(path: string, method = "GET", body?: unknown): Promise<Response> {
return fetch(`${apiOrigin}/connections${path}`, { method, headers: { authorization: "Bearer test-session", "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
}
async function begin(id?: string, provider = "mastodon"): Promise<{ redirectUrl: string; cookie: string }> {
const response = id ? await api(`/${id}/reconnect`, "POST", {}) : await api("/authorize", "POST", { provider, instanceUrl: origin });
expect(response.status).toBe(201);
const { redirectUrl } = await response.json() as { redirectUrl: string };
expect(new URL(redirectUrl).searchParams.get("code_challenge_method")).toBe("S256");
return { redirectUrl, cookie: response.headers.get("set-cookie")?.split(";")[0] ?? "" };
}
async function finish(authorization: { redirectUrl: string; cookie: string }): Promise<Response> {
const consent = await fetch(authorization.redirectUrl, { redirect: "manual" });
expect(consent.status).toBe(302);
return fetch(consent.headers.get("location") ?? "", { redirect: "manual", headers: { cookie: authorization.cookie } });
}
async function listed(): Promise<ConnectionSummary[]> { return (await (await api("")).json()) as ConnectionSummary[]; }
it("authorize → provider → unauthenticated callback → healthy list; reconnect rotates; remove revokes and clears row + vault envelope", async () => {
const auth = await begin();
expect(auth.cookie).toMatch(/^ap_connect_/);
expect(JSON.stringify([...redis.entries.values()])).not.toContain("registered-client-secret");
const callback = await finish(auth);
expect(callback.status).toBe(303);
expect(callback.headers.get("location")).toBe("http://localhost:3001/connections?connect=success");
expect(callback.headers.get("set-cookie")).toContain("Expires=");
const rows = await listed();
expect(rows).toHaveLength(1); expect(rows[0]?.health).toBe("healthy");
expect(rows[0]?.capabilities).toContain("publishText");
const id = rows[0]?.id ?? "";
const before = store.rows.get(id)?.credential?.ciphertext;
expect(before).toBeDefined();
const stored = store.rows.get(id)?.credential;
if (!stored) throw new Error("credential missing");
expect((await new Vault().open(sessionFixture.workspaceId, id, stored)).toString()).toContain("access-token-secret-1");
expect(JSON.stringify(rows)).not.toContain("token-secret");
const second = await finish(await begin(id));
expect(second.headers.get("location")).toContain("connect=success");
expect((await listed())[0]?.id).toBe(id);
expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false);
expect(store.audit.at(-1)?.action).toBe("connection.reconnect");
const replay = await finish(auth);
expect(replay.headers.get("location")).toContain("connect=failure");
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
expect(revokedTokens).toContain("access-token-secret-2");
expect(await listed()).toHaveLength(0); expect(store.rows.size).toBe(0); expect(redis.entries.size).toBe(0);
const observable = JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls });
for (const secret of ["access-token-secret", "refresh-token-secret", "registered-client-secret"]) expect(observable).not.toContain(secret);
});
it("rejects missing auth on authorize, missing/wrong browser cookie and preserves a valid pending state", async () => {
expect((await fetch(`${apiOrigin}/connections/authorize`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider: "mastodon", instanceUrl: origin }) })).status).toBe(401);
const auth = await begin();
const consent = await fetch(auth.redirectUrl, { redirect: "manual" });
const callbackUrl = consent.headers.get("location") ?? "";
const missing = await fetch(callbackUrl, { redirect: "manual" });
expect(missing.headers.get("location")).toContain("connect=failure");
const wrong = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie.replace(sessionFixture.workspaceId, randomUUID()) } });
expect(wrong.headers.get("location")).toContain("connect=failure");
const good = await fetch(callbackUrl, { redirect: "manual", headers: { cookie: auth.cookie } });
expect(good.headers.get("location")).toContain("connect=success");
});
it("failed exchange and different-account reconnect retain the previous encrypted credential; health detects revoked tokens", async () => {
await finish(await begin());
const row = (await listed())[0];
if (!row) throw new Error("connection missing");
const before = store.rows.get(row.id)?.credential?.ciphertext;
failExchange = true;
const failure = await finish(await begin(row.id));
failExchange = false;
expect(failure.headers.get("location")).toContain("connect=failure");
expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true);
nextAccount = "other-account";
const wrongAccount = await finish(await begin(row.id));
nextAccount = "account-1";
expect(wrongAccount.headers.get("location")).toContain("connect=failure");
expect(store.rows.get(row.id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(true);
activeTokens.clear();
expect((await listed())[0]?.health).toBe("needs_credentials");
expect((await finish(await begin(row.id))).headers.get("location")).toContain("connect=success");
expect((await listed())[0]?.health).toBe("healthy");
expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak");
expect(JSON.stringify(warn.mock.calls)).not.toContain("registered-client-secret");
});
it("rechecks workspace membership during callback, handles denial, and does not expose provider errors", async () => {
const revoked = await begin();
store.authorized = false;
expect((await finish(revoked)).headers.get("location")).toContain("connect=failure");
store.authorized = true;
const denied = await begin();
const url = new URL(`${apiOrigin}/connections/callback`);
url.searchParams.set("state", new URL(denied.redirectUrl).searchParams.get("state") ?? "");
url.searchParams.set("error", "access_denied token-secret-leak");
const response = await fetch(url, { redirect: "manual", headers: { cookie: denied.cookie } });
expect(response.headers.get("location")).toContain("connect=failure");
expect(JSON.stringify(warn.mock.calls)).not.toContain("token-secret-leak");
});
it("validates Telegram getMe, encrypts tokens, rotates in place and removes locally; PeerTube is capability-honest", async () => {
const bad = await api("/telegram/bot-token", "POST", { token: "12345:bad_secret" });
expect(bad.status).toBe(400); expect(await bad.text()).not.toContain("bad_secret");
const response = await api("/telegram/bot-token", "POST", { token: botToken, channelId: "@target" });
expect(response.status).toBe(201);
const bot = (await listed()).find((row) => row.provider === "telegram");
expect(bot?.health).toBe("healthy"); expect(bot?.channelId).toBe("@target");
const id = bot?.id ?? "";
const before = store.rows.get(id)?.credential?.ciphertext;
expect((await api("/telegram/bot-token", "POST", { token: botToken, connectionId: id })).status).toBe(201);
expect(store.rows.get(id)?.credential?.ciphertext.equals(before ?? Buffer.alloc(0))).toBe(false);
expect((await listed()).filter((row) => row.provider === "telegram")).toHaveLength(1);
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
expect(store.rows.has(id)).toBe(false);
const providers = await (await api("/providers")).json() as Array<{ id: string; enabled: boolean; note?: string }>;
expect(providers.find((provider) => provider.id === "peertube")?.enabled).toBe(false);
expect((await api("/authorize", "POST", { provider: "peertube", instanceUrl: origin })).status).toBe(400);
expect(JSON.stringify({ audit: store.audit, warn: warn.mock.calls, info: info.mock.calls })).not.toContain(botToken);
});
it("runs PeerTube code flow only with an explicitly configured instance bridge", async () => {
bridgeConfigured = true;
expect((await finish(await begin(undefined, "peertube"))).headers.get("location")).toContain("connect=success");
const row = (await listed()).find((connection) => connection.provider === "peertube");
expect(row?.health).toBe("healthy");
const id = row?.id ?? "";
expect((await finish(await begin(id))).headers.get("location")).toContain("connect=success");
expect((await listed()).filter((connection) => connection.id === id)).toHaveLength(1);
const stored = store.rows.get(id)?.credential;
if (!stored) throw new Error("PeerTube credential missing");
const secrets = JSON.parse((await encryption.open(sessionFixture.workspaceId, id, stored)).toString()) as Record<string, string>;
activeTokens.delete(secrets.access_token ?? "");
expect((await listed()).find((connection) => connection.id === id)?.health).toBe("healthy");
expect(store.audit.at(-1)?.action).toBe("credential.refresh");
expect(store.rows.get(id)?.credential?.ciphertext.equals(stored.ciphertext)).toBe(false);
expect((await api(`/${id}`, "DELETE")).status).toBe(200);
bridgeConfigured = false;
});
it("does not resurrect a connection removed while reconnect consent is pending", async () => {
await finish(await begin());
const row = (await listed())[0];
if (!row) throw new Error("connection missing");
const pending = await begin(row.id);
expect((await api(`/${row.id}`, "DELETE")).status).toBe(200);
expect((await finish(pending)).headers.get("location")).toContain("connect=failure");
expect(store.rows.has(row.id)).toBe(false);
});
});

View file

@ -0,0 +1,231 @@
import { BadRequestException, Inject, Injectable, Optional } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import type { ConnectorConnection, NormalizedIdentity } from "@tactical-media-broadcast-hub/core";
import { AuthError, type AuthorizationRequest, type AuthorizationResult, type CredentialAccessor, type ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import type { SessionPayload } from "../auth/session.js";
import { withTenant } from "../db/client.js";
import { issueSessionInTenant } from "../auth/auth.service.js";
import { vault, type Vault } from "../vault/vault.js";
import { logger } from "../telemetry/logger.js";
import { connectionRegistry } from "./connector-registry.js";
import { PgConnectionStore, type ConnectionStore, type StoredConnection } from "./connection-store.js";
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingStore } from "./pending.js";
import { oauthFetch } from "./oauth-fetch.js";
export type ConnectionHealth = "healthy" | "needs_credentials" | "unavailable";
export interface ConnectionSummary {
id: string; provider: string; channelId: string; fields: Record<string, unknown>;
health: ConnectionHealth; credentialRotatedAt: string | null; recentPublishes: string[]; createdAt: string;
capabilities?: string[];
}
export interface ConnectionDependencies {
store: ConnectionStore;
pending: PendingStore;
encryption: Vault;
registry: (credentials: CredentialAccessor) => ConnectorRegistry;
appUrl: string;
callbackUrl: string;
renewSession(session: SessionPayload): Promise<string>;
}
export const CONNECTION_DEPENDENCIES = Symbol("CONNECTION_DEPENDENCIES");
/** Each exchange has an isolated, tenant-bound working set; never writes plaintext to DB. */
class StagedCredentials implements CredentialAccessor {
dirty = false;
constructor(private readonly workspaceId: string, private readonly connectionId: string, readonly secrets: Record<string, string> = {}) {}
private assert(connection: ConnectorConnection): void {
if (connection.workspaceId !== this.workspaceId || connection.id !== this.connectionId || connection.credentialId.id !== this.connectionId) throw new Error("Credential binding mismatch");
}
async getAccessToken(connection: ConnectorConnection): Promise<string> { return this.getSecret(connection, "access_token"); }
async getSecret(connection: ConnectorConnection, key: string): Promise<string> {
this.assert(connection);
const value = this.secrets[key];
if (!value) throw new Error("Credential missing");
return value;
}
async put(connection: ConnectorConnection, key: string, value: string): Promise<void> {
this.assert(connection);
if (typeof value !== "string" || !value) throw new Error("Provider returned invalid credential");
this.secrets[key] = value;
this.dirty = true;
}
}
function defaults(): ConnectionDependencies {
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
return {
renewSession: (session) => withTenant(session.workspaceId, (client) => issueSessionInTenant(client, session.workspaceId, session.userId, session.expiresAt)),
store: new PgConnectionStore(), pending: new RedisPendingStore(), encryption: vault,
registry: (credentials) => connectionRegistry(credentials, oauthFetch), appUrl,
callbackUrl: process.env.CONNECTIONS_CALLBACK_URL ?? `${process.env.API_URL ?? "http://localhost:3000"}/connections/callback`,
};
}
@Injectable()
export class ConnectionsService {
private readonly deps: ConnectionDependencies;
constructor(@Optional() @Inject(CONNECTION_DEPENDENCIES) dependencies?: ConnectionDependencies) { this.deps = dependencies ?? defaults(); }
providers() {
const registry = this.deps.registry(new StagedCredentials("", ""));
return registry.list().map((connector) => {
const manifest = connector.manifest();
return {
id: manifest.id, displayName: manifest.displayName, kind: manifest.oauth?.kind,
enabled: manifest.oauth?.connectEnabled !== false && Boolean(connector.validateToken || (connector.buildAuthorizationUrl && connector.exchangeCode)),
note: manifest.oauth?.connectNote, targetField: manifest.oauth?.targetField,
instanceRequired: manifest.oauth?.kind === "per-instance-oauth2",
capabilities: Object.entries(manifest.capabilities).filter(([, enabled]) => enabled === true).map(([key]) => key),
};
});
}
landing(status: "success" | "failure"): string {
const url = new URL("/connections", this.deps.appUrl);
url.searchParams.set("connect", status);
return url.toString();
}
async authorize(session: SessionPayload, provider: string, instanceUrl?: string, connectionId?: string) {
await this.deps.store.assertAuthorized(session);
const previous = connectionId ? await this.deps.store.get(session.workspaceId, connectionId) : undefined;
if (previous && previous.connection.provider !== provider) throw new BadRequestException("Provider mismatch");
const id = connectionId ?? randomUUID();
const appSecrets: Record<string, string> = {};
if (previous?.credential) {
const bytes = await this.deps.encryption.open(session.workspaceId, id, previous.credential);
const secrets = JSON.parse(bytes.toString()) as Record<string, string>;
// Reuse the registered app, but stage no previous account token.
for (const key of ["client_id", "client_secret"]) if (secrets[key]) appSecrets[key] = secrets[key];
}
const staging = new StagedCredentials(session.workspaceId, id, appSecrets);
const registry = this.deps.registry(staging);
if (!registry.has(provider)) throw new BadRequestException("Provider is not available");
const connector = registry.get(provider);
if (!connector.buildAuthorizationUrl || !connector.exchangeCode || connector.manifest().oauth?.connectEnabled === false) throw new BadRequestException(connector.manifest().oauth?.connectNote ?? "Redirect linking is unavailable");
const state = randomState();
const binding = `${session.workspaceId}.${randomState()}`;
const instance = previous?.connection.fields.instanceUrl ?? instanceUrl;
const request: AuthorizationRequest = {
state, workspaceId: session.workspaceId, connectionId: id, redirectUri: this.deps.callbackUrl,
fields: instance ? { instanceUrl: instance } : {},
...(connector.manifest().oauth?.pkce ? { codeVerifier: randomState() } : {}),
};
try {
const redirect = await connector.buildAuthorizationUrl(request);
request.fields = redirect.connection.fields;
await this.deps.pending.put(state, { session, provider, request, secrets: staging.secrets, reconnect: Boolean(previous), expiresAt: Date.now() + AUTH_TTL_MS, browserHash: browserHash(binding) });
return { redirectUrl: redirect.authorizationUrl, state, binding };
} catch {
logger.warn({ event: "connection.authorize_failed", provider }, "Connection authorization failed");
throw new BadRequestException("Could not start authorization. Check the instance URL and provider configuration.");
}
}
async callback(state: string, binding: string, code?: string, providerError?: string): Promise<string> {
try {
const pending = await this.deps.pending.consume(state, binding);
await this.deps.store.assertAuthorized(pending.session);
if (providerError || !code || code.length > 4096) throw new Error("Provider authorization denied");
const id = pending.request.connectionId;
if (!id) throw new Error("Missing connection binding");
const staging = new StagedCredentials(pending.session.workspaceId, id, pending.secrets);
const connector = this.deps.registry(staging).get(pending.provider);
if (!connector.exchangeCode) throw new Error("Code exchange unavailable");
const result = await connector.exchangeCode({ ...pending.request, code });
try {
await this.persist(pending.session, result, staging, pending.reconnect);
} catch (error) {
// A failed DB write/account check must not leave an orphan authorization.
await connector.revokeCredentials?.(result.connection).catch(() => undefined);
throw error;
}
return this.landing("success");
} catch {
// Provider errors can echo secrets. Only a fixed event reaches logs/UI.
logger.warn({ event: "connection.callback_failed" }, "Connection callback failed");
return this.landing("failure");
}
}
async botToken(session: SessionPayload, token: string, fields: Record<string, string>, connectionId?: string): Promise<string> {
await this.deps.store.assertAuthorized(session);
const previous = connectionId ? await this.deps.store.get(session.workspaceId, connectionId) : undefined;
const id = connectionId ?? randomUUID();
const staging = new StagedCredentials(session.workspaceId, id);
const connector = this.deps.registry(staging).list().find((item) => item.manifest().oauth?.kind === "token" && item.validateToken);
if (!connector?.validateToken || (previous && previous.connection.provider !== connector.manifest().id)) throw new BadRequestException("Token linking is unavailable");
try {
const result = await connector.validateToken({ workspaceId: session.workspaceId, connectionId: id, token, fields: { ...previous?.connection.fields, ...fields } });
await this.persist(session, result, staging, Boolean(previous));
return await this.deps.renewSession(session);
} catch {
logger.warn({ event: "connection.token_failed" }, "Bot validation failed");
throw new BadRequestException("Bot validation failed. Check the token and try again.");
}
}
async reconnect(session: SessionPayload, id: string) {
const stored = await this.deps.store.get(session.workspaceId, id);
return this.authorize(session, stored.connection.provider, undefined, id);
}
private async persist(session: SessionPayload, result: AuthorizationResult, staging: StagedCredentials, reconnect: boolean): Promise<void> {
const { connection, identity } = result;
if (connection.workspaceId !== session.workspaceId || identity.provider !== connection.provider || !identity.remoteId || !identity.handle) throw new Error("Provider identity invalid");
if (reconnect) {
const previous = await this.deps.store.get(session.workspaceId, connection.id);
if (previous.connection.provider !== connection.provider || (previous.connection.fields.remoteId && previous.connection.fields.remoteId !== identity.remoteId)) throw new Error("Reconnect must use the same account");
connection.channelId = previous.connection.channelId;
} else if (!connection.fields.channelId) {
connection.channelId = identity.remoteId;
}
const encrypted = await this.deps.encryption.seal(session.workspaceId, connection.id, Buffer.from(JSON.stringify(staging.secrets)));
await this.deps.store.save(session, connection, identity, encrypted, reconnect);
}
private async credentials(workspaceId: string, id: string) {
const stored = await this.deps.store.get(workspaceId, id);
return this.storedCredentials(stored);
}
private async storedCredentials(stored: StoredConnection) {
const { workspaceId, id } = stored.connection;
if (!stored.credential) throw new AuthError("Credential missing", stored.connection.provider, false);
const bytes = await this.deps.encryption.open(workspaceId, id, stored.credential);
const secrets = JSON.parse(bytes.toString()) as Record<string, string>;
const staging = new StagedCredentials(workspaceId, id, secrets);
return { connection: stored.connection, connector: this.deps.registry(staging).get(stored.connection.provider), staging, stored };
}
async list(workspaceId: string): Promise<ConnectionSummary[]> {
const rows = await this.deps.store.list(workspaceId);
// Probe actual identity, rather than assuming a credential row means a valid token.
for (const row of rows) {
if (row.health === "needs_credentials") continue;
try {
const { connection, connector, staging, stored } = await this.credentials(workspaceId, row.id);
let identity: NormalizedIdentity;
try {
identity = await connector.getIdentity(connection);
} finally {
// Some connectors refresh during identity reads; keep the new refresh token durable.
if (staging.dirty && stored.credential) {
const sealed = await this.deps.encryption.seal(workspaceId, row.id, Buffer.from(JSON.stringify(staging.secrets)));
await this.deps.store.rotateCredential(workspaceId, row.id, stored.credential, sealed);
}
}
row.health = "healthy";
row.fields.handle = identity.handle;
row.capabilities = Object.entries(connector.manifest().capabilities).filter(([, enabled]) => enabled === true).map(([key]) => key);
} catch (error) {
row.health = error instanceof AuthError ? "needs_credentials" : "unavailable";
}
}
return rows;
}
async remove(session: SessionPayload, id: string): Promise<string> {
await this.deps.store.assertAuthorized(session);
await this.deps.store.remove(session, id, async (stored) => {
if (!stored.credential) return;
try {
const { connection, connector } = await this.storedCredentials(stored);
await connector.revokeCredentials?.(connection);
} catch (error) {
// Already invalid/revoked credentials are safe to remove locally.
if (!(error instanceof AuthError)) throw new BadRequestException("Provider revocation failed. Try removing again.");
}
});
return this.deps.renewSession(session);
}
}

View file

@ -0,0 +1,17 @@
import { ConnectorRegistry, type CredentialAccessor } from "@tactical-media-broadcast-hub/connector-sdk";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createMastodonConnector } from "@tactical-media-broadcast-hub/connector-mastodon";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createPeerTubeConnector } from "../../../../packages/connector-peertube/src/index.js";
// eslint-disable-next-line no-restricted-syntax -- Connector composition boundary.
import { createTelegramConnector } from "@tactical-media-broadcast-hub/connector-telegram";
/** Composition boundary only: services resolve manifests rather than branch on providers. */
export function connectionRegistry(credentials: CredentialAccessor, fetchImpl: typeof fetch = fetch): ConnectorRegistry {
const registry = new ConnectorRegistry();
const options = { credentials, fetchImpl, env: process.env };
registry.register(createMastodonConnector(options));
registry.register(createPeerTubeConnector(options));
registry.register(createTelegramConnector(options));
return registry;
}

View file

@ -0,0 +1,46 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Duplex } from "node:stream";
import type { Socket } from "node:net";
/** Runs real Node/Express HTTP handlers without binding a port (restricted CI). */
export async function injectHttp(
handler: (request: IncomingMessage, response: ServerResponse) => void,
input: string | URL | Request,
init: RequestInit = {},
): Promise<Response> {
const url = new URL(input instanceof Request ? input.url : String(input));
const output: Buffer[] = [];
const socket = new Duplex({
read() {},
write(chunk: Buffer, _encoding, callback) { output.push(Buffer.from(chunk)); callback(); },
});
// ServerResponse uses only Duplex operations; no network or unsafe dynamic values.
const httpSocket = socket as unknown as Socket;
const request = new IncomingMessage(httpSocket);
request.method = init.method ?? "GET";
request.complete = true;
request.url = `${url.pathname}${url.search}`;
request.headers = Object.fromEntries(new Headers(init.headers).entries());
request.headers.host = url.host;
const body = typeof init.body === "string" ? init.body : "";
if (body) request.headers["content-length"] = String(Buffer.byteLength(body));
request.push(body || null);
if (body) request.push(null);
const response = new ServerResponse(request);
response.assignSocket(httpSocket);
const completed = new Promise<Response>((resolve, reject) => {
response.once("error", reject);
response.once("finish", () => {
const bytes = Buffer.concat(output);
const bodyOffset = bytes.indexOf("\r\n\r\n") + 4;
const headers = new Headers();
for (const line of bytes.subarray(0, bodyOffset - 4).toString().split("\r\n").slice(1)) {
const separator = line.indexOf(":");
if (separator > 0) headers.append(line.slice(0, separator), line.slice(separator + 1).trim());
}
resolve(new Response([204, 304].includes(response.statusCode) ? null : bytes.subarray(bodyOffset), { status: response.statusCode, headers }));
});
});
handler(request, response);
return completed;
}

View file

@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { publicAddress } from "./oauth-fetch.js";
describe("authorization egress policy", () => {
it("rejects private, mapped, metadata, multicast and documentation addresses", () => {
for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "100.64.1.1", "0.0.0.0", "224.0.0.1", "::1", "::ffff:127.0.0.1", "fc00::1", "fe80::1", "2001:db8::1", "2002:7f00:1::"]) expect(publicAddress(address), address).toBe(false);
expect(publicAddress("8.8.8.8")).toBe(true);
expect(publicAddress("2606:4700:4700::1111")).toBe(true);
expect(publicAddress("2001:4860:4860::8888")).toBe(true);
});
});

View file

@ -0,0 +1 @@
export { publicAddress, providerFetch as oauthFetch } from "@tactical-media-broadcast-hub/connector-runtime";

View file

@ -0,0 +1,53 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { Vault } from "../vault/vault.js";
import { AUTH_TTL_MS, browserHash, randomState, RedisPendingStore, type PendingAuthorization } from "./pending.js";
import { MemoryPendingRedis } from "./testing.js";
function fixture() {
const redis = new MemoryPendingRedis();
const store = new RedisPendingStore(redis, new Vault());
const state = randomState();
const binding = `workspace-a.${randomState()}`;
const pending: PendingAuthorization = {
provider: "mastodon", request: { redirectUri: "http://localhost/callback", workspaceId: "workspace-a", state },
session: { userId: "user-a", workspaceId: "workspace-a", role: "Owner", authzEpoch: 1, issuedAt: 0, expiresAt: Date.now() / 1000 + 600 },
secrets: { client_secret: "registration-secret" }, reconnect: false, expiresAt: Date.now() + AUTH_TTL_MS, browserHash: browserHash(binding),
};
return { redis, store, state, binding, pending };
}
afterEach(() => vi.useRealTimers());
describe("pending authorization binding", () => {
it("encrypts provider secrets and consumes state exactly once", async () => {
const { redis, store, state, binding, pending } = fixture();
await store.put(state, pending);
expect(JSON.stringify([...redis.entries.values()])).not.toContain("registration-secret");
expect(await store.consume(state, binding)).toEqual(pending);
await expect(store.consume(state, binding)).rejects.toThrow(/already used/);
});
it("rejects CSRF, unknown states and a different workspace without burning the valid state", async () => {
const { store, state, binding, pending } = fixture();
await store.put(state, pending);
await expect(store.consume(state, "")).rejects.toThrow(/mismatch/);
await expect(store.consume(state, binding.replace("workspace-a", "workspace-b"))).rejects.toThrow(/mismatch/);
await expect(store.consume(randomState(), binding)).rejects.toThrow();
await expect(store.consume("malformed", binding)).rejects.toThrow();
expect(await store.consume(state, binding)).toEqual(pending);
});
it("rejects a workspace mismatch inside the encrypted request", async () => {
const { store, state, binding, pending } = fixture();
pending.request.workspaceId = "workspace-b";
await store.put(state, pending);
await expect(store.consume(state, binding)).rejects.toThrow(/workspace mismatch/);
});
it("expires state after ten minutes and atomically fences simultaneous callbacks", async () => {
vi.useFakeTimers();
const { store, state, binding, pending } = fixture();
await store.put(state, pending);
const results = await Promise.allSettled([store.consume(state, binding), store.consume(state, binding)]);
expect(results.filter((item) => item.status === "fulfilled")).toHaveLength(1);
const next = fixture();
await next.store.put(next.state, next.pending);
vi.advanceTimersByTime(AUTH_TTL_MS);
await expect(next.store.consume(next.state, next.binding)).rejects.toThrow(/expired/);
});
});

View 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;
}
}

View file

@ -0,0 +1,23 @@
import type { ControlRedis } from "../media-control/redis.js";
/** TTL-faithful Redis substitute; compare-and-delete runs synchronously like Redis Lua. */
export class MemoryPendingRedis implements Pick<ControlRedis, "get" | "set" | "eval"> {
readonly entries = new Map<string, { value: string; expiresAt: number }>();
async get(key: string): Promise<string | null> {
const entry = this.entries.get(key);
if (!entry || entry.expiresAt <= Date.now()) { this.entries.delete(key); return null; }
return entry.value;
}
async set(key: string, value: string, options: { nx?: boolean; pxMs?: number } = {}): Promise<boolean> {
if (options.nx && await this.get(key) !== null) return false;
this.entries.set(key, { value, expiresAt: Date.now() + (options.pxMs ?? 1000) });
return true;
}
async eval(_script: string, keys: string[], args: string[]): Promise<number> {
const key = keys[0] ?? "";
const entry = this.entries.get(key);
if (!entry || entry.value !== args[0] || entry.expiresAt <= Date.now()) return 0;
this.entries.delete(key);
return 1;
}
}