feat: POST /auth/workspaces — create an additional collective from an existing account

Until now collectives only existed at registration; an account with a
personal workspace had no path to start one. The creator becomes Owner
and gets a session scoped to the new workspace.
This commit is contained in:
glass beadian 2026-10-14 17:16:17 +00:00
parent 177ca6eb2b
commit cbdb8c0f81
18 changed files with 2584 additions and 8 deletions

View file

@ -0,0 +1,31 @@
-- 036: per-collective branding — logo, tagline, background, accent.
--
-- Owner need: the ACTIVE COLLECTIVE must be unmistakable in the shell, and
-- pages/screenshots must look different per workspace. That is presentation
-- state of the TENANT itself, so it lives on `workspaces` — not a new table,
-- not instance_settings (028 is the deployment's legal posture, one per
-- instance; branding is per collective).
--
-- One nullable jsonb column, nothing else:
-- * logoAssetKey text — storage key (sha256/…) of the collective's mark
-- * tagline text — one-line subtitle under the name
-- * backgroundKey text — storage key of the shell background image
-- * accent text — CSS hex color overriding the palette accent
-- * NULL / missing key ⇒ the Hub default for that field
--
-- NULL (the default) means "no branding": every existing workspace keeps the
-- stock look. The key set is validated at the API seam (branding.service.ts)
-- because the values are cross-cutting (storage keys must match real objects,
-- accent must be a safe CSS color); the DB only guarantees "object or absent"
-- so an undecodable row degrades to defaults instead of failing the shell.
--
-- `workspaces` is deliberately NOT under RLS (001): it holds no tenant rows,
-- it IS the tenant spine. Reads/writes stay behind the RbacGuard + service
-- seam exactly as before this migration.
BEGIN;
ALTER TABLE workspaces ADD COLUMN branding jsonb
CHECK (branding IS NULL OR jsonb_typeof(branding) = 'object');
COMMIT;

View file

@ -0,0 +1,60 @@
-- 037: per-workspace chat identity display settings (pseudonymization).
--
-- Owner need, two parts:
-- * Hub-local users must render as @username (never @hub_user:server) on the
-- surfaces the HUB renders — that part is code-only (mxid-display.ts).
-- * Per-collective pseudonymization: participants in PUBLIC chats appear
-- under automatically-assigned random display names; PRIVATE chats show
-- real names by default; an admin toggle decides whether participants can
-- see the real→pseudonym mapping.
--
-- STORAGE DECISION (why a new table and not instance_settings / branding):
-- * instance_settings (028) is the INSTANCE's legal/content posture — a
-- SYSTEM table with no workspace_id and a CHECK constraint pinning the key
-- set to exactly its five policy keys. chat.* keys cannot live there, and
-- pseudonymization is a PER-COLLECTIVE choice, not a deployment posture.
-- * workspaces.branding (036) is the tenant's LOOK — reusing it for chat
-- policy would couple two unrelated concerns inside another module's column.
-- So nothing that exists fits; this table is the per-workspace key/value
-- analogue of 028, following its conventions: the key set and the value
-- literals are CHECK-constrained so an undecodable row cannot exist, and the
-- ONE place that interprets the strings is chat-display.ts (missing/unknown
-- rows decode to the documented default).
--
-- Keys and defaults (decoded in apps/api/src/matrix-sso/chat-display.ts):
-- chat.pseudonymizePublic default true public chat: random display names
-- chat.pseudonymizePrivate default false private chat: real names
-- chat.revealMapping default false participants see Pseudonym (real)
-- No seed rows: absence IS the default, which keeps the table empty for every
-- workspace that never touches the toggles (the `pending` semantics of 028).
BEGIN;
CREATE TABLE workspace_chat_settings (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
key text NOT NULL,
value text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (workspace_id, key),
-- Unknown keys are rejected outright so a typo cannot fork the surface.
CONSTRAINT workspace_chat_settings_key_allowed CHECK (key IN (
'chat.pseudonymizePublic', 'chat.pseudonymizePrivate', 'chat.revealMapping'
)),
-- Every key is boolean-valued; only the literals the service layer decodes.
CONSTRAINT workspace_chat_settings_bool_value CHECK (value IN ('true', 'false'))
);
CREATE TRIGGER trg_workspace_chat_settings_ws_immutable BEFORE UPDATE ON workspace_chat_settings
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE workspace_chat_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspace_chat_settings FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON workspace_chat_settings
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON workspace_chat_settings TO sp_app;
COMMIT;

View file

@ -31,6 +31,8 @@ import { IntakeModule } from "./intake/intake.module.js";
import { GroupFilesModule } from "./group-files/group-files.module.js";
import { SettingsModule } from "./settings/settings.module.js";
import { SettingsController } from "./settings/settings.controller.js";
import { BrandingController, PublicBrandingController } from "./settings/branding.controller.js";
import { BrandingService } from "./settings/branding.service.js";
import { SettingsService } from "./settings/settings.service.js";
import { VirtualChannelsModule } from "./virtual-channels/virtual-channels.module.js";
import { DirectorModule } from "./director/director.module.js";
@ -56,11 +58,13 @@ import { SubmissionsModule } from "./submissions/submissions.module.js";
GroupsController,
AnalyticsController,
SettingsController,
BrandingController,
PublicBrandingController,
],
providers: [
{ provide: PublishingExecutionRepository, useFactory: () => new PublishingExecutionRepository(pool) },
TemporalWorkflowStarter,
{ provide: PUBLISHING_DEPENDENCIES, inject: [PublishingExecutionRepository, TemporalWorkflowStarter, ApprovalGateService], useFactory: (repository: PublishingExecutionRepository, workflowStarter: TemporalWorkflowStarter, approvalGate: ApprovalGateService) => ({ repository, workflowStarter, approvalGate, tenantTransaction: withTenant, assertReady: () => assertPublishingSchema(pool) }) },
PublishingService, ApprovalGateService, AuthService, ConnectionsService, TeamService, InviteLinkService, AuditQueryService, VideoService, GroupsService, SnapshotService, SettingsService],
PublishingService, ApprovalGateService, AuthService, ConnectionsService, TeamService, InviteLinkService, AuditQueryService, VideoService, GroupsService, SnapshotService, SettingsService, BrandingService],
})
export class AppModule {}

View file

@ -27,6 +27,16 @@ export class AuthController {
return this.auth.me(requireSession(request));
}
/** Create an additional collective owned by the caller; returns a session for it. */
@Post("workspaces")
@HttpCode(200)
@UseGuards(RbacGuard)
async createWorkspace(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<AuthResult> {
const session = requireSession(request);
const name = optionalString(requireObject(body, "body").workspaceName, "workspaceName") ?? "";
return this.auth.createCollective(session, name);
}
/** A signed, unexpired session can refresh its epoch and live role. */
@Post("reissue")
@HttpCode(200)

View file

@ -229,6 +229,36 @@ export class AuthService {
return { token, me };
}
/**
* Create an additional collective for an EXISTING account: the creator
* becomes its Owner and receives a fresh session scoped to the new
* workspace (the old token stays valid for the old workspace).
*/
async createCollective(session: SessionPayload, rawName: string): Promise<AuthResult> {
const name = rawName.trim().slice(0, 100);
if (name === "") throw new BadRequestException("workspaceName must not be empty");
const created = await withSystem(async (client) => {
const ws = await client.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id, slug", [
name, slugify(name),
]);
const workspaceId = ws.rows[0]?.id as string;
await client.query("SELECT set_config('app.workspace_id', $1, true)", [workspaceId]);
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
await client.query("INSERT INTO memberships (workspace_id, user_id, role, status) VALUES ($1, $2, 'Owner', 'active')", [workspaceId, session.userId]);
const token = await issueSessionInTenant(client, workspaceId, session.userId);
return { workspaceId, slug: ws.rows[0]?.slug as string, token };
});
await appendTenantAudit(created.workspaceId, {
actorUserId: session.userId,
actorRole: "Owner",
action: "workspace.created",
targetType: "workspace",
targetId: created.workspaceId,
diff: { name, slug: created.slug, createdBy: session.userId },
});
return { token: created.token, me: await this.meFor(created.workspaceId, session.userId, created.token) };
}
async me(session: SessionPayload): Promise<MeResponse> {
return this.meFor(session.workspaceId, session.userId, null, session.expiresAt);
}

View file

@ -0,0 +1,223 @@
/**
* Chat display policy for one workspace: reads the three `chat.*` keys from
* workspace_chat_settings (migration 037), mints deterministic pseudonyms, and
* applies them to the sender labels the Hub itself renders.
*
* Settings model (owner spec):
* chat.pseudonymizePublic default true public chats show random names
* chat.pseudonymizePrivate default false private chats show real names
* chat.revealMapping default false participants may see "Pseudonym (real)"
*
* Defaults are chosen so a collective that never touches the surface behaves
* the way the owner described the product: public chat is pseudonymized, and
* the mapping is hidden. All three are optional — an absent row IS the default.
*
* The pseudonym is DERIVED, never stored: adjective-animal picked by hashing
* (user uuid, workspace uuid) so the same member reads as the same name in
* every public chat of their collective, and two members of different
* collectives never share a name because their workspace ids differ. No new
* table, no migration risk, nothing to backfill — and rotating a mapping is
* impossible by construction, which is the point of pseudonymization.
*
* Ownership note: apps/api/src/settings is the BRAND agent's area; this module
* is the chat surface's own service and reads its own table. Nothing here
* touches instance_settings or workspaces.branding.
*/
import { Injectable, Optional, Inject } from "@nestjs/common";
import { createHash } from "node:crypto";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import { appendTenantAudit } from "../audit/audit.service.js";
/** Dependency seam for tests (the hub.service pattern: every port injectable). */
export const CHAT_DISPLAY_DEPENDENCIES = Symbol("CHAT_DISPLAY_DEPENDENCIES");
export interface ChatDisplayDependencies {
transaction: <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
}
/** The three toggles, decoded. */
export interface ChatDisplaySettings {
pseudonymizePublic: boolean;
pseudonymizePrivate: boolean;
revealMapping: boolean;
}
/** Default posture per the owner spec: public ON, private OFF, mapping hidden. */
export const DEFAULT_CHAT_DISPLAY: ChatDisplaySettings = {
pseudonymizePublic: true,
pseudonymizePrivate: false,
revealMapping: false,
};
export const CHAT_SETTING_KEYS = ["chat.pseudonymizePublic", "chat.pseudonymizePrivate", "chat.revealMapping"] as const;
export type ChatSettingKey = (typeof CHAT_SETTING_KEYS)[number];
/** Which surface a name is being rendered for. */
export type ChatVisibility = "public" | "private";
/** Roles that may change the toggles (the instance-settings convention). */
export const CHAT_ADMIN_ROLES: readonly string[] = ["Owner", "Admin"];
export function isChatAdmin(role: string): boolean {
return CHAT_ADMIN_ROLES.includes(role);
}
/** Small, warm word lists — enough pairs that a busy public room stays readable. */
const ADJECTIVES = [
"amber", "brave", "calm", "clever", "crimson", "dawn", "deft", "eager",
"fern", "flint", "gentle", "golden", "hazel", "iron", "jade", "keen",
"linen", "lucid", "meridian", "moss", "nimble", "north", "olive", "opal",
"quiet", "rust", "sable", "slate", "solar", "spry", "steady", "tarn",
"umber", "verdant", "wren", "zephyr",
];
const ANIMALS = [
"adder", "asp", "bat", "bear", "bee", "bittern", "boar", "caribou", "cat",
"cobra", "crane", "crow", "deer", "dotterel", "dove", "duck", "eagle",
"egret", "falcon", "ferret", "finch", "fox", "goose", "hare", "heron",
"ibex", "jay", "kestrel", "kite", "linnet", "lynx", "marten", "mite",
"moorhen", "moose", "otter", "owl", "plover", "puma", "raven", "rook",
"shrike", "siskin", "smew", "stoat", "swift", "tern", "vole", "wolverine",
];
/**
* Deterministic pseudonym for (userId, workspaceId): "Adjective Animal".
* Stable per user+workspace, so a member's public-chat identity survives page
* reloads and is consistent across every public room of their collective.
*/
export function pseudonymFor(userId: string, workspaceId: string): string {
const digest = createHash("sha256").update(`${workspaceId}:${userId}`).digest();
const adjective = ADJECTIVES[digest.readUInt16BE(0) % ADJECTIVES.length]!;
const animal = ANIMALS[digest.readUInt16BE(2) % ANIMALS.length]!;
return `${capitalize(adjective)} ${capitalize(animal)}`;
}
function capitalize(word: string): string {
return word.charAt(0).toUpperCase() + word.slice(1);
}
/** Wire shape for the settings read (GET /matrix/chat-display). */
export interface ChatDisplaySettingsResponse extends ChatDisplaySettings {
/** Keys with no row yet — unset ⇒ default in force (028's `pending`). */
pending: readonly ChatSettingKey[];
}
@Injectable()
export class ChatDisplayService {
private readonly deps: ChatDisplayDependencies;
constructor(@Optional() @Inject(CHAT_DISPLAY_DEPENDENCIES) dependencies?: Partial<ChatDisplayDependencies>) {
this.deps = { transaction: dependencies?.transaction ?? withTenant };
}
async getSettings(workspaceId: string): Promise<ChatDisplaySettingsResponse> {
const rows = await this.deps.transaction(workspaceId, async (client) =>
client.query<{ key: string; value: string }>("SELECT key, value FROM workspace_chat_settings WHERE workspace_id = $1", [workspaceId]));
return decodeChatDisplay(rows.rows.map((row) => [row.key, row.value]));
}
/** Partial update; Owner/Admin only, audited on the tenant chain. */
async updateSettings(
workspaceId: string,
patch: Record<string, unknown>,
actor: { userId: string; role: string },
): Promise<ChatDisplaySettingsResponse> {
if (!isChatAdmin(actor.role)) throw new BadRequestException("only Owner/Admin may change chat display settings");
const validated = validateChatDisplayPatch(patch);
const before = await this.getSettings(workspaceId);
const diff: Record<string, unknown> = {};
await this.deps.transaction(workspaceId, async (client) => {
for (const [key, value] of Object.entries(validated)) {
await client.query(
`INSERT INTO workspace_chat_settings (workspace_id, key, value, updated_by) VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now(), updated_by = EXCLUDED.updated_by`,
[workspaceId, key, String(value), actor.userId],
);
diff[key] = { from: (before as Record<string, unknown>)[key], to: value };
}
await client.query(
"SELECT audit_append($1, $2, $3, $4, $5, $6, $7)",
[workspaceId, actor.userId, actor.role, "chat.display.updated", "workspace", workspaceId, JSON.stringify(diff)],
);
});
return this.getSettings(workspaceId);
}
/**
* The display label for one sender on one surface.
*
* realName the Hub's own display name for the member (displayName ?? @username)
* isGroupMember group members posting in a PUBLIC chat carry their real
* name in parentheses (owner spec); non-members' handles are
* provider identities, not collective identities, so they
* pass through untouched when pseudonymization is off.
* visibility which surface the label is for
*
* revealMapping=true widens the pseudonym to "Pseudonym (real)" — the admin
* opt-in that lets participants see the mapping; with it off, the real name
* never crosses the wire for a pseudonymized surface.
*/
async displayFor(input: {
workspaceId: string;
userId: string;
realName: string;
visibility: ChatVisibility;
isGroupMember?: boolean;
}): Promise<string> {
const settings = await this.getSettings(input.workspaceId);
return applyChatDisplay(settings, { ...input, isGroupMember: input.isGroupMember ?? false });
}
}
/** Pure application of the policy — the unit-test surface, no DB. */
export function applyChatDisplay(
settings: ChatDisplaySettings,
input: { userId: string; workspaceId: string; realName: string; visibility: ChatVisibility; isGroupMember: boolean },
): string {
const pseudonymize = input.visibility === "public" ? settings.pseudonymizePublic : settings.pseudonymizePrivate;
if (!pseudonymize) {
// Real-name surface. A group member in a public chat still carries their
// real name, so the plain realName is exactly right here.
return input.realName;
}
const pseudonym = pseudonymFor(input.userId, input.workspaceId);
if (!settings.revealMapping) return pseudonym;
return `${pseudonym} (${input.realName})`;
}
/** Decode rows (or an empty list) into the settings object; unknown rows fall back. */
export function decodeChatDisplay(rows: readonly (readonly [string, string])[]): ChatDisplaySettingsResponse {
const map = new Map(rows);
return {
pseudonymizePublic: decodeBoolean(map, "chat.pseudonymizePublic", DEFAULT_CHAT_DISPLAY.pseudonymizePublic),
pseudonymizePrivate: decodeBoolean(map, "chat.pseudonymizePrivate", DEFAULT_CHAT_DISPLAY.pseudonymizePrivate),
revealMapping: decodeBoolean(map, "chat.revealMapping", DEFAULT_CHAT_DISPLAY.revealMapping),
pending: CHAT_SETTING_KEYS.filter((key) => !map.has(key)),
};
}
function decodeBoolean(map: Map<string, string>, key: ChatSettingKey, fallback: boolean): boolean {
const value = map.get(key);
if (value === "true") return true;
if (value === "false") return false;
return fallback; // absent or (impossible per 037 CHECK) undecodable
}
import { BadRequestException } from "@nestjs/common";
/** Strict write-side validation (unknown keys are a 400, never dropped). */
export function validateChatDisplayPatch(patch: Record<string, unknown>): Partial<Record<ChatSettingKey, boolean>> {
const out: Partial<Record<ChatSettingKey, boolean>> = {};
let count = 0;
for (const [key, value] of Object.entries(patch)) {
if (!(CHAT_SETTING_KEYS as readonly string[]).includes(key)) {
throw new BadRequestException(`unknown setting: ${key} (allowed: ${CHAT_SETTING_KEYS.join(", ")})`);
}
if (typeof value !== "boolean") throw new BadRequestException(`${key} must be a boolean`);
out[key as ChatSettingKey] = value;
count += 1;
}
if (count === 0) throw new BadRequestException("body must set at least one setting");
return out;
}

View file

@ -0,0 +1,83 @@
/**
* MXID → Hub display name.
*
* The Hub mints Matrix identities for its own users as
* `@<prefix><username>:<server_name>` (matrixUserIdFor in matrix-sso.config.ts,
* prefix `hub_` by default). Element renders MXIDs with its own local
* displayname logic — the Hub cannot change what Element shows for a sender
* — but EVERY surface the Hub itself renders (the chat page's identity notice,
* live-chat sender labels, group-files attribution) must show the short,
* human form `@glassbeadian`, never the full `@hub_glassbeadian:chat.occupymedia.cc`.
*
* This module is the single formatter for that rule. It is deliberately
* dependency-free and shared by api and web (a copy lives under
* apps/web/src/lib so the client never imports server code).
*/
/** Splits an MXID into localpart and server name; null when not an MXID. */
function parseMxid(mxid: string): { localpart: string; server: string } | null {
const match = /^@([^:@\s]+):([^@\s]+)$/.exec(mxid);
return match === null ? null : { localpart: match[1]!, server: match[2]! };
}
/** The Matrix localpart prefix the Hub mints Hub usernames with. */
export function hubPrefix(env: Readonly<Record<string, string | undefined>> = process.env): string {
const raw = (env.MATRIX_SSO_LOCALPART_PREFIX ?? "").trim();
if (raw === "none") return "";
return raw === "" ? "hub_" : raw;
}
/**
* `@hub_glassbeadian:chat.occupymedia.cc` → `@glassbeadian`.
*
* Rules, in order:
* 1. Not an MXID (no @, or malformed) → unchanged. Callers pass provider
* handles and display names through the same field; a Twitch login or a
* plain name is not ours to rewrite.
* 2. Our prefix + our server name → strip BOTH. `@hub_ada:chat.occupymedia.cc`
* becomes `@ada` — the Hub username form the owner asked for.
* 3. Foreign MXID (server name differs) → localpart only, server kept in the
* caller's title attribute when it wants it; the home-server suffix is
* noise for a member reading a chat.
*/
export function displayMxid(mxid: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
const parsed = parseMxid(mxid);
if (parsed === null) return mxid;
const prefix = hubPrefix(env);
const config = loadServerName(env);
if (config !== null && parsed.server === config) {
return `@${prefix === "" ? parsed.localpart : stripPrefix(parsed.localpart, prefix)}`;
}
return `@${stripPrefix(parsed.localpart, prefix)}`;
}
/** `hub_glassbeadian` → `glassbeadian`; a localpart without the prefix is kept. */
function stripPrefix(localpart: string, prefix: string): string {
return prefix !== "" && localpart.startsWith(prefix) ? localpart.slice(prefix.length) : localpart;
}
/**
* The Hub username behind a Hub-minted MXID (no leading @), or null when the
* MXID is not one of ours. Used where the caller wants the bare username.
*/
export function hubUsernameFor(mxid: string, env: Readonly<Record<string, string | undefined>> = process.env): string | null {
const parsed = parseMxid(mxid);
const server = loadServerName(env);
if (parsed === null || server === null || parsed.server !== server) return null;
const prefix = hubPrefix(env);
if (prefix !== "" && !parsed.localpart.startsWith(prefix)) return null;
return stripPrefix(parsed.localpart, prefix);
}
function loadServerName(env: Readonly<Record<string, string | undefined>>): string | null {
const homeserver = (env.MATRIX_SSO_HOMESERVER_URL ?? env.ARCHIVE_PROVISIONER_HOMESERVER_URL ?? "").trim();
if (homeserver !== "") {
try {
return new URL(homeserver).hostname;
} catch {
/* fall through to the explicit name below */
}
}
const named = (env.MATRIX_SSO_SERVER_NAME ?? "").trim();
return named === "" ? null : named;
}

View file

@ -0,0 +1,209 @@
/**
* Workspace (collective) branding API:
*
* GET /workspace/branding any active member — the shell boot read
* PUT /workspace/branding workspace.members.manage — set/clear fields
* POST /workspace/branding/logo workspace.members.manage — multipart upload, returns a key
* POST /workspace/branding/background workspace.members.manage — multipart upload, returns a key
*
* Public seam (no session — the same "narrow, pre-auth metadata" shape as
* /collectives-public/for-invite):
*
* GET /public/branding?workspaceId=<uuid> name / logo / tagline / accent only
* GET /public/branding/asset?key=<storage key> scoped, decrypted read of a branding image
*
* The asset read mirrors the video-thumbnail pattern: the API proxies the
* decrypted bytes through its own origin (encrypted S3 objects are never
* redirected to), sets a bounded cache and nosniff, and refuses keys that are
* not content-addressed sha256/… objects of image type.
*/
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
Inject,
Post,
Put,
Query,
Req,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import type { Request, Response } from "express";
import { isUuid, optionalString, requireObject, requireSession, requireString } from "../http/validation.js";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import {
BrandingService,
brandingAssetPath,
type BrandingPatch,
type BrandingRecord,
type PublicBranding,
} from "./branding.service.js";
interface UploadFile {
buffer: Buffer;
mimetype: string;
}
const FILE_LIMITS = {
// The branding service re-checks the cap after sniffing (magic bytes decide
// the type); this just bounds the buffered multipart request.
fileSize: 4 * 1024 * 1024,
files: 1,
fields: 2,
fieldSize: 1024,
parts: 3,
};
/** GET /workspace/branding — the shape the web shell boots from. */
export interface WorkspaceBrandingResponse {
workspace: { id: string; name: string };
logoAssetKey: string | null;
/** Scoped, same-origin URL the shell can put straight into an <img src>. */
logoUrl: string | null;
tagline: string | null;
backgroundKey: string | null;
backgroundUrl: string | null;
accent: string | null;
}
export interface BrandingUploadResult {
key: string;
contentType: string;
sizeBytes: number;
/** The ready-to-send body for PUT /workspace/branding. */
patch: Partial<BrandingPatch>;
assetUrl: string;
}
@Controller("workspace")
@UseGuards(RbacGuard)
export class BrandingController {
constructor(@Inject(BrandingService) private readonly branding: BrandingService) {}
/** The shell boot read — every active member, no permission beyond the session. */
@Get("branding")
@RequirePermission("workspace.view")
async get(@Req() request: Request & AuthenticatedRequest): Promise<WorkspaceBrandingResponse> {
const session = requireSession(request);
const record = await this.branding.getBranding(session.workspaceId);
return {
workspace: { id: session.workspaceId, name: record.name },
logoAssetKey: record.logoAssetKey,
logoUrl: record.logoUrl,
tagline: record.tagline,
backgroundKey: record.backgroundKey,
backgroundUrl: record.backgroundUrl,
accent: record.accent,
};
}
/** Partial set/clear. `null` clears a field back to the Hub default. */
@Put("branding")
@RequirePermission("workspace.members.manage")
async put(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<BrandingRecord> {
const session = requireSession(request);
const dto = requireObject(body, "body");
const patch: BrandingPatch = {};
for (const key of ["logoAssetKey", "tagline", "backgroundKey", "accent"] as const) {
if (!(key in dto)) continue;
const value = dto[key];
if (value === null) {
(patch as Record<string, unknown>)[key] = null;
} else {
(patch as Record<string, unknown>)[key] = optionalString(value, key);
}
}
return this.branding.updateBranding(session.workspaceId, patch, { userId: session.userId, role: session.role });
}
@Post("branding/logo")
@RequirePermission("workspace.members.manage")
@UseInterceptors(FileInterceptor("file", { limits: FILE_LIMITS }))
async logo(@Req() request: Request & AuthenticatedRequest, @UploadedFile() file?: UploadFile): Promise<BrandingUploadResult> {
return this.upload(request, file, "logo");
}
@Post("branding/background")
@RequirePermission("workspace.members.manage")
@UseInterceptors(FileInterceptor("file", { limits: FILE_LIMITS }))
async background(@Req() request: Request & AuthenticatedRequest, @UploadedFile() file?: UploadFile): Promise<BrandingUploadResult> {
return this.upload(request, file, "background");
}
private async upload(
request: Request & AuthenticatedRequest,
file: UploadFile | undefined,
slot: "logo" | "background",
): Promise<BrandingUploadResult> {
requireSession(request);
if (file === undefined) throw new BadRequestException(`an image file is required (field: file) for the ${slot}`);
const stored = await this.branding.uploadImage(file.buffer, file.mimetype ?? "");
return {
key: stored.key,
contentType: stored.contentType,
sizeBytes: stored.sizeBytes,
patch: { [slot === "logo" ? "logoAssetKey" : "backgroundKey"]: stored.key },
assetUrl: brandingAssetPath(stored.key),
};
}
}
/**
* Public branding for pre-auth pages (join screens, screenshots, embeds). Only
* the LOOK is exposed — name, slug, logo URL, tagline, accent — no member
* counts, no roles, no room ids, and the lookup needs an exact workspace id or
* slug, so it cannot be used to enumerate tenants.
*/
@Controller("public/branding")
export class PublicBrandingController {
constructor(@Inject(BrandingService) private readonly branding: BrandingService) {}
@Get()
@HttpCode(200)
async publicBranding(
@Query("workspaceId") workspaceId: string | undefined,
@Query("slug") slug: string | undefined,
@Res({ passthrough: true }) response: Response,
): Promise<PublicBranding> {
const identifier = (workspaceId ?? slug ?? "").trim();
if (identifier === "") throw new BadRequestException("workspaceId or slug is required");
if (workspaceId !== undefined && workspaceId !== "" && !isUuid(workspaceId)) {
throw new BadRequestException("workspaceId must be a UUID");
}
response.setHeader("Cache-Control", "public, max-age=60");
return this.branding.getPublicBranding(requireString(identifier, "workspaceId"));
}
/**
* Scoped image read: the same-origin, no-redirect proxy the video thumbnail
* route uses. Content-addressed key ⇒ immutable bytes ⇒ a day-long cache,
* nosniff, and only ever an image content type.
*/
@Get("asset")
@HttpCode(200)
async asset(@Query("key") key: string | undefined, @Req() request: Request, @Res() response: Response): Promise<void> {
if (key === undefined || key === "") throw new BadRequestException("key is required");
const origin = request.headers.origin;
const ownOrigin = process.env.PUBLIC_ORIGIN ?? `${request.protocol}://${request.get("host")}`;
if (origin !== undefined && origin !== ownOrigin) throw new ForbiddenException("cross-origin branding asset denied");
const asset = await this.branding.readAsset(key);
const etag = `"${key}"`;
response.setHeader("ETag", etag);
response.setHeader("Cache-Control", "public, max-age=86400, immutable");
response.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
response.setHeader("X-Content-Type-Options", "nosniff");
const match = request.headers["if-none-match"];
if (match === etag || match === "*" || (typeof match === "string" && match.split(/,\s*/).includes(etag))) {
response.status(304).end();
return;
}
response.type(asset.contentType).send(asset.bytes);
}
}

View file

@ -0,0 +1,339 @@
/**
* Per-workspace (per-collective) branding: logo, tagline, background, accent.
*
* Storage of record is `workspaces.branding` (migration 036) — one nullable
* jsonb. The four known keys are exactly the fields the web shell renders;
* NULL/absent means "the Hub default".
*
* This module is the ONE place that interprets that column:
* * decode() never throws — an unknown/malformed row degrades to defaults,
* because branding failure must never break the shell at boot;
* * validatePatch() is strict the other way: an admin write with a wrong
* shape is a 400, never a silent drop;
* * asset keys are verified against the storage service before they are
* accepted, so branding cannot point at an object nobody can read.
*
* Accent is restricted to a 3/6-digit hex literal (#RRGGBB / #RGB). It is
* emitted as a CSS color by the web shell; hex-only keeps injection,
* `url()` tricks and CSS-wide keywords (`inherit`, `unset`) out of the value.
*/
import { BadRequestException, Inject, Injectable, Optional, ServiceUnavailableException } from "@nestjs/common";
import type { PoolClient } from "pg";
import { withSystem, withTenant } from "../db/client.js";
import { appendTenantAudit } from "../audit/audit.service.js";
import { S3Storage, s3ConfigFromEnv, type StorageService } from "../storage/storage.js";
export const BRANDING_DEPENDENCIES = Symbol("BRANDING_DEPENDENCIES");
/** Bytes that may be uploaded as a logo or background. */
export const BRANDING_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
export type BrandingImageType = (typeof BRANDING_IMAGE_TYPES)[number];
/** Hard cap: the shell renders these small; a big upload buys nothing. */
export const MAX_BRANDING_IMAGE_BYTES = 4 * 1024 * 1024;
/** The four branding keys (mirrors migration 036's documented set). */
export const BRANDING_KEYS = ["logoAssetKey", "tagline", "backgroundKey", "accent"] as const;
export type BrandingKey = (typeof BRANDING_KEYS)[number];
export interface BrandingPatch {
logoAssetKey?: string | null;
tagline?: string | null;
backgroundKey?: string | null;
accent?: string | null;
}
/** What the shell renders. Every field NULL = the Hub default. */
export interface BrandingRecord {
/** Storage key of the collective's mark (sha256/<hex>.<ext>). */
logoAssetKey: string | null;
/** One-line subtitle under the collective name. */
tagline: string | null;
/** Storage key of the shell background image. */
backgroundKey: string | null;
/** CSS hex color for the accent, e.g. "#2E7D32". */
accent: string | null;
}
/** Minimal, PUBLIC shape for pre-auth pages: name + look, nothing else. */
export interface PublicBranding {
workspaceId: string;
name: string;
slug: string;
tagline: string | null;
logoUrl: string | null;
accent: string | null;
}
export interface BrandingActor {
userId: string;
role: string;
}
export interface BrandingDependencies {
storage: StorageService;
now: () => Date;
/** Signs a read URL for an asset key (same pattern as video thumbnails). */
signAssetUrl: (key: string) => string | null;
}
/** The zero-branding record — every field the default. */
export const DEFAULT_BRANDING: BrandingRecord = { logoAssetKey: null, tagline: null, backgroundKey: null, accent: null };
const STORAGE_KEY = /^sha256\/[0-9a-f]{64}\.[a-z0-9]+$/;
const ACCENT_HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/;
const MAX_TAGLINE = 160;
export function isHexAccent(value: string): boolean {
return ACCENT_HEX.test(value);
}
/** URL path of the scoped read route for a branding asset. */
export function brandingAssetPath(key: string): string {
return `/public/branding/asset?key=${encodeURIComponent(key)}`;
}
/**
* Decode a raw jsonb value. Tolerant by design: wrong shapes, unknown keys and
* oversized strings all fall back to the default for that field — a corrupt
* row must not take the shell down.
*/
export function decodeBranding(value: unknown): BrandingRecord {
if (typeof value !== "object" || value === null || Array.isArray(value)) return { ...DEFAULT_BRANDING };
const raw = value as Record<string, unknown>;
return {
logoAssetKey: optionalKey(raw.logoAssetKey),
tagline: optionalTagline(raw.tagline),
backgroundKey: optionalKey(raw.backgroundKey),
accent: optionalAccent(raw.accent),
};
}
function optionalKey(value: unknown): string | null {
return typeof value === "string" && STORAGE_KEY.test(value) ? value : null;
}
function optionalTagline(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed === "" ? null : trimmed.slice(0, MAX_TAGLINE);
}
function optionalAccent(value: unknown): string | null {
return typeof value === "string" && ACCENT_HEX.test(value) ? value.toLowerCase() : null;
}
@Injectable()
export class BrandingService {
private readonly deps: BrandingDependencies;
private readonly storage?: StorageService;
constructor(@Optional() @Inject(BRANDING_DEPENDENCIES) dependencies?: Partial<BrandingDependencies>) {
this.deps = {
storage: dependencies?.storage ?? new S3Storage(s3ConfigFromEnv()),
now: dependencies?.now ?? (() => new Date()),
signAssetUrl: dependencies?.signAssetUrl ?? ((key) => brandingAssetPath(key)),
};
this.storage = this.deps.storage;
}
/** Full branding for any ACTIVE member of the workspace (shell boot read). */
async getBranding(workspaceId: string): Promise<BrandingRecord & { name: string; logoUrl: string | null; backgroundUrl: string | null }> {
const row = await this.readRow(workspaceId);
const branding = decodeBranding(row?.branding);
return {
...branding,
name: row?.name ?? "",
logoUrl: branding.logoAssetKey === null ? null : this.deps.signAssetUrl(branding.logoAssetKey),
backgroundUrl: branding.backgroundKey === null ? null : this.deps.signAssetUrl(branding.backgroundKey),
};
}
/**
* PUBLIC branding (name/logo/tagline/accent) for pre-auth pages keyed by
* workspace id or slug. No membership, no role: this is what a join page or
* a screenshot shows BEFORE a session exists. The lookup is by exact
* identifier, so it cannot be used to enumerate workspaces.
*/
async getPublicBranding(identifier: string): Promise<PublicBranding> {
const row = await this.readRow(identifier);
if (row === undefined) throw new BadRequestException("unknown workspace");
const branding = decodeBranding(row.branding);
return {
workspaceId: row.id,
name: row.name,
slug: row.slug,
tagline: branding.tagline,
logoUrl: branding.logoAssetKey === null ? null : this.deps.signAssetUrl(branding.logoAssetKey),
accent: branding.accent,
};
}
/**
* Validate + store an image and return its storage key. Reuses the storage
* service exactly like video uploads: content-addressed, envelope-encrypted,
* deduplicated. The declared content type is never trusted — the magic bytes
* decide (the group-files / submissions convention).
*/
async uploadImage(bytes: Buffer, declaredType: string): Promise<{ key: string; contentType: string; sizeBytes: number }> {
const sniffed = sniffImageType(bytes);
if (sniffed === null) {
throw new BadRequestException(`unsupported image type (allowed: ${BRANDING_IMAGE_TYPES.join(", ")})`);
}
if (bytes.length === 0 || bytes.length > MAX_BRANDING_IMAGE_BYTES) {
throw new BadRequestException(`image must be 1 byte to ${MAX_BRANDING_IMAGE_BYTES} bytes`);
}
if (declaredType !== "" && declaredType !== sniffed) {
throw new BadRequestException("file content does not match the declared image type");
}
const storage = this.requireStorage();
const stored = await storage.putContent(bytes, sniffed).catch(() => {
throw new ServiceUnavailableException("branding storage unavailable: could not store the image in S3/MinIO");
});
return { key: stored.key, contentType: stored.contentType, sizeBytes: stored.sizeBytes };
}
/**
* Partial update of the branding JSON. `null` clears a field back to the
* default; an empty patch is a 400. Writes land in ONE tenant transaction
* with a tenant audit event (workspace.branding.updated) carrying the
* before→after diff, so a visual identity change is provable.
*/
async updateBranding(workspaceId: string, patch: BrandingPatch, actor: BrandingActor): Promise<BrandingRecord> {
const validated = validateBrandingPatch(patch);
const before = decodeBranding((await this.readRow(workspaceId))?.branding);
const after: BrandingRecord = { ...before };
for (const key of BRANDING_KEYS) {
const value = validated[key];
if (value !== undefined) after[key] = value;
}
// Asset keys must point at a readable object: a logo nobody can fetch
// would blank the shell mark for everyone.
for (const key of [after.logoAssetKey, after.backgroundKey]) {
if (key === null) continue;
await this.requireReadable(key);
}
const json = brandingJson(after);
const diff: Record<string, unknown> = {};
for (const key of BRANDING_KEYS) {
if (validated[key] !== undefined) diff[key] = { from: before[key], to: after[key] };
}
await withTenant(workspaceId, (client) =>
client.query("UPDATE workspaces SET branding = $2 WHERE id = $1", [workspaceId, json]),
);
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "workspace.branding.updated",
targetType: "workspace",
targetId: workspaceId,
diff,
});
return after;
}
/** Fetch the bytes of a branding asset for the scoped read route. */
async readAsset(key: string): Promise<{ bytes: Buffer; contentType: string }> {
if (!STORAGE_KEY.test(key)) throw new BadRequestException("invalid branding asset key");
const storage = this.requireStorage();
const bytes = await storage.getObject(key).catch(() => {
throw new ServiceUnavailableException("branding asset unavailable");
});
if (bytes.length > MAX_BRANDING_IMAGE_BYTES) throw new BadRequestException("branding asset too large");
return { bytes, contentType: contentTypeForKey(key) };
}
private async readRow(workspaceId: string): Promise<{ id: string; name: string; slug: string; branding: unknown } | undefined> {
// `workspaces` is not tenant-scoped (no RLS, 001) — read via the system
// pool; the caller's authority was established by the RbacGuard.
const result = await withSystem((client) =>
client.query<{ id: string; name: string; slug: string; branding: unknown }>(
"SELECT id, name, slug, branding FROM workspaces WHERE id::text = $1 OR slug = $1 LIMIT 1",
[workspaceId],
),
);
return result.rows[0];
}
private async requireReadable(key: string): Promise<void> {
const storage = this.requireStorage();
const bytes = await storage.getObject(key).catch(() => {
throw new BadRequestException("asset key must identify an existing readable encrypted object; upload the image first");
});
if (bytes.length === 0 || bytes.length > MAX_BRANDING_IMAGE_BYTES) {
throw new BadRequestException("branding image exceeds the size cap");
}
}
private requireStorage(): StorageService {
if (this.storage === undefined) {
throw new ServiceUnavailableException("branding storage is not configured: set S3_ACCESS_KEY and S3_SECRET_KEY, plus S3_ENDPOINT for MinIO");
}
return this.storage;
}
}
/** Strict write-side validation (the tolerant side is decodeBranding). */
export function validateBrandingPatch(patch: BrandingPatch): BrandingPatch {
if (typeof patch !== "object" || patch === null || Array.isArray(patch)) {
throw new BadRequestException("body must be an object");
}
const out: BrandingPatch = {};
let count = 0;
for (const [key, value] of Object.entries(patch)) {
if (!(BRANDING_KEYS as readonly string[]).includes(key)) {
throw new BadRequestException(`unknown branding field: ${key} (allowed: ${BRANDING_KEYS.join(", ")})`);
}
if (value === null) {
(out as Record<string, unknown>)[key] = null;
count += 1;
continue;
}
if (typeof value !== "string") throw new BadRequestException(`${key} must be a string or null`);
if (key === "accent") {
if (!ACCENT_HEX.test(value)) throw new BadRequestException("accent must be a hex color like #2E7D32 or #2E7");
(out as Record<string, unknown>)[key] = value.toLowerCase();
} else if (key === "tagline") {
const trimmed = value.trim();
if (trimmed === "") throw new BadRequestException("tagline cannot be empty — send null to clear it");
if (trimmed.length > MAX_TAGLINE) throw new BadRequestException(`tagline must be at most ${MAX_TAGLINE} characters`);
(out as Record<string, unknown>)[key] = trimmed;
} else if (!STORAGE_KEY.test(value)) {
throw new BadRequestException(`${key} must be a storage key from POST /workspace/branding/logo (sha256/<hash>.<ext>)`);
}
count += 1;
}
if (count === 0) throw new BadRequestException("body must set at least one branding field");
return out;
}
/** Encode a record back to the stored jsonb (only non-default fields kept). */
export function brandingJson(record: BrandingRecord): Record<string, string> | null {
const json: Record<string, string> = {};
for (const key of BRANDING_KEYS) {
const value = record[key];
if (value !== null) json[key] = value;
}
return Object.keys(json).length === 0 ? null : json;
}
function contentTypeForKey(key: string): string {
const extension = key.split(".")[1] ?? "";
const types: Record<string, string> = { png: "image/png", jpg: "image/jpeg", webp: "image/webp", gif: "image/gif" };
return types[extension] ?? "application/octet-stream";
}
/** Magic-byte sniffing for the image types branding accepts (declared type never trusted). */
function sniffImageType(bytes: Buffer): BrandingImageType | null {
if (bytes.length < 12) return null;
if (
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a
) return "image/png";
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") return "image/webp";
const gif = bytes.subarray(0, 6).toString("latin1");
if (gif === "GIF87a" || gif === "GIF89a") return "image/gif";
return null;
}

View file

@ -0,0 +1,439 @@
import { describe, expect, it, vi } from "vitest";
import { BadRequestException, ServiceUnavailableException } from "@nestjs/common";
import type { PoolClient } from "pg";
import {
BRANDING_KEYS,
brandingAssetPath,
brandingJson,
BrandingService,
decodeBranding,
DEFAULT_BRANDING,
isHexAccent,
validateBrandingPatch,
type BrandingDependencies,
} from "./branding.service.js";
import { BrandingController } from "./branding.controller.js";
import { appendTenantAudit } from "../audit/audit.service.js";
import { withSystem, withTenant } from "../db/client.js";
/**
* Unit level: decode tolerance, write-side strictness, upload sniffing, the
* storage-key guard, the actor/audit contract — with the DB and S3 faked.
* Live-DB CRUD + migration coverage is branding.integration.test.ts.
*/
vi.mock("../db/client.js", () => ({
withSystem: vi.fn(),
withTenant: vi.fn(),
}));
vi.mock("../audit/audit.service.js", () => ({ appendTenantAudit: vi.fn(async () => "audit-id") }));
const PNG_HEADER = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
const JPEG_HEADER = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.alloc(16, 0x00)]);
const FAKE_KEY = "sha256/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.png";
const FAKE_KEY_2 = "sha256/fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210.jpg";
function fakeStorage(objects: Record<string, Buffer> = {}): {
storage: BrandingDependencies["storage"];
puts: Array<{ bytes: Buffer; contentType: string }>;
} {
const puts: Array<{ bytes: Buffer; contentType: string }> = [];
const storage = {
putContent: vi.fn(async (bytes: Buffer, contentType: string) => {
puts.push({ bytes, contentType });
const hash = `hash${puts.length}`.padEnd(64, "0");
return { key: `sha256/${hash}.${contentType.split("/")[1] === "jpeg" ? "jpg" : contentType.split("/")[1]}`, hash, sizeBytes: bytes.length, contentType, deduplicated: false };
}),
getObject: vi.fn(async (key: string) => {
const body = objects[key];
if (body === undefined) throw new Error("missing");
return body;
}),
hasContent: vi.fn(async () => false),
presign: vi.fn(async () => ""),
};
return { storage, puts };
}
function serviceWith(deps: Partial<BrandingDependencies> = {}): BrandingService {
const { storage } = fakeStorage();
return new BrandingService({
storage: deps.storage ?? storage,
now: () => new Date("2026-10-14T12:00:00Z"),
signAssetUrl: deps.signAssetUrl ?? ((key) => brandingAssetPath(key)),
});
}
/** Point the mocked withSystem/withTenant at a canned workspaces row. */
function stubWorkspace(row: { id: string; name: string; slug: string; branding: unknown } | undefined): void {
const query = vi.fn(async () => ({ rows: row === undefined ? [] : [row], rowCount: row === undefined ? 0 : 1 }));
const client = { query } as unknown as PoolClient;
vi.mocked(withSystem).mockImplementation(async (fn) => fn(client));
vi.mocked(withTenant).mockImplementation(async (fn) => fn(client));
}
const WS = "00000000-0000-4000-8000-00000000000a";
const ACTOR = { userId: "00000000-0000-4000-8000-00000000000b", role: "Admin" };
describe("branding — decode tolerance (the shell must never break)", () => {
it("null / absent branding decodes to all-defaults", () => {
expect(decodeBranding(null)).toEqual(DEFAULT_BRANDING);
expect(decodeBranding(undefined)).toEqual(DEFAULT_BRANDING);
});
it("the four known keys round-trip", () => {
const branding = { logoAssetKey: FAKE_KEY, tagline: "Antifascista siempre", backgroundKey: FAKE_KEY_2, accent: "#2E7D32" };
expect(decodeBranding(branding)).toEqual(branding);
});
it("malformed values degrade field-by-field, never throw", () => {
const bad = {
logoAssetKey: "../../etc/passwd",
tagline: 42,
backgroundKey: { nested: true },
accent: "javascript:alert(1)",
unknown: "ignored",
};
expect(decodeBranding(bad)).toEqual(DEFAULT_BRANDING);
expect(decodeBranding("nope")).toEqual(DEFAULT_BRANDING);
expect(decodeBranding([1, 2, 3])).toEqual(DEFAULT_BRANDING);
});
it("accent is normalized to lowercase; tagline is trimmed and capped", () => {
expect(decodeBranding({ accent: "#2E7D32" }).accent).toBe("#2e7d32");
expect(decodeBranding({ accent: "#abc" }).accent).toBe("#abc");
expect(decodeBranding({ tagline: " newsroom " }).tagline).toBe("newsroom");
expect(decodeBranding({ tagline: `x`.repeat(400) }).tagline).toHaveLength(160);
expect(decodeBranding({ tagline: " " }).tagline).toBeNull();
});
it("storage keys must be content-addressed sha256 objects", () => {
expect(decodeBranding({ logoAssetKey: "sha256/zz.png" }).logoAssetKey).toBeNull();
expect(decodeBranding({ logoAssetKey: "logo.png" }).logoAssetKey).toBeNull();
expect(decodeBranding({ logoAssetKey: "http://evil/x.png" }).logoAssetKey).toBeNull();
});
});
describe("branding — write-side validation (strict)", () => {
it("rejects non-object bodies and empty patches", () => {
for (const bad of [undefined, null, "x", 42, [], true]) {
expect(() => validateBrandingPatch(bad as never)).toThrow(BadRequestException);
}
expect(() => validateBrandingPatch({})).toThrow(/at least one/);
});
it("rejects unknown keys instead of dropping them", () => {
expect(() => validateBrandingPatch({ favicon: FAKE_KEY })).toThrow(/unknown branding field/);
});
it("null clears a field; a string sets it", () => {
expect(validateBrandingPatch({ tagline: null })).toEqual({ tagline: null });
expect(validateBrandingPatch({ tagline: " Radio libre " })).toEqual({ tagline: "Radio libre" });
});
it("accent must be a 3/6-digit hex literal (no CSS injection surface)", () => {
expect(isHexAccent("#2E7D32")).toBe(true);
expect(isHexAccent("#abc")).toBe(true);
for (const bad of ["#12345", "#1234567", "red", "inherit", "url(x)", "#GGGGGG", "##123456", ""]) {
expect(() => validateBrandingPatch({ accent: bad })).toThrow(BadRequestException);
expect(isHexAccent(bad)).toBe(false);
}
});
it("asset keys must come from the upload route", () => {
expect(() => validateBrandingPatch({ logoAssetKey: "logo.png" })).toThrow(/must be a storage key/);
expect(validateBrandingPatch({ logoAssetKey: FAKE_KEY })).toEqual({ logoAssetKey: FAKE_KEY });
});
it("empty-string tagline is rejected (null is the clear)", () => {
expect(() => validateBrandingPatch({ tagline: " " })).toThrow(/cannot be empty/);
expect(() => validateBrandingPatch({ tagline: `y`.repeat(200) })).toThrow(/at most 160/);
});
});
describe("branding — jsonb encoding", () => {
it("an all-default record encodes to NULL (no branding row weight)", () => {
expect(brandingJson(DEFAULT_BRANDING)).toBeNull();
});
it("only non-default fields are kept", () => {
expect(brandingJson({ ...DEFAULT_BRANDING, tagline: "t", accent: "#111111" })).toEqual({ tagline: "t", accent: "#111111" });
});
});
describe("branding service — reads", () => {
it("getBranding returns defaults + signed URLs for a branded workspace", async () => {
stubWorkspace({
id: WS,
name: "Acampada 26S",
slug: "acampada",
branding: { logoAssetKey: FAKE_KEY, tagline: "Toma los medios", backgroundKey: FAKE_KEY_2, accent: "#2E7D32" },
});
const branding = await serviceWith().getBranding(WS);
expect(branding).toMatchObject({
name: "Acampada 26S",
logoAssetKey: FAKE_KEY,
tagline: "Toma los medios",
backgroundKey: FAKE_KEY_2,
accent: "#2e7d32",
});
expect(branding.logoUrl).toBe(brandingAssetPath(FAKE_KEY));
expect(branding.backgroundUrl).toBe(brandingAssetPath(FAKE_KEY_2));
});
it("an unbranded workspace serves name + nulls, not an error", async () => {
stubWorkspace({ id: WS, name: "Solo", slug: "solo", branding: null });
const branding = await serviceWith().getBranding(WS);
expect(branding.logoUrl).toBeNull();
expect(branding.logoAssetKey).toBeNull();
expect(branding.accent).toBeNull();
expect(branding.name).toBe("Solo");
});
it("a corrupt stored row still renders (degrades to defaults)", async () => {
stubWorkspace({ id: WS, name: "X", slug: "x", branding: { logoAssetKey: "not-a-key", accent: 7 } });
const branding = await serviceWith().getBranding(WS);
expect(branding.logoAssetKey).toBeNull();
expect(branding.accent).toBeNull();
});
it("public branding exposes ONLY the look: name, slug, tagline, logo, accent", async () => {
stubWorkspace({
id: WS,
name: "Acampada 26S",
slug: "acampada",
branding: { logoAssetKey: FAKE_KEY, backgroundKey: FAKE_KEY_2, tagline: "Toma los medios", accent: "#2E7D32" },
});
const publicBranding = await serviceWith().getPublicBranding(WS);
expect(Object.keys(publicBranding).sort()).toEqual(["accent", "logoUrl", "name", "slug", "tagline", "workspaceId"]);
expect(publicBranding.name).toBe("Acampada 26S");
expect(publicBranding.logoUrl).toBe(brandingAssetPath(FAKE_KEY));
// The background never leaves the public seam: it is shell-only.
expect(publicBranding).not.toHaveProperty("backgroundKey");
});
it("public branding resolves by slug too, and rejects unknown workspaces", async () => {
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: null });
expect((await serviceWith().getPublicBranding("acampada")).workspaceId).toBe(WS);
stubWorkspace(undefined);
await expect(serviceWith().getPublicBranding("nope")).rejects.toMatchObject({ status: 400 });
});
});
describe("branding service — uploads (storage reuse, magic-byte sniffing)", () => {
it("sniffs the type from magic bytes and stores through putContent", async () => {
const { storage, puts } = fakeStorage();
const branding = serviceWith({ storage });
const result = await branding.uploadImage(PNG_HEADER, "image/png");
expect(puts).toHaveLength(1);
expect(puts[0]).toMatchObject({ contentType: "image/png" });
expect(result.sizeBytes).toBe(PNG_HEADER.length);
expect(result.key).toMatch(/^sha256\/[0-9a-f]{64}\.png$/);
});
it("a lying declared type is rejected (declared mime is never trusted)", async () => {
const branding = serviceWith();
await expect(branding.uploadImage(PNG_HEADER, "image/jpeg")).rejects.toMatchObject({ status: 400 });
});
it("non-image bytes are rejected", async () => {
const branding = serviceWith();
await expect(branding.uploadImage(Buffer.from("<script>alert(1)</script>script-pad"), "image/png")).rejects.toMatchObject({
status: 400,
message: expect.stringMatching(/unsupported image type/),
});
await expect(branding.uploadImage(Buffer.alloc(4), "image/png")).rejects.toBeInstanceOf(BadRequestException);
});
it("jpeg / webp / gif sniff correctly", async () => {
const { storage } = fakeStorage();
const branding = serviceWith({ storage });
expect((await branding.uploadImage(JPEG_HEADER, "image/jpeg")).contentType).toBe("image/jpeg");
const webp = Buffer.concat([Buffer.from("RIFF", "latin1"), Buffer.alloc(4), Buffer.from("WEBP", "latin1"), Buffer.alloc(8)]);
expect((await branding.uploadImage(webp, "image/webp")).contentType).toBe("image/webp");
const gif = Buffer.concat([Buffer.from("GIF89a", "latin1"), Buffer.alloc(16)]);
expect((await branding.uploadImage(gif, "image/gif")).contentType).toBe("image/gif");
});
it("a storage outage surfaces as 503, never as a crash", async () => {
const broken = {
putContent: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),
getObject: vi.fn(async () => Buffer.alloc(0)),
hasContent: vi.fn(async () => false),
presign: vi.fn(async () => ""),
};
const branding = serviceWith({ storage: broken });
await expect(branding.uploadImage(PNG_HEADER, "image/png")).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
describe("branding service — update (keys must be readable, audit must land)", () => {
it("applies a partial patch and writes only the touched keys to the audit diff", async () => {
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: { tagline: "old", accent: "#111111" } });
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
const branding = serviceWith({ storage });
const after = await branding.updateBranding(WS, { logoAssetKey: FAKE_KEY, tagline: "Toma los medios" }, ACTOR);
expect(after).toMatchObject({ logoAssetKey: FAKE_KEY, tagline: "Toma los medios", accent: "#111111", backgroundKey: null });
expect(appendTenantAudit).toHaveBeenCalledWith(WS, expect.objectContaining({
action: "workspace.branding.updated",
targetType: "workspace",
targetId: WS,
actorUserId: ACTOR.userId,
diff: {
logoAssetKey: { from: null, to: FAKE_KEY },
tagline: { from: "old", to: "Toma los medios" },
},
}));
});
it("null clears fields back to the defaults", async () => {
stubWorkspace({
id: WS, name: "A", slug: "a",
branding: { logoAssetKey: FAKE_KEY, tagline: "t", backgroundKey: FAKE_KEY_2, accent: "#111111" },
});
const after = await serviceWith().updateBranding(WS, { tagline: null, accent: null }, ACTOR);
expect(after).toMatchObject({ tagline: null, accent: null, logoAssetKey: FAKE_KEY, backgroundKey: FAKE_KEY_2 });
});
it("an asset key that storage cannot read is rejected before the write", async () => {
stubWorkspace({ id: WS, name: "A", slug: "a", branding: null });
const branding = serviceWith(); // no objects seeded
await expect(branding.updateBranding(WS, { logoAssetKey: FAKE_KEY }, ACTOR)).rejects.toMatchObject({
status: 400,
message: expect.stringMatching(/upload the image first/),
});
expect(withTenant).not.toHaveBeenCalled();
expect(appendTenantAudit).not.toHaveBeenCalled();
});
it("an oversized stored asset is refused", async () => {
stubWorkspace({ id: WS, name: "A", slug: "a", branding: null });
const huge = Buffer.alloc(4 * 1024 * 1024 + 1, 0x89);
const { storage } = fakeStorage({ [FAKE_KEY]: huge });
await expect(serviceWith({ storage }).updateBranding(WS, { logoAssetKey: FAKE_KEY }, ACTOR)).rejects.toBeInstanceOf(BadRequestException);
});
});
describe("branding service — asset reads (the scoped GET)", () => {
it("returns decrypted bytes with the right content type", async () => {
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
const asset = await serviceWith({ storage }).readAsset(FAKE_KEY);
expect(asset.bytes.equals(PNG_HEADER)).toBe(true);
expect(asset.contentType).toBe("image/png");
});
it("rejects keys that are not content-addressed objects", async () => {
await expect(serviceWith().readAsset("../etc/passwd")).rejects.toMatchObject({ status: 400 });
await expect(serviceWith().readAsset("http://evil/x.png")).rejects.toBeInstanceOf(BadRequestException);
});
it("an unreadable object is a 503, not a crash", async () => {
await expect(serviceWith().readAsset(FAKE_KEY)).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
describe("branding controller — shape and RBAC metadata", () => {
const request = (role: string) => ({ session: { userId: "u1", workspaceId: WS, role, authzEpoch: 1, issuedAt: 0, expiresAt: 9e12 } }) as never;
it("GET returns the workspace name with the branding fields", async () => {
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: { tagline: "Toma los medios" } });
const branding = serviceWith();
const controller = new BrandingController(branding);
const response = await controller.get(request("Publisher"));
expect(response).toMatchObject({
workspace: { id: WS, name: "Acampada 26S" },
tagline: "Toma los medios",
logoUrl: null,
backgroundUrl: null,
});
});
it("PUT maps body keys to the patch, treating null as a clear", async () => {
stubWorkspace({ id: WS, name: "A", slug: "a", branding: { tagline: "old" } });
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
const controller = new BrandingController(serviceWith({ storage }));
const after = await controller.put(request("Admin"), { logoAssetKey: FAKE_KEY, tagline: null });
expect(after).toMatchObject({ logoAssetKey: FAKE_KEY, tagline: null });
});
it("PUT ignores absent keys (partial semantics) and unknown keys still 400", async () => {
stubWorkspace({ id: WS, name: "A", slug: "a", branding: null });
const controller = new BrandingController(serviceWith());
const after = await controller.put(request("Admin"), { accent: "#2E7D32" });
expect(after.accent).toBe("#2e7d32");
await expect(controller.put(request("Admin"), { favicon: "x" })).rejects.toBeInstanceOf(BadRequestException);
});
it("uploads require the multipart file field", async () => {
const controller = new BrandingController(serviceWith());
await expect(controller.logo(request("Admin"), undefined)).rejects.toMatchObject({ status: 400 });
await expect(controller.background(request("Admin"), undefined)).rejects.toMatchObject({ status: 400 });
});
it("an upload returns the key plus the ready-to-save patch and asset URL", async () => {
const { storage } = fakeStorage();
const controller = new BrandingController(serviceWith({ storage }));
const result = await controller.logo(request("Admin"), { buffer: PNG_HEADER, mimetype: "image/png" });
expect(result.patch).toEqual({ logoAssetKey: result.key });
expect(result.assetUrl).toBe(brandingAssetPath(result.key));
expect(result.key).toMatch(/^sha256\/[0-9a-f]{64}\.png$/);
});
it("the public controller requires an identifier", async () => {
const controller = new PublicBrandingController(serviceWith());
const response = { setHeader: vi.fn() } as never;
await expect(controller.publicBranding(undefined, undefined, response)).rejects.toBeInstanceOf(BadRequestException);
await expect(controller.publicBranding("", "", response)).rejects.toBeInstanceOf(BadRequestException);
});
it("the public controller accepts a uuid workspaceId or a slug", async () => {
stubWorkspace({ id: WS, name: "Acampada 26S", slug: "acampada", branding: { tagline: "t" } });
const controller = new PublicBrandingController(serviceWith());
const response = { setHeader: vi.fn() } as never;
await expect(controller.publicBranding("not-a-uuid", undefined, response)).rejects.toMatchObject({ status: 400 });
expect((await controller.publicBranding(WS, undefined, response)).name).toBe("Acampada 26S");
expect((await controller.publicBranding(undefined, "acampada", response)).name).toBe("Acampada 26S");
expect(response.setHeader).toHaveBeenCalledWith("Cache-Control", "public, max-age=60");
});
it("the asset route sets the immutable cache + nosniff headers and honors ETag", async () => {
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
const controller = new PublicBrandingController(serviceWith({ storage }));
const headers: Record<string, string> = {};
const response = {
setHeader: vi.fn((k: string, v: string) => { headers[k.toLowerCase()] = String(v); }),
status: vi.fn(() => response),
end: vi.fn(),
type: vi.fn(() => response),
send: vi.fn(),
} as never;
await controller.asset(FAKE_KEY, { headers: {} } as never, response);
expect(headers["cache-control"]).toContain("immutable");
expect(headers["x-content-type-options"]).toBe("nosniff");
expect(headers.etag).toBe(`"${FAKE_KEY}"`);
// A matching If-None-Match short-circuits to 304 without a body send.
const notModified = { ...response };
await controller.asset(FAKE_KEY, { headers: { "if-none-match": `"${FAKE_KEY}"` } } as never, notModified);
expect(notModified.status).toHaveBeenCalledWith(304);
expect(notModified.send).not.toHaveBeenCalled();
});
it("the asset route refuses cross-origin requests (same-origin only)", async () => {
const { storage } = fakeStorage({ [FAKE_KEY]: PNG_HEADER });
const controller = new PublicBrandingController(serviceWith({ storage }));
const response = { setHeader: vi.fn(), status: vi.fn(), end: vi.fn(), type: vi.fn(), send: vi.fn() } as never;
await expect(
controller.asset(FAKE_KEY, { headers: { origin: "https://evil.example" }, protocol: "https", get: (h: string) => (h === "host" ? "api.local" : "") } as never, response),
).rejects.toMatchObject({ status: 403 });
});
});
describe("branding — key set (mirrors migration 036)", () => {
it("exposes exactly the four documented fields", () => {
expect([...BRANDING_KEYS]).toEqual(["logoAssetKey", "tagline", "backgroundKey", "accent"]);
});
it("the asset path is the scoped same-origin GET with an encoded key", () => {
expect(brandingAssetPath(FAKE_KEY)).toBe(`/public/branding/asset?key=${encodeURIComponent(FAKE_KEY)}`);
});
});

View file

@ -1,16 +1,25 @@
/**
* 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.
* Settings module wiring.
*
* Two concerns share this module because both are "configuration the API
* owns and the shell reads":
* * SettingsService — INSTANCE posture (system table 028, no tenant scope)
* * BrandingService — PER-WORKSPACE look (workspaces.branding, 036)
*
* Branding talks to the tenant pool via withTenant (the audit append lands on
* the workspace chain) and to the storage service directly, exactly like
* video/submissions: no shared provider, the service constructs its own
* S3Storage from env so the module stays import-free of the video module.
*/
import { Module } from "@nestjs/common";
import { SettingsController } from "./settings.controller.js";
import { SettingsService } from "./settings.service.js";
import { BrandingController, PublicBrandingController } from "./branding.controller.js";
import { BrandingService } from "./branding.service.js";
@Module({
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
controllers: [SettingsController, BrandingController, PublicBrandingController],
providers: [SettingsService, BrandingService],
exports: [SettingsService, BrandingService],
})
export class SettingsModule {}