Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
35
apps/api/src/settings/settings.controller.ts
Normal file
35
apps/api/src/settings/settings.controller.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
351
apps/api/src/settings/settings.integration.test.ts
Normal file
351
apps/api/src/settings/settings.integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
16
apps/api/src/settings/settings.module.ts
Normal file
16
apps/api/src/settings/settings.module.ts
Normal 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 {}
|
||||
229
apps/api/src/settings/settings.service.ts
Normal file
229
apps/api/src/settings/settings.service.ts
Normal 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 */
|
||||
137
apps/api/src/settings/settings.test.ts
Normal file
137
apps/api/src/settings/settings.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue