feat: POST /auth/workspaces — create an additional collective from an existing account
Until now collectives only existed at registration; an account with a personal workspace had no path to start one. The creator becomes Owner and gets a session scoped to the new workspace.
This commit is contained in:
parent
177ca6eb2b
commit
cbdb8c0f81
18 changed files with 2584 additions and 8 deletions
223
apps/api/src/matrix-sso/chat-display.ts
Normal file
223
apps/api/src/matrix-sso/chat-display.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Chat display policy for one workspace: reads the three `chat.*` keys from
|
||||
* workspace_chat_settings (migration 037), mints deterministic pseudonyms, and
|
||||
* applies them to the sender labels the Hub itself renders.
|
||||
*
|
||||
* Settings model (owner spec):
|
||||
* chat.pseudonymizePublic default true public chats show random names
|
||||
* chat.pseudonymizePrivate default false private chats show real names
|
||||
* chat.revealMapping default false participants may see "Pseudonym (real)"
|
||||
*
|
||||
* Defaults are chosen so a collective that never touches the surface behaves
|
||||
* the way the owner described the product: public chat is pseudonymized, and
|
||||
* the mapping is hidden. All three are optional — an absent row IS the default.
|
||||
*
|
||||
* The pseudonym is DERIVED, never stored: adjective-animal picked by hashing
|
||||
* (user uuid, workspace uuid) so the same member reads as the same name in
|
||||
* every public chat of their collective, and two members of different
|
||||
* collectives never share a name because their workspace ids differ. No new
|
||||
* table, no migration risk, nothing to backfill — and rotating a mapping is
|
||||
* impossible by construction, which is the point of pseudonymization.
|
||||
*
|
||||
* Ownership note: apps/api/src/settings is the BRAND agent's area; this module
|
||||
* is the chat surface's own service and reads its own table. Nothing here
|
||||
* touches instance_settings or workspaces.branding.
|
||||
*/
|
||||
import { Injectable, Optional, Inject } from "@nestjs/common";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { PoolClient } from "pg";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
|
||||
/** Dependency seam for tests (the hub.service pattern: every port injectable). */
|
||||
export const CHAT_DISPLAY_DEPENDENCIES = Symbol("CHAT_DISPLAY_DEPENDENCIES");
|
||||
|
||||
export interface ChatDisplayDependencies {
|
||||
transaction: <T>(workspaceId: string, fn: (client: PoolClient) => Promise<T>) => Promise<T>;
|
||||
}
|
||||
|
||||
/** The three toggles, decoded. */
|
||||
export interface ChatDisplaySettings {
|
||||
pseudonymizePublic: boolean;
|
||||
pseudonymizePrivate: boolean;
|
||||
revealMapping: boolean;
|
||||
}
|
||||
|
||||
/** Default posture per the owner spec: public ON, private OFF, mapping hidden. */
|
||||
export const DEFAULT_CHAT_DISPLAY: ChatDisplaySettings = {
|
||||
pseudonymizePublic: true,
|
||||
pseudonymizePrivate: false,
|
||||
revealMapping: false,
|
||||
};
|
||||
|
||||
export const CHAT_SETTING_KEYS = ["chat.pseudonymizePublic", "chat.pseudonymizePrivate", "chat.revealMapping"] as const;
|
||||
export type ChatSettingKey = (typeof CHAT_SETTING_KEYS)[number];
|
||||
|
||||
/** Which surface a name is being rendered for. */
|
||||
export type ChatVisibility = "public" | "private";
|
||||
|
||||
/** Roles that may change the toggles (the instance-settings convention). */
|
||||
export const CHAT_ADMIN_ROLES: readonly string[] = ["Owner", "Admin"];
|
||||
|
||||
export function isChatAdmin(role: string): boolean {
|
||||
return CHAT_ADMIN_ROLES.includes(role);
|
||||
}
|
||||
|
||||
/** Small, warm word lists — enough pairs that a busy public room stays readable. */
|
||||
const ADJECTIVES = [
|
||||
"amber", "brave", "calm", "clever", "crimson", "dawn", "deft", "eager",
|
||||
"fern", "flint", "gentle", "golden", "hazel", "iron", "jade", "keen",
|
||||
"linen", "lucid", "meridian", "moss", "nimble", "north", "olive", "opal",
|
||||
"quiet", "rust", "sable", "slate", "solar", "spry", "steady", "tarn",
|
||||
"umber", "verdant", "wren", "zephyr",
|
||||
];
|
||||
|
||||
const ANIMALS = [
|
||||
"adder", "asp", "bat", "bear", "bee", "bittern", "boar", "caribou", "cat",
|
||||
"cobra", "crane", "crow", "deer", "dotterel", "dove", "duck", "eagle",
|
||||
"egret", "falcon", "ferret", "finch", "fox", "goose", "hare", "heron",
|
||||
"ibex", "jay", "kestrel", "kite", "linnet", "lynx", "marten", "mite",
|
||||
"moorhen", "moose", "otter", "owl", "plover", "puma", "raven", "rook",
|
||||
"shrike", "siskin", "smew", "stoat", "swift", "tern", "vole", "wolverine",
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministic pseudonym for (userId, workspaceId): "Adjective Animal".
|
||||
* Stable per user+workspace, so a member's public-chat identity survives page
|
||||
* reloads and is consistent across every public room of their collective.
|
||||
*/
|
||||
export function pseudonymFor(userId: string, workspaceId: string): string {
|
||||
const digest = createHash("sha256").update(`${workspaceId}:${userId}`).digest();
|
||||
const adjective = ADJECTIVES[digest.readUInt16BE(0) % ADJECTIVES.length]!;
|
||||
const animal = ANIMALS[digest.readUInt16BE(2) % ANIMALS.length]!;
|
||||
return `${capitalize(adjective)} ${capitalize(animal)}`;
|
||||
}
|
||||
|
||||
function capitalize(word: string): string {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
/** Wire shape for the settings read (GET /matrix/chat-display). */
|
||||
export interface ChatDisplaySettingsResponse extends ChatDisplaySettings {
|
||||
/** Keys with no row yet — unset ⇒ default in force (028's `pending`). */
|
||||
pending: readonly ChatSettingKey[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChatDisplayService {
|
||||
private readonly deps: ChatDisplayDependencies;
|
||||
|
||||
constructor(@Optional() @Inject(CHAT_DISPLAY_DEPENDENCIES) dependencies?: Partial<ChatDisplayDependencies>) {
|
||||
this.deps = { transaction: dependencies?.transaction ?? withTenant };
|
||||
}
|
||||
|
||||
async getSettings(workspaceId: string): Promise<ChatDisplaySettingsResponse> {
|
||||
const rows = await this.deps.transaction(workspaceId, async (client) =>
|
||||
client.query<{ key: string; value: string }>("SELECT key, value FROM workspace_chat_settings WHERE workspace_id = $1", [workspaceId]));
|
||||
return decodeChatDisplay(rows.rows.map((row) => [row.key, row.value]));
|
||||
}
|
||||
|
||||
/** Partial update; Owner/Admin only, audited on the tenant chain. */
|
||||
async updateSettings(
|
||||
workspaceId: string,
|
||||
patch: Record<string, unknown>,
|
||||
actor: { userId: string; role: string },
|
||||
): Promise<ChatDisplaySettingsResponse> {
|
||||
if (!isChatAdmin(actor.role)) throw new BadRequestException("only Owner/Admin may change chat display settings");
|
||||
const validated = validateChatDisplayPatch(patch);
|
||||
const before = await this.getSettings(workspaceId);
|
||||
const diff: Record<string, unknown> = {};
|
||||
await this.deps.transaction(workspaceId, async (client) => {
|
||||
for (const [key, value] of Object.entries(validated)) {
|
||||
await client.query(
|
||||
`INSERT INTO workspace_chat_settings (workspace_id, key, value, updated_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (workspace_id, key) DO UPDATE SET value = EXCLUDED.value, updated_at = now(), updated_by = EXCLUDED.updated_by`,
|
||||
[workspaceId, key, String(value), actor.userId],
|
||||
);
|
||||
diff[key] = { from: (before as Record<string, unknown>)[key], to: value };
|
||||
}
|
||||
await client.query(
|
||||
"SELECT audit_append($1, $2, $3, $4, $5, $6, $7)",
|
||||
[workspaceId, actor.userId, actor.role, "chat.display.updated", "workspace", workspaceId, JSON.stringify(diff)],
|
||||
);
|
||||
});
|
||||
return this.getSettings(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display label for one sender on one surface.
|
||||
*
|
||||
* realName the Hub's own display name for the member (displayName ?? @username)
|
||||
* isGroupMember group members posting in a PUBLIC chat carry their real
|
||||
* name in parentheses (owner spec); non-members' handles are
|
||||
* provider identities, not collective identities, so they
|
||||
* pass through untouched when pseudonymization is off.
|
||||
* visibility which surface the label is for
|
||||
*
|
||||
* revealMapping=true widens the pseudonym to "Pseudonym (real)" — the admin
|
||||
* opt-in that lets participants see the mapping; with it off, the real name
|
||||
* never crosses the wire for a pseudonymized surface.
|
||||
*/
|
||||
async displayFor(input: {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
realName: string;
|
||||
visibility: ChatVisibility;
|
||||
isGroupMember?: boolean;
|
||||
}): Promise<string> {
|
||||
const settings = await this.getSettings(input.workspaceId);
|
||||
return applyChatDisplay(settings, { ...input, isGroupMember: input.isGroupMember ?? false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Pure application of the policy — the unit-test surface, no DB. */
|
||||
export function applyChatDisplay(
|
||||
settings: ChatDisplaySettings,
|
||||
input: { userId: string; workspaceId: string; realName: string; visibility: ChatVisibility; isGroupMember: boolean },
|
||||
): string {
|
||||
const pseudonymize = input.visibility === "public" ? settings.pseudonymizePublic : settings.pseudonymizePrivate;
|
||||
if (!pseudonymize) {
|
||||
// Real-name surface. A group member in a public chat still carries their
|
||||
// real name, so the plain realName is exactly right here.
|
||||
return input.realName;
|
||||
}
|
||||
const pseudonym = pseudonymFor(input.userId, input.workspaceId);
|
||||
if (!settings.revealMapping) return pseudonym;
|
||||
return `${pseudonym} (${input.realName})`;
|
||||
}
|
||||
|
||||
/** Decode rows (or an empty list) into the settings object; unknown rows fall back. */
|
||||
export function decodeChatDisplay(rows: readonly (readonly [string, string])[]): ChatDisplaySettingsResponse {
|
||||
const map = new Map(rows);
|
||||
return {
|
||||
pseudonymizePublic: decodeBoolean(map, "chat.pseudonymizePublic", DEFAULT_CHAT_DISPLAY.pseudonymizePublic),
|
||||
pseudonymizePrivate: decodeBoolean(map, "chat.pseudonymizePrivate", DEFAULT_CHAT_DISPLAY.pseudonymizePrivate),
|
||||
revealMapping: decodeBoolean(map, "chat.revealMapping", DEFAULT_CHAT_DISPLAY.revealMapping),
|
||||
pending: CHAT_SETTING_KEYS.filter((key) => !map.has(key)),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeBoolean(map: Map<string, string>, key: ChatSettingKey, fallback: boolean): boolean {
|
||||
const value = map.get(key);
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return fallback; // absent or (impossible per 037 CHECK) undecodable
|
||||
}
|
||||
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
/** Strict write-side validation (unknown keys are a 400, never dropped). */
|
||||
export function validateChatDisplayPatch(patch: Record<string, unknown>): Partial<Record<ChatSettingKey, boolean>> {
|
||||
const out: Partial<Record<ChatSettingKey, boolean>> = {};
|
||||
let count = 0;
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (!(CHAT_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
throw new BadRequestException(`unknown setting: ${key} (allowed: ${CHAT_SETTING_KEYS.join(", ")})`);
|
||||
}
|
||||
if (typeof value !== "boolean") throw new BadRequestException(`${key} must be a boolean`);
|
||||
out[key as ChatSettingKey] = value;
|
||||
count += 1;
|
||||
}
|
||||
if (count === 0) throw new BadRequestException("body must set at least one setting");
|
||||
return out;
|
||||
}
|
||||
83
apps/api/src/matrix-sso/mxid-display.ts
Normal file
83
apps/api/src/matrix-sso/mxid-display.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* MXID → Hub display name.
|
||||
*
|
||||
* The Hub mints Matrix identities for its own users as
|
||||
* `@<prefix><username>:<server_name>` (matrixUserIdFor in matrix-sso.config.ts,
|
||||
* prefix `hub_` by default). Element renders MXIDs with its own local
|
||||
* displayname logic — the Hub cannot change what Element shows for a sender
|
||||
* — but EVERY surface the Hub itself renders (the chat page's identity notice,
|
||||
* live-chat sender labels, group-files attribution) must show the short,
|
||||
* human form `@glassbeadian`, never the full `@hub_glassbeadian:chat.occupymedia.cc`.
|
||||
*
|
||||
* This module is the single formatter for that rule. It is deliberately
|
||||
* dependency-free and shared by api and web (a copy lives under
|
||||
* apps/web/src/lib so the client never imports server code).
|
||||
*/
|
||||
|
||||
/** Splits an MXID into localpart and server name; null when not an MXID. */
|
||||
function parseMxid(mxid: string): { localpart: string; server: string } | null {
|
||||
const match = /^@([^:@\s]+):([^@\s]+)$/.exec(mxid);
|
||||
return match === null ? null : { localpart: match[1]!, server: match[2]! };
|
||||
}
|
||||
|
||||
/** The Matrix localpart prefix the Hub mints Hub usernames with. */
|
||||
export function hubPrefix(env: Readonly<Record<string, string | undefined>> = process.env): string {
|
||||
const raw = (env.MATRIX_SSO_LOCALPART_PREFIX ?? "").trim();
|
||||
if (raw === "none") return "";
|
||||
return raw === "" ? "hub_" : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* `@hub_glassbeadian:chat.occupymedia.cc` → `@glassbeadian`.
|
||||
*
|
||||
* Rules, in order:
|
||||
* 1. Not an MXID (no @, or malformed) → unchanged. Callers pass provider
|
||||
* handles and display names through the same field; a Twitch login or a
|
||||
* plain name is not ours to rewrite.
|
||||
* 2. Our prefix + our server name → strip BOTH. `@hub_ada:chat.occupymedia.cc`
|
||||
* becomes `@ada` — the Hub username form the owner asked for.
|
||||
* 3. Foreign MXID (server name differs) → localpart only, server kept in the
|
||||
* caller's title attribute when it wants it; the home-server suffix is
|
||||
* noise for a member reading a chat.
|
||||
*/
|
||||
export function displayMxid(mxid: string, env: Readonly<Record<string, string | undefined>> = process.env): string {
|
||||
const parsed = parseMxid(mxid);
|
||||
if (parsed === null) return mxid;
|
||||
const prefix = hubPrefix(env);
|
||||
const config = loadServerName(env);
|
||||
if (config !== null && parsed.server === config) {
|
||||
return `@${prefix === "" ? parsed.localpart : stripPrefix(parsed.localpart, prefix)}`;
|
||||
}
|
||||
return `@${stripPrefix(parsed.localpart, prefix)}`;
|
||||
}
|
||||
|
||||
/** `hub_glassbeadian` → `glassbeadian`; a localpart without the prefix is kept. */
|
||||
function stripPrefix(localpart: string, prefix: string): string {
|
||||
return prefix !== "" && localpart.startsWith(prefix) ? localpart.slice(prefix.length) : localpart;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hub username behind a Hub-minted MXID (no leading @), or null when the
|
||||
* MXID is not one of ours. Used where the caller wants the bare username.
|
||||
*/
|
||||
export function hubUsernameFor(mxid: string, env: Readonly<Record<string, string | undefined>> = process.env): string | null {
|
||||
const parsed = parseMxid(mxid);
|
||||
const server = loadServerName(env);
|
||||
if (parsed === null || server === null || parsed.server !== server) return null;
|
||||
const prefix = hubPrefix(env);
|
||||
if (prefix !== "" && !parsed.localpart.startsWith(prefix)) return null;
|
||||
return stripPrefix(parsed.localpart, prefix);
|
||||
}
|
||||
|
||||
function loadServerName(env: Readonly<Record<string, string | undefined>>): string | null {
|
||||
const homeserver = (env.MATRIX_SSO_HOMESERVER_URL ?? env.ARCHIVE_PROVISIONER_HOMESERVER_URL ?? "").trim();
|
||||
if (homeserver !== "") {
|
||||
try {
|
||||
return new URL(homeserver).hostname;
|
||||
} catch {
|
||||
/* fall through to the explicit name below */
|
||||
}
|
||||
}
|
||||
const named = (env.MATRIX_SSO_SERVER_NAME ?? "").trim();
|
||||
return named === "" ? null : named;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue