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:
parent
c31762b82d
commit
4a7e78b28d
23 changed files with 2892 additions and 8 deletions
131
apps/api/migrations/030_collectives.sql
Normal file
131
apps/api/migrations/030_collectives.sql
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
100
apps/api/src/collectives/collectives.controller.ts
Normal file
100
apps/api/src/collectives/collectives.controller.ts
Normal 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;
|
||||
}
|
||||
276
apps/api/src/collectives/collectives.integration.test.ts
Normal file
276
apps/api/src/collectives/collectives.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
apps/api/src/collectives/collectives.module.ts
Normal file
51
apps/api/src/collectives/collectives.module.ts
Normal 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 {}
|
||||
383
apps/api/src/collectives/collectives.service.ts
Normal file
383
apps/api/src/collectives/collectives.service.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
342
apps/api/src/collectives/collectives.test.ts
Normal file
342
apps/api/src/collectives/collectives.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
318
apps/api/src/collectives/matrix-provisioner.test.ts
Normal file
318
apps/api/src/collectives/matrix-provisioner.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
247
apps/api/src/collectives/matrix-provisioner.ts
Normal file
247
apps/api/src/collectives/matrix-provisioner.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
apps/api/src/collectives/onboarding-content.ts
Normal file
34
apps/api/src/collectives/onboarding-content.ts
Normal 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]!)];
|
||||
}
|
||||
188
apps/api/src/collectives/provisioner.config.ts
Normal file
188
apps/api/src/collectives/provisioner.config.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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*` }];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'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)} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
136
apps/web/src/lib/participation-manual.test.ts
Normal file
136
apps/web/src/lib/participation-manual.test.ts
Normal 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}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
156
packages/core/src/participation-manual.ts
Normal file
156
packages/core/src/participation-manual.ts
Normal file
|
|
@ -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"];
|
||||
}
|
||||
206
pnpm-lock.yaml
generated
206
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue