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 {}

View file

@ -13,10 +13,13 @@
"soak": "node --import tsx src/compositor/soak.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1146.0",
"pg": "^8.23.1",
"pino": "^9.3.2"
},
"devDependencies": {
"@types/node": "^22.5.0",
"@types/pg": "^8.23.1",
"tsx": "^4.17.0"
}
}

View file

@ -0,0 +1,76 @@
"use client";
import { Badge, Card } from "../../../components/ui/primitives";
import { Loading } from "../../../components/brand/loading";
import { EmptyState } from "../../../components/brand/empty-state";
import { OfflineBadge } from "../../../components/ui/offline-badge";
import { useApiResource } from "../../../lib/use-api";
import { useAuth } from "../../../lib/auth-store";
import { GroupSubmissionsCard } from "../../../components/submissions/group-submissions-card";
import type { SubmissionGroupSummary } from "../../../lib/submissions-types";
/**
* Groups screen — the moderation home for the anonymous submission boxes.
*
* GET /groups/mine lists the caller's groups; each renders a
* GroupSubmissionsCard with its own "Submission box" section (public link,
* rotate + enable/disable) and moderation queue (pending / approved /
* rejected). All four admin endpoints are gated on
* workspace.members.manage, so the cards stay read-only for plain members.
*
* The API is offline (or the session is anonymous, which useApiResource folds
* into sample) → the list is read-only fixtures rather than fake mutations.
*/
const SAMPLE_GROUPS: SubmissionGroupSummary[] = [
{ id: "sample-group-1", name: "Collective media" },
{ id: "sample-group-2", name: "Street footage" },
];
const identityGroups = (groups: SubmissionGroupSummary[]): SubmissionGroupSummary[] => groups;
export default function GroupsPage(): React.ReactElement {
const { can, session } = useAuth();
const groups = useApiResource<SubmissionGroupSummary[], SubmissionGroupSummary[]>("/groups/mine", identityGroups, SAMPLE_GROUPS);
const live = groups.source === "live";
const canManage = live && (can("workspace.members.manage") || session?.role === "Owner" || session?.role === "Admin");
return (
<div className="h-full overflow-y-auto p-4 pb-24 scrollbar-thin lg:p-6">
<div className="mb-5 flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold">Groups</h1>
<OfflineBadge source={groups.source} />
{!live && <Badge tone="warn">read-only sample</Badge>}
</div>
<p className="mb-5 max-w-2xl text-xs leading-relaxed text-text-faint">
Each group can open a public submission box: a link anyone can use to send footage and
photos — no account, no sign-in. Everything lands as <span className="text-text-dim">pending</span> until
the group&apos;s managers approve or reject it with a note.
</p>
{groups.error !== null && (
<Card className="mb-5 p-4">
<p className="text-xs text-danger">{groups.error}</p>
</Card>
)}
{groups.source === "loading" ? (
<div className="flex justify-center py-10"><Loading size={28} label="Loading groups" /></div>
) : groups.data.length === 0 ? (
<EmptyState title="No groups yet" hint="Join or create a collective and its groups appear here." />
) : (
<div className="flex flex-col gap-4">
{groups.data.map((group) => (
<GroupSubmissionsCard key={group.id} group={group} canManage={canManage} live={live} />
))}
</div>
)}
{!live && (
<p className="mt-5 text-2xs leading-relaxed text-text-faint">
The API is unreachable, so this screen shows sample groups and cannot change anything.
</p>
)}
</div>
);
}

View file

@ -0,0 +1,442 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { LogoMark } from "../../../components/brand/logo";
import { Loading } from "../../../components/brand/loading";
import { ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import {
SUBMISSION_CONTACT_MAX,
SUBMISSION_CREDIT_MAX,
SUBMISSION_LICENSE_TEXT,
SUBMISSION_SNIFF_BYTES,
submissionMaxBytes,
type SubmissionBoxInfo,
type SubmissionKind,
type SubmissionReceiveResult,
} from "../../../lib/submissions-types";
/**
* /submit/[token] — the public, accountless intake for a group's submission
* box (backend 599a68c). Everything here works signed-out: metadata comes
* from GET /public/submit/:token, the file goes to POST /public/submit/:token.
*
* The route deliberately lives OUTSIDE the (main) group: that layout mounts
* the app Shell and wraps children in AuthGate, which bounces anonymous
* visitors to /onboarding — the exact opposite of what a handed-out link
* needs (same reasoning as app/join/layout.tsx).
*
* Client-side pre-checks mirror the API's receive path
* (apps/api/src/submissions/submissions.service.ts): magic bytes decide the
* kind — never the name or the browser's declared mime — mp4/webm video caps
* at 200 MiB, jpeg/png photo at 50 MiB, and the licence box is mandatory.
* Failing them here gives a friendly one-line reason instead of a 400.
*/
type Phase = "checking" | "open" | "unreachable" | "gone";
const API_URL = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
/** Byte-level kind sniff, mirroring the API's sniffMedia() (32-byte window). */
async function sniffFile(file: File): Promise<SubmissionKind | null> {
const head = new Uint8Array(await file.slice(0, SUBMISSION_SNIFF_BYTES).arrayBuffer());
if (head.length < SUBMISSION_SNIFF_BYTES) return null;
const latin = (start: number, length: number): string => {
let out = "";
for (let i = start; i < start + length; i += 1) out += String.fromCharCode(head[i] ?? 0);
return out;
};
const hex = (start: number, length: number): string => {
let out = "";
for (let i = start; i < start + length; i += 1) out += (head[i] ?? 0).toString(16).padStart(2, "0");
return out;
};
// mp4 (ftyp at offset 4) and webm/mkv (EBML) are video; jpeg and png are photo.
if (latin(4, 4) === "ftyp") return "video";
if (hex(0, 4) === "1a45dfa3") return "video";
if (hex(0, 3) === "ffd8ff") return "photo";
if (hex(0, 8) === "89504e470d0a1a0a") return "photo";
return null;
}
function formatBytes(bytes: number): string {
const mib = bytes / (1024 * 1024);
return mib >= 10 ? `${Math.round(mib)} MiB` : `${mib.toFixed(1)} MiB`;
}
/** The API's JSON {message} bodies, softened where the field-level text leaks implementation detail. */
function friendlyError(err: unknown): string {
if (err instanceof ApiUnreachableError) return "Can't reach the server — check your connection and try again.";
if (err instanceof ApiError) {
const m = err.message.toLowerCase();
if (err.status === 403 && m.includes("too many")) return "Too many submissions from this network already — try again in a little while.";
if (err.status === 403 && m.includes("full")) return "This box is full right now — the collective has to review what's already in it first.";
if (err.status === 400 && m.includes("already submitted")) return "You already sent this exact file to this box.";
if (err.status === 400 && m.includes("unrecognised")) return "That file type isn't accepted — mp4 or webm video, jpeg or png photo.";
if (err.status === 400 && m.includes("licence") || m.includes("license")) {
return "Tick the licence box so the collective knows they may share your footage.";
}
if (err.status === 503) return "The box's storage isn't ready — try again shortly.";
if (err.status === 404) return "This box isn't open anymore — ask the collective for a fresh link.";
return err.message;
}
return errorMessage(err);
}
export default function SubmitPage({ params }: { params: { token: string } }): React.ReactElement {
const token = params.token;
const [phase, setPhase] = useState<Phase>("checking");
const [box, setBox] = useState<SubmissionBoxInfo | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setPhase("checking");
fetch(`${API_URL}/public/submit/${encodeURIComponent(token)}`, { credentials: "omit" })
.then(async (response) => {
const text = await response.text();
let body: unknown = null;
if (text !== "") {
try { body = JSON.parse(text) as unknown; } catch { body = text; }
}
if (!response.ok) throw new ApiError(response.status, typeof body === "object" && body !== null && "message" in body && typeof (body as { message: unknown }).message === "string" ? (body as { message: string }).message : `HTTP ${response.status}`, body);
return body as SubmissionBoxInfo;
})
.then((info) => {
if (cancelled) return;
setBox(info);
setPhase("open");
})
.catch((err: unknown) => {
if (cancelled) return;
// A missing box renders the dead card; only a network failure offers a retry.
if (err instanceof ApiUnreachableError) setPhase("unreachable");
else setPhase("gone");
});
return () => {
cancelled = true;
};
}, [token, attempt]);
if (phase === "checking") {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] px-6">
<div className="flex flex-col items-center gap-3">
<Loading size={32} label="Opening the box…" />
<span className="text-2xs text-[#a4a09e]">Opening the box…</span>
</div>
</main>
);
}
if (phase === "unreachable") {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
<DeadCard
tone="warn"
title="Can't reach the server"
body="Your link is still fine — the server isn't answering. Try again in a moment."
action={<button type="button" onClick={() => setAttempt((n) => n + 1)} className="mt-6 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white hover:bg-[#e3130e]">Retry</button>}
/>
</main>
);
}
if (phase === "gone" || box === null) {
return (
<main className="flex min-h-dvh items-center justify-center bg-[#060403] p-6">
<DeadCard
tone="danger"
title="This box isn't open"
body="The link is no longer valid — it may have been rotated, or the box is closed. Ask the collective for a fresh link."
/>
</main>
);
}
return (
<main className="min-h-dvh bg-[#060403] px-4 py-10 sm:px-6">
<SubmitForm key={token} token={token} box={box} />
</main>
);
}
function DeadCard({ tone, title, body, action }: { tone: "warn" | "danger"; title: string; body: string; action?: React.ReactNode }): React.ReactElement {
return (
<div className="w-full max-w-md rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 text-center">
<div
className={`mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full text-lg ${
tone === "warn" ? "bg-[#FF1610]/15 text-[#FF1610]" : "bg-[#FF1610]/15 text-[#FF1610]"
}`}
>
{tone === "warn" ? "!" : "✕"}
</div>
<h1 className="text-base font-semibold text-[#f5efed]">{title}</h1>
<p className="mt-2 text-xs leading-relaxed text-[#a4a09e]">{body}</p>
{action}
</div>
);
}
function SubmitForm({ token, box }: { token: string; box: SubmissionBoxInfo }): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const busyRef = useRef(false);
const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState<SubmissionKind | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
const [creditName, setCreditName] = useState("");
const [contact, setContact] = useState("");
const [licence, setLicence] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<SubmissionReceiveResult | null>(null);
const [phase, setPhase] = useState<"idle" | "uploading" | "sent">("idle");
const pick = useCallback(async (picked: File): Promise<void> => {
if (busyRef.current || phase === "uploading") return;
setFileError(null);
const sniffed = await sniffFile(picked).catch(() => null);
if (sniffed === null) {
setFile(null);
setKind(null);
setFileError(`${picked.name}: that file type isn't recognised — mp4 or webm video, jpeg or png photo.`);
return;
}
const cap = submissionMaxBytes(sniffed);
if (picked.size === 0 || picked.size > cap) {
setFile(null);
setKind(null);
setFileError(`${picked.name} is ${formatBytes(picked.size)} — the limit for a ${sniffed} is ${formatBytes(cap)}.`);
return;
}
setFile(picked);
setKind(sniffed);
}, [phase]);
useEffect(() => {
// Paste a file straight into the box (the video library's pattern).
const onPaste = (event: ClipboardEvent): void => {
const pasted = Array.from(event.clipboardData?.files ?? [])[0];
if (pasted !== undefined) void pick(pasted);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [pick]);
const creditTooLong = creditName.length > SUBMISSION_CREDIT_MAX;
const contactTooLong = contact.length > SUBMISSION_CONTACT_MAX;
const ready = file !== null && kind !== null && licence && !creditTooLong && !contactTooLong;
async function submit(event: React.FormEvent): Promise<void> {
event.preventDefault();
if (!ready || file === null || busyRef.current) return;
busyRef.current = true;
setPhase("uploading");
setProgress(0);
setError(null);
try {
const result = await new Promise<SubmissionReceiveResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${API_URL}/public/submit/${encodeURIComponent(token)}`);
xhr.timeout = 900_000; // a 200 MiB video on a slow line needs room.
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) setProgress(Math.floor((event.loaded / event.total) * 100));
};
xhr.upload.onload = () => setProgress(100);
xhr.onload = () => {
let body: unknown = null;
if (xhr.responseText !== "") {
try { body = JSON.parse(xhr.responseText) as unknown; } catch { body = xhr.responseText; }
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as SubmissionReceiveResult);
} else {
const message =
typeof body === "object" && body !== null && "message" in body
? String((body as { message: unknown }).message)
: `HTTP ${xhr.status}`;
reject(new ApiError(xhr.status, message, body));
}
};
xhr.onerror = () => reject(new ApiUnreachableError(new Error("network")));
xhr.ontimeout = () => reject(new Error("The upload timed out — check your connection and try again."));
xhr.onabort = () => reject(new Error("The upload was cancelled."));
const form = new FormData();
form.append("file", file);
form.append("licenseAccepted", "true");
if (creditName.trim() !== "") form.append("creditName", creditName.trim());
if (contact.trim() !== "") form.append("contact", contact.trim());
xhr.send(form);
});
setDone(result);
setPhase("sent");
} catch (err) {
setError(friendlyError(err));
setPhase("idle");
} finally {
busyRef.current = false;
}
}
if (phase === "sent" && done !== null) {
return (
<div className="mx-auto flex w-full max-w-xl flex-col items-center gap-4 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-8 text-center">
<LogoMark size={44} className="text-[#FF1610]" title="" />
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Received</h1>
<p className="max-w-sm text-sm leading-relaxed text-[#c7c3c1]">
Received — the collective will review it. Nothing is published until they approve it.
</p>
<p className="max-w-sm text-2xs leading-relaxed text-[#a4a09e]">
Your footage stays under {box.licenseDefault}: it may be shared and remixed with attribution.
</p>
</div>
);
}
const uploading = phase === "uploading";
const dropDisabled = uploading;
return (
<form
onSubmit={(event) => void submit(event)}
className="mx-auto flex w-full max-w-xl flex-col gap-6 rounded-lg border border-[#2a2320] bg-[#0F0A09] p-6 sm:p-8"
>
<header className="flex flex-col items-center gap-3 text-center">
<LogoMark size={36} className="text-[#FF1610]" title="Tactical Media Broadcast Hub" />
<div>
<h1 className="text-xl font-semibold tracking-tight text-[#f5efed]">Send footage to the collective</h1>
<p className="mt-1 text-xs leading-relaxed text-[#a4a09e]">
No account needed. mp4 or webm video up to 200 MiB, jpeg or png photo up to 50 MiB.
</p>
</div>
</header>
<div
role="button"
tabIndex={dropDisabled ? -1 : 0}
aria-disabled={dropDisabled}
aria-label="Choose a file to send"
onClick={() => {
if (!dropDisabled) inputRef.current?.click();
}}
onKeyDown={(event) => {
if (dropDisabled) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
inputRef.current?.click();
}
}}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={(event) => {
event.preventDefault();
if (dropDisabled) return;
const dropped = event.dataTransfer.files[0];
if (dropped !== undefined) void pick(dropped);
}}
className={`flex min-h-[180px] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors duration-150 ${
uploading
? "cursor-not-allowed border-[#3a312e] opacity-60"
: "cursor-pointer border-[#3a312e] hover:border-[#FF1610] focus-visible:border-[#FF1610]"
}`}
>
{uploading ? (
<div className="w-full max-w-md space-y-2">
<span className="inline-flex items-center gap-2 text-xs text-[#c7c3c1]">
<Loading size={14} label="Uploading" />
{progress < 100 ? `Uploading ${progress}%` : "Upload sent — the collective's server is taking it in…"}{" "}
{file !== null && `${file.name} (${formatBytes(file.size)})`}
</span>
<progress className="h-2 w-full accent-[#FF1610]" aria-label="Upload progress" value={progress} max={100} />
</div>
) : file !== null && kind !== null ? (
<>
<span className="text-sm font-medium text-[#f5efed]">{file.name}</span>
<span className="text-2xs text-[#a4a09e]">
{kind === "video" ? "video" : "photo"} · {formatBytes(file.size)} · click or drop to choose another
</span>
</>
) : (
<>
<span className="text-sm text-[#c7c3c1]">Drop your video or photo here</span>
<span className="text-2xs leading-relaxed text-[#a4a09e]">
or browse your device, or paste a file. Nothing is sent until you press Send.
</span>
<span className="mt-2 rounded-md bg-[#FF1610] px-3 py-1.5 text-xs font-medium text-white">Browse files</span>
</>
)}
<input
ref={inputRef}
type="file"
accept="video/mp4,video/webm,image/jpeg,image/png,.mp4,.webm,.jpg,.jpeg,.png"
className="hidden"
onChange={(event) => {
const picked = event.target.files?.[0];
event.target.value = "";
if (picked !== undefined) void pick(picked);
}}
/>
</div>
{fileError !== null && <p role="alert" className="text-xs text-[#ff6f6b]">{fileError}</p>}
{error !== null && <p role="alert" className="rounded-md border border-[#FF1610]/30 bg-[#FF1610]/10 px-3 py-2 text-xs text-[#ff9b97]">{error}</p>}
<div className="space-y-4">
<label className="flex cursor-pointer items-start gap-2.5 rounded-lg border border-[#3a312e] bg-[#060403] p-3">
<input
type="checkbox"
checked={licence}
onChange={(event) => setLicence(event.target.checked)}
disabled={uploading}
className="mt-0.5 h-4 w-4 shrink-0 accent-[#FF1610]"
/>
<span className="text-xs leading-relaxed text-[#c7c3c1]">
{SUBMISSION_LICENSE_TEXT} <span className="text-[#FF1610]">(required)</span>
</span>
</label>
<label className="block text-2xs text-[#a4a09e]">
Credit name (optional, max {SUBMISSION_CREDIT_MAX})
<input
type="text"
value={creditName}
maxLength={SUBMISSION_CREDIT_MAX}
onChange={(event) => setCreditName(event.target.value)}
disabled={uploading}
placeholder="How your name should appear in the credit"
className="mt-1 w-full rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
/>
{creditTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CREDIT_MAX} characters.</span>}
</label>
<label className="block text-2xs text-[#a4a09e]">
Contact (optional, max {SUBMISSION_CONTACT_MAX})
<textarea
value={contact}
maxLength={SUBMISSION_CONTACT_MAX}
onChange={(event) => setContact(event.target.value)}
disabled={uploading}
rows={2}
placeholder="How licensors can reach you — shown only to admins"
className="mt-1 w-full resize-none rounded-md border border-[#3a312e] bg-[#060403] px-3 py-2 text-sm text-[#f5efed] placeholder:text-[#6b6563] focus:border-[#FF1610] focus:outline-none"
/>
{contactTooLong && <span className="mt-1 block text-[#ff6f6b]">Up to {SUBMISSION_CONTACT_MAX} characters.</span>}
</label>
</div>
<button
type="submit"
disabled={!ready || uploading}
className="w-full rounded-md bg-[#FF1610] px-4 py-2.5 text-sm font-semibold text-white transition-colors duration-150 hover:bg-[#e3130e] disabled:cursor-not-allowed disabled:opacity-40"
>
{uploading ? `Sending… ${progress}%` : "Send to the collective"}
</button>
<p className="text-center text-2xs leading-relaxed text-[#a4a09e]">
The collective reviews every submission before anything is used. Your contact details stay with their admins.
</p>
</form>
);
}

View file

@ -0,0 +1,397 @@
"use client";
import { useEffect, useState } from "react";
import { Badge, Button, Card } from "../ui/primitives";
import { Loading } from "../brand/loading";
import { EmptyState } from "../brand/empty-state";
import { api, ApiError, errorMessage } from "../../lib/api-client";
import {
SUBMISSION_NOTE_MAX,
submissionLink,
type SubmissionBoxConfig,
type SubmissionGroupSummary,
type SubmissionQueueFilter,
type SubmissionRecord,
} from "../../lib/submissions-types";
/**
* One group's card on the Groups screen: the "Submission box" admin section
* (public link show/copy, rotate token, enable/disable) plus the moderation
* queue — pending by default, approved/rejected tabs, approve and
* reject-with-note.
*
* Both halves are permission-aware in the API (workspace.members.manage on
* all four endpoints), so the card hides its controls for plain members and
* still shows their group name — matching how the More page lists groups.
*/
/** Local byte formatter (components/video/format.ts shape, kept unshared per file ownership). */
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
const text = value >= 100 ? String(Math.round(value)) : value.toFixed(1);
return `${text} ${units[unit] ?? "KB"}`;
}
function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
const kindLabel = (kind: SubmissionRecord["kind"]): string => (kind === "video" ? "video" : "photo");
const stateTone = (state: SubmissionRecord["state"]): "live" | "danger" | "warn" =>
state === "approved" ? "live" : state === "rejected" ? "danger" : "warn";
type TabFilter = Exclude<SubmissionQueueFilter, "all">;
export function GroupSubmissionsCard({
group,
canManage,
live,
}: {
group: SubmissionGroupSummary;
canManage: boolean;
live: boolean;
}): React.ReactElement {
const [box, setBox] = useState<SubmissionBoxConfig | null>(null);
const [boxState, setBoxState] = useState<"loading" | "ready" | "missing" | "error">("loading");
const [boxError, setBoxError] = useState<string | null>(null);
const [linkShown, setLinkShown] = useState(false);
const [copied, setCopied] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [queue, setQueue] = useState<SubmissionRecord[]>([]);
const [queueState, setQueueState] = useState<"loading" | "ready" | "error">("loading");
const [queueError, setQueueError] = useState<string | null>(null);
const [filter, setFilter] = useState<TabFilter>("pending");
const [rejecting, setRejecting] = useState<SubmissionRecord | null>(null);
const [note, setNote] = useState("");
const [boxNonce, setBoxNonce] = useState(0);
const [queueNonce, setQueueNonce] = useState(0);
// The box config is nested under /groups/:id and shares its busy/error
// surface with the rotate/enable mutations, so it owns a plain effect rather
// than useApiResource.
useEffect(() => {
if (!canManage || !live) {
setBoxState("loading");
return;
}
let cancelled = false;
setBoxState("loading");
api
.get<SubmissionBoxConfig>(`/groups/${encodeURIComponent(group.id)}/submission-box`)
.then((config) => {
if (cancelled) return;
setBox(config);
setBoxState("ready");
setBoxError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof ApiError && err.status === 404) {
// "this group has no submission box" is a state, not a failure.
setBoxState("missing");
setBoxError(null);
return;
}
setBoxState("error");
setBoxError(errorMessage(err));
});
return () => {
cancelled = true;
};
}, [group.id, canManage, live, boxNonce]);
// The moderation queue: ?state=pending|approved|rejected|all (the API folds
// anything else into pending).
useEffect(() => {
if (!live) {
setQueue([]);
setQueueState("ready");
return;
}
let cancelled = false;
setQueueState("loading");
api
.get<SubmissionRecord[]>(`/groups/${encodeURIComponent(group.id)}/submissions`, { state: filter })
.then((rows) => {
if (cancelled) return;
setQueue(rows);
setQueueState("ready");
setQueueError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
if (err instanceof ApiError && err.status === 404) {
// No box for this group yet — nothing has ever been submitted.
setQueue([]);
setQueueState("ready");
setQueueError(null);
return;
}
setQueueState("error");
setQueueError(errorMessage(err));
});
return () => {
cancelled = true;
};
}, [group.id, filter, live, queueNonce]);
async function configure(action: { enabled?: boolean; rotateToken?: boolean }, label: string): Promise<void> {
if (!live) return;
setBusy(label);
setActionError(null);
setNotice(null);
try {
const config = await api.post<SubmissionBoxConfig>(`/groups/${encodeURIComponent(group.id)}/submission-box`, action);
setBox(config);
setBoxState("ready");
if (action.rotateToken === true) {
// The old token died with the rotate: never show a stale link again.
setLinkShown(false);
setCopied(false);
setNotice("Token rotated — the previous link no longer works. Copy the new one.");
} else if (action.enabled === true) {
setNotice("Box open — the public link accepts submissions again.");
} else if (action.enabled === false) {
setNotice("Box closed — the public link now says the box isn't open.");
}
} catch (err) {
setActionError(errorMessage(err));
} finally {
setBusy(null);
}
}
async function decide(submission: SubmissionRecord, decision: "approve" | "reject", rejectionNote?: string): Promise<void> {
if (!live) return;
setBusy(submission.id);
setActionError(null);
setNotice(null);
try {
const updated = await api.post<SubmissionRecord>(`/submissions/${encodeURIComponent(submission.id)}/${decision}`, {
groupId: group.id,
...(decision === "reject" && rejectionNote !== undefined ? { note: rejectionNote } : {}),
});
// The pending tab drops the row; the decided tabs show the new state.
setQueue((current) =>
filter === "pending"
? current.filter((row) => row.id !== updated.id)
: current.map((row) => (row.id === updated.id ? updated : row)),
);
setRejecting(null);
setNote("");
setNotice(
decision === "approve"
? `Approved — the ${kindLabel(updated.kind)} is in the ${updated.kind === "video" ? "video library" : "group's assets"}.`
: "Rejected — the note is kept in the moderation log.",
);
setQueueNonce((n) => n + 1);
} catch (err) {
setActionError(errorMessage(err));
} finally {
setBusy(null);
}
}
const link = box !== null ? submissionLink(box.token) : null;
const tabs: Array<{ value: TabFilter; label: string }> = [
{ value: "pending", label: "Pending" },
{ value: "approved", label: "Approved" },
{ value: "rejected", label: "Rejected" },
];
return (
<Card className="p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold">{group.name}</h2>
{canManage && boxState === "ready" && box !== null && (
<Badge tone={box.enabled ? "live" : "neutral"}>{box.enabled ? "box open" : "box closed"}</Badge>
)}
</div>
{canManage && (
<section className="mt-4" aria-label={`Submission box for ${group.name}`}>
<h3 className="mb-2 text-2xs font-bold uppercase tracking-wider text-text-faint">Submission box</h3>
{boxState === "loading" && (
<span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading the box" /> Loading…</span>
)}
{boxState === "error" && <p role="alert" className="text-xs text-danger">{boxError}</p>}
{boxState === "missing" && (
<>
<p className="text-xs leading-relaxed text-text-faint">
No submission box for this group yet — it is created the first time it is configured.
</p>
<Button size="sm" variant="outline" className="mt-2" disabled={busy !== null} onClick={() => setBoxNonce((n) => n + 1)}>
{busy === null ? "Create box" : "Working…"}
</Button>
</>
)}
{boxState === "ready" && box !== null && link !== null && (
<>
<div className="flex flex-wrap items-center gap-2">
{linkShown ? (
<code className="min-w-0 flex-1 basis-64 truncate rounded-md border border-border bg-surface-2 px-2 py-1.5 font-mono text-2xs text-text">{link}</code>
) : (
<Button size="sm" variant="outline" onClick={() => setLinkShown(true)}>Show public link</Button>
)}
{linkShown && (
<Button
size="sm"
onClick={() => {
void navigator.clipboard?.writeText(link).then(() => setCopied(true)).catch(() => setCopied(false));
}}
>
{copied ? "Copied" : "Copy"}
</Button>
)}
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => void configure({ rotateToken: true }, "rotate")}>
{busy === "rotate" ? "Rotating…" : "Rotate token"}
</Button>
<Button
size="sm"
variant={box.enabled ? "danger" : "primary"}
disabled={busy !== null}
onClick={() => void configure({ enabled: !box.enabled }, "toggle")}
>
{busy === "toggle" ? "Saving…" : box.enabled ? "Disable box" : "Enable box"}
</Button>
</div>
<p className="mt-2 text-2xs leading-relaxed text-text-faint">
Anyone with this link can send footage and photos — no account needed. Rotating kills the old link immediately;
disabling keeps the token but turns the link away.
</p>
</>
)}
</section>
)}
<section className="mt-5" aria-label={`Submissions for ${group.name}`}>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-2xs font-bold uppercase tracking-wider text-text-faint">Submissions</h3>
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label="Submission state">
{tabs.map((tab) => (
<button
key={tab.value}
type="button"
onClick={() => setFilter(tab.value)}
aria-pressed={filter === tab.value}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
filter === tab.value ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{notice !== null && <p className="mb-2 text-2xs text-live">{notice}</p>}
{actionError !== null && <p role="alert" className="mb-2 text-xs text-danger">{actionError}</p>}
{queueError !== null && <p role="alert" className="mb-2 text-xs text-danger">{queueError}</p>}
{queueState === "loading" && (
<span className="inline-flex items-center gap-2 text-xs text-text-faint"><Loading size={14} label="Loading submissions" /> Loading…</span>
)}
{queueState === "ready" && queue.length === 0 && (
<EmptyState
size={40}
title={filter === "pending" ? "Nothing waiting for review" : `No ${filter} submissions yet`}
hint={filter === "pending" ? "Hand out the public link — whatever people send lands here." : undefined}
/>
)}
{queueState === "ready" && queue.length > 0 && (
<ul className="divide-y divide-border-subtle">
{queue.map((row) => (
<li key={row.id} className="flex flex-wrap items-start justify-between gap-3 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{kindLabel(row.kind)}</Badge>
<span className="font-mono text-2xs text-text-dim">{formatBytes(row.sizeBytes)}</span>
<span className="text-2xs text-text-faint">{formatDate(row.createdAt)}</span>
{filter !== "pending" && <Badge tone={stateTone(row.state)}>{row.state}</Badge>}
</div>
<div className="mt-1 text-xs text-text-dim">
{row.creditName !== null && row.creditName !== "" ? `credit: ${row.creditName}` : "no credit name given"}
</div>
{row.contact !== null && row.contact !== "" && (
<div className="mt-0.5 text-2xs text-text-faint">contact (admins only): {row.contact}</div>
)}
{row.rejectionNote !== null && row.rejectionNote !== "" && (
<div className="mt-0.5 text-2xs text-danger">note: {row.rejectionNote}</div>
)}
</div>
{row.state === "pending" && canManage && (
<div className="flex shrink-0 items-center gap-2">
<Button size="sm" variant="primary" disabled={busy !== null} onClick={() => void decide(row, "approve")}>
{busy === row.id ? "Approving…" : "Approve"}
</Button>
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => { setRejecting(row); setNote(""); setActionError(null); }}>
Reject…
</Button>
</div>
)}
</li>
))}
</ul>
)}
{queueState === "ready" && queue.length > 0 && filter === "pending" && (
<p className="mt-2 text-2xs text-text-faint">
Approving sends video to the video library and photos to the group&apos;s assets; rejecting keeps only the note.
</p>
)}
</section>
{rejecting !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setRejecting(null)}>
<div
role="dialog"
aria-modal="true"
aria-label="Reject submission"
className="w-[460px] max-w-[92vw] rounded-lg border border-border bg-surface p-4 shadow-2xl"
onClick={(event: React.MouseEvent) => event.stopPropagation()}
>
<h2 className="text-sm font-semibold">Reject this submission</h2>
<p className="mt-1 text-xs text-text-dim">
{kindLabel(rejecting.kind)} · {formatBytes(rejecting.sizeBytes)} · {formatDate(rejecting.createdAt)}
</p>
<p className="mt-2 text-xs leading-relaxed text-text-faint">
A note is required — it goes in the moderation log.
</p>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={SUBMISSION_NOTE_MAX}
rows={3}
placeholder="Why this isn't being used (kept in the moderation log)"
className="mt-3 w-full resize-none rounded-md border border-border bg-surface-2 p-2 text-sm text-text focus:border-accent focus:outline-none"
/>
<p className="mt-1 text-right text-2xs text-text-faint">{note.length}/{SUBMISSION_NOTE_MAX}</p>
{actionError !== null && <p role="alert" className="mt-2 text-xs text-danger">{actionError}</p>}
<div className="mt-3 flex justify-end gap-2">
<Button variant="ghost" onClick={() => { setRejecting(null); setNote(""); setActionError(null); }}>Cancel</Button>
<Button
variant="danger"
disabled={busy !== null || note.trim() === ""}
onClick={() => void decide(rejecting, "reject", note.trim())}
>
{busy !== null ? "Rejecting…" : "Reject with note"}
</Button>
</div>
</div>
</div>
)}
</Card>
);
}

View file

@ -0,0 +1,100 @@
/**
* Wire shapes for the anonymous submission boxes (apps/api/src/submissions).
*
* Kept local to the web app so it does not collide with the shared
* api-types.ts other builders may be editing in parallel — the same standing
* as components/files/types.ts. Hand-synced with submissions.controller.ts
* and submissions.service.ts (commit 599a68c).
*
* JSON note: the API returns `createdAt: Date`, which serialises to an ISO
* string on the wire, so every date in here is typed as string.
*/
export type SubmissionKind = "video" | "photo";
export type SubmissionState = "pending" | "approved" | "rejected";
/** Moderation queue filter the API accepts (GET ?state=). */
export type SubmissionQueueFilter = "pending" | "approved" | "rejected" | "all";
/** GET /public/submit/:token — public box metadata for the submit page. */
export interface SubmissionBoxInfo {
id: string;
groupId: string;
licenseDefault: string;
enabled: boolean;
}
/** POST /public/submit/:token → 202 */
export interface SubmissionReceiveResult {
submissionId: string;
state: "pending";
}
/** GET/POST /groups/:groupId/submission-box — token + open switch. */
export interface SubmissionBoxConfig {
token: string;
enabled: boolean;
}
/** GET /groups/:groupId/submissions — contact is licensing-inquiry data that
* only this moderation surface ever renders; it is never copied onto assets. */
export interface SubmissionRecord {
id: string;
boxId: string;
groupId: string;
kind: SubmissionKind;
mime: string;
sizeBytes: number;
sha256: string;
license: string;
creditName: string | null;
contact: string | null;
state: SubmissionState;
rejectionNote: string | null;
createdAt: string;
}
/** POST /submissions/:id/approve|reject — the decided record plus where it landed. */
export type SubmissionDecisionResult = SubmissionRecord & {
videoAssetId?: string;
groupAssetId?: string;
};
/** The id/name slice of GET /groups/mine the submission surfaces need. */
export interface SubmissionGroupSummary {
id: string;
name: string;
}
/* Intake caps, mirroring apps/api/src/submissions/submissions.service.ts. */
export const SUBMISSION_MAX_VIDEO_BYTES = 200 * 1024 * 1024;
export const SUBMISSION_MAX_PHOTO_BYTES = 50 * 1024 * 1024;
/** The magic-byte window the API reads before deciding a type; shorter files are "unrecognised". */
export const SUBMISSION_SNIFF_BYTES = 32;
/** optionalText() caps in the API's receive path. */
export const SUBMISSION_CREDIT_MAX = 100;
export const SUBMISSION_CONTACT_MAX = 200;
/** submissions.rejection_note CHECK constraint (≤ 500). */
export const SUBMISSION_NOTE_MAX = 500;
/** The qualifier every submitter must tick — exact text from the box design. */
export const SUBMISSION_LICENSE_TEXT =
"I took or own this footage and release it under CC BY-SA 4.0 — it may be shared and remixed with attribution";
/** Canonical public origin of the web app; overridable for local/dev runs. */
export const SUBMIT_APP_ORIGIN = (process.env.NEXT_PUBLIC_APP_ORIGIN ?? "https://app.occupymedia.cc").replace(/\/$/, "");
/** The link a collective hands out: https://app.occupymedia.cc/submit/<token>. */
export function submissionLink(token: string): string {
return `${SUBMIT_APP_ORIGIN}/submit/${token}`;
}
/** Size cap per kind — the same rule the API applies after sniffing the bytes. */
export function submissionMaxBytes(kind: SubmissionKind): number {
return kind === "video" ? SUBMISSION_MAX_VIDEO_BYTES : SUBMISSION_MAX_PHOTO_BYTES;
}