Collectives P1: participation landing (ES/EN), group tree, room provisioning APIs

- 030: groups.parent_id tree + kind discriminator + group_rooms mapping
- /collectives module: create/tree/join APIs, MatrixProvisioner (open room
  public+federated, closed room E2EE-at-creation, onboarding room), NullProvisioner
  fail-soft when ARCHIVE_PROVISIONER_* unset
- web root becomes the public participation manual (ES-first + EN toggle,
  three paths: join by invite, start collective, solo account)
- participation manual content in core
This commit is contained in:
glass beadian 2026-10-12 13:53:40 +00:00
parent c31762b82d
commit 4a7e78b28d
23 changed files with 2892 additions and 8 deletions

View file

@ -0,0 +1,131 @@
-- 030: collectives — group tree (P1 of the collectives architecture,
-- docs/design/2026-10-12-collectives-architecture.md §"Collective model").
--
-- A COLLECTIVE IS A GROUP in the Hub's group tree:
-- * groups.parent_id becomes self-referencing (top-level groups = collectives,
-- children = subcollectives, recursive);
-- * groups.kind is the minimal discriminator ('collective' | 'subcollective' |
-- 'plain') so the tree query never has to infer structure from parent_id
-- alone — a legacy sharing-scope group (008) and a collective are both
-- parentless, and 'plain' keeps the existing semantics untouched;
-- * group_rooms maps each collective/subcollective to its room set
-- (open | closed | onboarding), extending the archive mapping pattern
-- (ROOM_MAPPINGS / chat_media_archive) into a durable, tenant-scoped table.
--
-- This deliberately rides the existing groups/RBAC/files machinery
-- (group_files, group_members, chat_media_archive are keyed workspace+group)
-- instead of introducing cross-tenant writes.
--
-- ON DELETE RESTRICT on groups.parent_id (not CASCADE), on purpose:
-- * Every other child table of groups (group_members, group_connections,
-- group_assets, chat_media_archive, group_rooms) CASCADES, so deleting a
-- group already tears down its own members/assets/rooms rows correctly.
-- * A collective with subcollectives is a TREE ROOT whose children are
-- independent units with their own room sets and their own files folders.
-- CASCADE would delete a whole subtree on a single root delete — silently
-- removing sibling subcollectives' chat archives and room mappings that the
-- operator never named. The audit log would then record one delete while
-- the tree lost N groups.
-- * RESTRICT forces the explicit, audited order the product wants: delete (or
-- re-parent) the subcollectives first, then the collective. Deleting a
-- subcollective still cascades its own rooms/members exactly like today.
-- * The FK is tenant-pinned via the existing composite (id, workspace_id), so
-- RESTRICT can never be a cross-tenant coupling: a subcollective cannot
-- reference a parent in another workspace even if ids collided.
--
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the 001/008/029
-- conventions: no app.workspace_id => NULL => no rows; FORCE binds sp_app too.
BEGIN;
-- ---------------------------------------------------------------------------
-- groups.parent_id — self-reference, tenant-pinned, RESTRICT
-- ---------------------------------------------------------------------------
-- NULL = top-level (a collective or a legacy plain group). Tenant-pinned by the
-- composite FK so the tree can never span workspaces, even transiently.
ALTER TABLE groups ADD COLUMN parent_id uuid;
ALTER TABLE groups
ADD CONSTRAINT groups_parent_fk
FOREIGN KEY (parent_id, workspace_id) REFERENCES groups(id, workspace_id)
ON DELETE RESTRICT ON UPDATE RESTRICT;
-- Subcollective lookup (children of a collective) — the only parent->children
-- access path P1 needs (the tree read walks parent_id in the other direction).
CREATE INDEX groups_parent_idx ON groups (parent_id, workspace_id);
-- No cycle guard at the DB level in P1: a cycle needs two UPDATEs of rows the
-- API never writes directly (the service creates roots and one-level children
-- only, and rejects a parent that is not itself a collective or subcollective).
-- A trigger walking an unbounded graph inside the mutating transaction would
-- burn locks for a shape the API layer structurally prevents; revisit if/when
-- re-parenting becomes a product surface.
-- ---------------------------------------------------------------------------
-- groups.kind — minimal discriminator
-- ---------------------------------------------------------------------------
-- 'plain' is the DEFAULT and is exactly the 008 semantics: a sharing scope for
-- connections/assets with no collective meaning. Existing rows are backfilled
-- explicitly (batch would be pointless: the table is small and the migration is
-- already inside one transaction).
-- Two named constraints so the backstop below can be added without guessing a
-- PostgreSQL auto-generated name.
ALTER TABLE groups ADD COLUMN kind text NOT NULL DEFAULT 'plain';
ALTER TABLE groups ADD CONSTRAINT groups_kind_values CHECK (kind IN ('collective','subcollective','plain'));
-- A subcollective must actually be a child; a collective must be a root. Kept
-- as a DB backstop so a bad write can never split kind from parent_id — the
-- typed validation lives in the API service (collectives.service.ts).
ALTER TABLE groups ADD CONSTRAINT groups_kind_shape CHECK (
(kind = 'subcollective' AND parent_id IS NOT NULL)
OR (kind IN ('collective','plain') AND parent_id IS NULL)
);
-- ---------------------------------------------------------------------------
-- group_rooms — the immutable group ↔ room mapping
-- ---------------------------------------------------------------------------
-- Extends the ROOM_MAPPINGS pattern (archive bot / chat_media_archive): room
-- IDs only, never aliases (`!opaque:server`), because a mapping must stay
-- resolvable. room_type is one of the three product room kinds. One row per
-- (group, room_type): a collective has exactly one open, one closed room; a
-- user's onboarding room is keyed to the group it onboards into. room_id is
-- UNIQUE across the table: a Matrix room belongs to exactly one slot in one
-- workspace, so a mis-provisioned duplicate cannot be silently re-pointed.
CREATE TABLE group_rooms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
group_id uuid NOT NULL,
-- 'open' (public+federated, never E2EE) | 'closed' (invite-only, E2EE at
-- birth, local only) | 'onboarding' (invite-only, disposable, bot-posted).
room_type text NOT NULL CHECK (room_type IN ('open','closed','onboarding')),
-- Matrix room ID, never an alias — same immutability rule as ROOM_MAPPINGS.
room_id text NOT NULL CHECK (room_id ~ '^![^:\s]+:[^\s]+$'),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT group_rooms_ws_group FOREIGN KEY (group_id, workspace_id)
REFERENCES groups(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Exactly one room of each type per group.
CONSTRAINT group_rooms_type_unique UNIQUE (group_id, room_type),
-- A room is mapped to exactly one (workspace, group, slot).
CONSTRAINT group_rooms_room_unique UNIQUE (workspace_id, room_id)
);
CREATE TRIGGER trg_group_rooms_ws_immutable BEFORE UPDATE ON group_rooms
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE group_rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE group_rooms FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON group_rooms
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- Room address lookup per collective (tree read joins on this), and the
-- reverse "which group does this room belong to" lookup the archive bot path
-- needs once mappings move out of env config (P2).
CREATE INDEX group_rooms_group_idx ON group_rooms (group_id, room_type);
CREATE INDEX group_rooms_room_idx ON group_rooms (room_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON group_rooms TO sp_app;
COMMIT;

View file

@ -36,9 +36,10 @@ import { VirtualChannelsModule } from "./virtual-channels/virtual-channels.modul
import { DirectorModule } from "./director/director.module.js";
import { AnalyticsController } from "./analytics/analytics.controller";
import { SnapshotService } from "./analytics/snapshot.service";
import { CollectivesModule } from "./collectives/collectives.module.js";
@Module({
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule],
imports: [HealthModule, LiveMonitorModule, MediaControlModule, ProductionModule, RealtimeModule, LiveChatModule, LiveModule, VirtualChannelsModule, ChatgovModule, IntakeModule, GroupFilesModule, SettingsModule, CollectivesModule],
controllers: [
DemoController,
PublishingController,

View file

@ -0,0 +1,100 @@
import { Body, Controller, Get, Inject, Param, Post, Req, Res, UseGuards } from "@nestjs/common";
import type { Request, Response } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { optionalString, parseId, requireObject, requireSession, requireString } from "../http/validation.js";
import { CollectivesService, parseLanguage, type CollectiveNode } from "./collectives.service.js";
/**
* Collective surface (P1): create + tree, plus the public join seam.
*
* Creation is authenticated and permission-gated (workspace.members.manage —
* the same permission that mints invite links, since founding a collective
* makes you the person who lets others in). The tree read is workspace.view,
* matching GET /groups.
*
* The invite/join flow itself stays EXACTLY where it is (POST /invites/:token/
* claim, InviteClaimController): this controller only exposes the public
* metadata a joiner needs to see WHICH collective they are entering, so the
* existing /join/[token] page can label the claim card with the collective
* name instead of the bare workspace name.
*/
type AuthedRequest = Request & AuthenticatedRequest;
function actorOf(request: AuthedRequest): { userId: string; role: string } {
const session = requireSession(request);
return { userId: session.userId, role: session.role };
}
@Controller("collectives")
@UseGuards(RbacGuard)
export class CollectivesController {
constructor(
@Inject(CollectivesService) private readonly collectives: CollectivesService,
) {}
/**
* Create a collective: group + room set (via the provisioner) + founder
* membership. Body: { name, language?, description? }. Room provisioning
* failures surface as 500 with a clear message and NOTHING is persisted.
*/
@Post()
@RequirePermission("workspace.members.manage")
async create(@Req() request: AuthedRequest, @Body() body: unknown): Promise<CollectiveNode> {
const session = requireSession(request);
const dto = requireObject(body, "body");
return this.collectives.createCollective(
session.workspaceId,
{
name: requireString(dto.name, "name"),
language: parseLanguage(dto.language),
description: optionalString(dto.description, "description") ?? "",
},
actorOf(request),
);
}
/** The workspace's collective tree: collectives as roots, subcollectives nested. */
@Get("tree")
@RequirePermission("workspace.view")
async tree(@Req() request: AuthedRequest): Promise<CollectiveNode[]> {
return this.collectives.collectiveTree(requireSession(request).workspaceId);
}
/** One collective subtree. Declared before :id so the literal path wins. */
@Get("tree/:id")
@RequirePermission("workspace.view")
async subtree(@Req() request: AuthedRequest, @Param("id") id: string): Promise<CollectiveNode | null> {
const tree = await this.collectives.collectiveTree(requireSession(request).workspaceId);
return findInTree(tree, parseId(id));
}
}
/** Public, pre-auth metadata for the /join/[token] page (collective labelling). */
@Controller("collectives-public")
export class CollectivesPublicController {
constructor(@Inject(CollectivesService) private readonly collectives: CollectivesService) {}
/**
* GET /collectives-public/for-invite/:token — the collectives of the
* workspace an invite token points at, by NAME ONLY.
*
* This is deliberately narrow: the caller has no tenant context (they have not
* claimed yet), so the service resolves it through the same SECURITY DEFINER
* shape as invite_link_for_claim — one row keyed by a secret the caller
* already proved possession of. No room ids, no member counts.
*/
@Get("for-invite/:token")
async forInvite(@Param("token") token: string, @Res({ passthrough: true }) response: Response): Promise<{ collectives: Array<{ id: string; name: string }> }> {
response.setHeader("Cache-Control", "no-store");
return this.collectives.collectivesForInviteToken(requireString(token, "token"));
}
}
function findInTree(nodes: CollectiveNode[], id: string): CollectiveNode | null {
for (const node of nodes) {
if (node.id === id) return node;
const found = findInTree(node.children, id);
if (found !== null) return found;
}
return null;
}

View file

@ -0,0 +1,276 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
/**
* Collectives against a live PostgreSQL, as sp_app (FORCE RLS binds honestly).
* Own database (COLLECTIVES_IT_DATABASE_URL + COLLECTIVES_IT_SUPER_DATABASE_URL
* for migrations/seeding), the same isolation pattern as groups.integration.test.ts:
* tenancy.integration.test.ts wipes every workspace in the shared DB and vitest
* runs files in parallel. Skipped when the env vars are unset.
*
* The provisioner is swap-faked (InMemoryProvisioner): these tests assert the
* DATABASE contract of migration 030 (tenant pinning, RESTRICT delete, kind
* backstop, one-room-per-type) and the service's transaction shape, not Matrix.
*/
const appUrl = process.env.COLLECTIVES_IT_DATABASE_URL;
const ownerUrl = process.env.COLLECTIVES_IT_SUPER_DATABASE_URL;
type Modules = {
collectives: typeof import("./collectives.service.js");
db: typeof import("../db/client.js");
};
class InMemoryProvisioner {
readonly botUserId = "@provisioner:chat.example.com";
calls = 0;
fail = false;
async provisionCollective(): Promise<{ open: { roomId: string }; closed: { roomId: string }; onboarding: { roomId: string } }> {
this.calls += 1;
if (this.fail) throw new (await import("./collectives.service.js")).ProvisionerUnavailableError("homeserver down");
const id = this.calls;
return {
open: { roomId: `!open-${id}:chat.example.com` },
closed: { roomId: `!closed-${id}:chat.example.com` },
onboarding: { roomId: `!onboarding-${id}:chat.example.com` },
};
}
}
describe.skipIf(appUrl === undefined || ownerUrl === undefined)("collectives (live DB)", () => {
let m: Modules;
let ownerPool: Pool;
let provisioner: InMemoryProvisioner;
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
let wsA: string;
let wsB: string;
let founder: string;
let outsider: string;
/**
* Owner connection with guaranteed rollback: an assertion query that throws
* must not poison the pool client for the next caller (a released client in
* an aborted transaction is the classic pg-pool footgun).
*/
async function asOwner<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await ownerPool.connect();
try {
return await fn(client);
} finally {
await client.query("ROLLBACK").catch(() => undefined);
client.release();
}
}
beforeAll(async () => {
process.env.DATABASE_URL = appUrl;
process.env.SUPER_DATABASE_URL = ownerUrl;
const { runMigrations } = await import("../db/migrate.js");
await runMigrations(join(process.cwd(), "migrations"));
m = {
collectives: await import("./collectives.service.js"),
db: await import("../db/client.js"),
};
ownerPool = new Pool({ connectionString: ownerUrl });
provisioner = new InMemoryProvisioner();
await asOwner(async (c) => {
const ws = async (name: string): Promise<string> =>
(await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [name, `${name}-${suffix}`])).rows[0].id as string;
wsA = await ws("col-a");
wsB = await ws("col-b");
const user = async (tag: string): Promise<string> =>
(
await c.query("INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id", [
`col:${tag}:${suffix}`,
`test-${tag}-${suffix}`,
`${tag}-${suffix}@example.com`,
tag,
])
).rows[0].id as string;
founder = await user("founder");
outsider = await user("outsider");
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsA, founder]);
await c.query("COMMIT");
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsB]);
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsB, outsider]);
await c.query("COMMIT");
});
});
afterAll(async () => {
if (ownerPool !== undefined) {
await asOwner(async (c) => {
await c.query("DELETE FROM workspaces WHERE id = ANY($1::uuid[])", [[wsA, wsB]]);
await c.query("DELETE FROM users WHERE external_id LIKE $1", [`col:%:${suffix}`]);
});
await ownerPool.end();
}
await m?.db.pool.end();
});
function service(): InstanceType<typeof m.collectives.CollectivesService> {
return new m.collectives.CollectivesService(provisioner);
}
it("creates a collective with its room set, founder membership and audit row", async () => {
const created = await service().createCollective(wsA, { name: "Acampada", language: "es" }, { userId: founder, role: "Owner" });
expect(created.kind).toBe("collective");
expect(created.parentId).toBeNull();
expect(created.language).toBe("es");
expect(created.memberCount).toBe(1);
expect(created.rooms.map((room) => room.roomType).sort()).toEqual(["closed", "onboarding", "open"]);
for (const room of created.rooms) expect(room.roomId).toMatch(/^![a-z0-9-]+:chat\.example\.com$/);
await asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
const members = await c.query("SELECT user_id FROM group_members WHERE group_id = $1", [created.id]);
expect(members.rows.map((row) => row.user_id)).toEqual([founder]);
const rooms = await c.query("SELECT room_type, room_id FROM group_rooms WHERE group_id = $1 ORDER BY room_type", [created.id]);
expect(rooms.rows.map((row) => row.room_type)).toEqual(["closed", "onboarding", "open"]);
const audit = await c.query("SELECT action, target_id FROM audit_log WHERE target_id = $1 AND action = 'collective.created'", [created.id]);
expect(audit.rowCount).toBe(1);
await c.query("COMMIT");
});
});
it("collective creation bumps the authz epoch (founder membership is an authz input)", async () => {
const before = await asOwner(async (c) => Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0].authz_epoch));
await service().createCollective(wsA, { name: "Época", language: "es" }, { userId: founder, role: "Owner" });
const after = await asOwner(async (c) => Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0].authz_epoch));
expect(after).toBeGreaterThan(before);
});
it("rejects a duplicate collective name in the same workspace, allows it across workspaces", async () => {
await service().createCollective(wsA, { name: "Doble", language: "es" }, { userId: founder, role: "Owner" });
await expect(service().createCollective(wsA, { name: "Doble", language: "en" }, { userId: founder, role: "Owner" })).rejects.toMatchObject({ status: 409 });
});
it("RLS: workspace B sees none of workspace A's collectives or rooms", async () => {
const created = await service().createCollective(wsA, { name: "Aislada", language: "es" }, { userId: founder, role: "Owner" });
const treeB = await service().collectiveTree(wsB);
expect(treeB).toHaveLength(0);
// sp_app with NO tenant context sees zero rows (fail-closed): RLS holds
// even for the table owner because group_rooms is FORCE RLS (030).
const bare = new Pool({ connectionString: process.env.DATABASE_URL });
try {
const none = await bare.query("SELECT current_user, COUNT(*)::int AS n FROM group_rooms");
expect(none.rows[0].current_user).toBe("sp_app");
expect(none.rows[0].n).toBe(0);
} finally {
await bare.end();
}
// And the rows are real from inside A.
const treeA = await service().collectiveTree(wsA);
expect(treeA.some((node) => node.id === created.id)).toBe(true);
});
it("a provisioner failure persists NOTHING (no group, no rooms, no audit)", async () => {
provisioner.fail = true;
const groupsBefore = await asOwner(async (c) => Number((await c.query("SELECT COUNT(*)::int AS n FROM groups WHERE workspace_id = $1", [wsA])).rows[0].n));
await expect(service().createCollective(wsA, { name: "Fantasma", language: "es" }, { userId: founder, role: "Owner" })).rejects.toThrow();
const after = await asOwner(async (c) => Number((await c.query("SELECT COUNT(*)::int AS n FROM groups WHERE workspace_id = $1", [wsA])).rows[0].n));
expect(after).toBe(groupsBefore);
provisioner.fail = false;
});
it("030 constraints: one room per type per group, room ids unique per workspace, kinds shape-checked", async () => {
const created = await service().createCollective(wsA, { name: "Límites", language: "es" }, { userId: founder, role: "Owner" });
const open = created.rooms.find((room) => room.roomType === "open")!;
await asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
// Each rejection aborts its own transaction; run them one by one.
const attempt = async (statement: string, values: unknown[], pattern: RegExp): Promise<void> => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
await expect(c.query(statement, values)).rejects.toThrow(pattern);
await c.query("ROLLBACK").catch(() => undefined);
};
// Duplicate (group, room_type) is rejected.
await attempt("INSERT INTO group_rooms (workspace_id, group_id, room_type, room_id) VALUES ($1, $2, 'open', '!other:chat.example.com')", [wsA, created.id], /duplicate key|group_rooms_type_unique/);
// A room cannot be mapped twice in one workspace.
await attempt("INSERT INTO group_rooms (workspace_id, group_id, room_type, room_id) VALUES ($1, $2, 'onboarding', $3)", [wsA, created.id, open.roomId], /duplicate key|group_rooms_room_unique/);
// Aliases are not valid room ids.
await attempt("INSERT INTO group_rooms (workspace_id, group_id, room_type, room_id) VALUES ($1, $2, 'onboarding', '#alias:chat.example.com')", [wsA, created.id], /room_id/);
// kind/parent shape: a collective cannot gain a parent via direct SQL.
const other = (await c.query("SELECT id FROM groups WHERE workspace_id = $1 AND name = 'Acampada'", [wsA])).rows[0]?.id;
await attempt("UPDATE groups SET parent_id = $2 WHERE id = $1", [created.id, other], /groups_kind_shape|groups_parent_fk/);
});
});
it("030 FK: a subcollective's parent delete is RESTRICTed, not cascaded", async () => {
const parent = await service().createCollective(wsA, { name: "Raíz", language: "es" }, { userId: founder, role: "Owner" });
const child = await service().createCollective(wsA, { name: "Rama", language: "es" }, { userId: founder, role: "Owner" });
await asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
await c.query("UPDATE groups SET parent_id = $2, kind = 'subcollective' WHERE id = $1", [child.id, parent.id]);
await c.query("COMMIT");
});
// Deleting the parent while it still has a child is blocked (RESTRICT).
const client = await ownerPool.connect();
try {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
await expect(client.query("DELETE FROM groups WHERE id = $1", [parent.id])).rejects.toThrow(/groups_parent_fk/);
} finally {
await client.query("ROLLBACK").catch(() => undefined);
client.release();
}
// The tree nests the child under the parent.
const tree = await service().collectiveTree(wsA);
const parentNode = tree.find((node) => node.id === parent.id);
expect(parentNode?.children.some((node) => node.id === child.id)).toBe(true);
expect(parentNode?.kind).toBe("collective");
});
it("cross-tenant parent pins are impossible (composite FK)", async () => {
const a = await service().createCollective(wsA, { name: "Norte", language: "es" }, { userId: founder, role: "Owner" });
const b = await service().createCollective(wsB, { name: "Sur", language: "es" }, { userId: outsider, role: "Owner" });
await expect(
asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
await c.query("UPDATE groups SET parent_id = $2 WHERE id = $1", [a.id, b.id]);
await c.query("COMMIT");
}),
).rejects.toThrow();
});
it("collectivesForInviteToken returns names only, and only for a live link", async () => {
const created = await service().createCollective(wsA, { name: "Invitable", language: "es" }, { userId: founder, role: "Owner" });
await asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
await c.query(
"INSERT INTO workspace_invite_links (workspace_id, role, token, created_by, expires_at) VALUES ($1, 'Publisher', $2, $3, now() + interval '1 day')",
[wsA, `invite-${suffix}`, founder],
);
await c.query("COMMIT");
});
const result = await service().collectivesForInviteToken(`invite-${suffix}`);
const entry = result.collectives.find((candidate) => candidate.id === created.id);
expect(entry).toBeDefined();
expect(entry!.name).toBe("Invitable");
// Names only: no rooms, no member counts, no descriptions leak to a pre-claim joiner.
expect(Object.keys(entry!).sort()).toEqual(["id", "name"]);
// Dead links and unknown tokens stay 404/410 exactly like the claim path.
await expect(service().collectivesForInviteToken("no-such-token")).rejects.toMatchObject({ status: 404 });
});
it("plain groups stay out of the collective tree", async () => {
await asOwner(async (c) => {
await c.query("BEGIN");
await c.query("SELECT set_config('app.workspace_id', $1, true)", [wsA]);
await c.query("INSERT INTO groups (workspace_id, name, kind) VALUES ($1, 'Alcance', 'plain')", [wsA]);
await c.query("COMMIT");
});
const tree = await service().collectiveTree(wsA);
expect(tree.some((node) => node.name === "Alcance")).toBe(false);
expect(tree.every((node) => node.kind === "collective" || node.kind === "subcollective")).toBe(true);
});
});

View file

@ -0,0 +1,51 @@
import { Module } from "@nestjs/common";
import { CollectivesController, CollectivesPublicController } from "./collectives.controller.js";
import { CollectivesService, PROVISIONER_PORT, type ProvisionerPort } from "./collectives.service.js";
import { MatrixProvisioner } from "./matrix-provisioner.js";
import { MatrixHttpClient, loadProvisionerConfig } from "./provisioner.config.js";
import { ServiceUnavailableException } from "@nestjs/common";
import type { Logger } from "@nestjs/common";
/**
* Collectives module — P1 of the collectives architecture.
*
* PROVISIONER_PORT is bound to the real MatrixProvisioner when
* ARCHIVE_PROVISIONER_* env vars are present (login happens lazily on first
* use, following the GroupFilesModule precedent for env-dependent surfaces) and
* to a NullProvisioner otherwise, so a deployment without Matrix answers 503 on
* collective creation instead of failing to boot. Tests bind their own fake.
*/
class NullProvisioner implements ProvisionerPort {
readonly botUserId = "";
async provisionCollective(): Promise<never> {
throw new ServiceUnavailableException(
"collective rooms are not configured: set ARCHIVE_PROVISIONER_HOMESERVER_URL, ARCHIVE_PROVISIONER_USER and ARCHIVE_PROVISIONER_PASSWORD",
);
}
}
let cachedClient: MatrixHttpClient | null = null;
/** Lazily logs in the provisioner bot (once per process) and wraps it. */
async function buildProvisioner(logger?: Logger): Promise<ProvisionerPort> {
const config = loadProvisionerConfig();
if (config === null) return new NullProvisioner();
if (cachedClient === null) {
cachedClient = new MatrixHttpClient(config);
await cachedClient.login();
}
return new MatrixProvisioner({ matrix: cachedClient, logger });
}
@Module({
controllers: [CollectivesController, CollectivesPublicController],
providers: [
CollectivesService,
{
provide: PROVISIONER_PORT,
useFactory: async () => buildProvisioner(),
},
],
exports: [CollectivesService],
})
export class CollectivesModule {}

View file

@ -0,0 +1,383 @@
import { BadRequestException, ConflictException, GoneException, Inject, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
import {
DEFAULT_MANUAL_LANGUAGE,
MANUAL_LANGUAGES,
isManualLanguage,
type ManualLanguage,
} from "@tactical-media-broadcast-hub/core";
import type { PoolClient } from "pg";
import { appendTenantAudit } from "../audit/audit.service.js";
import { withSystem, withTenant } from "../db/client.js";
/**
* Collectives — P1 of docs/design/2026-10-12-collectives-architecture.md.
*
* A collective IS a group: `groups.parent_id` self-references (root groups are
* collectives, children are subcollectives) and `groups.kind` discriminates them
* from the 008-era sharing-scope groups ('plain'). The room set is mapped in
* `group_rooms` (one open, one closed room per collective plus the founder's
* onboarding room), provisioned through PROVISIONER_PORT so the real Matrix
* implementation is swap-faked in tests.
*
* Provisioning order: rooms are created on the homeserver FIRST, then the group,
* the founder's group_members row and the group_rooms mappings land in ONE
* tenant transaction. A remote side effect cannot be rolled back, so the DB is
* only written once the full room set exists — a failed homeserver leaves no
* collective at all (orphan rooms are invisible; a collective with missing
* rooms would be a lie).
*/
export const COLLECTIVE_ROOM_TYPES = ["open", "closed", "onboarding"] as const;
export type CollectiveRoomType = (typeof COLLECTIVE_ROOM_TYPES)[number];
export type GroupKind = "collective" | "subcollective" | "plain";
/** Power levels the provisioner pins at creation (design §Provisioning). */
export const FOUNDER_POWER_LEVEL = 100;
export const MEMBER_POWER_LEVEL = 0;
export interface CollectiveActor {
userId: string;
role: string;
}
export interface ProvisionedRoom {
roomId: string;
}
/** A whole provisioned room set, exactly as the provisioner produced it. */
export interface ProvisionedRoomSet {
open: ProvisionedRoom;
closed: ProvisionedRoom;
onboarding: ProvisionedRoom;
}
export interface ProvisionCollectiveInput {
workspaceId: string;
/** Informational only at provision time; never used as a foreign key. */
groupId: string;
name: string;
language: ManualLanguage;
founderUserId: string;
/** Founder's Matrix user when this deployment knows it; null = skip invites. */
founderMxid: string | null;
}
/**
* The seam between the Hub and the homeserver. MatrixProvisioner is the only
* implementation that knows Matrix exists; the service, the controller and
* every test work against this interface (see InMemoryProvisioner in tests).
*
* Throws ProvisionerUnavailableError when the homeserver cannot be reached or
* refuses — the service maps that to 503 without persisting anything.
*/
export interface ProvisionerPort {
readonly botUserId: string;
provisionCollective(input: ProvisionCollectiveInput): Promise<ProvisionedRoomSet>;
}
export class ProvisionerUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "ProvisionerUnavailableError";
}
}
/** DI token; the module binds MatrixProvisioner (or a fake) to it. */
export const PROVISIONER_PORT = Symbol("PROVISIONER_PORT");
export interface CollectiveRoomSummary {
roomType: CollectiveRoomType;
roomId: string;
}
export interface CollectiveNode {
id: string;
name: string;
kind: GroupKind;
language: ManualLanguage;
parentId: string | null;
description: string;
createdAt: string;
memberCount: number;
rooms: CollectiveRoomSummary[];
children: CollectiveNode[];
}
export interface CreateCollectiveInput {
name: string;
language: ManualLanguage;
description?: string;
}
const UNIQUE_VIOLATION = "23505";
const FK_VIOLATION = "23503";
export function pgCode(err: unknown): string | undefined {
if (typeof err !== "object" || err === null || !("code" in err)) return undefined;
const code = (err as { code: unknown }).code;
return typeof code === "string" ? code : undefined;
}
/** Language parsing shared by the controller and the service. */
export function parseLanguage(value: unknown): ManualLanguage {
if (value === undefined || value === null || value === "") return DEFAULT_MANUAL_LANGUAGE;
if (!isManualLanguage(value)) {
throw new BadRequestException(`language must be one of ${MANUAL_LANGUAGES.join(" | ")}`);
}
return value;
}
/** A name a collective can carry; same shape rule as GroupsService. */
export function collectiveName(name: string): string {
const clean = name.trim();
if (clean === "") throw new BadRequestException("name must be a non-empty string");
if (clean.length > 120) throw new BadRequestException("name must be at most 120 characters");
return clean;
}
/**
* The collective's language rides the existing `description` column as a
* trailing `lang:<es|en>` line rather than a new column: P1 keeps the migration
* to exactly the three objects the design names (parent_id, kind, group_rooms),
* and the language is presentation metadata, not relational data.
*/
const LANGUAGE_TAG_PREFIX = "lang:";
function stripLanguageTag(description: string): string {
const lines = description.split("\n");
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
if (lines.length > 0 && lines[lines.length - 1]!.startsWith(LANGUAGE_TAG_PREFIX)) lines.pop();
return lines.join("\n");
}
function encodeLanguage(description: string, language: ManualLanguage): string {
const stripped = stripLanguageTag(description);
return stripped === "" ? `${LANGUAGE_TAG_PREFIX}${language}` : `${stripped}\n${LANGUAGE_TAG_PREFIX}${language}`;
}
export function decodeLanguage(description: string | null, fallback: ManualLanguage = DEFAULT_MANUAL_LANGUAGE): ManualLanguage {
if (typeof description !== "string") return fallback;
const lines = description.split("\n");
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i]!.trim();
if (line === "") continue;
if (line.startsWith(LANGUAGE_TAG_PREFIX)) {
const value = line.slice(LANGUAGE_TAG_PREFIX.length);
return isManualLanguage(value) ? value : fallback;
}
return fallback;
}
return fallback;
}
/** The description as any client should render it: stored text minus the language tag. */
export function publicDescription(description: string | null): string {
return stripLanguageTag(description ?? "");
}
interface GroupRow {
id: string;
name: string;
description: string;
kind: GroupKind;
parent_id: string | null;
created_at: Date;
member_count: string | number;
}
interface RoomRow {
group_id: string;
room_type: CollectiveRoomType;
room_id: string;
}
function findNode(nodes: CollectiveNode[], id: string): CollectiveNode | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const found = findNode(node.children, id);
if (found !== undefined) return found;
}
return undefined;
}
@Injectable()
export class CollectivesService {
constructor(@Inject(PROVISIONER_PORT) private readonly provisioner: ProvisionerPort) {}
async createCollective(workspaceId: string, input: CreateCollectiveInput, actor: CollectiveActor): Promise<CollectiveNode> {
const name = collectiveName(input.name);
const language = input.language;
let rooms: ProvisionedRoomSet;
try {
rooms = await this.provisioner.provisionCollective({
workspaceId,
groupId: "pending",
name,
language,
founderUserId: actor.userId,
founderMxid: null,
});
} catch (err) {
if (err instanceof ProvisionerUnavailableError) {
throw new InternalServerErrorException(`collective rooms could not be created: ${err.message}`);
}
throw err;
}
const groupId = await withTenant(workspaceId, async (client) => {
await client.query("SELECT id FROM workspaces WHERE id = $1 FOR UPDATE", [workspaceId]);
let id: string;
try {
const res = await client.query(
`INSERT INTO groups (workspace_id, name, description, kind, parent_id)
VALUES ($1, $2, $3, 'collective', NULL) RETURNING id`,
[workspaceId, name, encodeLanguage(input.description ?? "", language)],
);
id = res.rows[0]!.id as string;
} catch (err) {
if (pgCode(err) === UNIQUE_VIOLATION) throw new ConflictException(`a group named "${name}" already exists`);
throw err;
}
await this.insertFounder(client, workspaceId, id, actor.userId);
await this.insertRooms(client, workspaceId, id, [
["open", rooms.open.roomId],
["closed", rooms.closed.roomId],
["onboarding", rooms.onboarding.roomId],
]);
return id;
});
await appendTenantAudit(workspaceId, {
actorUserId: actor.userId,
actorRole: actor.role,
action: "collective.created",
targetType: "group",
targetId: groupId,
// Room IDs only — never tokens, never power-level payloads.
diff: { name, language, kind: "collective", openRoom: rooms.open.roomId, closedRoom: rooms.closed.roomId },
});
const tree = await this.collectiveTree(workspaceId);
const node = findNode(tree, groupId);
if (node === undefined) throw new InternalServerErrorException("collective was created but is not readable in its workspace");
return node;
}
/**
* The collective tree for the workspace: collectives as roots, subcollectives
* nested, every node carrying its room set. 'plain' groups are sharing
* scopes, not participation units, so they are not part of this tree.
*/
async collectiveTree(workspaceId: string): Promise<CollectiveNode[]> {
const res = await withTenant(workspaceId, async (client) => {
const groups = await client.query(
`SELECT g.id, g.name, g.description, g.kind, g.parent_id, g.created_at,
(SELECT count(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count
FROM groups g
WHERE g.kind IN ('collective','subcollective')
ORDER BY g.created_at ASC, g.id ASC`,
);
const rooms = await client.query(
"SELECT group_id, room_type, room_id FROM group_rooms WHERE workspace_id = $1",
[workspaceId],
);
return { groups, rooms: rooms.rows as RoomRow[] };
});
const byId = new Map<string, CollectiveNode>();
for (const row of res.groups.rows as GroupRow[]) {
byId.set(row.id, {
id: row.id,
name: row.name,
kind: row.kind,
language: decodeLanguage(row.description),
parentId: row.parent_id,
description: publicDescription(row.description),
createdAt: new Date(row.created_at).toISOString(),
memberCount: Number(row.member_count),
rooms: [],
children: [],
});
}
const roomIndex = new Map<string, CollectiveRoomSummary[]>();
for (const row of res.rooms) {
const list = roomIndex.get(row.group_id) ?? [];
list.push({ roomType: row.room_type, roomId: row.room_id });
roomIndex.set(row.group_id, list);
}
const roots: CollectiveNode[] = [];
for (const node of byId.values()) {
node.rooms = (roomIndex.get(node.id) ?? []).sort((a, b) => a.roomType.localeCompare(b.roomType));
if (node.parentId !== null && byId.has(node.parentId)) byId.get(node.parentId)!.children.push(node);
else roots.push(node);
}
return roots.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
/**
* Join-by-invite integration (P1): given an invite token, the collective
* NAMES of the workspace that token points at — nothing else.
*
* The joiner has NO tenant context (they have not claimed yet), so the
* workspace is resolved through the existing narrow SECURITY DEFINER lookup
* `invite_link_for_claim(token)` (017): the caller proves possession of the
* secret first, and the projection stays at names-only (no room ids, no
* member counts, no descriptions). The claim itself is untouched —
* InviteClaimController keeps doing exactly what it did.
*/
async collectivesForInviteToken(token: string): Promise<{ collectives: Array<{ id: string; name: string }> }> {
// Step 1 resolves the workspace from the secret the caller proved possession
// of (SECURITY DEFINER, no tenant context needed). Step 2 reads the names
// inside a tenant transaction, because `groups` is FORCE RLS: without
// app.workspace_id even this service would see zero rows — by design.
const workspaceId = await withSystem(async (client) => {
const link = await client.query("SELECT workspace_id, revoked_at, expires_at FROM invite_link_for_claim($1)", [token]);
const row = link.rows[0] as { workspace_id: string; revoked_at: Date | null; expires_at: Date } | undefined;
if (row === undefined) throw new NotFoundException("invite link is invalid or has expired");
if (row.revoked_at !== null) throw new GoneException("invite link has been revoked");
if (new Date(row.expires_at).getTime() <= Date.now()) throw new GoneException("invite link has expired");
return row.workspace_id;
});
return withTenant(workspaceId, async (client) => {
const names = await client.query(
"SELECT id, name FROM groups WHERE workspace_id = $1 AND kind IN ('collective','subcollective') ORDER BY created_at ASC",
[workspaceId],
);
return {
collectives: (names.rows as Array<{ id: string; name: string }>).map((row) => ({ id: row.id, name: row.name })),
};
});
}
private async insertFounder(client: PoolClient, workspaceId: string, groupId: string, userId: string): Promise<void> {
try {
await client.query(
`INSERT INTO group_members (workspace_id, group_id, user_id)
VALUES ($1, $2, $3) ON CONFLICT (group_id, user_id) DO NOTHING`,
[workspaceId, groupId, userId],
);
} catch (err) {
// Composite FK to memberships: the founder is not a member of this workspace.
if (pgCode(err) === FK_VIOLATION) throw new BadRequestException("founder is not a member of this workspace");
throw err;
}
}
private async insertRooms(
client: PoolClient,
workspaceId: string,
groupId: string,
rooms: ReadonlyArray<readonly [CollectiveRoomType, string]>,
): Promise<void> {
for (const [roomType, roomId] of rooms) {
await client.query("INSERT INTO group_rooms (workspace_id, group_id, room_type, room_id) VALUES ($1, $2, $3, $4)", [
workspaceId,
groupId,
roomType,
roomId,
]);
}
}
}

View file

@ -0,0 +1,342 @@
import { BadRequestException, ConflictException, GoneException, NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QueryResult } from "pg";
import { MANUAL_LANGUAGES } from "@tactical-media-broadcast-hub/core";
import {
CollectivesService,
PROVISIONER_PORT,
collectiveName,
decodeLanguage,
parseLanguage,
publicDescription,
type CollectiveNode,
type ProvisionCollectiveInput,
type ProvisionedRoomSet,
} from "./collectives.service.js";
/**
* Unit tests with a swap-faked ProvisionerPort and a faked tenant client (the
* same shape as groups.test.ts). Live-DB behaviour (RLS, FK scoping, the 030
-- constraints) is in collectives.integration.test.ts.
*/
type Row = Record<string, unknown>;
const fake = vi.hoisted(() => ({
calls: [] as Array<{ sql: string; values: readonly unknown[] }>,
responders: [] as Array<(sql: string, values: readonly unknown[]) => Array<Row> | Error | undefined>,
tenants: [] as string[],
systems: 0,
provisioned: [] as ProvisionCollectiveInput[],
roomSeed: 0,
}));
const client = {
async query(sql: string, values: readonly unknown[] = []): Promise<QueryResult<Row>> {
fake.calls.push({ sql, values });
for (const respond of fake.responders) {
const out = respond(sql, values);
if (out instanceof Error) throw out;
if (out !== undefined) return { command: "", rowCount: out.length, oid: 0, fields: [], rows: out };
}
return { command: "", rowCount: 0, oid: 0, fields: [], rows: [] };
},
};
vi.mock("../db/client.js", () => ({
pool: { query: async (sql: string, values: readonly unknown[] = []) => client.query(sql, values) },
withTenant: async <T>(workspaceId: string, fn: (c: typeof client) => Promise<T>): Promise<T> => {
fake.tenants.push(workspaceId);
return fn(client);
},
withSystem: async <T>(fn: (c: typeof client) => Promise<T>): Promise<T> => {
fake.systems += 1;
return fn(client);
},
}));
vi.mock("../audit/audit.service.js", () => ({
appendTenantAudit: async () => "audit-id",
}));
/** The swap-fake provisioner: deterministic rooms, records its input. */
class FakeProvisioner {
readonly botUserId = "@provisioner:chat.example.com";
rooms: ProvisionedRoomSet | null = null;
calls = 0;
async provisionCollective(input: ProvisionCollectiveInput): Promise<ProvisionedRoomSet> {
fake.provisioned.push(input);
this.calls += 1;
fake.roomSeed += 1;
const id = fake.roomSeed;
this.rooms = {
open: { roomId: `!open-${id}:chat.example.com` },
closed: { roomId: `!closed-${id}:chat.example.com` },
onboarding: { roomId: `!onboarding-${id}:chat.example.com` },
};
return this.rooms;
}
}
function pgError(code: string): Error {
const err = new Error("pg error");
(err as { code?: string }).code = code;
return err;
}
describe("parseLanguage / collectiveName", () => {
it("defaults to Spanish and accepts only the supported languages", () => {
expect(parseLanguage(undefined)).toBe("es");
expect(parseLanguage(null)).toBe("es");
expect(parseLanguage("")).toBe("es");
expect(parseLanguage("es")).toBe("es");
expect(parseLanguage("en")).toBe("en");
for (const bad of ["fr", "ES", "english", 1, true]) {
expect(() => parseLanguage(bad)).toThrow(BadRequestException);
}
expect(MANUAL_LANGUAGES).toEqual(["es", "en"]);
});
it("collectiveName trims, rejects empty and over-long names", () => {
expect(collectiveName(" Colectivo ")).toBe("Colectivo");
expect(() => collectiveName(" ")).toThrow(BadRequestException);
expect(() => collectiveName("x".repeat(121))).toThrow(BadRequestException);
expect(collectiveName("x".repeat(120))).toBe("x".repeat(120));
});
});
describe("language tag encoding", () => {
it("round-trips a language through the description and hides the tag from clients", () => {
const service = decodeLanguage;
expect(service("Bienvenida\nlang:en")).toBe("en");
expect(service("lang:es")).toBe("es");
expect(service("sin etiqueta")).toBe("es");
expect(service(null)).toBe("es");
expect(publicDescription("Bienvenida\nlang:en")).toBe("Bienvenida");
expect(publicDescription("lang:es")).toBe("");
});
});
describe("CollectivesService.createCollective (fake provisioner)", () => {
let provisioner: FakeProvisioner;
let service: CollectivesService;
/** A fresh, per-test responder chain (responders are first-match-wins). */
function installResponder(respond: (sql: string, values: readonly unknown[]) => Array<Row> | Error): void {
fake.responders.length = 0;
fake.responders.push((sql, values) => {
const out = respond(sql, values);
return out instanceof Error ? out : out;
});
}
/**
* The success chain: the group INSERT returns a fresh id, the tree read
* returns that same row, everything else returns no rows (workspace lock,
* member insert, room inserts).
*/
function installSuccess(): void {
let created: Row | null = null;
installResponder((sql) => {
if (sql.startsWith("INSERT INTO groups")) {
fake.roomSeed += 1;
created = { id: `group-${fake.roomSeed}`, name: "X", description: "lang:es", kind: "collective", parent_id: null, created_at: new Date(0), member_count: "1" };
return [created];
}
if (sql.includes("FROM groups g") && created !== null) return [created];
return [];
});
}
beforeEach(() => {
vi.clearAllMocks();
fake.calls.length = 0;
fake.responders.length = 0;
fake.tenants.length = 0;
fake.provisioned.length = 0;
fake.roomSeed = 0;
provisioner = new FakeProvisioner();
service = new CollectivesService(provisioner as unknown as typeof provisioner & { provisionCollective: never });
installSuccess();
});
it("provisions the room set BEFORE any database write", async () => {
expect(fake.provisioned).toHaveLength(0); // sanity: nothing provisioned yet
await service.createCollective("ws", { name: "Colectivo", language: "es" }, { userId: "u1", role: "Owner" });
expect(provisioner.calls).toBe(1);
// The group insert happened, and only after provisioning (rooms existed first).
expect(fake.calls.some((call) => call.sql.startsWith("INSERT INTO groups"))).toBe(true);
expect(fake.provisioned).toHaveLength(1);
});
it("passes the founder and the language to the provisioner", async () => {
await service.createCollective("ws", { name: "Colectivo", language: "en" }, { userId: "founder", role: "Owner" });
expect(fake.provisioned).toHaveLength(1);
expect(fake.provisioned[0]!.name).toBe("Colectivo");
expect(fake.provisioned[0]!.language).toBe("en");
expect(fake.provisioned[0]!.founderUserId).toBe("founder");
});
it("writes the group as a root collective, the founder membership and all three room rows", async () => {
await service.createCollective("ws", { name: "Colectivo", language: "es" }, { userId: "founder", role: "Owner" });
const groupInsert = fake.calls.find((call) => call.sql.startsWith("INSERT INTO groups"));
expect(groupInsert!.values).toEqual(expect.arrayContaining(["ws", "Colectivo", "lang:es"]));
expect(groupInsert!.sql).toContain("'collective'");
expect(groupInsert!.sql).toContain("parent_id");
const memberInsert = fake.calls.find((call) => call.sql.startsWith("INSERT INTO group_members"));
expect(memberInsert!.values).toEqual(["ws", expect.any(String), "founder"]);
const roomInserts = fake.calls.filter((call) => call.sql.startsWith("INSERT INTO group_rooms"));
expect(roomInserts.map((call) => call.values[2])).toEqual(["open", "closed", "onboarding"]);
for (const insert of roomInserts) expect(String(insert.values[3])).toMatch(/^!.*:chat\.example\.com$/);
});
it("maps a duplicate group name to 409 Conflict", async () => {
installResponder((sql) => (sql.startsWith("INSERT INTO groups") ? pgError("23505") : []));
await expect(
service.createCollective("ws", { name: "Dup", language: "es" }, { userId: "u", role: "Owner" }),
).rejects.toMatchObject({ status: 409 });
});
it("rejects a founder who is not a workspace member (FK violation)", async () => {
let groupId = "group-fk";
installResponder((sql) => {
if (sql.startsWith("INSERT INTO groups")) return [{ id: groupId }];
if (sql.startsWith("INSERT INTO group_members")) return pgError("23503");
return [];
});
await expect(
service.createCollective("ws", { name: "NoMember", language: "es" }, { userId: "u", role: "Owner" }),
).rejects.toMatchObject({ status: 400, message: expect.stringContaining("not a member") });
});
it("rejects invalid input before touching the provisioner", async () => {
await expect(
service.createCollective("ws", { name: " ", language: "es" }, { userId: "u", role: "Owner" }),
).rejects.toMatchObject({ status: 400 });
expect(fake.provisioned).toHaveLength(0);
// parseLanguage is the API boundary that keeps invalid languages out of the
// typed input; see the parseLanguage suite above.
});
});
describe("CollectivesService.collectiveTree", () => {
let service: CollectivesService;
beforeEach(() => {
vi.clearAllMocks();
fake.calls.length = 0;
fake.responders.length = 0;
service = new CollectivesService(new FakeProvisioner() as never);
});
it("nests subcollectives under their parent and ignores plain groups", async () => {
let sawKindFilter = false;
fake.responders.push((sql) => {
if (sql.includes("FROM groups g")) {
// The fake simulates the WHERE kind IN ('collective','subcollective'):
// a plain sharing-scope group exists in the workspace but is filtered.
sawKindFilter = sql.includes("kind IN ('collective','subcollective')");
return [
{ id: "root", name: "Raíz", description: "lang:es", kind: "collective", parent_id: null, created_at: new Date(0), member_count: "3" },
{ id: "child", name: "Hija", description: "lang:en", kind: "subcollective", parent_id: "root", created_at: new Date(1), member_count: "1" },
];
}
if (sql.startsWith("SELECT group_id, room_type, room_id FROM group_rooms")) {
return [
{ group_id: "root", room_type: "open", room_id: "!o:c" },
{ group_id: "root", room_type: "closed", room_id: "!c:c" },
{ group_id: "root", room_type: "onboarding", room_id: "!b:c" },
];
}
return [];
});
const tree = await service.collectiveTree("ws");
expect(sawKindFilter).toBe(true); // plain groups are excluded by the query itself
expect(tree).toHaveLength(1);
const root = tree[0] as CollectiveNode;
expect(root.kind).toBe("collective");
expect(root.language).toBe("es");
expect(root.description).toBe("");
expect(root.memberCount).toBe(3);
expect(root.rooms.map((room) => room.roomType)).toEqual(["closed", "onboarding", "open"]);
expect(root.children).toHaveLength(1);
expect(root.children[0]!.kind).toBe("subcollective");
expect(root.children[0]!.language).toBe("en");
expect(root.children[0]!.parentId).toBe("root");
});
it("orphaned subcollectives surface as roots instead of disappearing", async () => {
fake.responders.push((sql) => {
if (sql.includes("FROM groups g")) {
return [{ id: "orphan", name: "Huérfana", description: "", kind: "subcollective", parent_id: "missing", created_at: new Date(0), member_count: "0" }];
}
return [];
});
const tree = await service.collectiveTree("ws");
expect(tree).toHaveLength(1);
expect(tree[0]!.name).toBe("Huérfana");
});
});
describe("CollectivesService.collectivesForInviteToken (join integration)", () => {
let service: CollectivesService;
beforeEach(() => {
vi.clearAllMocks();
fake.calls.length = 0;
fake.responders.length = 0;
fake.systems = 0;
service = new CollectivesService(new FakeProvisioner() as never);
});
it("resolves the workspace through invite_link_for_claim and returns names only", async () => {
fake.responders.push((sql) => {
if (sql.includes("invite_link_for_claim")) {
return [{ workspace_id: "ws-1", revoked_at: null, expires_at: new Date(Date.now() + 3_600_000) }];
}
if (sql.includes("FROM groups")) {
expect(sql).toContain("kind IN");
return [
{ id: "g1", name: "Raíz" },
{ id: "g2", name: "Hija" },
];
}
return [];
});
const result = await service.collectivesForInviteToken("tok");
expect(fake.systems).toBe(1); // no tenant context: the joiner has none yet
expect(result).toEqual({ collectives: [{ id: "g1", name: "Raíz" }, { id: "g2", name: "Hija" }] });
// Names only: no room ids, no member counts in the projection.
expect(result.collectives[0]).not.toHaveProperty("rooms");
});
it("404s an unknown token, 410s revoked and expired ones", async () => {
// Unknown token: the lookup returns no row at all.
fake.responders.push((sql) => (sql.includes("invite_link_for_claim") ? [] : []));
await expect(service.collectivesForInviteToken("nope")).rejects.toMatchObject({ status: 404 });
fake.responders.length = 0;
fake.responders.push((sql) =>
sql.includes("invite_link_for_claim")
? [{ workspace_id: "ws", revoked_at: new Date(), expires_at: new Date(Date.now() + 3_600_000) }]
: [],
);
await expect(service.collectivesForInviteToken("revoked")).rejects.toBeInstanceOf(GoneException);
fake.responders.length = 0;
fake.responders.push((sql) =>
sql.includes("invite_link_for_claim")
? [{ workspace_id: "ws", revoked_at: null, expires_at: new Date(Date.now() - 1_000) }]
: [],
);
await expect(service.collectivesForInviteToken("expired")).rejects.toBeInstanceOf(GoneException);
});
});
describe("PROVISIONER_PORT token", () => {
it("is a stable symbol the module and tests bind the implementation to", () => {
expect(typeof PROVISIONER_PORT).toBe("symbol");
expect(new NotFoundException()).toBeInstanceOf(NotFoundException);
expect(new ConflictException()).toBeInstanceOf(ConflictException);
});
});

View file

@ -0,0 +1,318 @@
import { describe, expect, it } from "vitest";
import type { ManualLanguage } from "@tactical-media-broadcast-hub/core";
import { MANUAL_LANGUAGES, PARTICIPATION_MANUAL } from "@tactical-media-broadcast-hub/core";
import { onboardingSequence } from "./onboarding-content.js";
import { MatrixProvisioner, CLOSED_ROOM_ENCRYPTION, type MatrixAdminPort, type ProvisionerCall } from "./matrix-provisioner.js";
import { FOUNDER_POWER_LEVEL, MEMBER_POWER_LEVEL, type ProvisionCollectiveInput } from "./collectives.service.js";
import { loadProvisionerConfig, MatrixHttpClient, MatrixHttpError } from "./provisioner.config.js";
/**
* Provisioner unit tests against a fake MatrixAdminPort. These assert the ROOM
* SET SHAPE the design mandates (§"Rooms per collective"): open public+federated
* and never encrypted; closed invite-only with m.room.encryption present at
* creation with algorithm m.megolm.v1.aes-sha2; onboarding present, unencrypted,
* carrying the manual ES-first; founder pinned to PL 100.
*/
interface RecordedRoom {
roomId: string;
body: Record<string, unknown>;
}
/** Swap-fake for the Matrix port: no network, deterministic room ids. */
class FakeMatrix implements MatrixAdminPort {
readonly userId = "@provisioner:chat.example.com";
rooms: RecordedRoom[] = [];
invites: Array<{ roomId: string; userId: string }> = [];
messages: Array<{ roomId: string; txnId: string; content: Record<string, unknown> }> = [];
stateEvents: Array<{ roomId: string; eventType: string; stateKey: string; content: Record<string, unknown> }> = [];
failCreateRoom = false;
sequence = 0;
private nextId(): string {
this.sequence += 1;
return `!room${this.sequence}:chat.example.com`;
}
async createRoom(body: Record<string, unknown>): Promise<{ roomId: string }> {
if (this.failCreateRoom) throw new Error("connection refused");
const roomId = this.nextId();
this.rooms.push({ roomId, body });
return { roomId };
}
async sendStateEvent(roomId: string, eventType: string, stateKey: string, content: Record<string, unknown>): Promise<{ eventId: string }> {
this.stateEvents.push({ roomId, eventType, stateKey, content });
return { eventId: `$evt${this.stateEvents.length}` };
}
async invite(roomId: string, userId: string): Promise<void> {
this.invites.push({ roomId, userId });
}
async sendMessage(roomId: string, txnId: string, content: Record<string, unknown>): Promise<{ eventId: string }> {
this.messages.push({ roomId, txnId, content });
return { eventId: `$msg${this.messages.length}` };
}
}
function input(overrides: Partial<ProvisionCollectiveInput> = {}): ProvisionCollectiveInput {
return {
workspaceId: "11111111-1111-4111-8111-111111111111",
groupId: "22222222-2222-4222-8222-222222222222",
name: "Acampada 26S",
language: "es",
founderUserId: "33333333-3333-4333-8333-333333333333",
founderMxid: "@founder:chat.example.com",
...overrides,
};
}
function initialState(room: RecordedRoom, type: string): Record<string, unknown> | undefined {
const list = Array.isArray(room.body.initial_state) ? (room.body.initial_state as Array<Record<string, unknown>>) : [];
return list.find((event) => event.type === type)?.content as Record<string, unknown> | undefined;
}
function provisioner(fake: FakeMatrix): MatrixProvisioner {
return new MatrixProvisioner({ matrix: fake, txnPrefix: "test" });
}
describe("MatrixProvisioner room set shape", () => {
it("creates exactly three rooms: open, closed, onboarding", async () => {
const fake = new FakeMatrix();
const set = await provisioner(fake).provisionCollective(input());
expect(fake.rooms).toHaveLength(3);
expect(set.open.roomId).toBe("!room1:chat.example.com");
expect(set.closed.roomId).toBe("!room2:chat.example.com");
expect(set.onboarding.roomId).toBe("!room3:chat.example.com");
expect(new Set([set.open.roomId, set.closed.roomId, set.onboarding.roomId]).size).toBe(3);
});
it("open room: public, listed, federated, history shared, NO encryption state", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input());
const open = fake.rooms[0]!;
expect(open.body.visibility).toBe("public");
expect(open.body.preset).toBe("public_chat");
expect((open.body.creation_content as Record<string, unknown>)["m.federate"]).toBe(true);
expect(initialState(open, "m.room.history_visibility")).toEqual({ history_visibility: "shared" });
// The single most important negative assertion in this suite.
expect(initialState(open, "m.room.encryption")).toBeUndefined();
expect(JSON.stringify(open.body)).not.toContain("m.megolm");
});
it("closed room: private, invite-only preset, NOT federated, m.room.encryption set at creation with megolm", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input());
const closed = fake.rooms[1]!;
expect(closed.body.visibility).toBe("private");
expect(closed.body.preset).toBe("private_chat");
expect((closed.body.creation_content as Record<string, unknown>)["m.federate"]).toBe(false);
expect(initialState(closed, "m.room.encryption")).toEqual(CLOSED_ROOM_ENCRYPTION);
expect((initialState(closed, "m.room.encryption") as { algorithm: string }).algorithm).toBe("m.megolm.v1.aes-sha2");
});
it("CLOSED_ROOM_ENCRYPTION is exactly the megolm algorithm constant", () => {
expect(CLOSED_ROOM_ENCRYPTION).toEqual({ algorithm: "m.megolm.v1.aes-sha2" });
});
it("onboarding room: private, not federated, NOT encrypted, carries the manual", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input());
const onboarding = fake.rooms[2]!;
expect(onboarding.body.visibility).toBe("private");
expect((onboarding.body.creation_content as Record<string, unknown>)["m.federate"]).toBe(false);
expect(initialState(onboarding, "m.room.encryption")).toBeUndefined();
for (const message of fake.messages) expect(message.roomId).toBe(onboarding.roomId);
});
it("posts the manual ES-first (Spanish header before English header)", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input({ language: "es" }));
const bodies = fake.messages.map((m) => String(m.content.body));
const esHeader = bodies.findIndex((b) => b.startsWith("Manual de participación"));
const enHeader = bodies.findIndex((b) => b.startsWith("Participation manual"));
expect(esHeader).toBeGreaterThanOrEqual(0);
expect(enHeader).toBeGreaterThan(esHeader);
// One header + one message per section, per language.
expect(bodies).toHaveLength(2 * (PARTICIPATION_MANUAL.length + 1));
// Sections appear in manual order within each language block: ES section 1
// right after the ES header, EN section 1 right after the EN header.
expect(bodies[1]).toContain(PARTICIPATION_MANUAL[0]!.heading.es);
expect(bodies[2 + PARTICIPATION_MANUAL.length]).toContain(PARTICIPATION_MANUAL[0]!.heading.en);
});
it("posts the collective's own language first when it is English", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input({ language: "en" }));
const bodies = fake.messages.map((m) => String(m.content.body));
expect(bodies[0]).toMatch(/^Participation manual/);
expect(bodies[1 + PARTICIPATION_MANUAL.length]).toMatch(/^Manual de participación/);
});
it("manual posts are m.text notices with deterministic txn ids", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input());
for (const message of fake.messages) {
expect(message.content.msgtype).toBe("m.text");
expect(message.txnId).toMatch(/^test-!room3:chat\.example\.com-\d+$/);
}
expect(new Set(fake.messages.map((m) => m.txnId)).size).toBe(fake.messages.length);
});
it("power levels: founder and bot at 100, default member at 0, message allowed for members", async () => {
const fake = new FakeMatrix();
const founder = "@founder:chat.example.com";
await provisioner(fake).provisionCollective(input({ founderMxid: founder }));
for (const room of fake.rooms.slice(0, 2)) {
const pl = room.body.power_level_content_override as Record<string, unknown>;
const users = pl.users as Record<string, number>;
expect(users[founder]).toBe(FOUNDER_POWER_LEVEL);
expect(users["@provisioner:chat.example.com"]).toBe(FOUNDER_POWER_LEVEL);
expect(pl.users_default).toBe(MEMBER_POWER_LEVEL);
expect((pl.events as Record<string, number>)["m.room.message"]).toBe(MEMBER_POWER_LEVEL);
expect(pl.state_default).toBe(FOUNDER_POWER_LEVEL);
}
});
it("invites the founder to all three rooms when an mxid is known", async () => {
const fake = new FakeMatrix();
const set = await provisioner(fake).provisionCollective(input());
const invited = new Set(fake.invites.filter((i) => i.userId === "@founder:chat.example.com").map((i) => i.roomId));
expect(invited).toEqual(new Set([set.open.roomId, set.closed.roomId, set.onboarding.roomId]));
});
it("skips invites and power-level grants when no founder mxid is known", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input({ founderMxid: null }));
expect(fake.invites).toHaveLength(0);
for (const room of fake.rooms) {
const users = (room.body.power_level_content_override as Record<string, unknown>).users as Record<string, number>;
expect(Object.keys(users)).toEqual(["@provisioner:chat.example.com"]);
}
});
it("maps any homeserver failure to ProvisionerUnavailableError and persists nothing", async () => {
const fake = new FakeMatrix();
fake.failCreateRoom = true;
const provisioner = new MatrixProvisioner({ matrix: fake });
await expect(provisioner.provisionCollective(input())).rejects.toMatchObject({ name: "ProvisionerUnavailableError" });
// No partial room set was produced, and no manual was posted.
expect(provisioner.calls).toHaveLength(0);
expect(fake.messages).toHaveLength(0);
});
it("records every Matrix call in order (createRoom x3, manual posts, invites)", async () => {
const fake = new FakeMatrix();
const provisioner = new MatrixProvisioner({ matrix: fake, txnPrefix: "rec" });
await provisioner.provisionCollective(input());
const kinds = (provisioner.calls as ProvisionerCall[]).map((call) => call.kind);
expect(kinds.filter((kind) => kind === "createRoom")).toHaveLength(3);
// The open/closed founder invites legitimately precede the manual posts;
// what must hold is: every room exists before any post, and the onboarding
// invite (the last call) happens after the manual is already in the room.
expect(kinds.indexOf("sendMessage")).toBeGreaterThan(kinds.lastIndexOf("createRoom"));
expect(kinds.lastIndexOf("invite")).toBeGreaterThan(kinds.lastIndexOf("sendMessage"));
});
it("room names/topics follow the collective's language per room type", async () => {
const fake = new FakeMatrix();
await provisioner(fake).provisionCollective(input({ language: "es", name: "Colectivo" }));
expect(fake.rooms[0]!.body.name).toContain("abierta");
expect(fake.rooms[1]!.body.name).toContain("producción");
expect(fake.rooms[2]!.body.name).toContain("Acogida");
const fakeEn = new FakeMatrix();
await provisioner(fakeEn).provisionCollective(input({ language: "en", name: "Collective" }));
expect(fakeEn.rooms[0]!.body.name).toContain("open");
expect(fakeEn.rooms[1]!.body.name).toContain("production");
expect(fakeEn.rooms[2]!.body.name).toContain("Onboarding");
});
});
describe("onboardingSequence", () => {
it("is the two-language manual, primary first, for every supported language", () => {
for (const language of MANUAL_LANGUAGES) {
const sequence = onboardingSequence(language);
expect(sequence).toHaveLength(2 * (PARTICIPATION_MANUAL.length + 1));
const first = language === "es" ? /^Manual de participación/ : /^Participation manual/;
const second = language === "es" ? /^Participation manual/ : /^Manual de participación/;
expect(sequence[0]).toMatch(first);
expect(sequence[1 + PARTICIPATION_MANUAL.length]).toMatch(second);
}
});
it("covers every manual section in both languages", () => {
const sequence = onboardingSequence("es");
for (const section of PARTICIPATION_MANUAL) {
expect(sequence.some((text) => text.includes(section.heading.es))).toBe(true);
expect(sequence.some((text) => text.includes(section.heading.en))).toBe(true);
}
});
});
describe("loadProvisionerConfig", () => {
it("returns null when nothing is configured", () => {
expect(loadProvisionerConfig({})).toBeNull();
expect(
loadProvisionerConfig({
ARCHIVE_PROVISIONER_HOMESERVER_URL: "",
ARCHIVE_PROVISIONER_USER: "",
ARCHIVE_PROVISIONER_PASSWORD: "",
}),
).toBeNull();
});
it("reads the ARCHIVE_PROVISIONER_* names and defaults the device id", () => {
const config = loadProvisionerConfig({
ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://chat.example.com",
ARCHIVE_PROVISIONER_USER: "@provisioner:chat.example.com",
ARCHIVE_PROVISIONER_PASSWORD: "hunter2",
});
expect(config).toEqual({
homeserverUrl: new URL("https://chat.example.com"),
user: "@provisioner:chat.example.com",
password: "hunter2",
deviceId: "HUB_PROVISIONER_BOT",
});
});
it("fails loudly on a partial configuration", () => {
expect(() =>
loadProvisionerConfig({
ARCHIVE_PROVISIONER_HOMESERVER_URL: "https://chat.example.com",
ARCHIVE_PROVISIONER_USER: "@provisioner:chat.example.com",
}),
).toThrow(/ARCHIVE_PROVISIONER_PASSWORD is required/);
expect(() =>
loadProvisionerConfig({
ARCHIVE_PROVISIONER_HOMESERVER_URL: "ftp://nope",
ARCHIVE_PROVISIONER_USER: "@p:chat.example.com",
ARCHIVE_PROVISIONER_PASSWORD: "x",
}),
).toThrow(/must be http\(s\)/);
});
it("MatrixHttpClient maps login failures to ProvisionerUnavailableError", async () => {
const client = new MatrixHttpClient({
homeserverUrl: new URL("https://chat.example.com"),
user: "@p:chat.example.com",
password: "x",
deviceId: "D",
});
const original = globalThis.fetch;
globalThis.fetch = (async () => new Response(JSON.stringify({ errcode: "M_FORBIDDEN", error: "bad credentials" }), { status: 403 })) as typeof fetch;
try {
const thrown = await client.login().then(
() => null,
(err: unknown) => err,
);
expect(thrown).toBeInstanceOf(MatrixHttpError);
const error = thrown as InstanceType<typeof MatrixHttpError>;
expect(error.status).toBe(403);
expect(error.errcode).toBe("M_FORBIDDEN");
expect(error.message).toContain("matrix HTTP 403");
} finally {
globalThis.fetch = original;
}
});
});

View file

@ -0,0 +1,247 @@
import { Injectable, Logger } from "@nestjs/common";
import type { ManualLanguage } from "@tactical-media-broadcast-hub/core";
import {
FOUNDER_POWER_LEVEL,
MEMBER_POWER_LEVEL,
ProvisionerUnavailableError,
type ProvisionCollectiveInput,
type ProvisionedRoom,
type ProvisionedRoomSet,
type ProvisionerPort,
} from "./collectives.service.js";
import { onboardingSequence } from "./onboarding-content.js";
/**
* MatrixProvisioner — the real ProvisionerPort implementation.
*
* Room matrix (design §"Rooms per collective"), built with the same plain
* client-server patterns as apps/archive-bot/src/matrix.ts (login once, Bearer
* token, no SDK, no crypto — the provisioner is crypto-blind by construction):
*
* open public + federated, history_visibility shared, NEVER encrypted
* (the archive bot must be able to read it)
* closed invite-only, NOT federated, `m.room.encryption` state set at
* creation (algorithm m.megolm.v1.aes-sha2) so the room is born
* encrypted — structural, not a switch that gets flipped
* onboarding invite-only, not federated, not encrypted (bot-posted guide,
* disposable); the guide sequence is posted ES-first
*
* Power levels at creation: the founder is pinned to PL 100 (design
* §Provisioning) in open + closed rooms; everyone else stays at PL 0. The bot
* keeps its own 100 so it can still administer the rooms it created.
*
* The bot account is SEPARATE from the archive bot, configured with
* ARCHIVE_PROVISIONER_* env vars following apps/archive-bot/src/config.ts
* conventions (see provisioner.config.ts).
*/
/** m.room.encryption content the closed room is created with. */
export const CLOSED_ROOM_ENCRYPTION = Object.freeze({ algorithm: "m.megolm.v1.aes-sha2" });
/** Every Matrix call the provisioner makes, in order — the unit-test surface. */
export type ProvisionerCall =
| { kind: "createRoom"; roomId: string; visibility: "public" | "private"; body: Record<string, unknown> }
| { kind: "invite"; roomId: string; userId: string }
| { kind: "sendMessage"; roomId: string; txnId: string; content: Record<string, unknown> };
/** The narrow Matrix surface provisioning needs; MatrixHttpClient is the only implementation. */
export interface MatrixAdminPort {
readonly userId: string;
/** POST /_matrix/client/v3/createRoom */
createRoom(body: Record<string, unknown>): Promise<{ roomId: string }>;
/** PUT /_matrix/client/v3/rooms/:id/state/:type/:stateKey */
sendStateEvent(roomId: string, eventType: string, stateKey: string, content: Record<string, unknown>): Promise<{ eventId: string }>;
/** POST /_matrix/client/v3/rooms/:id/invite */
invite(roomId: string, userId: string): Promise<void>;
/** PUT /_matrix/client/v3/rooms/:id/send/m.room.message/:txnId */
sendMessage(roomId: string, txnId: string, content: Record<string, unknown>): Promise<{ eventId: string }>;
}
export interface MatrixProvisionerOptions {
matrix: MatrixAdminPort;
logger?: Logger;
/** Deterministic txn-id seed: a retried manual post is idempotent per (room, i). */
txnPrefix?: string;
}
const ROOM_NAME_MAX = 50;
/** Room name/topic per type, in the collective's own language. */
export function roomNames(
name: string,
language: ManualLanguage,
roomType: "open" | "closed" | "onboarding",
): { name: string; topic: string } {
const trimmed = name.slice(0, ROOM_NAME_MAX);
if (roomType === "open") {
return language === "es"
? { name: `${trimmed} — abierta`, topic: "Sala abierta y federada: pública, nunca cifrada. Habla en tu propio nombre." }
: { name: `${trimmed} — open`, topic: "Open, federated room: public and never encrypted. Speak in your own name." };
}
if (roomType === "closed") {
return language === "es"
? { name: `${trimmed} — producción`, topic: "Sala cerrada: solo por invitación, cifrada de extremo a extremo desde su creación." }
: { name: `${trimmed} — production`, topic: "Closed room: invite-only, end-to-end encrypted since creation." };
}
return language === "es"
? { name: `Acogida — ${trimmed}`, topic: "Sala de acogida: el bot guía publica el manual de participación." }
: { name: `Onboarding — ${trimmed}`, topic: "Onboarding room: the guide bot posts the participation manual." };
}
/** Maps any Matrix failure to ProvisionerUnavailableError so the service can 503 cleanly. */
function unavailable(message: string): never {
throw new ProvisionerUnavailableError(message);
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
@Injectable()
export class MatrixProvisioner implements ProvisionerPort {
readonly botUserId: string;
private readonly logger: Logger;
private readonly txnPrefix: string;
/** Recorded for tests and post-hoc debugging; never persisted. */
readonly calls: ProvisionerCall[] = [];
constructor(private readonly opts: MatrixProvisionerOptions) {
this.botUserId = opts.matrix.userId;
this.logger = opts.logger ?? new Logger("MatrixProvisioner");
this.txnPrefix = opts.txnPrefix ?? "hub-provisioner";
}
async provisionCollective(input: ProvisionCollectiveInput): Promise<ProvisionedRoomSet> {
const open = await this.createOpenRoom(input);
const closed = await this.createClosedRoom(input);
const onboarding = await this.createOnboardingRoom(input);
this.logger.log(`provisioned room set: open=${open.roomId} closed=${closed.roomId} onboarding=${onboarding.roomId}`);
return {
open: { roomId: open.roomId },
closed: { roomId: closed.roomId },
onboarding: { roomId: onboarding.roomId },
};
}
/**
* Open room: public + federated. `visibility: "public"` lists it; federation
* is the homeserver default and is only narrowed for the two private rooms.
* history_visibility shared so newcomers can read context (design
* §Provisioning). NO m.room.encryption event — ever: the archive bot must be
* able to read this room, and an open room is public by definition.
*/
private async createOpenRoom(input: ProvisionCollectiveInput): Promise<ProvisionedRoom> {
const { name, topic } = roomNames(input.name, input.language, "open");
const roomId = await this.createRoom({
name,
topic,
preset: "public_chat",
visibility: "public",
creation_content: { "m.federate": true },
initial_state: [{ type: "m.room.history_visibility", state_key: "", content: { history_visibility: "shared" } }],
power_level_content_override: this.powerLevels(input),
});
if (input.founderMxid !== null) await this.inviteUser(roomId, input.founderMxid);
return { roomId };
}
/**
* Closed room: private_chat preset (join_rule invite), federation OFF, and
* `m.room.encryption` in initial_state so the room is encrypted FROM CREATION
* — there is no window in which it exists unencrypted. The bot is crypto-blind:
* it writes this state event and can never read the room's later content.
*/
private async createClosedRoom(input: ProvisionCollectiveInput): Promise<ProvisionedRoom> {
const { name, topic } = roomNames(input.name, input.language, "closed");
const roomId = await this.createRoom({
name,
topic,
preset: "private_chat",
visibility: "private",
creation_content: { "m.federate": false },
initial_state: [
{ type: "m.room.encryption", state_key: "", content: { ...CLOSED_ROOM_ENCRYPTION } },
],
power_level_content_override: this.powerLevels(input),
});
if (input.founderMxid !== null) await this.inviteUser(roomId, input.founderMxid);
return { roomId };
}
/**
* Onboarding room: invite-only, not federated, not encrypted (bot-posted
* guide, disposable). The manual is posted via onboardingSequence() — the
* collective's language first, the other language second — then the founder
* is invited so the guide is already waiting when they arrive.
*/
private async createOnboardingRoom(input: ProvisionCollectiveInput): Promise<ProvisionedRoom> {
const { name, topic } = roomNames(input.name, input.language, "onboarding");
const roomId = await this.createRoom({
name,
topic,
preset: "private_chat",
visibility: "private",
creation_content: { "m.federate": false },
initial_state: [{ type: "m.room.history_visibility", state_key: "", content: { history_visibility: "shared" } }],
power_level_content_override: this.powerLevels(input),
});
for (const [index, text] of onboardingSequence(input.language).entries()) {
await this.post(roomId, index, { msgtype: "m.text", body: text });
}
if (input.founderMxid !== null) await this.inviteUser(roomId, input.founderMxid);
return { roomId };
}
/** Founder pinned at PL 100; everyone else PL 0. Bot keeps 100 to administer its rooms. */
private powerLevels(input: ProvisionCollectiveInput): Record<string, unknown> {
const users: Record<string, number> = { [this.botUserId]: FOUNDER_POWER_LEVEL };
if (input.founderMxid !== null) users[input.founderMxid] = FOUNDER_POWER_LEVEL;
return {
users,
users_default: MEMBER_POWER_LEVEL,
events: { "m.room.message": MEMBER_POWER_LEVEL },
// Only founder/bot may reshape the room (encryption state included).
state_default: FOUNDER_POWER_LEVEL,
};
}
private async createRoom(body: Record<string, unknown>): Promise<string> {
let roomId: string;
try {
const res = await this.opts.matrix.createRoom(body);
roomId = res.roomId;
} catch (err) {
this.logger.warn(`createRoom failed: ${errorText(err)}`);
unavailable(`homeserver refused room creation (${errorText(err)})`);
}
if (typeof roomId !== "string" || roomId === "") unavailable("homeserver returned no room id");
this.calls.push({
kind: "createRoom",
roomId,
visibility: body.visibility === "private" ? "private" : "public",
body,
});
return roomId;
}
private async inviteUser(roomId: string, userId: string): Promise<void> {
try {
await this.opts.matrix.invite(roomId, userId);
} catch (err) {
this.logger.warn(`invite ${userId} to ${roomId} failed: ${errorText(err)}`);
unavailable(`invite to ${roomId} failed (${errorText(err)})`);
}
this.calls.push({ kind: "invite", roomId, userId });
}
private async post(roomId: string, index: number, content: Record<string, unknown>): Promise<void> {
const txnId = `${this.txnPrefix}-${roomId}-${index}`;
try {
await this.opts.matrix.sendMessage(roomId, txnId, content);
} catch (err) {
this.logger.warn(`manual post ${txnId} failed: ${errorText(err)}`);
unavailable(`onboarding manual post failed (${errorText(err)})`);
}
this.calls.push({ kind: "sendMessage", roomId, txnId, content });
}
}

View file

@ -0,0 +1,34 @@
import {
PARTICIPATION_MANUAL,
onboardingHeader,
onboardingLanguageOrder,
onboardingMessages,
type ManualLanguage,
} from "@tactical-media-broadcast-hub/core";
/**
* Onboarding content adapter.
*
* The bilingual manual copy lives in packages/core/src/participation-manual.ts
* so there is ONE source: the public landing page renders it and the
* provisioner posts the byte-identical text into Matrix onboarding rooms. This
* module only derives the message sequence and its language order.
*/
/** The manual as the guide bot posts it: one m.text message per section. */
export function manualMessages(lang: ManualLanguage): string[] {
return [
onboardingHeader(lang),
...PARTICIPATION_MANUAL.map((section) => `${section.heading[lang]}\n\n${section.body[lang]}`),
];
}
/**
* The full onboarding sequence for a collective: the collective's language
* first, the other language second (Spanish-first by default). Exported for the
* provisioner and for the tests that assert the ES-first order.
*/
export function onboardingSequence(language: ManualLanguage): string[] {
const order = onboardingLanguageOrder(language);
return [...onboardingMessages(order[0]!), ...onboardingMessages(order[1]!)];
}

View file

@ -0,0 +1,188 @@
import { ProvisionerUnavailableError } from "./collectives.service.js";
import type { MatrixAdminPort } from "./matrix-provisioner.js";
/**
* Provisioner bot configuration, following apps/archive-bot/src/config.ts
* conventions: explicit required() parsing, http(s)-only URLs, fail-loud
* ConfigError. The provisioner is a SEPARATE Matrix account from the archive
* bot (both crypto-blind); it is identified by ARCHIVE_PROVISIONER_* vars so
* the two bots never share credentials or device ids.
*/
export interface ProvisionerConfig {
homeserverUrl: URL;
user: string;
password: string;
/** Stable device id: Matrix txn-id idempotency survives restarts. */
deviceId: string;
}
export class ConfigError extends Error {}
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name];
if (value === undefined || value.trim() === "") throw new ConfigError(`${name} is required`);
return value.trim();
}
function httpUrl(name: string, raw: string): URL {
let url: URL;
try {
url = new URL(raw);
} catch {
throw new ConfigError(`${name} must be an absolute URL`);
}
if (url.protocol !== "https:" && url.protocol !== "http:") throw new ConfigError(`${name} must be http(s)`);
return url;
}
/**
* Reads ARCHIVE_PROVISIONER_HOMESERVER_URL, ARCHIVE_PROVISIONER_USER,
* ARCHIVE_PROVISIONER_PASSWORD and (optional) ARCHIVE_PROVISIONER_DEVICE_ID.
* Missing config is NOT fatal at boot: the collective-create route answers 503
* (ServiceUnavailableException) instead of taking the API down, mirroring the
* GroupFilesModule precedent for env-dependent surfaces.
*/
export function loadProvisionerConfig(env: NodeJS.ProcessEnv = process.env): ProvisionerConfig | null {
const rawUrl = env.ARCHIVE_PROVISIONER_HOMESERVER_URL?.trim();
const user = env.ARCHIVE_PROVISIONER_USER?.trim();
const password = env.ARCHIVE_PROVISIONER_PASSWORD?.trim();
if (rawUrl === undefined && user === undefined && password === undefined) return null;
if (rawUrl === "" && user === "" && password === "") return null;
return {
homeserverUrl: httpUrl("ARCHIVE_PROVISIONER_HOMESERVER_URL", required(env, "ARCHIVE_PROVISIONER_HOMESERVER_URL")),
user: required(env, "ARCHIVE_PROVISIONER_USER"),
password: required(env, "ARCHIVE_PROVISIONER_PASSWORD"),
deviceId: env.ARCHIVE_PROVISIONER_DEVICE_ID?.trim() || "HUB_PROVISIONER_BOT",
};
}
export class MatrixHttpError extends Error {
constructor(
readonly status: number,
readonly errcode: string | null,
message: string,
) {
super(message);
}
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
/**
* Minimal Matrix admin client for provisioning: login + createRoom + state +
* invite + send. Same plain-fetch shape as the archive bot's MatrixClient (no
* SDK, no sync loop, no crypto), restricted to the endpoints provisioning uses.
*/
export class MatrixHttpClient implements MatrixAdminPort {
private accessToken: string | null = null;
private resolvedUserId = "";
private readonly config: ProvisionerConfig;
constructor(config: ProvisionerConfig) {
this.config = config;
}
get userId(): string {
return this.resolvedUserId;
}
/** m.login.password with the stable device id; called once per process. */
async login(signal?: AbortSignal): Promise<void> {
const body = record(
await this.request("POST", "/_matrix/client/v3/login", {
auth: false,
signal,
json: {
type: "m.login.password",
identifier: { type: "m.id.user", user: this.config.user },
password: this.config.password,
device_id: this.config.deviceId,
initial_device_display_name: "Hub provisioner bot",
},
}),
);
if (typeof body.access_token !== "string" || typeof body.user_id !== "string") {
throw new ProvisionerUnavailableError("login response missing access_token/user_id");
}
this.accessToken = body.access_token;
this.resolvedUserId = body.user_id;
}
async createRoom(room: Record<string, unknown>): Promise<{ roomId: string }> {
const body = record(await this.request("POST", "/_matrix/client/v3/createRoom", { json: room }));
if (typeof body.room_id !== "string" || body.room_id === "") {
throw new ProvisionerUnavailableError("createRoom response missing room_id");
}
return { roomId: body.room_id };
}
async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }> {
const body = record(
await this.request(
"PUT",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`,
{ json: content },
),
);
return { eventId: typeof body.event_id === "string" ? body.event_id : "" };
}
async invite(roomId: string, userId: string): Promise<void> {
await this.request("POST", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, { json: { user_id: userId } });
}
async sendMessage(roomId: string, txnId: string, content: Record<string, unknown>): Promise<{ eventId: string }> {
const body = record(
await this.request(
"PUT",
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
{ json: content },
),
);
return { eventId: typeof body.event_id === "string" ? body.event_id : "" };
}
private token(): string {
if (this.accessToken === null) throw new ProvisionerUnavailableError("provisioner bot is not logged in");
return this.accessToken;
}
private async request(
method: string,
path: string,
opts: { auth?: boolean; json?: unknown; signal?: AbortSignal } = {},
): Promise<unknown> {
const headers: Record<string, string> = { accept: "application/json" };
if (opts.auth !== false) headers.authorization = `Bearer ${this.token()}`;
if (opts.json !== undefined) headers["content-type"] = "application/json";
const res = await fetch(new URL(path, this.config.homeserverUrl), {
method,
headers,
body: opts.json === undefined ? undefined : JSON.stringify(opts.json),
redirect: "error",
signal: opts.signal,
});
if (!res.ok) throw await toError(res);
return res.json() as Promise<unknown>;
}
}
async function toError(res: Response): Promise<MatrixHttpError> {
let errcode: string | null = null;
let message = `matrix HTTP ${res.status}`;
try {
const body = record(await res.json());
if (typeof body.errcode === "string") errcode = body.errcode;
if (typeof body.error === "string") message = `${message}: ${body.error}`;
} catch {
// Non-JSON body; the status is enough.
}
return new MatrixHttpError(res.status, errcode, message);
}

View file

@ -1,9 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
async redirects() {
return [{ source: "/", destination: "/streams", permanent: false }];
},
// The app root ("/") is the public participation-manual landing page
// (collectives P1): no redirect — see src/app/page.tsx. Signed-in visitors
// are sent to /streams client-side so the manual stays reachable.
async rewrites() {
const api = (process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000").replace(/\/$/, "");
return [{ source: "/media-control/preview/:path*", destination: `${api}/media-control/preview/:path*` }];

View file

@ -6,10 +6,12 @@
"dev": "next dev",
"build": "next build",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:manual": "node --require ./scripts/control-room-register.cjs --test src/lib/participation-manual.test.ts"
},
"dependencies": {
"@tactical-media-broadcast-hub/connector-sdk": "workspace:^",
"@tactical-media-broadcast-hub/core": "workspace:^",
"@tailwindcss/postcss": "^4.3.3",
"@tanstack/react-virtual": "^3.14.13",
"@tiptap/extension-character-count": "^3.31.4",

View file

@ -6,6 +6,7 @@ import { Badge, Button, Card, Input } from "../../../components/ui/primitives";
import { LoadingPanel } from "../../../components/brand/loading";
import { apiFetch, ApiError, ApiUnreachableError, errorMessage } from "../../../lib/api-client";
import type { InviteInfo } from "../../../lib/api-types";
import type { ManualLanguage } from "@tactical-media-broadcast-hub/core";
import { useAuth } from "../../../lib/auth-store";
const MIN_PASSWORD = 10;
@ -29,6 +30,16 @@ function deadReasonFrom(err: unknown): DeadReason {
const isAlreadyMember = (err: unknown): boolean => err instanceof ApiError && err.status === 409;
/**
* One-line collective labelling for the claim card ("Collectives: X · Y").
* Null when unknown or empty — the card must read fine without it.
*/
function collectiveLabel(collectives: Array<{ id: string; name: string }> | null, lang: ManualLanguage): string | null {
if (collectives === null || collectives.length === 0) return null;
const names = collectives.map((c) => c.name).join(" · ");
return lang === "es" ? `Colectivos: ${names}` : `Collectives: ${names}`;
}
function usesLeftLine(info: InviteInfo): string {
const left = info.maxUses === null ? "unlimited uses" : `${Math.max(info.maxUses - info.usedCount, 0)} use${info.maxUses - info.usedCount === 1 ? "" : "s"} left`;
return `${left} · expires ${new Date(info.expiresAt).toLocaleDateString()}`;
@ -183,6 +194,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
const [claimError, setClaimError] = useState<string | null>(null);
const [alreadyMember, setAlreadyMember] = useState(false);
const [attempt, setAttempt] = useState(0);
// Collective names for the claim card (names-only public read). Purely
// cosmetic labelling: a failure renders the card without it, never blocks.
const [collectives, setCollectives] = useState<Array<{ id: string; name: string }> | null>(null);
// The preview is public: fetch it immediately rather than waiting for the session to settle.
useEffect(() => {
@ -193,6 +207,14 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
if (cancelled) return;
setInfo(preview);
setPhase("preview");
// Best-effort collective labelling: the token is already proven valid.
apiFetch<{ collectives: Array<{ id: string; name: string }> }>(`/collectives-public/for-invite/${encodeURIComponent(params.token)}`, {
redirectOn401: false,
})
.then((result) => {
if (!cancelled) setCollectives(result.collectives);
})
.catch(() => undefined);
})
.catch((err: unknown) => {
if (cancelled) return;
@ -288,6 +310,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
<Badge tone="accent">{info.role}</Badge>.
</p>
<p className="mt-2 text-2xs text-text-faint">{usesLeftLine(info)}</p>
{collectiveLabel(collectives, "en") !== null && (
<p className="mt-1 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
)}
{claimError !== null && <p className="mt-2 text-xs text-danger">{claimError}</p>}
{memberCta}
</Card>
@ -309,6 +334,9 @@ export default function JoinWorkspacePage({ params }: { params: { token: string
<p className="mt-1 text-2xs text-text-faint">
You&apos;ve been invited as <Badge tone="accent">{info.role}</Badge> · {usesLeftLine(info)}
</p>
{collectiveLabel(collectives, "en") !== null && (
<p className="mt-0.5 text-2xs text-text-faint">{collectiveLabel(collectives, "en")}</p>
)}
</div>
{mode === "signin" ? (
<JoinSignInForm token={params.token} onSwitch={() => setMode("register")} onAlreadyMember={() => setAlreadyMember(true)} />

View file

@ -1,5 +1,190 @@
import { redirect } from "next/navigation";
"use client";
export default function RootPage(): never {
redirect("/streams");
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
MANUAL_INTRO,
MANUAL_PATHS_HEADING,
MANUAL_TITLE,
PARTICIPATION_MANUAL,
PARTICIPATION_PATHS,
isManualLanguage,
type ManualLanguage,
} from "@tactical-media-broadcast-hub/core";
import { Logo } from "../components/brand/logo";
import { AuthProvider, useAuth } from "../lib/auth-store";
/**
* Public participation-manual landing page (the app root).
*
* Design (docs/design/2026-10-12-collectives-architecture.md §"Participation
* manual"): the root explains the platform in plain language and offers the
* three participation paths — join a collective, start one, work on your own —
* before any auth wall. The copy is the SINGLE source in
* packages/core/src/participation-manual.ts: the provisioner posts the
* byte-identical text into onboarding rooms, so this page and the bot guide can
* never drift apart.
*
* Spanish is the primary language: the page opens in ES and the EN toggle is a
* one-tap switch, mirroring the bot's ES-first posting order.
*/
const STORAGE_KEY = "tmbh.manual.lang";
function readStoredLanguage(): ManualLanguage {
if (typeof window === "undefined") return "es";
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return isManualLanguage(stored) ? stored : "es";
} catch {
return "es";
}
}
/** Renders manual body text: blank-line paragraphs, "- " bullets. */
function ManualBody({ text }: { text: string }): React.ReactElement {
const blocks = text.split("\n\n");
return (
<>
{blocks.map((block, index) => {
const lines = block.split("\n");
return lines.every((line) => line.startsWith("- ")) ? (
<ul key={index} className="mb-3 ml-1 list-disc space-y-1.5 pl-4">
{lines.map((line, i) => (
<li key={i}>{line.slice(2)}</li>
))}
</ul>
) : (
<p key={index} className="mb-3 leading-relaxed">{block}</p>
);
})}
</>
);
}
/**
* The root sits OUTSIDE the (main) route group, so its AuthProvider must be
* local: the (main) layout's provider does not wrap this page, and useAuth
* without a provider throws during prerender.
*/
export default function ManualLandingPage(): React.ReactElement {
return (
<AuthProvider>
<ManualLanding />
</AuthProvider>
);
}
function ManualLanding(): React.ReactElement {
const { status, session } = useAuth();
const router = useRouter();
const [lang, setLang] = useState<ManualLanguage>("es");
useEffect(() => setLang(readStoredLanguage()), []);
function switchLanguage(next: ManualLanguage): void {
setLang(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private-browsing refusal: the choice still holds for this view.
}
}
// Signed-in visitors go straight to the app; the manual stays reachable at /wiki.
useEffect(() => {
if (status === "authenticated") router.replace("/streams");
}, [status, router]);
const t = <T extends { es: string; en: string }>(text: T): string => text[lang];
return (
<div className="min-h-dvh bg-bg text-text">
<header className="border-b border-border-subtle bg-surface">
<div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-4">
<Logo size={26} />
<span className="flex-1 truncate text-xs font-bold tracking-tight text-accent">Tactical Media Broadcast Hub</span>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5" role="group" aria-label={t({ es: "Idioma", en: "Language" })}>
{(["es", "en"] as const).map((code) => (
<button
key={code}
type="button"
onClick={() => switchLanguage(code)}
aria-pressed={lang === code}
className={`min-h-8 rounded px-2.5 text-2xs font-bold uppercase transition-colors duration-150 ${
lang === code ? "bg-accent text-white" : "text-text-dim hover:text-text"
}`}
>
{code}
</button>
))}
</div>
<Link
href={status === "authenticated" ? "/streams" : "/onboarding"}
className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-2xs font-medium text-white hover:bg-accent-hover"
>
{status === "authenticated"
? t({ es: "Abrir la app", en: "Open app" })
: t({ es: "Entrar", en: "Sign in" })}
</Link>
</div>
</header>
<main className="mx-auto max-w-2xl px-4 pb-20 pt-8">
<h1 className="mb-2 text-2xl font-bold">{t(MANUAL_TITLE)}</h1>
<p className="mb-8 text-sm leading-relaxed text-text-dim">{t(MANUAL_INTRO)}</p>
{/* The three participation paths — the core of the page. */}
<section aria-labelledby="paths-heading" className="mb-10">
<h2 id="paths-heading" className="mb-3 text-xs font-bold uppercase tracking-wider text-text-faint">
{t(MANUAL_PATHS_HEADING)}
</h2>
<ul className="grid gap-3">
{PARTICIPATION_PATHS.map((path) => {
// All three land on /onboarding: that page holds the sign-in and
// create-account forms. A join link itself arrives from outside
// (the only /join route is /join/[token]); starting/solo need the
// same forms, so one destination is honest for all three.
const href = "/onboarding";
return (
<li key={path.id}>
<Link
href={href}
className="flex min-h-24 flex-col justify-center gap-1.5 rounded-lg border border-border-subtle bg-surface p-4 transition-colors duration-150 hover:border-accent"
>
<span className="text-sm font-semibold">{t(path.title)}</span>
<span className="text-xs leading-relaxed text-text-faint">{t(path.summary)}</span>
<span className="mt-1 text-2xs font-bold uppercase tracking-wider text-accent">{t(path.action)} ›</span>
</Link>
</li>
);
})}
</ul>
</section>
{/* The manual itself. */}
<div className="flex flex-col gap-8">
{PARTICIPATION_MANUAL.map((section, index) => (
<section key={section.id} aria-labelledby={`section-${section.id}`}>
<h2 id={`section-${section.id}`} className="mb-2 flex items-baseline gap-2.5 text-base font-bold">
<span className="font-mono text-xs font-bold text-accent">{String(index + 1).padStart(2, "0")}</span>
{t(section.heading)}
</h2>
<div className="text-sm text-text-dim">
<ManualBody text={section.body[lang]} />
</div>
</section>
))}
</div>
<footer className="mt-12 border-t border-border-subtle pt-6 text-2xs leading-relaxed text-text-faint">
{t({
es: "Sin cuenta no se puede entrar al área de trabajo, pero esta página es pública por diseño: compártela con quien quiera entender cómo funciona un colectivo antes de pedir su invitación.",
en: "The workspace itself needs an account, but this page is public by design: share it with anyone who wants to understand how a collective works before asking for their invite.",
})}
</footer>
</main>
</div>
);
}

View file

@ -58,6 +58,32 @@ export interface InviteInfo {
/** Claiming installs a fresh workspace session — same envelope as POST /auth/login. */
export type InviteClaimResult = AuthResult;
/** GET /collectives-public/for-invite/:token — names-only, pre-claim labelling. */
export interface CollectiveNameList {
collectives: Array<{ id: string; name: string }>;
}
/** A collective node in the workspace tree (GET /collectives/tree). */
export interface CollectiveRoomSummary {
roomType: "open" | "closed" | "onboarding";
roomId: string;
}
export type GroupKind = "collective" | "subcollective" | "plain";
export interface CollectiveNode {
id: string;
name: string;
kind: GroupKind;
language: "es" | "en";
parentId: string | null;
description: string;
createdAt: string;
memberCount: number;
rooms: CollectiveRoomSummary[];
children: CollectiveNode[];
}
/** Anonymous claimant credentials; ignored when the request carries a valid session. */
export interface InviteClaimInput {
username: string;

View file

@ -0,0 +1,136 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import {
MANUAL_LANGUAGES,
MANUAL_TITLE,
MANUAL_INTRO,
MANUAL_PATHS_HEADING,
PARTICIPATION_MANUAL,
PARTICIPATION_PATHS,
DEFAULT_MANUAL_LANGUAGE,
MANUAL_LANGUAGE_ORDER,
isManualLanguage,
onboardingHeader,
onboardingMessages,
onboardingLanguageOrder,
type ManualLanguage,
} from "@tactical-media-broadcast-hub/core";
/**
* Landing-page content tests (node:test, the control-room.test.ts runner).
* The copy module is the single source shared by the public root page and the
* Matrix onboarding bot, so these assertions protect BOTH surfaces at once.
*/
test("the manual covers the four sections the design mandates", () => {
assert.deepEqual(
PARTICIPATION_MANUAL.map((section) => section.id),
["what-this-is", "rooms", "safety", "help"],
);
for (const section of PARTICIPATION_MANUAL) {
for (const lang of MANUAL_LANGUAGES) {
assert.ok(section.heading[lang].length > 0, `${section.id}.${lang} heading`);
assert.ok(section.body[lang].length > 0, `${section.id}.${lang} body`);
}
}
});
test("the three participation paths are exactly join, start and solo", () => {
assert.deepEqual(
PARTICIPATION_PATHS.map((path) => path.id),
["join", "start", "solo"],
);
for (const path of PARTICIPATION_PATHS) {
assert.ok(path.title.es && path.title.en);
assert.ok(path.summary.es && path.summary.en);
assert.ok(path.action.es && path.action.en);
}
});
test("the safety section states the non-negotiables in both languages", () => {
const safety = PARTICIPATION_MANUAL.find((section) => section.id === "safety")!;
for (const [lang, needles] of [
["es", ["cifrado de extremo a extremo", "borradones", "invitación", "cifrado"]],
["en", ["end-to-end encryption", "redaction", "invitation", "ciphertext"]],
] as const) {
for (const needle of needles) assert.ok(safety.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
});
test("the rooms section names all three room types and their privacy stance", () => {
const rooms = PARTICIPATION_MANUAL.find((section) => section.id === "rooms")!;
for (const [lang, needles] of [
["es", ["abierta", "cerrada", "acogida", "nunca está cifrada", "nace cifrada"]],
["en", ["open room", "closed room", "onboarding room", "never end-to-end encrypted", "born encrypted"]],
] as const) {
for (const needle of needles) assert.ok(rooms.body[lang].toLowerCase().includes(needle), `${lang}: ${needle}`);
}
});
test("Spanish is the default and first language everywhere", () => {
assert.equal(DEFAULT_MANUAL_LANGUAGE, "es");
assert.deepEqual(MANUAL_LANGUAGE_ORDER, ["es", "en"]);
assert.equal(onboardingLanguageOrder("es")[0], "es");
assert.equal(onboardingLanguageOrder("en")[0], "en");
// Title/intro exist for both languages (the page renders whichever is active).
assert.ok(MANUAL_TITLE.es && MANUAL_TITLE.en);
assert.ok(MANUAL_INTRO.es && MANUAL_INTRO.en);
assert.ok(MANUAL_PATHS_HEADING.es && MANUAL_PATHS_HEADING.en);
});
test("isManualLanguage accepts exactly es and en", () => {
assert.equal(isManualLanguage("es"), true);
assert.equal(isManualLanguage("en"), true);
for (const bad of ["fr", "ES", "", null, undefined, 1]) {
assert.equal(isManualLanguage(bad), false);
}
});
test("onboardingMessages is one header plus one message per section, in section order", () => {
for (const lang of MANUAL_LANGUAGES) {
const messages = onboardingMessages(lang);
assert.equal(messages.length, PARTICIPATION_MANUAL.length + 1);
assert.ok(messages[0]!.startsWith(onboardingHeader(lang)));
for (const [index, section] of PARTICIPATION_MANUAL.entries()) {
const message = messages[index + 1];
assert.ok(message !== undefined, `${lang} section ${index} exists`);
assert.ok(message!.startsWith(section.heading[lang]), `${lang} section ${index} starts with its heading`);
assert.ok(message!.includes(section.body[lang].split("\n\n")[0]!), `${lang} section ${index} carries its body`);
}
}
});
test("the onboarding header cross-references the other language", () => {
assert.ok(onboardingHeader("es").includes("inglés"));
assert.ok(onboardingHeader("en").includes("Spanish"));
});
test("every body block renders with the two known conventions only (paragraphs and '- ' bullets)", () => {
for (const section of PARTICIPATION_MANUAL) {
for (const lang of MANUAL_LANGUAGES) {
for (const block of section.body[lang].split("\n\n")) {
const lines = block.split("\n");
const allBullets = lines.every((line) => line.startsWith("- "));
const noBullets = lines.every((line) => !line.startsWith("- "));
assert.ok(allBullets || noBullets, `${section.id}.${lang}: mixed bullet/paragraph block`);
// No markdown the renderer does not understand.
for (const line of lines) assert.ok(!/^#{1,6} /.test(line), `${section.id}.${lang}: stray heading`);
}
}
}
});
test("language type is closed: assigning an unknown value is a compile-time error, runtime is guarded", () => {
const langs: ManualLanguage[] = ["es", "en"];
assert.deepEqual(langs.sort(), ["en", "es"]);
});
test("manual copy stays plain-language: no dev jargon in user-facing sections", () => {
const jargon = ["api", "http", "rls", "endpoint", "json", "sdk", "federate=true", "megolm"];
for (const section of PARTICIPATION_MANUAL) {
for (const lang of MANUAL_LANGUAGES) {
const body = section.body[lang].toLowerCase();
for (const word of jargon) assert.ok(!body.includes(word), `${section.id}.${lang}: jargon "${word}"`);
}
}
});

View file

@ -83,6 +83,45 @@ export const WIKI_PAGES: readonly WikiPage[] = [
},
],
},
{
slug: "collectives",
title: { en: "Collectives and rooms", es: "Colectivos y salas" },
blurb: {
en: "How the collective model works: the open, closed and onboarding rooms, who gets in, and what stays private.",
es: "Cómo funciona el modelo de colectivos: las salas abierta, cerrada y de acogida, quién entra y qué se mantiene privado.",
},
sections: [
{
heading: { en: "What a collective is", es: "Qué es un colectivo" },
body: {
en: "A collective is your group inside the workspace: it has its own name, its own language (Spanish or English), its own files folder and its own set of Matrix rooms. A workspace can hold several collectives, and a collective can hold subcollectives — a tree, with the people who found it at the root.\n\nEverything else you already know still applies: drafts, approvals, scheduling and the archive are shared through the workspace, while the collective decides who talks with whom and where.",
es: "Un colectivo es tu grupo dentro del espacio de trabajo: tiene su propio nombre, su propio idioma (español o inglés), su propia carpeta de archivos y su propio conjunto de salas de Matrix. Un espacio puede contener varios colectivos, y un colectivo puede tener subcolectivos — un árbol, con quienes lo fundan en la raíz.\n\nTodo lo demás que ya conoces sigue igual: borradores, revisiones, programación y archivo se comparten por el espacio de trabajo, mientras el colectivo decide quién habla con quién y dónde.",
},
},
{
heading: { en: "The three rooms", es: "Las tres salas" },
body: {
en: "Creating a collective provisions three rooms automatically.\n\n- **Open room** — public, listed and federated: anyone from any Matrix server can join and talk to you without an account from us. It is never end-to-end encrypted, because the archive bot must be able to read it to index what is shared. Everything written there is public — treat it that way.\n- **Closed room** (production) — invite-only and limited to this server. It is end-to-end encrypted from the moment it is created: there is no switch to flip and no unencrypted version of its content anywhere on the server. Internal coordination, people's data and decisions belong here.\n- **Onboarding room** — invite-only and disposable. The guide bot posts the participation manual in Spanish and in English, so every new person reads the same orientation.",
es: "Crear un colectivo provisiona tres salas automáticamente.\n\n- **Sala abierta** — pública, listada y federada: cualquiera desde cualquier servidor de Matrix puede entrar y hablarte sin pedirte cuenta. Nunca está cifrada de extremo a extremo, porque el bot de archivo debe poder leerla para indexar lo compartido. Todo lo que se escriba ahí es público — trátalo así.\n- **Sala cerrada** (producción) — solo por invitación y limitada a este servidor. Está cifrada de extremo a extremo desde el momento en que se crea: no hay interruptor que activar ni versión sin cifrar de su contenido en ninguna parte del servidor. La coordinación interna, los datos de personas y las decisiones van aquí.\n- **Sala de acogida** — solo por invitación y desechable. El bot guía publica el manual de participación en español y en inglés, así que cada persona nueva lee la misma orientación.",
},
},
{
heading: { en: "Who can do what", es: "Quién puede hacer qué" },
body: {
en: "The person who creates a collective is its **founder**: they hold the highest permission level in the open and closed rooms and they are the one who invites. Everyone else starts at the normal level — they can speak, but not reshape the rooms.\n\nJoining happens by invite link, exactly as it always has: open the link, create your account if you need one, and you are in. If the link has expired or run out of uses, ask for a fresh one — the expiry is a safety measure, not a fault.",
es: "Quien crea un colectivo es su **fundador**: tiene el nivel máximo de permisos en las salas abierta y cerrada, y es quien invita. Las demás personas empiezan en el nivel normal — pueden hablar, pero no reconfigurar las salas.\n\nEntrar se hace por enlace de invitación, igual que siempre: abre el enlace, crea tu cuenta si la necesitas, y ya estás dentro. Si el enlace caducó o agotó sus usos, pide uno nuevo: la caducidad es una medida de seguridad, no un fallo.",
},
},
{
heading: { en: "What stays private", es: "Qué se mantiene privado" },
body: {
en: "If the server is seized, what leaves the closed rooms is ciphertext without the keys to read it — the material itself lives in the clients of the room's members. A redaction in Matrix is honoured in the index and in derived links. Open-room content was public by definition and is archived as public; closed-room material is never archived to the public index.\n\nThe public participation manual at the site root explains this same model for people who have no account at all — share that link freely.",
es: "Si el servidor se incauta, lo que sale de las salas cerradas es texto cifrado sin las llaves para leerlo — el material mismo vive en los clientes de quienes son miembros de la sala. Un borradón en Matrix se respeta en el índice y en los enlaces derivados. El contenido de la sala abierta era público por definición y se archiva como público; el material de las salas cerradas nunca llega al índice público.\n\nEl manual de participación público, en la raíz del sitio, explica este mismo modelo para quien no tiene cuenta ninguna — comparte ese enlace con libertad.",
},
},
],
},
{
slug: "composing-posts",
title: { en: "Composing posts", es: "Redactar publicaciones" },