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,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;
}