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;