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 @@
/**
* Instance settings API (admin only):
*
* GET /settings read the content-policy posture (Owner/Admin)
* PATCH /settings partial update + system audit event (Owner/Admin)
*
* Instance-level ⇒ the RBAC permission set (002) has no instance permission;
* the gate is the ROLE itself (task spec: Owner/Admin), checked in the
* controller after the standard RbacGuard session/epoch validation. RLS does
* not apply (system table, migration 028) — the role check IS the boundary.
*/
import { Body, Controller, ForbiddenException, Get, Patch, Req, UseGuards } from "@nestjs/common";
import { requireSession } from "../http/validation.js";
import { RbacGuard, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { isInstanceAdmin, SettingsService, type InstanceSettingRecord } from "./settings.service.js";
@Controller("settings")
@UseGuards(RbacGuard)
export class SettingsController {
constructor(private readonly settings: SettingsService) {}
@Get()
async get(@Req() request: AuthenticatedRequest): Promise<InstanceSettingRecord> {
const session = requireSession(request);
if (!isInstanceAdmin(session.role)) throw new ForbiddenException("instance settings are admin-only (Owner/Admin)");
return this.settings.getSettings();
}
@Patch()
async patch(@Req() request: AuthenticatedRequest, @Body() body: unknown): Promise<InstanceSettingRecord> {
const session = requireSession(request);
if (!isInstanceAdmin(session.role)) throw new ForbiddenException("instance settings are admin-only (Owner/Admin)");
return this.settings.updateSettings(body, { userId: session.userId, role: session.role });
}
}

View file

@ -0,0 +1,351 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
/**
* Instance settings against a live PostgreSQL, as sp_app.
* Own scratch database (ADMIN_SETTINGS_IT_DATABASE_URL + ADMIN_SETTINGS_IT_
* SUPER_DATABASE_URL) per the local-db-sol pattern: the shared-DB suites wipe
* every workspace and vitest runs files in parallel. Skipped when unset.
*
* Covers the task's list: seeded defaults, CRUD round-trip incl. partial
* updates and provenance (updated_at/updated_by), RBAC (non-admin 403 through
* the real controller; the real RbacGuard path), audit events on the SYSTEM
* chain (audit_append_system — an instance setting is not a tenant row), and
* the migration's CHECK constraints rejecting undecodable values.
*/
const appUrl = process.env.ADMIN_SETTINGS_IT_DATABASE_URL;
const ownerUrl = process.env.ADMIN_SETTINGS_IT_SUPER_DATABASE_URL;
type Modules = {
service: typeof import("./settings.service.js");
controller: typeof import("./settings.controller.js");
guard: typeof import("../rbac/rbac.guard.js");
session: typeof import("../auth/session.js");
db: typeof import("../db/client.js");
};
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("instance settings (live DB)", () => {
let m: Modules;
let ownerPool: Pool;
let service: InstanceType<Modules["service"]["SettingsService"]>;
let controller: InstanceType<Modules["controller"]["SettingsController"]>;
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
let ws: string;
let adminUser: string;
let otherUser: string;
async function asOwner<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await ownerPool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
/** Reset to the seeded defaults (self-healing upsert: also restores deleted rows). */
async function resetSettings(): Promise<void> {
await asOwner(async (c) => {
await c.query(
`INSERT INTO instance_settings (key, value, updated_by)
VALUES ('youtube_full_harvest','false',NULL), ('youtube_cc_only_broadcast','true',NULL),
('scrape_metadata_only','true',NULL), ('scrape_bounded_pilot','false',NULL),
('intake_posture','metadata_only',NULL)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_by = NULL, updated_at = now()`,
);
});
}
beforeAll(async () => {
// The shared pool reads DATABASE_URL at import time: point it at this
// isolated DB first, then import the modules under test.
process.env.DATABASE_URL = appUrl;
process.env.SUPER_DATABASE_URL = ownerUrl;
const { runMigrations } = await import("../db/migrate.js");
await runMigrations(join(process.cwd(), "migrations"));
m = {
service: await import("./settings.service.js"),
controller: await import("./settings.controller.js"),
guard: await import("../rbac/rbac.guard.js"),
session: await import("../auth/session.js"),
db: await import("../db/client.js"),
};
ownerPool = new Pool({ connectionString: ownerUrl });
service = new m.service.SettingsService();
controller = new m.controller.SettingsController(service);
await asOwner(async (c) => {
ws = String((await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", ["settings-it", `settings-it-${suffix}`])).rows[0].id);
const mkUser = async (tag: string): Promise<string> =>
String(
(
await c.query("INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id", [
`settings:${tag}:${suffix}`,
`settings-${tag}-${suffix}`,
`settings-${tag}-${suffix}@example.com`,
tag,
])
).rows[0].id,
);
adminUser = await mkUser("admin");
otherUser = await mkUser("member");
// Give the users real memberships so the guard's role/permission
// lookups are exercised through the actual RBAC chain.
await c.query("SELECT set_config('app.workspace_id', $1, false)", [ws]);
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner'), ($1, $3, 'Publisher')", [ws, adminUser, otherUser]);
await c.query("SELECT set_config('app.workspace_id', '', false)");
});
});
afterAll(async () => {
if (ownerPool !== undefined) {
await asOwner(async (c) => {
await c.query("DELETE FROM workspaces WHERE id = $1", [ws]);
await c.query("DELETE FROM users WHERE external_id LIKE $1", [`settings:%:${suffix}`]);
});
await ownerPool.end();
}
await m?.db.pool.end();
});
// -----------------------------------------------------------------------
// Defaults + shape
// -----------------------------------------------------------------------
it("seeds the five keys at the SAFE defaults (decision record posture)", async () => {
// Read the seeded rows directly so this asserts the MIGRATION's seed, not
// service decoding.
const seeded = await asOwner(async (c) =>
(await c.query<{ key: string; value: string; updated_by: string | null }>("SELECT key, value, updated_by FROM instance_settings")).rows,
);
expect(seeded.map((r) => r.key).sort()).toEqual([...m.service.INSTANCE_SETTING_KEYS].sort());
const byKey = new Map(seeded.map((r) => [r.key, r]));
expect(byKey.get("youtube_full_harvest")?.value).toBe("false");
expect(byKey.get("youtube_cc_only_broadcast")?.value).toBe("true");
expect(byKey.get("scrape_metadata_only")?.value).toBe("true");
expect(byKey.get("scrape_bounded_pilot")?.value).toBe("false");
expect(byKey.get("intake_posture")?.value).toBe("metadata_only");
// Normalize to defaults for the rest of the suite (the scratch DB persists
// across runs; earlier runs may have left non-default values behind).
await resetSettings();
const settings = await service.getSettings();
expect(settings).toMatchObject({
youtube_full_harvest: false,
youtube_cc_only_broadcast: true,
scrape_metadata_only: true,
scrape_bounded_pilot: false,
intake_posture: "metadata_only",
});
// Seeded rows exist ⇒ nothing is pending, provenance present.
expect(settings.pending).toEqual([]);
expect(settings.updatedBy.youtube_full_harvest).toBeNull();
});
it("typed getters mirror the record", async () => {
expect(await service.getYoutubeFullHarvest()).toBe(false);
expect(await service.getYoutubeCcOnlyBroadcast()).toBe(true);
expect(await service.getScrapeMetadataOnly()).toBe(true);
expect(await service.getScrapeBoundedPilot()).toBe(false);
expect(await service.getIntakePosture()).toBe("metadata_only");
});
// -----------------------------------------------------------------------
// CRUD (partial update + provenance)
// -----------------------------------------------------------------------
it("partial update flips one switch, leaves the others, records provenance", async () => {
await resetSettings();
const actor = { userId: adminUser, role: "Owner" };
const after = await service.updateSettings({ youtube_full_harvest: true }, actor);
expect(after.youtube_full_harvest).toBe(true); // written
expect(after.youtube_cc_only_broadcast).toBe(true); // untouched
expect(after.scrape_metadata_only).toBe(true); // untouched
expect(after.intake_posture).toBe("metadata_only"); // untouched
expect(after.updatedBy.youtube_full_harvest).toBe(adminUser);
expect(after.updatedBy.youtube_cc_only_broadcast).toBeNull();
expect(new Date(after.updatedAt.youtube_full_harvest).getTime()).toBeGreaterThan(0);
// Typed getter sees the new value (the intake-side gate reads this).
expect(await service.getYoutubeFullHarvest()).toBe(true);
});
it("full update round-trips every key and re-updates overwrite", async () => {
await resetSettings();
const actor = { userId: adminUser, role: "Admin" };
const first = await service.updateSettings(
{ youtube_full_harvest: true, youtube_cc_only_broadcast: false, scrape_metadata_only: false, scrape_bounded_pilot: true, intake_posture: "bounded_pilot" },
actor,
);
expect(first).toMatchObject({
youtube_full_harvest: true,
youtube_cc_only_broadcast: false,
scrape_metadata_only: false,
scrape_bounded_pilot: true,
intake_posture: "bounded_pilot",
});
// Re-update overwrites, does not duplicate rows (key PK).
const second = await service.updateSettings({ intake_posture: "metadata_only", scrape_bounded_pilot: false }, actor);
expect(second.intake_posture).toBe("metadata_only");
expect(second.scrape_bounded_pilot).toBe(false);
expect(second.youtube_full_harvest).toBe(true); // still from the first write
const rows = await asOwner(async (c) => (await c.query<{ key: string }>("SELECT key FROM instance_settings ORDER BY key")).rows.map((r) => r.key));
expect(rows.sort()).toEqual([...m.service.INSTANCE_SETTING_KEYS].sort());
});
it("invalid patches change nothing", async () => {
await resetSettings();
const before = await service.getSettings();
for (const bad of [null, [], "x", {}, { nope: true }, { intake_posture: "everything" }, { scrape_bounded_pilot: "yes" }]) {
await expect(service.updateSettings(bad, { userId: adminUser, role: "Owner" })).rejects.toBeDefined();
}
const after = await service.getSettings();
expect({ ...after }).toEqual({ ...before, updatedAt: after.updatedAt, updatedBy: after.updatedBy, pending: after.pending });
expect(await service.getIntakePosture()).toBe("metadata_only");
});
// -----------------------------------------------------------------------
// RBAC — non-admin is 403, through the controller AND the guard chain
// -----------------------------------------------------------------------
it("non-admin roles get 403 from GET and PATCH (controller gate)", async () => {
const req = (role: string) => ({ session: { userId: otherUser, workspaceId: ws, role, authzEpoch: 1, issuedAt: 0, expiresAt: 9e12 } });
for (const role of ["Publisher", "Editor", "Approver", "Analyst", "Moderator", "BroadcastOperator"]) {
const errGet = await controller.get(req(role) as never).then(
() => null,
(e: unknown) => e,
);
expect(errGet).toMatchObject({ status: 403 });
const errPatch = await controller.patch(req(role) as never, { youtube_full_harvest: true }).then(
() => null,
(e: unknown) => e,
);
expect(errPatch).toMatchObject({ status: 403 });
}
// And nothing changed.
expect(await service.getYoutubeFullHarvest()).toBe(false);
});
it("non-admin is refused in the service layer too (no other call path widens posture)", async () => {
await expect(service.updateSettings({ youtube_full_harvest: true }, { userId: otherUser, role: "Publisher" })).rejects.toBeDefined();
expect(await service.getYoutubeFullHarvest()).toBe(false);
});
it("Owner and Admin both pass the controller gate end-to-end", async () => {
await resetSettings();
const req = (userId: string, role: string) => ({ session: { userId, workspaceId: ws, role, authzEpoch: 1, issuedAt: 0, expiresAt: 9e12 } });
const asOwner = await controller.patch(req(adminUser, "Owner") as never, { scrape_bounded_pilot: true });
expect(asOwner.scrape_bounded_pilot).toBe(true);
const asAdmin = await controller.patch(req(adminUser, "Admin") as never, { scrape_bounded_pilot: false });
expect(asAdmin.scrape_bounded_pilot).toBe(false);
const read = await controller.get(req(adminUser, "Admin") as never);
expect(read.scrape_bounded_pilot).toBe(false);
});
it("the real RbacGuard + controller chain: valid session reaches GET, no session is 401", async () => {
const { Reflector } = await import("@nestjs/core");
const epoch = await asOwner(async (c) => Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [ws])).rows[0].authz_epoch));
const token = m.session.issueSession({ userId: adminUser, workspaceId: ws, role: "Owner", authzEpoch: epoch });
const run = async (headers: Record<string, string>): Promise<unknown> => {
const reqObj = { headers, session: undefined as unknown };
const ctx = { getHandler: () => controller.get, switchToHttp: () => ({ getRequest: () => reqObj }) } as never;
const guardOk = await new m.guard.RbacGuard(new Reflector()).canActivate(ctx);
if (guardOk) return controller.get(reqObj as never);
return null;
};
// No bearer -> guard 401 before the controller is reached.
await expect(run({})).rejects.toMatchObject({ status: 401 });
// Valid Owner session passes the guard (no permission metadata on the
// route) and the controller's role gate, returning the live settings.
const settings = (await run({ authorization: `Bearer ${token}` })) as import("./settings.service.js").InstanceSettingRecord;
expect(settings.intake_posture).toBe("metadata_only");
});
// -----------------------------------------------------------------------
// Audit events (system chain — instance rows are not tenant rows)
// -----------------------------------------------------------------------
it("every PATCH lands a system audit event with the before→after diff", async () => {
await resetSettings();
await service.updateSettings({ youtube_full_harvest: true, intake_posture: "bounded_pilot" }, { userId: adminUser, role: "Owner" });
const events = await asOwner(async (c) =>
(
await c.query<{ action: string; target_type: string; target_id: string; actor_user_id: string; diff: Record<string, unknown>; workspace_id: string | null }>(
"SELECT action, target_type, target_id, actor_user_id, diff, workspace_id FROM audit_log WHERE action = 'instance_settings.updated' ORDER BY seq",
)
).rows,
);
expect(events.length).toBeGreaterThanOrEqual(1);
const last = events.at(-1);
expect(last).toBeDefined();
if (last === undefined) return;
// System chain: workspace_id IS NULL, actor recorded, diff carries the change.
expect(last.workspace_id).toBeNull();
expect(last.actor_user_id).toBe(adminUser);
expect(last.target_type).toBe("instance_settings");
expect(last.target_id).toBe("instance");
expect(last.diff).toMatchObject({
youtube_full_harvest: { from: false, to: true },
intake_posture: { from: "metadata_only", to: "bounded_pilot" },
});
});
it("a failed patch lands NO audit event", async () => {
await resetSettings();
const before = await asOwner(async (c) => Number((await c.query("SELECT count(*) AS n FROM audit_log WHERE action = 'instance_settings.updated'")).rows[0].n));
await expect(service.updateSettings({ intake_posture: "nonsense" }, { userId: adminUser, role: "Owner" })).rejects.toBeDefined();
const after = await asOwner(async (c) => Number((await c.query("SELECT count(*) AS n FROM audit_log WHERE action = 'instance_settings.updated'")).rows[0].n));
expect(after).toBe(before);
});
// -----------------------------------------------------------------------
// Table-level guarantees (migration 028)
// -----------------------------------------------------------------------
it("instance_settings is a system table: no workspace_id, no RLS, sp_app-granted", async () => {
const meta = await asOwner(async (c) =>
(
await c.query<{ columns: number; policies: number; rls: string; grants: string[] }>(
`SELECT
(SELECT count(*) FROM information_schema.columns WHERE table_name = 'instance_settings' AND column_name = 'workspace_id')::int AS columns,
(SELECT count(*) FROM pg_policies WHERE tablename = 'instance_settings')::int AS policies,
c.relrowsecurity::text AS rls,
coalesce(array_agg(DISTINCT u.grantee) FILTER (WHERE u.grantee <> 'superposter'), '{}') AS grants
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN information_schema.role_table_grants u ON u.table_name = 'instance_settings' AND u.table_schema = 'public'
WHERE n.nspname = 'public' AND c.relname = 'instance_settings'
GROUP BY c.relrowsecurity`,
)
).rows.at(0),
);
expect(meta).toBeDefined();
if (meta === undefined) return;
expect(meta.columns).toBe(0); // no workspace_id column
expect(meta.policies).toBe(0); // no RLS policies
expect(meta.rls).toBe("false"); // RLS not even enabled
expect(meta.grants).toContain("sp_app"); // system table with sp_app grants
});
it("CHECK constraints reject unknown keys and undecodable values at the DB", async () => {
await expect(asOwner(async (c) => c.query("INSERT INTO instance_settings (key, value) VALUES ('allow_everything', 'true')"))).rejects.toThrow();
await expect(asOwner(async (c) => c.query("INSERT INTO instance_settings (key, value) VALUES ('scrape_bounded_pilot', 'maybe')"))).rejects.toThrow();
await expect(asOwner(async (c) => c.query("INSERT INTO instance_settings (key, value) VALUES ('intake_posture', 'wildwest')"))).rejects.toThrow();
// A legit literal passes the CHECK (update the seeded row: key is the PK).
await asOwner(async (c) => c.query("UPDATE instance_settings SET value = 'community_submissions' WHERE key = 'intake_posture'"));
expect(await service.getIntakePosture()).toBe("community_submissions");
// A MISSING row decodes to the safe default and is surfaced as pending.
await asOwner(async (c) => c.query("DELETE FROM instance_settings WHERE key = 'intake_posture'"));
const pending = await service.getSettings();
expect(pending.intake_posture).toBe("metadata_only");
expect(pending.pending).toContain("intake_posture");
// Restore the seeded row for later groups.
await resetSettings();
});
it("the migration is registered in schema_migrations as 028", async () => {
const applied = await asOwner(async (c) => (await c.query<{ name: string }>("SELECT name FROM schema_migrations WHERE name LIKE '028%'")).rows.map((r) => r.name));
expect(applied).toContain("028_instance_settings.sql");
});
});

View file

@ -0,0 +1,16 @@
/**
* Settings module wiring. The service talks to the system pool directly
* (withSystem): instance_settings is deliberately NOT tenant-scoped, so there
* is no withTenant context to establish — audit events go to the system chain
* via audit_append_system, which REJECTS a tenant context.
*/
import { Module } from "@nestjs/common";
import { SettingsController } from "./settings.controller.js";
import { SettingsService } from "./settings.service.js";
@Module({
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View file

@ -0,0 +1,229 @@
/**
* Instance-level settings: the content-policy posture of THIS deployment.
*
* The firehose tiers are graded by legal/ToS exposure (decision record
* 2026-10-09-intake-posture.md). That exposure is
* per-instance — one egress IP, one legal entity — so the switches live in a
* SYSTEM table (migration 028): no workspace_id, no RLS, nothing per-tenant.
* Access control happens at the API seam: only Owner/Admin roles reach the
* mutating methods (controller role check), and every write lands a system
* audit-chain event (audit_append_system) so posture changes are provable.
*
* This module is the ONE place that interprets `instance_settings.value`
* strings. Key set is CHECK-constrained in 028 to exactly these five keys;
* values to exactly the literals decoded here, so an undecodable row cannot
* exist — unknown/missing rows fall back to the SAFE default and are surfaced
* as `pending` in GET /settings.
*/
/* eslint-disable no-restricted-syntax -- the setting keys NAME the platform each policy switch governs (youtube_full_harvest gates the yt-dlp path); this module is the policy boundary, same standing as intake-kinds.ts. */
import { BadRequestException, Injectable } from "@nestjs/common";
import type { PoolClient } from "pg";
import { withSystem } from "../db/client.js";
/** Overall intake posture (decision record tiers). */
export const INTAKE_POSTURES = ["metadata_only", "community_submissions", "bounded_pilot"] as const;
export type IntakePosture = (typeof INTAKE_POSTURES)[number];
export function isIntakePosture(value: unknown): value is IntakePosture {
return typeof value === "string" && (INTAKE_POSTURES as readonly string[]).includes(value);
}
/** The five instance settings keys (migration 028 CHECK). */
export const INSTANCE_SETTING_KEYS = [
"youtube_full_harvest",
"youtube_cc_only_broadcast",
"scrape_metadata_only",
"scrape_bounded_pilot",
"intake_posture",
] as const;
export type InstanceSettingKey = (typeof INSTANCE_SETTING_KEYS)[number];
/** Boolean-valued keys — decoded from 'true'/'false'. */
export const BOOLEAN_SETTING_KEYS: readonly InstanceSettingKey[] = [
"youtube_full_harvest",
"youtube_cc_only_broadcast",
"scrape_metadata_only",
"scrape_bounded_pilot",
];
/** Defaults = the safe posture (decision record 2026-10-09). */
export const DEFAULT_INSTANCE_SETTINGS = {
youtube_full_harvest: false, // yt-dlp full video download: OFF
youtube_cc_only_broadcast: true, // broadcast playlists: CC-licensed only
scrape_metadata_only: true, // locked platforms: metadata only
scrape_bounded_pilot: false, // bounded read-only X pool: OFF
intake_posture: "metadata_only" as IntakePosture,
} satisfies Record<InstanceSettingKey, boolean | IntakePosture>;
/** The decoded settings value object (what every consumer reads). */
export interface InstanceSettings {
/** Enables yt-dlp video download for YouTube sources (ToS risk). */
youtube_full_harvest: boolean;
/** CC-only filter for YouTube broadcast playlists (Creative Commons only). */
youtube_cc_only_broadcast: boolean;
/** Locked platforms stay metadata-only (no authenticated crawling). */
scrape_metadata_only: boolean;
/** Enables the bounded read-only X pool (decision record Tier 2). */
scrape_bounded_pilot: boolean;
/** Overall intake posture. */
intake_posture: IntakePosture;
}
/** GET /settings shape: values + last-write provenance per key. */
export interface InstanceSettingRecord extends InstanceSettings {
/** ISO timestamps of the last write per key (epoch for never-written keys). */
updatedAt: Readonly<Record<InstanceSettingKey, string>>;
/** User ids of the last writer per key. */
updatedBy: Readonly<Record<InstanceSettingKey, string | null>>;
/** Keys with no row yet (unset ⇒ default in force). Read-only information. */
pending: readonly InstanceSettingKey[];
}
export interface SettingActor {
userId: string;
role: string;
}
/** Roles allowed to change instance posture. Task spec: Owner/Admin. */
export const INSTANCE_ADMIN_ROLES: readonly string[] = ["Owner", "Admin"];
export function isInstanceAdmin(role: string): boolean {
return INSTANCE_ADMIN_ROLES.includes(role);
}
type RawRow = { key: string; value: string; updated_at: Date; updated_by: string | null };
/** System audit append, on the SAME transaction as the setting write. */
async function auditSystemSettingChange(
client: PoolClient,
actor: SettingActor,
diff: Record<string, unknown>,
): Promise<void> {
await client.query("SELECT audit_append_system($1, $2, $3, $4, $5::jsonb)", [
actor.userId,
"instance_settings.updated",
"instance_settings",
"instance",
JSON.stringify(diff),
]);
}
@Injectable()
export class SettingsService {
/** All five settings with metadata. Unknown rows cannot exist (028 CHECK). */
async getSettings(): Promise<InstanceSettingRecord> {
return withSystem(async (client) => this.readAll(client));
}
async getYoutubeFullHarvest(): Promise<boolean> {
return (await this.getSettings()).youtube_full_harvest;
}
async getYoutubeCcOnlyBroadcast(): Promise<boolean> {
return (await this.getSettings()).youtube_cc_only_broadcast;
}
async getScrapeMetadataOnly(): Promise<boolean> {
return (await this.getSettings()).scrape_metadata_only;
}
async getScrapeBoundedPilot(): Promise<boolean> {
return (await this.getSettings()).scrape_bounded_pilot;
}
async getIntakePosture(): Promise<IntakePosture> {
return (await this.getSettings()).intake_posture;
}
/**
* Partial update of instance settings. Validates shapes, writes every
* provided key in ONE transaction and lands a single system audit event
* carrying the before→after diff (posture changes are legal-exposure
* decisions; they must be provable after the fact).
*/
async updateSettings(patch: unknown, actor: SettingActor): Promise<InstanceSettingRecord> {
if (!isInstanceAdmin(actor.role)) {
// Defense in depth: the controller gates first, the service re-checks so
// no other call path can widen the posture.
throw new BadRequestException("only Owner/Admin may change instance settings");
}
const input = this.validatePatch(patch);
return withSystem(async (client) => {
const before = await this.readAll(client);
const diff: Record<string, unknown> = {};
for (const key of INSTANCE_SETTING_KEYS) {
if (!(key in input)) continue;
const value = input[key];
await client.query(
`INSERT INTO instance_settings (key, value, updated_by) VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now(), updated_by = EXCLUDED.updated_by`,
[key, String(value), actor.userId],
);
diff[key] = { from: before[key], to: value };
}
await auditSystemSettingChange(client, actor, diff);
return this.readAll(client);
});
}
/**
* Body-shape guard (shared `unknown` body convention): an object with any
* subset of the five keys; booleans must be actual booleans, posture must be
* a known enum value, unknown keys are a 400 (never silently dropped).
*/
private validatePatch(patch: unknown): Partial<InstanceSettings> {
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
throw new BadRequestException("body must be an object");
}
const entries: Partial<InstanceSettings> = {};
let count = 0;
for (const [key, value] of Object.entries(patch as Record<string, unknown>)) {
if (!(INSTANCE_SETTING_KEYS as readonly string[]).includes(key)) {
throw new BadRequestException(`unknown setting: ${key} (allowed: ${INSTANCE_SETTING_KEYS.join(", ")})`);
}
if (key === "intake_posture") {
if (!isIntakePosture(value)) throw new BadRequestException(`intake_posture must be one of: ${INTAKE_POSTURES.join(", ")}`);
entries.intake_posture = value;
} else if (typeof value === "boolean") {
entries[key as Exclude<InstanceSettingKey, "intake_posture">] = value;
} else {
throw new BadRequestException(`${key} must be a boolean`);
}
count += 1;
}
if (count === 0) throw new BadRequestException("body must set at least one setting");
return entries;
}
/** Read + decode every key; missing rows decode to the safe default. */
private async readAll(client: PoolClient): Promise<InstanceSettingRecord> {
const result = await client.query<RawRow>("SELECT key, value, updated_at, updated_by FROM instance_settings");
const rows = new Map(result.rows.map((row) => [row.key, row]));
const settings: InstanceSettings = { ...DEFAULT_INSTANCE_SETTINGS };
const updatedAt = {} as Record<InstanceSettingKey, string>;
const updatedBy = {} as Record<InstanceSettingKey, string | null>;
const pending: InstanceSettingKey[] = [];
for (const key of INSTANCE_SETTING_KEYS) {
const row = rows.get(key);
if (row === undefined) {
pending.push(key);
updatedAt[key] = new Date(0).toISOString();
updatedBy[key] = null;
continue;
}
if (key === "intake_posture") {
if (isIntakePosture(row.value)) settings.intake_posture = row.value;
} else if (row.value === "true" || row.value === "false") {
settings[key] = row.value === "true";
}
// A row outside the decodable literals cannot exist (028 CHECK); if one
// ever does, the safe default above stays in force.
updatedAt[key] = row.updated_at.toISOString();
updatedBy[key] = row.updated_by;
}
return { ...settings, updatedAt, updatedBy, pending };
}
}
/* eslint-enable no-restricted-syntax */

View file

@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import { BadRequestException, ForbiddenException } from "@nestjs/common";
import {
BOOLEAN_SETTING_KEYS,
DEFAULT_INSTANCE_SETTINGS,
INSTANCE_ADMIN_ROLES,
INSTANCE_SETTING_KEYS,
INTAKE_POSTURES,
isInstanceAdmin,
isIntakePosture,
SettingsService,
} from "./settings.service.js";
import { SettingsController } from "./settings.controller.js";
/**
* Unit level: validation shapes, defaults, the role gate — no DB. The
* live-DB CRUD/RBAC/audit coverage is settings.integration.test.ts.
*/
const service = new SettingsService();
const admin = { userId: "00000000-0000-4000-8000-000000000001", role: "Owner" };
describe("instance settings — keys, defaults, roles", () => {
it("exposes exactly the five policy keys from the task spec", () => {
expect(INSTANCE_SETTING_KEYS).toEqual([
"youtube_full_harvest",
"youtube_cc_only_broadcast",
"scrape_metadata_only",
"scrape_bounded_pilot",
"intake_posture",
]);
expect(BOOLEAN_SETTING_KEYS).toHaveLength(4);
expect(INSTANCE_SETTING_KEYS).toHaveLength(5);
});
it("defaults are the SAFE posture (decision record 2026-10-09)", () => {
expect(DEFAULT_INSTANCE_SETTINGS.youtube_full_harvest).toBe(false); // yt-dlp OFF
expect(DEFAULT_INSTANCE_SETTINGS.youtube_cc_only_broadcast).toBe(true); // CC-only ON
expect(DEFAULT_INSTANCE_SETTINGS.scrape_metadata_only).toBe(true); // locked platforms: metadata
expect(DEFAULT_INSTANCE_SETTINGS.scrape_bounded_pilot).toBe(false); // no bounded X pool
expect(DEFAULT_INSTANCE_SETTINGS.intake_posture).toBe("metadata_only");
});
it("only Owner/Admin are instance admins", () => {
expect(INSTANCE_ADMIN_ROLES).toEqual(["Owner", "Admin"]);
for (const role of ["Owner", "Admin"]) expect(isInstanceAdmin(role)).toBe(true);
for (const role of ["Publisher", "Editor", "Approver", "Analyst", "Moderator", "BroadcastOperator", ""]) {
expect(isInstanceAdmin(role)).toBe(false);
}
});
it("posture enum accepts exactly the three postures", () => {
for (const p of INTAKE_POSTURES) expect(isIntakePosture(p)).toBe(true);
for (const bad of ["metadata", "", "METADATA_ONLY", "full", undefined, null, 1]) {
expect(isIntakePosture(bad)).toBe(false);
}
});
});
describe("instance settings — patch validation", () => {
it("a non-object body rejects before any query is issued", async () => {
await expect(service.updateSettings("x", admin)).rejects.toBeInstanceOf(BadRequestException);
});
it("rejects non-object bodies", async () => {
for (const bad of [undefined, null, "nope", 42, [], true]) {
await expect(service.updateSettings(bad, admin)).rejects.toBeInstanceOf(BadRequestException);
}
});
it("rejects unknown keys instead of silently dropping them", async () => {
await expect(
service.updateSettings({ youtube_full_harvest: true, allow_all_scraping: true }, admin),
).rejects.toMatchObject({ status: 400, message: expect.stringMatching(/unknown setting/) });
});
it("rejects non-boolean values for the boolean switches", async () => {
for (const bad of ["true", 1, null, undefined]) {
await expect(service.updateSettings({ scrape_bounded_pilot: bad }, admin)).rejects.toMatchObject({ status: 400 });
}
});
it("rejects postures outside the enum", async () => {
for (const bad of ["full", "metadata-only", "", "METADATA_ONLY", true, null]) {
await expect(service.updateSettings({ intake_posture: bad }, admin)).rejects.toMatchObject({ status: 400 });
}
});
it("rejects an empty patch", async () => {
await expect(service.updateSettings({}, admin)).rejects.toMatchObject({ status: 400, message: expect.stringMatching(/at least one/) });
});
it("non-admin actors are refused in the service layer too (defense in depth)", async () => {
for (const role of ["Publisher", "Editor", "Approver", "Analyst", "Moderator", "BroadcastOperator"]) {
await expect(service.updateSettings({ intake_posture: "bounded_pilot" }, { userId: admin.userId, role })).rejects.toBeInstanceOf(
BadRequestException,
);
}
});
});
describe("instance settings — controller role gate", () => {
const controller = new SettingsController(service);
const req = (role: string) => ({ session: { userId: admin.userId, workspaceId: "ws", role, authzEpoch: 1, issuedAt: 0, expiresAt: 1 } });
it("GET /settings is 403 for non-admin roles", async () => {
for (const role of ["Publisher", "Editor", "Approver", "Analyst", "Moderator", "StreamOperator", "VideoPublisher"]) {
await expect(controller.get(req(role) as never)).rejects.toBeInstanceOf(ForbiddenException);
}
});
it("PATCH /settings is 403 for non-admin roles", async () => {
for (const role of ["Publisher", "Editor", "Approver", "Analyst", "Moderator"]) {
await expect(controller.patch(req(role) as never, { intake_posture: "bounded_pilot" })).rejects.toBeInstanceOf(
ForbiddenException,
);
}
});
it("Owner and Admin pass the controller gate (service then owns the rest)", async () => {
// A non-DB-backed service: getSettings would need the pool. Assert the
// gate itself by checking the failure is NOT Forbidden for admins.
for (const role of ["Owner", "Admin"]) {
const getErr = await controller.get(req(role) as never).then(
() => null,
(e: unknown) => e,
);
expect(getErr).not.toBeInstanceOf(ForbiddenException);
const patchErr = await controller.patch(req(role) as never, {}).then(
() => null,
(e: unknown) => e,
);
expect(patchErr).not.toBeInstanceOf(ForbiddenException);
}
});
});