diff --git a/README.md b/README.md index 8410019..650f286 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Every feature is multi-tenant with row-level security. ## Features -- **Publishing pipeline** — drafts → group approval → schedule → fenced dispatch → receipts. Multi-platform fan-out (Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord, Slack). DST-proof scheduling. +- **Publishing pipeline** — drafts → group approval → schedule → fenced dispatch → receipts. Per-connection publish governance: `auto`, one-admin, majority or consensus sign-off per account — boosts included. Multi-platform fan-out (Mastodon, Telegram, Bluesky, PeerTube, Facebook, Instagram, Threads, LinkedIn, Reddit, Twitch, Discord, Slack). DST-proof scheduling. +- **Files SSO** — the Hub is the OIDC issuer for oCIS Drive: one sign-in, auto-provisioned personal drives, rotating refresh tokens that die with your Hub session (logout propagates). - **Column wall** — live multi-platform feed columns, TweetDeck-style. Safe rendering (hostile-HTML tested). Per-source cooldown + adaptive backoff. - **Live Control Room** — browser multiview, TAKE/CUT, preview/program, lower-thirds, scrolling chyrons, audio mixing, tally. - **Auto-Director** — priority rules switch sources automatically so the stream never dies mid-action. diff --git a/docs/admin-guide.md b/docs/admin-guide.md index 99d258e..dbc124e 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -68,7 +68,7 @@ openssl rand -base64 32 # → SESSION_SECRET - `sp_app` — created by migration `003_app_role.sql`; `NOSUPERUSER`, grants on all tables/sequences. **All runtime connections.** `FORCE ROW LEVEL SECURITY` means even the table owner cannot silently bypass tenancy. ### 4.2 Migrations -Ordered SQL files in `apps/api/migrations/` (001 tenants + RLS + audit chain · 002 RBAC seed · 003 sp_app role · 004 NULLIF policy fix · 005 epoch trigger fix · 006 publishing). Idempotent runner (`apps/api/src/db/migrate.ts`) tracks `schema_migrations`: +Ordered SQL files in `apps/api/migrations/` — currently **001–034** (001 tenants + RLS + audit chain · 002 RBAC seed · 003 sp_app role · 004 NULLIF policy fix · 005 epoch trigger fix · 006 publishing · 019 publishing integration · 024 chatgov · 029 group files · 031 personal workspaces · 033 publish governance · 034 auth sessions + OIDC refresh tokens). Idempotent runner (`apps/api/src/db/migrate.ts`) tracks `schema_migrations`: ```bash cd apps/api && SUPER_DATABASE_URL='postgresql://tmbh:…@localhost:5433/tmbh' \ @@ -95,6 +95,43 @@ Truth model: raising a value **does not bypass the provider** — it declares wh 37 permissions across 10 seeded roles (Owner, Admin, Publisher, Editor, Approver, Analyst, Moderator, VideoPublisher, StreamOperator, BroadcastOperator). Role changes bump the workspace `authz_epoch`; affected live sessions fail closed on their next request. Audit every membership change (hash-chained; `audit_verify_chain()` detects tampering). +## 6.1 Publish governance (who signs off a post) + +Each social connection carries a publish-governance setting. Manage per connection (requires `chatgov.manage`): + +```bash +curl -X PATCH https://api.occupymedia.cc/connections/$CONN/publish-governance \ + -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \ + -d '{"publishGovernance": "admin1"}' # auto | admin1 | admin_majority | admin_consensus | null +``` + +| Mode | Who signs off the publish | +|---|---| +| `null` (unset) | manual: anyone with `publication.publish` | +| `auto` | nobody — dispatch fires when the draft's approval lands | +| `admin1` | one admin, via a publish poll | +| `admin_majority` | majority of admins (snapshot at poll open) | +| `admin_consensus` | every admin; one veto blocks at once | + +While held, the publish/boost route answers `202 awaiting_publish_approval` with the poll id; the poll closes after a day and rejects unanswered. Reconnecting an account keeps the setting. Changing the setting does not cancel an already-open poll. The Mastodon **boost** route runs behind the same gate. + +## 6.2 Files SSO (Hub as the OIDC issuer for oCIS) + +`files.occupymedia.cc` signs in through the Hub: the issuer is `https://api.occupymedia.cc/oidc`, client id `web`, PKCE only, auto-consent for that first-party client. Phase 2 (migration 034) makes sessions revocable and adds rotating refresh tokens bound to the Hub session — Hub logout reaches Drive within one access-token TTL (≤1 h). Operational notes: + +- `OIDC_KEY_DIR` must be a persistent volume (the API generates and seals its ES256 keyset there on first boot; losing it logs every Drive session out). +- `FILES_OCID_USER` and `OIDC_RESERVED_USERNAMES` are never claimable as files accounts (`admin` is reserved by default). +- Sign-in requires an **active membership** and a username usable as an account name; otherwise the consent endpoint fails closed. + +## 6.3 Verification harnesses + +```bash +RADAR_EMAIL=… RADAR_PASSWORD=… node scripts/e2e/governance-sim.mjs # S1–S9 governance scenarios, live +node scripts/e2e/brand-palette-check.mjs # fails on blue-family pixels in embedded surfaces +``` + +The governance sim is re-runnable (all fixtures prefixed `e2e-gov-`) and writes a report to `/tmp/opencode/workers/GOV-SIM-REPORT.md` by default. + ## 7. Monitoring & health - `GET /healthz` — liveness (no dependencies). @@ -128,6 +165,8 @@ Truth model: raising a value **does not bypass the provider** — it declares wh 1. Back up DB + confirm KEK backups (§4.3). 2. `git pull && pnpm install`. -3. Run migrations (§4.2) — additive by design; expand/contract policy documented per migration. +3. Run migrations (§4.2) — additive by design; expand/contract policy documented per migration. **Order matters**: 033 must exist before the scheduler that queries its columns rolls, or the E3 pre-dispatch check fails open. 4. Restart API, then scheduler. 5. Watch `/readyz` and the audit chain verification. + +**Session note (migration 034+):** tokens issued before 034 carry no `sid`; they keep working until expiry but `reissue` refuses them — after upgrading, users re-login at their next epoch bump or within 12 h at the latest. Logout only revokes server-side for tokens issued after the upgrade. diff --git a/docs/design/2026-10-13-files-oidc-sso.md b/docs/design/2026-10-13-files-oidc-sso.md index ceaae12..63a5684 100644 --- a/docs/design/2026-10-13-files-oidc-sso.md +++ b/docs/design/2026-10-13-files-oidc-sso.md @@ -1,6 +1,7 @@ # Files SSO design — Hub as the OIDC issuer for oCIS -Status: proposed (design pass; not implemented) +Status: **implemented** (phase 1: provider + sign-in, shipped; phase 2: session-bound +refresh tokens + logout propagation, shipped — see §Phases update below) Date: 2026-10-13 Related: docs/design/2026-10-12-collectives-architecture.md, brand review P0 #2 (files.occupymedia.cc navy login wall), matrix-sso module (chat SSO, shipped). @@ -67,12 +68,31 @@ already holds the session, the RBAC roles, and the username namespace. ## Phases 1. **Provider + sign-in** (≈1–2 days): the module above; Drive view switches - `authMode` to `"oidc"`; the navy wall disappears. + `authMode` to `"oidc"`; the navy wall disappears. **Shipped.** 2. **Personal drives** (≈1 day): uploaded files land in the user's own drive; - the Library keeps serving collective folders from the service account. + the Library keeps serving collective folders from the service account. **Shipped** (autoprovisioned personal spaces verified live). 3. **Share-through** (≈1 day, optional): group folders shared into members' drives so the Drive tab also shows collective Library content; only then - consider deprecating the Library tab. + consider deprecating the Library tab. *(open)* + +## Phase 2 as shipped (refresh + logout, migration 034) + +The security note below said logout propagation was a nice-to-have; it shipped +as part of hardening instead: + +- `auth_sessions` rows (`sid` in the session payload): login/reissue write the + row, `POST /auth/logout` revokes it, `reissue` refuses revoked or pre-034 + tokens. Request validation stays stateless. +- `oidc_refresh_tokens`: opaque tokens, SHA-256 at rest, rotate-on-use, + `family_id` chains with reuse detection (presenting a consumed token revokes + the chain). Every refresh re-checks the session row and active membership — + so an oCIS session can never outlive the Hub sign-in that minted it, and + logout propagates within one access-token TTL (≤1 h). +- The token endpoint is unauthenticated: lookups go through the definer-rights + `oidc_refresh_lookup()` (audit_append pattern). Refresh TTL has no constant — + it *is* the Hub session's remaining lifetime. +- Matrix SSO stays out of propagation scope by design: the broker mints + 2-minute `m.login.token`s and Element holds a Matrix-native session after. ## Security notes diff --git a/docs/engineering-guide.md b/docs/engineering-guide.md index fc86e23..e40e962 100644 --- a/docs/engineering-guide.md +++ b/docs/engineering-guide.md @@ -60,8 +60,10 @@ Cross-tenant functions take an explicit `p_workspace_id` and call `fn_assert_ten ### 3.4 Continuous authorization (audit finding #2) Any membership insert/update/delete fires `bump_authz_epoch()` — `COALESCE(NEW.workspace_id, OLD.workspace_id)` (OLD is NULL on INSERT; the original bug silently no-oped). Sessions embed the epoch at issue; `RbacGuard` compares against the live `workspaces.authz_epoch` and fails closed (`401, epoch stale`) on mismatch. Revocation propagates to live sessions without waiting for re-login. -### 3.5 Sessions & OIDC -HMAC-SHA256 signed tokens (`apps/api/src/auth/session.ts`), 12h TTL, constant-time verify. OIDC: generic code flow + PKCE S256, discovery-document driven (`auth/oidc.ts`) — Authentik/Keycloak/any compliant issuer via `OIDC_*` env. +### 3.5 Sessions, logout & the Hub as OIDC issuer +HMAC-SHA256 signed tokens (`apps/api/src/auth/session.ts`), 12h TTL, constant-time verify. Since migration `034`, every session also carries a `sid` backed by an `auth_sessions` row: login/reissue write the row, `POST /auth/logout` revokes it, and `reissue` refuses both revoked rows and pre-`034` tokens (no `sid`) so revocation applies from the next epoch bump. Request validation (`RbacGuard`) stays stateless — rows are consulted only by reissue and the OIDC refresh path. + +The Hub is also its own OpenID Connect **provider** for oCIS (`apps/api/src/oidc/`, issuer `https://api.occupymedia.cc/oidc`): discovery, JWKS (ES256, keys sealed via the vault pattern), `/oidc/authorize` → SPA consent → single-use code (60 s, PKCE S256 enforced) → tokens. Only the first-party client `web` (the oCIS web app) is registered; anything else fails closed. Phase 2 added **session-bound rotating refresh tokens** (`oidc_refresh_tokens`, SHA-256 at rest, `family_id` chains): a chain lives and dies with the consenting Hub session, reuse of a consumed token revokes the whole family (OAuth BCP), and every accept re-checks that the session row is live and the membership active — so Hub logout propagates to oCIS at the next refresh instead of at the next silent authorize. The unauthenticated token endpoint resolves tokens through the definer-rights `oidc_refresh_lookup()` (the `audit_append` pattern). Matrix SSO (`apps/api/src/matrix-sso/`) is a separate 2-minute `m.login.token` broker and intentionally out of propagation scope: Element holds its own Matrix-native session after mint. ### 3.6 Credential vault Envelope encryption (`vault/vault.ts`): per-workspace DEK (AES-256-GCM) wrapped by master KEK (`SUPERPOSTER_KEK`, 32B base64; production-required). Rotation re-wraps; old DEK dropped → pre-rotation ciphertext unreadable (tested). Connectors receive opaque `CredentialRef` handles via `CredentialAccessor` — never ciphertext, never raw tokens. @@ -105,6 +107,23 @@ Hash-chained per branch (`audit_append` for tenant rows, `audit_append_system` f - **State machine** (`publishing.service.ts`): `editing → in_review → approved → scheduled → published` (+ `rejected`, `cancelled`). Every transition permission-checked (`approval.request` / `approval.grant` / `publication.schedule`) and audited. - **Durability** (`006_publishing.sql`): `scheduled_publications` carries a `UNIQUE idempotency_key`; the DB row commits first; if Temporal is unreachable the row stays `pending` and the worker's startup sweep picks it up. - **Temporal** (`scheduler/src/workflows/publish.workflow.ts`): `scheduledPublish(id)` activity (5-min timeout) claims pending-only, resolves the connector via registry, publishes, records the publication, idempotent on re-run. +- **Receipts**: `GET /publishing/drafts/:draftId/publications` returns per-attempt outcomes (`ready/in_progress/completed/failed/unknown/abandoned`, `remoteId/remoteUrl`); the calendar page renders the latest outcome per scheduled draft as a badge with an open-remote link. + +### 5.1 Per-connection publish governance (migration 033) +`connections.fields.publishGovernance` decides what happens when an approved draft targets that connection: + +| Setting | Behavior on approval | +|---|---| +| unset | today's manual flow: a `publication.publish` holder calls `POST :draftId/publish` | +| `auto` | the approval itself schedules the publish (console approve or passing proposal poll) | +| `admin1` | a second chatgov poll (`subject_kind='draft_publish'`) opens; one admin yes releases | +| `admin_majority` | same, `quorum [{admins, x: floor(n/2)+1}]` snapshotted at open | +| `admin_consensus` | same, `[{admins, x: n}] join all`, veto allowed | + +Route: `PATCH /connections/:id/publish-governance` (requires `chatgov.manage`). The publish route answers `202 {state:"awaiting_publish_approval", pollId, closesAt, rule}` while held; a passing poll schedules the publish as the original requester (re-checked at claim). The scheduler's **E3 pre-dispatch check** refuses any dispatch to a gated destination without a passed publish poll for the current approved revision — including schedules created before the gate was switched on. One open publish poll per (draft, destination), index-enforced, never colliding with review polls. + +### 5.2 Governed boost (amplify) +`POST /publishing/drafts/:draftId/amplify {connectionId, kind: "repost"|"quote"}` executes the connector's `amplify` against `providerMetadata.sourceRemoteRef` recorded at draft creation — never a caller-supplied id. Target gates: draft state `approved` + the connection's publish governance. A gated boost holds with `202 awaiting_publish_approval` on the same publish-phase poll and is released by re-invoking amplify after the pass (hold-and-retry: no durable amplify queue exists, and the poll-close transaction must not do network I/O). Unset/`auto` connections behave exactly as before (`200` + execute). ## 6. Frontend (apps/web) @@ -140,12 +159,14 @@ Live Source Monitor (adaptive polling, anti-flapping, per-host budgets); media e | Concern | Path | |---|---| | Tenant context / pools | `apps/api/src/db/client.ts` | -| Migrations 001–006 | `apps/api/migrations/` | +| Migrations 001–034 | `apps/api/migrations/` | | Vault / storage crypto | `apps/api/src/vault/vault.ts`, `apps/api/src/storage/storage.ts` | -| Sessions / OIDC | `apps/api/src/auth/` | +| Sessions / OIDC provider (issuer + refresh) | `apps/api/src/auth/`, `apps/api/src/oidc/` | | RBAC guard | `apps/api/src/rbac/rbac.guard.ts` | | Audit chain | `migrations/001_tenants.sql` (SQL) + `apps/api/src/audit/` | -| Publishing | `apps/api/src/publishing/`, `apps/scheduler/src/` | +| Publishing + publish governance + amplify | `apps/api/src/publishing/`, `apps/scheduler/src/activities/dispatch-gate.ts` | +| Matrix SSO broker | `apps/api/src/matrix-sso/` | | Connector SDK | `packages/connector-sdk/src/` | | Frontend mock-registry | `apps/web/src/lib/` | | Lint: no-provider-branching | `eslint.config.mjs` | +| E2E: governance sim (S1–S9), brand palette guard | `scripts/e2e/governance-sim.mjs`, `scripts/e2e/brand-palette-check.mjs` | diff --git a/docs/user-manual-v2.md b/docs/user-manual-v2.md index 991b276..91bd3b9 100644 --- a/docs/user-manual-v2.md +++ b/docs/user-manual-v2.md @@ -152,6 +152,7 @@ - **Live.** Reads `GET /connections` for the workspace. A new workspace shows *"No channels connected yet"*, followed by one dashed *not connected* card per known platform. - **Connect buttons.** **Connect account**, **Connect**, **Reconnect** and **Remove** are **disabled when live**. Their tooltip reads: *"OAuth connect flows arrive with the connector wiring phase"*. **There is no way to add a channel from the UI today.** +- **Publish governance** (per connection): admins choose who signs off each publish — manual, automatic on approval, or an admin vote (1 / majority / consensus). Managed via the API (`PATCH /connections/:id/publish-governance`, admin guide §6.1); there is no web control for it yet. A governed connection holds publishes and boosts with *"awaiting publish approval"* until the admins' poll passes. - **Each connected card** (seen in sample mode) shows: - a health badge (*healthy*, *token expiring*, *needs credentials*) - capability chips (`publishText`, `amplifyRepost`, `feed`, `directMessages`, `comments`…) @@ -257,17 +258,11 @@ Invite emails and invite acceptance are **not wired**. The page says so itself. ## 11. Calendar, Approvals, Analytics, Settings -**Status: stub.** Each of these four screens is a placeholder reading *"Arrives with WP3 (publishing pipeline)."* +**Calendar** is the scheduled-drafts list (live against the API when signed in). Each row shows its draft, its schedule, and — since the receipts feature — a **dispatch receipt badge** for any draft that has been dispatched: the latest attempt's outcome (`completed` green, `failed` red, `in progress`/`ready` amber), with an **open ↗** link when the platform returned a post URL. Badges refresh on load, on schedule changes, every 30 s, and on **Refresh**. Drafts never dispatched show no badge. -| Calendar | Approvals | -|---|---| -| ![](manual/screenshots/desktop-calendar.png) | ![](manual/screenshots/desktop-approvals.png) | +**Boosts (amplify).** A boost reposts or quotes someone else's post through one of your connections, and it is governed like any publish: the draft must be approved, and if the connection's publish governance is an admin mode (see the admin guide §6.1), the boost is held with *"awaiting publish approval"* until admins approve the publish poll — then re-send it and it executes. On ungoverned connections a boost executes immediately. -| Analytics | Settings | -|---|---| -| ![](manual/screenshots/desktop-analytics.png) | ![](manual/screenshots/desktop-settings.png) | - -The approval *state machine* does exist in the API: drafts move editing → in_review → approved, verified in §5. There is just no review-queue screen yet. +**Approvals / Analytics / Settings**: Approvals renders the review queue; Analytics and Settings are live screens (workspace settings, sessions, connections). --- diff --git a/docs/wiki/EN/getting-started.md b/docs/wiki/EN/getting-started.md index 7fb003d..879e8c2 100644 --- a/docs/wiki/EN/getting-started.md +++ b/docs/wiki/EN/getting-started.md @@ -27,3 +27,11 @@ Go to **More → Team** and use the invite link. Share the link with your crew ## 4. Connect your first accounts Go to **Connections** and add your platform accounts (Mastodon, Telegram, Bluesky, etc.). Each account's credentials are stored in an encrypted vault — the database never sees a plaintext key. + +## 5. Sign in to Files (your Drive) + +Open **Files** from the Hub — no second password. Files (your cloud drive) uses your Hub sign-in: the first time, your account is created in the Drive automatically. If the Hub signs you out, the Drive follows within the hour. Same account, same crew, no extra credentials to leak. + +## 6. Boosts and approvals + +Boosting someone else's post works like publishing your own: the proposal is approved first, and if your collective enabled **publish approval** for that account, admins get a second vote before anything leaves. You'll see *"awaiting publish approval"* while it waits — resend once approved. Whether an account auto-publishes, needs one admin, a majority, or full consensus is your collective's choice (admins: see the admin guide). diff --git a/docs/wiki/ES/getting-started.md b/docs/wiki/ES/getting-started.md index 400821c..89f45ef 100644 --- a/docs/wiki/ES/getting-started.md +++ b/docs/wiki/ES/getting-started.md @@ -27,3 +27,11 @@ Ve a **Más → Equipo** y usa el enlace de invitación. Comparte el enlace con ## 4. Conecta tus primeras cuentas Ve a **Conexiones** y agrega tus cuentas (Mastodon, Telegram, Bluesky, etc.). Las credenciales de cada cuenta se guardan en una bóveda cifrada — la base de datos nunca ve una clave en texto plano. + +## 5. Entra a Archivos (tu Drive) + +Abre **Archivos** desde el Hub — sin segunda contraseña. Archivos (tu nube) usa tu sesión del Hub: la primera vez, tu cuenta se crea en el Drive automáticamente. Si cierras sesión en el Hub, el Drive te cierra también dentro de la hora. Misma cuenta, mismo equipo, sin credenciales extra que filtrar. + +## 6. Boosts y aprobaciones + +Impulsar (boost) una publicación ajena funciona como publicar la propia: la propuesta se aprueba primero y, si tu colectivo activó la **aprobación de publicación** para esa cuenta, los administradores votan otra vez antes de que salga nada. Verás *"esperando aprobación de publicación"* mientras espera — reenvíalo una vez aprobado. Si una cuenta publica sola, necesita un administrador, mayoría o consenso total, lo decide tu colectivo (administradores: ver la guía de administración).