+
+ {/* The three participation paths — the core of the page. */}
+
+
+ {t(MANUAL_PATHS_HEADING)}
+
+
+ {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 (
+
+ );
}
diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts
index 0c782fa..a498223 100644
--- a/apps/web/src/lib/api-types.ts
+++ b/apps/web/src/lib/api-types.ts
@@ -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;
diff --git a/apps/web/src/lib/participation-manual.test.ts b/apps/web/src/lib/participation-manual.test.ts
new file mode 100644
index 0000000..9414f7a
--- /dev/null
+++ b/apps/web/src/lib/participation-manual.test.ts
@@ -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}"`);
+ }
+ }
+});
diff --git a/apps/web/src/lib/wiki-content.ts b/apps/web/src/lib/wiki-content.ts
index 9f4bb42..e224a5a 100644
--- a/apps/web/src/lib/wiki-content.ts
+++ b/apps/web/src/lib/wiki-content.ts
@@ -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" },
diff --git a/docs/design/2026-10-12-collectives-architecture.md b/docs/design/2026-10-12-collectives-architecture.md
index f811bf6..e8a052d 100644
--- a/docs/design/2026-10-12-collectives-architecture.md
+++ b/docs/design/2026-10-12-collectives-architecture.md
@@ -1,5 +1,14 @@
# Collectives architecture — participation, rooms, subcollectives, diffusion
+> **Status — P1 shipped (implementation note).** The three scope pillars of P1 are in:
+> `groups.parent_id`/`groups.kind`/`group_rooms` (migration `030_collectives.sql`),
+> the participation-manual landing page at the app root (`apps/web/src/app/page.tsx`, copy in
+> `packages/core/src/participation-manual.ts`), and the provisioner port + Matrix implementation
+> (`apps/api/src/collectives/`). Join-by-invite is unchanged: an invite token claims workspace
+> membership exactly as before; `GET /collectives-public/for-invite/:token` adds names-only
+> collective labelling to the join card. Room addresses in the UI, directory, and the UI/state
+> re-parenting surface remain P2+.
+
Status: design for build (phase 1 dispatch in flight)
Date: 2026-10-12
Decides: landing flow, collective model, room matrix, provisioning, diffusion
diff --git a/docs/matrix-setup.md b/docs/matrix-setup.md
index c90ffdd..e39cb39 100644
--- a/docs/matrix-setup.md
+++ b/docs/matrix-setup.md
@@ -35,6 +35,31 @@ Conduwuit supports formatted bodies natively — clients (Element et al.) give M
## Branding
Element Web (if self-hosted as the client) accepts custom CSS — the same propagation rule as PeerTube: repo is the source of truth, a sync script pushes (accent #FF1610 on black, solid disk mark) after any brand change. Native clients keep their own themes; brand consistency applies to web surfaces.
+## Provisioner bot (collectives P1)
+
+Room provisioning for collectives (`docs/design/2026-10-12-collectives-architecture.md`) runs as a
+**separate bot account from the archive bot** — both are crypto-blind, but they hold different
+powers, so they never share credentials or a device id:
+
+```bash
+# apps/api reads these; missing config = collective creation answers 503 (never a boot failure)
+ARCHIVE_PROVISIONER_HOMESERVER_URL=https://chat.example.com
+ARCHIVE_PROVISIONER_USER=@hub-provisioner:chat.example.com # create it like any bot account below
+ARCHIVE_PROVISIONER_PASSWORD=...
+ARCHIVE_PROVISIONER_DEVICE_ID=HUB_PROVISIONER_BOT # stable: txn-id idempotency survives restarts
+```
+
+What it provisions per collective, in one pass (see `apps/api/src/collectives/matrix-provisioner.ts`):
+
+| Room | preset / visibility | federation | encryption | notes |
+| --- | --- | --- | --- | --- |
+| Open | `public_chat` / public | on (`m.federate: true`) | **never** | `history_visibility: shared`; archive-mirrorable by definition |
+| Closed (production) | `private_chat` / private | **off** | **at creation** (`m.megolm.v1.aes-sha2` in `initial_state`) | no unencrypted form ever exists server-side |
+| Onboarding | `private_chat` / private | off | no | bot posts the participation manual ES-first, then EN; disposable |
+
+Founder power level is pinned to 100, everyone else 0; the bot keeps 100 to administer the rooms it
+built. Room ids (never aliases) are recorded in `group_rooms` (migration 030), tenant-scoped and RLS'd.
+
## Tactical Media Broadcast Hub integration points (follow-ups)
- **Inbox screen**: swap fixtures for Matrix client-sdk (matrix-js-sdk) — DMs + team rooms render in-app
- **Review notifications**: WP5.1 posts approval-needed events into a team room
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index fb3975a..90b37bd 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -14,6 +14,7 @@ export * from "./connection.js";
export * from "./file-mime.js";
export * from "./file-names.js";
export * from "./messaging.js";
+export * from "./participation-manual.js";
export * from "./publishing.js";
export * from "./shared-files.js";
export * from "./social.js";
diff --git a/packages/core/src/participation-manual.ts b/packages/core/src/participation-manual.ts
new file mode 100644
index 0000000..0721fa6
--- /dev/null
+++ b/packages/core/src/participation-manual.ts
@@ -0,0 +1,156 @@
+/**
+ * Participation manual — the bilingual (ES/EN) copy that lives at the app root
+ * and inside every onboarding room (docs/design/2026-10-12-collectives-
+ * architecture.md §"Participation manual").
+ *
+ * Spanish is the primary language: the manual is Spanish-first with an English
+ * toggle, and the provisioner posts the ES sequence before the EN one. The copy
+ * is written for people who organise — plain register, no marketing tone, no
+ * unexplained jargon — and it is deliberately conservative about what the
+ * platform promises (anything security-relevant is stated as behaviour, not as
+ * aspiration).
+ *
+ * This module is the SINGLE source for the copy: the public landing page
+ * renders it and the provisioner bot posts the same text into onboarding rooms,
+ * so the manual a founder reads in the browser is byte-identical to the one the
+ * guide bot posts in Matrix.
+ */
+
+/** Manual languages. `es` is the default everywhere (Spanish-first product). */
+export const MANUAL_LANGUAGES = ["es", "en"] as const;
+export type ManualLanguage = (typeof MANUAL_LANGUAGES)[number];
+
+export function isManualLanguage(value: unknown): value is ManualLanguage {
+ return value === "es" || value === "en";
+}
+
+/** es first, en second — the order every "post both" path uses. */
+export const MANUAL_LANGUAGE_ORDER: readonly ManualLanguage[] = ["es", "en"];
+
+export interface LocalizedManualText {
+ readonly es: string;
+ readonly en: string;
+}
+
+/** Paragraphs are separated by a blank line; lines starting with "- " are bullets. */
+export interface ManualSection {
+ readonly id: string;
+ readonly heading: LocalizedManualText;
+ readonly body: LocalizedManualText;
+}
+
+export interface ParticipationPath {
+ readonly id: "join" | "start" | "solo";
+ readonly title: LocalizedManualText;
+ readonly summary: LocalizedManualText;
+ /** What the button/link says on the landing page. */
+ readonly action: LocalizedManualText;
+}
+
+export const DEFAULT_MANUAL_LANGUAGE: ManualLanguage = "es";
+
+export const MANUAL_TITLE: LocalizedManualText = {
+ es: "Manual de participación",
+ en: "Participation manual",
+};
+
+export const MANUAL_INTRO: LocalizedManualText = {
+ es: "Este es el punto de entrada público de un colectivo de medios. Antes de entrar, lee esto: explica qué es la plataforma, los tres caminos para participar, cómo funcionan las salas y qué protege el modelo de seguridad. No hace falta cuenta para leer esta página.",
+ en: "This is the public entry point of a media collective. Read this first: it explains what the platform is, the three ways to take part, how the rooms work and what the safety model protects. No account is needed to read this page.",
+};
+
+export const PARTICIPATION_MANUAL: readonly ManualSection[] = [
+ {
+ id: "what-this-is",
+ heading: { es: "Qué es esta plataforma", en: "What this platform is" },
+ body: {
+ es: "Un centro de operaciones para colectivos que publican: redactar, revisar, programar y publicar en varias redes a la vez, mantener un archivo que sobrevive a los borradones y coordinar salas en vivo.\n\nCada colectivo tiene su propio árbol de grupos (colectivo y subcolectivos), su carpeta de archivos y su conjunto de salas. Todo funciona en el navegador; no hay nada que instalar.",
+ en: "An operations room for collectives that publish: write, review, schedule and post to several networks at once, keep an archive that survives takedowns, and coordinate live rooms.\n\nEvery collective has its own group tree (the collective and its subcollectives), its own files folder and its own room set. Everything runs in the browser; there is nothing to install.",
+ },
+ },
+ {
+ id: "rooms",
+ heading: { es: "Las salas: abierta, cerrada y de acogida", en: "The rooms: open, closed and onboarding" },
+ body: {
+ es: "- Sala abierta: pública, listada y federada. Cualquier persona desde cualquier servidor Matrix puede entrar a hablar con el colectivo sin pedirnos cuenta. Nunca está cifrada de extremo a extremo: el bot de archivo debe poder leerla para indexar lo que se comparte, así que todo lo que escribas ahí es público.\n- Sala cerrada (producción): solo por invitación y solo en este servidor. Cifrarla de extremo a extremo no es una opción que se activa: la sala nace cifrada y el servidor guarda texto cifrado. La coordinación interna va aquí.\n- Sala de acogida (onboarding): solo por invitación y desechable. El bot guía publica este manual en español y en inglés.\n\nEtiqueta mínima: en la sala abierta habla en nombre propio; la coordinación interna, los datos de personas y las decisiones van en la sala cerrada.",
+ en: "- Open room: public, listed and federated. Anyone from any Matrix server can join and talk to the collective without asking us for an account. It is never end-to-end encrypted: the archive bot has to be able to read it to index what is shared, so everything you write there is public.\n- Closed room (production): invite-only and on this server only. End-to-end encryption is not a switch that gets flipped: the room is born encrypted and the server stores ciphertext. Internal coordination belongs here.\n- Onboarding room: invite-only and disposable. The guide bot posts this manual in Spanish and in English.\n\nMinimum etiquette: in the open room speak in your own name; internal coordination, people's data and decisions belong in the closed room.",
+ },
+ },
+ {
+ id: "safety",
+ heading: { es: "Modelo de seguridad", en: "Safety model" },
+ body: {
+ es: "- Cifrado de extremo a extremo por defecto en producción: cada sala cerrada se crea ya cifrada; no existe una versión sin cifrar de su contenido en el servidor.\n- Cuentas solo con invitación: el registro está cerrado por token. Quien participa desde fuera lo hace federado, desde su propio servidor, y nunca recibe credenciales nuestras.\n- Borradones: una redacción en Matrix se respeta en el índice y en los enlaces derivados; el material de una sala cerrada vive en los clientes de quienes son miembros. El contenido de una sala abierta era público por definición y se archiva como público.\n- Si el servidor se incauta, lo que se lleva de las salas cerradas es texto cifrado sin claves de lectura.",
+ en: "- End-to-end encryption by default in production: every closed room is created already encrypted; no unencrypted copy of its content exists on the server.\n- Accounts only by invitation: registration is token-gated. Outsiders take part federated, from their own server, and never receive credentials of ours.\n- Redaction: a Matrix redaction is honoured in the index and in any derived links; closed-room material lives in the clients of its members. Open-room content was public by definition and is archived as public.\n- If the server is seized, what leaves the closed rooms is ciphertext without reading keys.",
+ },
+ },
+ {
+ id: "help",
+ heading: { es: "Cómo pedir ayuda", en: "How to get help" },
+ body: {
+ es: "Tu sala de acogida es el primer sitio: ahí está este manual en español y en inglés, y el bot guía responde con lo básico.\n\nSi tu enlace de invitación caducó o se agotó, pide uno nuevo a quien lo creó; la caducidad es una medida de seguridad, no un fallo. Si algo urgente pasa en una sala cerrada, escribe en la sala: quien administra el colectivo ve los avisos.",
+ en: "Your onboarding room is the first place to look: this manual is there in Spanish and in English, and the guide bot answers with the basics.\n\nIf your invite link expired or ran out of uses, ask whoever created it for a fresh one; the expiry is a safety measure, not a fault. If something urgent happens in a closed room, write in the room: whoever administers the collective sees the notices.",
+ },
+ },
+];
+
+/** The three participation paths, in the order the landing page shows them. */
+export const PARTICIPATION_PATHS: readonly ParticipationPath[] = [
+ {
+ id: "join",
+ title: { es: "Únete a un colectivo", en: "Join a collective" },
+ summary: {
+ es: "Te han pasado un enlace de invitación. Ábrelo, crea tu cuenta si aún no tienes y entras directamente al colectivo.",
+ en: "Someone gave you an invite link. Open it, create your account if you do not have one yet, and you land inside the collective.",
+ },
+ action: { es: "Abrir mi invitación", en: "Open my invite" },
+ },
+ {
+ id: "start",
+ title: { es: "Empieza un colectivo", en: "Start a collective" },
+ summary: {
+ es: "Con cuenta activa, creas el colectivo con su nombre y su idioma; las salas (abierta, cerrada y de acogida) se crean solas y quedas como fundador.",
+ en: "With an active account you create the collective with its name and language; the room set (open, closed and onboarding) is provisioned for you and you are its founder.",
+ },
+ action: { es: "Crear un colectivo", en: "Create a collective" },
+ },
+ {
+ id: "solo",
+ title: { es: "Trabaja por tu cuenta", en: "Work on your own" },
+ summary: {
+ es: "Todavía no hay colectivo: entra o crea tu cuenta y empieza en tu propio espacio. Cuando te unas o crees un colectivo, su sala de acogida trae este manual y el bot guía.",
+ en: "No collective yet: sign in or create your account and start in your own workspace. When you join or start a collective, its onboarding room carries this manual and the guide bot.",
+ },
+ action: { es: "Entrar o crear cuenta", en: "Sign in or create account" },
+ },
+];
+
+/** Landing-page heading for the three paths block. */
+export const MANUAL_PATHS_HEADING: LocalizedManualText = {
+ es: "Los tres caminos",
+ en: "The three paths",
+};
+
+/** Short header the guide bot posts before each language's manual sequence. */
+export function onboardingHeader(lang: ManualLanguage): string {
+ return lang === "es"
+ ? "Manual de participación — español. La versión en inglés sigue a continuación."
+ : "Participation manual — English. The Spanish version is above.";
+}
+
+/**
+ * The manual as the guide bot posts it into an onboarding room: one message per
+ * section, plain text (no HTML), headings inline. The provisioner posts the
+ * primary language's sequence first and the other language second.
+ */
+export function onboardingMessages(lang: ManualLanguage): string[] {
+ return [
+ onboardingHeader(lang),
+ ...PARTICIPATION_MANUAL.map((section) => `${section.heading[lang]}\n\n${section.body[lang]}`),
+ ];
+}
+
+/** The order the provisioner posts: the collective's language first, the other second. */
+export function onboardingLanguageOrder(primary: ManualLanguage): readonly ManualLanguage[] {
+ return primary === "es" ? MANUAL_LANGUAGE_ORDER : ["en", "es"];
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1705466..d3d98b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -108,15 +108,24 @@ importers:
apps/archive-bot:
dependencies:
+ '@aws-sdk/client-s3':
+ specifier: ^3.1146.0
+ version: 3.1146.0
'@tactical-media-broadcast-hub/core':
specifier: workspace:*
version: link:../../packages/core
+ age-encryption:
+ specifier: ^0.3.1
+ version: 0.3.1
pg:
specifier: ^8.23.1
version: 8.23.1
pino:
specifier: ^9.3.2
version: 9.14.0
+ tar-stream:
+ specifier: ^3.2.2
+ version: 3.2.2
devDependencies:
'@types/node':
specifier: ^22.5.0
@@ -210,6 +219,9 @@ importers:
'@tactical-media-broadcast-hub/connector-sdk':
specifier: workspace:^
version: link:../../packages/connector-sdk
+ '@tactical-media-broadcast-hub/core':
+ specifier: workspace:^
+ version: link:../../packages/core
'@tailwindcss/postcss':
specifier: ^4.3.3
version: 4.3.3
@@ -1417,6 +1429,30 @@ packages:
cpu: [x64]
os: [win32]
+ '@noble/ciphers@2.4.0':
+ resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/curves@2.0.1':
+ resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/curves@2.4.0':
+ resolution: {integrity: sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@2.0.1':
+ resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/hashes@2.4.0':
+ resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==}
+ engines: {node: '>= 20.19.0'}
+
+ '@noble/post-quantum@0.5.4':
+ resolution: {integrity: sha512-leww0zzIirrvwaYMPI9fj6aRIlA/c6Y0/lifQQ1YOOyHEr0MNH3yYpjXeiVG+tWdPps4XxGclFWX2INPO3Yo5w==}
+ engines: {node: '>= 20.19.0'}
+
'@nuxtjs/opencollective@0.3.2':
resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==}
engines: {node: '>=8.0.0', npm: '>=5.0.0'}
@@ -2216,6 +2252,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@scure/base@2.4.0':
+ resolution: {integrity: sha512-thZ1TuJwFwBblOhgsjDKvvGirBxNp+wSvY/DR6tJBJOTDhdAAcHJ8Vbr2eFnqaxeca4+t0i9KBf+uHYGWwZORg==}
+
'@smithy/core@3.35.1':
resolution: {integrity: sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==}
engines: {node: '>=18.0.0'}
@@ -2887,6 +2926,9 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ age-encryption@0.3.1:
+ resolution: {integrity: sha512-bYgd7lxM7tEANmb9bXf7xTFB3Qpq+JGKinVSdFh5MV+t2fJJZSTdKhWkvXTFJQGysZd+K9Dt3F+n+4DKazXlnQ==}
+
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
@@ -2947,6 +2989,14 @@ packages:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
+ b4a@1.9.0:
+ resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==}
+ peerDependencies:
+ react-native-b4a: '*'
+ peerDependenciesMeta:
+ react-native-b4a:
+ optional: true
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2954,6 +3004,43 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ bare-events@2.9.2:
+ resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+
+ bare-fs@4.8.4:
+ resolution: {integrity: sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==}
+ engines: {bare: '>=1.28.0'}
+ peerDependencies:
+ bare-buffer: '*'
+ peerDependenciesMeta:
+ bare-buffer:
+ optional: true
+
+ bare-path@3.1.2:
+ resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==}
+
+ bare-stream@2.13.4:
+ resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==}
+ peerDependencies:
+ bare-abort-controller: '*'
+ bare-buffer: '*'
+ bare-events: '*'
+ peerDependenciesMeta:
+ bare-abort-controller:
+ optional: true
+ bare-buffer:
+ optional: true
+ bare-events:
+ optional: true
+
+ bare-url@2.5.4:
+ resolution: {integrity: sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==}
+
baseline-browser-mapping@2.11.27:
resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==}
engines: {node: '>=6.0.0'}
@@ -3262,6 +3349,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
+ events-universal@1.0.1:
+ resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
+
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -3284,6 +3374,9 @@ packages:
resolution: {integrity: sha512-8unmvA0qrfpFWBYUate9BTGtQdKHWBYt+KdAIkag7Vvxe7wz8Q2nJg7fLMC0bJqe0bjP31kca7Ckaawrs4Istg==}
engines: {node: '>=6.0.0'}
+ fast-fifo@1.3.2:
+ resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
+
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -4250,6 +4343,9 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
+ streamx@2.28.1:
+ resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -4317,11 +4413,20 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ tar-stream@3.2.2:
+ resolution: {integrity: sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==}
+
+ teex@1.0.1:
+ resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==}
+
terser@5.51.2:
resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==}
engines: {node: '>=10'}
hasBin: true
+ text-decoder@1.2.7:
+ resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
+
thingies@2.6.1:
resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==}
engines: {node: '>=10.18'}
@@ -5271,6 +5376,25 @@ snapshots:
'@next/swc-win32-x64-msvc@14.2.33':
optional: true
+ '@noble/ciphers@2.4.0': {}
+
+ '@noble/curves@2.0.1':
+ dependencies:
+ '@noble/hashes': 2.0.1
+
+ '@noble/curves@2.4.0':
+ dependencies:
+ '@noble/hashes': 2.4.0
+
+ '@noble/hashes@2.0.1': {}
+
+ '@noble/hashes@2.4.0': {}
+
+ '@noble/post-quantum@0.5.4':
+ dependencies:
+ '@noble/curves': 2.0.1
+ '@noble/hashes': 2.0.1
+
'@nuxtjs/opencollective@0.3.2':
dependencies:
chalk: 4.1.2
@@ -6192,6 +6316,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.64.0':
optional: true
+ '@scure/base@2.4.0': {}
+
'@smithy/core@3.35.1':
dependencies:
'@smithy/types': 4.19.0
@@ -6991,6 +7117,14 @@ snapshots:
acorn@8.19.0: {}
+ age-encryption@0.3.1:
+ dependencies:
+ '@noble/ciphers': 2.4.0
+ '@noble/curves': 2.4.0
+ '@noble/hashes': 2.4.0
+ '@noble/post-quantum': 0.5.4
+ '@scure/base': 2.4.0
+
agent-base@7.1.4: {}
ajv-formats@3.0.1(ajv@8.20.0):
@@ -7040,10 +7174,41 @@ snapshots:
atomic-sleep@1.0.0: {}
+ b4a@1.9.0: {}
+
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
+ bare-events@2.9.2: {}
+
+ bare-fs@4.8.4:
+ dependencies:
+ bare-events: 2.9.2
+ bare-path: 3.1.2
+ bare-stream: 2.13.4(bare-events@2.9.2)
+ bare-url: 2.5.4
+ fast-fifo: 1.3.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
+ bare-path@3.1.2: {}
+
+ bare-stream@2.13.4(bare-events@2.9.2):
+ dependencies:
+ b4a: 1.9.0
+ streamx: 2.28.1
+ teex: 1.0.1
+ optionalDependencies:
+ bare-events: 2.9.2
+ transitivePeerDependencies:
+ - react-native-b4a
+
+ bare-url@2.5.4:
+ dependencies:
+ bare-path: 3.1.2
+
baseline-browser-mapping@2.11.27: {}
bignumber.js@9.3.1: {}
@@ -7391,6 +7556,12 @@ snapshots:
event-target-shim@5.0.1: {}
+ events-universal@1.0.1:
+ dependencies:
+ bare-events: 2.9.2
+ transitivePeerDependencies:
+ - bare-abort-controller
+
events@3.3.0: {}
expect-type@1.4.0: {}
@@ -7437,6 +7608,8 @@ snapshots:
fast-equals@5.4.3: {}
+ fast-fifo@1.3.2: {}
+
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
@@ -8377,6 +8550,15 @@ snapshots:
streamsearch@1.1.0: {}
+ streamx@2.28.1:
+ dependencies:
+ events-universal: 1.0.1
+ fast-fifo: 1.3.2
+ text-decoder: 1.2.7
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -8432,6 +8614,24 @@ snapshots:
tapable@2.3.3: {}
+ tar-stream@3.2.2:
+ dependencies:
+ b4a: 1.9.0
+ bare-fs: 4.8.4
+ fast-fifo: 1.3.2
+ streamx: 2.28.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - bare-buffer
+ - react-native-b4a
+
+ teex@1.0.1:
+ dependencies:
+ streamx: 2.28.1
+ transitivePeerDependencies:
+ - bare-abort-controller
+ - react-native-b4a
+
terser@5.51.2:
dependencies:
'@jridgewell/source-map': 0.3.11
@@ -8439,6 +8639,12 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
+ text-decoder@1.2.7:
+ dependencies:
+ b4a: 1.9.0
+ transitivePeerDependencies:
+ - react-native-b4a
+
thingies@2.6.1(tslib@2.8.1):
dependencies:
tslib: 2.8.1