"CyberOS-native chat - slice 1 (skeleton: channels, messages, live delivery, CyberOS-token auth)"
§1 - Description (BCP-14 normative)
CHAT is rebuilt as a CyberOS-native service - a first-party Rust service on the existing identity, database, and audit chain - replacing the Mattermost fork entirely. Slice 1 is the walking skeleton that proves the spine end to end. Each requirement:
- MUST be a new first-party Rust service
cyberos-chatatservices/chat(axum + sqlx + tokio), a new member of theservices/Cargo workspace. No third-party chat server, no Mattermost.
- MUST authenticate every request with the CyberOS access token the TASK-AUTH-110 provider issues, verified against the TASK-AUTH-004 JWKS (RS256) - the same verification
obs-compliance-viewand the MCP gateway already do. No separate chat login, no chat-local password store. An HS256 secret path is allowed for tests and local dev only.
- MUST take the tenant and the caller identity from the verified token (
tenant_id,sub), never from a request parameter. A cross-tenant request is impossible because every query runs under the tenant.
- MUST persist to Postgres with per-tenant row-level security, using the established GUC idiom (
app.current_tenant_id; the nil tenant bypasses for admin paths) - identical to the auth and sessions tables. Slice 1 uses a dedicatedcyberos_chatdatabase.
- MUST expose channels:
POST /v1/chat/channels(create; the creator becomes a member) andGET /v1/chat/channels(list the channels the caller is a member of, in their tenant).
- MUST expose messages:
POST /v1/chat/channels/{id}/messages(post; the caller must be a member) andGET /v1/chat/channels/{id}/messages?before=&limit=(most-recent-first, paged; members only).
- MUST deliver messages live over a websocket:
GET /v1/chat/ws?channel={id}(token in theAuthorizationheader or anaccess_tokenquery param). When any client posts to a channel, every websocket subscribed to that channel receives the message. Slice 1 uses an in-process per-channel broadcast; Redis pub/sub for multi-instance fan-out is a later slice.
- MUST write an audit row to the memory chain for
chat.channel_createdandchat.message_posted(viacyberos-audit-chain), best-effort and non-blocking, the way the other services emit.
- MUST serve
GET /healthz(200 when the pool is reachable) so the deploy stack healthcheck passes.
- MUST refuse an absent, malformed, or expired token at every HTTP endpoint and at the websocket handshake (401), and refuse a non-member posting to or reading a channel (403).
Slice-1 non-goals (named, not gaps): threads, direct messages, presence and typing, read receipts, file attachments, search, multi-tenant team mapping, federation, and the production client UI. A minimal test client (or a websocket CLI) is enough to prove slice 1; the web client is its own slice.
§2 - Why this design (rationale for humans)
CyberOS is a from-scratch, owned platform. Chat was the one surface leaning on a third party (Mattermost); this makes it first-party too. It costs less than it looks because it stands on what already exists: identity from the TASK-AUTH-110 provider, Postgres with the tenant-RLS pattern, the audit chain, the obs SDK, and the same service shape as auth and memory. Slice 1 deliberately builds only the spine - authenticated channels and messages with live delivery - so there is a running, testable chat before any of the heavier features.
Real-time starts in-process (a tokio::sync::broadcast per channel) because a single instance is enough to prove the model and to run for the team early; the move to Redis pub/sub is a contained change behind the same realtime interface when more than one instance runs.
§3 - Architecture
- Service
cyberos-chat(services/chat): axum router, sqlx Postgres pool, tokio. BindsCHAT_LISTEN_ADDR(default0.0.0.0:7720). ReadsDATABASE_URLand the provider issuerCHAT_AUTH_ISSUER(to fetch/.well-known/jwks.jsonat boot, cache the keys bykid). - Auth (
src/auth.rs): anAuthenticatormirroringobs-compliance-view::auth-from_jwks(RS256) orfrom_hs256_secret(tests),verify(token) -> Claims { sub, tenant_id, roles, exp }. A small extractor turns theAuthorization: Bearerheader (or theaccess_tokenws query param) into verifiedClaims. - DB (
src/db.rs): the pool plus a helper that, per request, opens a transaction and runsSELECT set_config('app.current_tenant_id', $tenant, true)before the query, so RLS scopes every read and write to the caller's tenant. - Realtime (
src/realtime.rs): a process-global mapchannel_id -> broadcast::Sender<MessageEvent>. The websocket handler verifies the token, checks membership, subscribes to the channel sender, and forwards each event to the socket.messages::postpublishes to the sender after the row commits.
§4 - Schema (migrations/0001_chat_core.sql)
CREATE TABLE chat_channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE chat_channel_members (
channel_id UUID NOT NULL REFERENCES chat_channels(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL,
subject_id UUID NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel_id, subject_id)
);
CREATE TABLE chat_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
channel_id UUID NOT NULL REFERENCES chat_channels(id) ON DELETE CASCADE,
sender_subject_id UUID NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX chat_messages_channel_created_idx ON chat_messages (channel_id, created_at DESC);
-- RLS by tenant on all three (the 0021_sessions idiom: tenant match OR nil bypass).
-- ALTER TABLE ... ENABLE ROW LEVEL SECURITY + FORCE; policy USING/WITH CHECK
-- tenant_id::text = current_setting('app.current_tenant_id', true)
-- OR current_setting('app.current_tenant_id', true) = '00000000-0000-0000-0000-000000000000'
§5 - HTTP + websocket contract
POST /v1/chat/channels {"name":"general"} -> 201 {channel}
GET /v1/chat/channels -> 200 [{channel}]
POST /v1/chat/channels/{id}/messages {"body":"hello"} -> 201 {message}
GET /v1/chat/channels/{id}/messages?before=<iso>&limit=50 -> 200 [{message}] (newest first)
GET /v1/chat/ws?channel={id} (Bearer or ?access_token=) -> websocket; server pushes {message} events
GET /healthz -> 200
All HTTP routes require Authorization: Bearer <CyberOS access token>; tenant + sender come from the token.
§6 - Acceptance criteria
- A user with a valid CyberOS token creates a channel and lists it; the creator is a member.
- Posting a message persists it and
GET messagesreturns it; a non-member posting or reading is 403. - Two websocket clients subscribed to the same channel both receive a posted message live.
- A token from tenant A sees none of tenant B's channels or messages (RLS proven, not just app logic).
- An absent, malformed, or expired token is refused 401 at every endpoint and at the ws handshake.
chat.message_postedandchat.channel_createdaudit rows are written to the memory chain.cargo test -p cyberos-chatis green; the service boots healthy in the deploy stack (/healthz200).
§7 - Slice roadmap (native chat)
- This task - skeleton: auth, channels, messages, live delivery, one tenant.
- Threads and replies; channel membership and roles; message history paging and edits/deletes.
- Vietnamese search (the TASK-CHAT-004 goal, re-homed natively) and file attachments.
- Presence, typing, read receipts, mobile push.
- Voice and video; mobile clients. The heavy, later items.
The web client lands alongside slice 1-2 inside the existing console app (its own task under the APP module).
§8 - Dependencies and what this retires
Upstream: TASK-AUTH-110 (the provider that issues the identity token), TASK-AUTH-004 (the JWKS chat verifies against). Reuses the cyberos-audit-chain and cyberos-obs-sdk shared crates and the tenant-RLS idiom.
Retires: the Mattermost-fork CHAT module - TASK-CHAT-001 through TASK-CHAT-012 and TASK-CHAT-013 - is superseded by this native series. services/chat is repurposed from the Mattermost fork to the cyberos-chat crate; the old Mattermost scaffolding (Dockerfile, patches, plugins, the python helpers, the deploy OIDC config) is archived out of the build, not deleted, so its history and any reusable logic remain available.
End of TASK-CHAT-101.