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:
parent
177ca6eb2b
commit
cbdb8c0f81
18 changed files with 2584 additions and 8 deletions
31
apps/api/migrations/036_branding.sql
Normal file
31
apps/api/migrations/036_branding.sql
Normal 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;
|
||||
60
apps/api/migrations/037_chat_display.sql
Normal file
60
apps/api/migrations/037_chat_display.sql
Normal 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;
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
223
apps/api/src/matrix-sso/chat-display.ts
Normal file
223
apps/api/src/matrix-sso/chat-display.ts
Normal 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;
|
||||
}
|
||||
83
apps/api/src/matrix-sso/mxid-display.ts
Normal file
83
apps/api/src/matrix-sso/mxid-display.ts
Normal 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;
|
||||
}
|
||||
209
apps/api/src/settings/branding.controller.ts
Normal file
209
apps/api/src/settings/branding.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
339
apps/api/src/settings/branding.service.ts
Normal file
339
apps/api/src/settings/branding.service.ts
Normal 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;
|
||||
}
|
||||
439
apps/api/src/settings/branding.test.ts
Normal file
439
apps/api/src/settings/branding.test.ts
Normal 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)}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue