Cariosan

Changelog

Notable changes shipped in each Cariosan release.

Updated Apr 2026

Cariosan follows semantic versioning. Pre-1.0 minor versions may include breaking changes; patch versions never do. This page is manually curated from release tags; published server images are listed on GHCR (ghcr.io/cariosan/server).

Unreleased

Added — strict Content-Security-Policy guidance and checks: a new Security headers page lists the origins Cariosan needs:

  • connect-src for REST, wss://, and the storage origin (attachment uploads are a PUT straight to storage, so it is needed there too);
  • img-src and media-src for the storage origin.

It also gives a reference policy (nonce + 'strict-dynamic' scripts, style-src 'self', no 'unsafe-eval'), a Next.js nonce setup, and a Report-Only rollout. Every build of @cariosan/client, @cariosan/react and @cariosan/react-native is now checked in CI for code a strict policy blocks (eval, new Function, HTML string sinks). The kit was verified in a real browser under the reference policy, with no violations. That run covered connecting, sending and receiving, reactions, typing, attachment upload and download, and a virtualised long history. Server-rendered kit markup has no style="…" attributes, so style-src 'self' works with SSR.

Added — token provider for @cariosan/client; auth.renew on the server: new Cariosan({ token }) now also accepts a function that fetches a token from your backend.

  • When the SDK calls it: on connect(), shortly before each token expires (45 s by default, tokenRenewLeadMs, at most 20 % of the lifetime), and when the server rejects a token.
  • Renewal timing follows the token's lifetime (exp - iat) from when it arrives, so a device with a wrong clock still renews on time.
  • No reconnect on renewal. A renewed token moves the open WebSocket to it with the new auth.renew frame. Against an older server the SDK reconnects instead (disconnected carries planned: true, and client.connected stays true). The client, its channels, listeners and outbox survive every renewal: there is no need to rebuild the client.
  • Retries. A REST 401 renews once and retries. Outbox messages that failed for want of a valid token are re-sent on the next token, under the same client_message_id.
  • Failures. A provider that throws is reported as error with code TOKEN_PROVIDER_FAILED and retried with backoff up to 30 s. After two refused renewals in a row the SDK stops with UNAUTHORIZED.
  • client.setToken(token) is new, for apps that obtain tokens themselves.
  • Server: auth.renew accepts only a token for the same user and workspace that expires no earlier than the current one, answers auth.renewed, and leaves the current token in force when it refuses.
  • Compatibility: a plain string token behaves as before. Deploy the server before relying on auth.renew; the new SDK still works against an older server.

Fixed — endless reconnects with an expired token: a token that expired while the socket was down (an app in the background, a network drop) made @cariosan/client reconnect forever. The server refuses such a token during the upgrade with HTTP 401, which browsers report as close code 1006, so the SDK took it for a network error. Now:

  • with a fixed token, the SDK stops with UNAUTHORIZED once its exp has passed;
  • with a token provider, it checks with one REST request and renews.

Changed — @cariosan/client is about 8 KB min+gzip (was about 5.8 KB; the published figures of 3–5 KB were stale), because of the token provider and renewal logic.

Fixed — docs: Backend integration used a token endpoint that does not exist (POST /v1/auth/token), HTTP Basic auth in the Python example, and a system-message route that does not exist. It now uses POST /v1/users/{external_id}/token with ttl_seconds and shows the token provider. Users no longer shows a TypeScript server SDK, which does not exist, and recommends short tokens plus a provider.

Security fix — unregistering a push token is scoped to its owner: DELETE /v1/me/devices?token=… (client.unregisterDevice) deleted a token whoever had registered it. Any signed-in user, in any workspace, who knew another device's FCM token could therefore switch off that user's push notifications. It now removes the token only when it is registered to the caller. Any other token is left alone and the response is still 204, so the endpoint reveals nothing about other users' devices. Registering a token still moves it to the user signed in on that device, as before. No migration and no SDK change. Apps that unregister on logout should do so before discarding the user's chat token, which was already required.

Fixed — WebSocket sends are now idempotent: the server used to echo a message.send frame's client_message_id without using it, so resending a frame after a lost message.ack stored and broadcast the message twice. A client_message_id that is a UUID is now the idempotency key, exactly as on POST /v1/channels/{external_id}/messages. The retry's message.ack names the message already stored and carries "replayed": true, and nothing is stored, broadcast, pushed or rate-limited again. Reusing a key in another channel returns an IDEMPOTENCY_KEY_REUSED error frame. Free-form values such as "tmp_abc" are still only echoed, so existing clients behave as before. No migration and no SDK change: @cariosan/client sends over REST. See message.send.

Fixed — WebSocket send errors for quota and outages: a message.send refused because the workspace's daily quota was used up, or because Redis was unreachable, used to come back as INTERNAL_ERROR. That looked like a server bug and gave no hint about when to try again. These sends now get QUOTA_EXCEEDED and SERVICE_UNAVAILABLE, matching REST's 429 and 503. Error frames gain retry_after_seconds, the WebSocket counterpart of Retry-After, set on RATE_LIMITED, SERVICE_UNAVAILABLE and a QUOTA_EXCEEDED that resets. QUOTA_EXCEEDED also carries details (metric, limit, current, reset_at) like the REST body. Both fields are new and optional; clients that ignore unknown fields are unaffected. See error codes.

@cariosan/client — live messages, receipts and typing now reach apps that never call listChannels(). Live frames identify a channel by its server id, and the SDK only learned that id from listChannels(). An app that opened a channel with client.channel("...") (a hooks-only UI such as the support-widget example) connected fine and could send, but silently received nothing live. The SDK now looks the channel up the first time it sees a frame for one it does not know, holds the frames that arrive meanwhile and delivers them in order. One lookup serves a burst, and lookups are spaced at least 2 s apart.

React web read receipts: opening a visible channel now acknowledges history as delivered/read (matching the native component), with markReadOnView={false} to opt out. Previously the web list never marked history read, so a sender's ✓ stayed a single tick (even after a refresh) when the recipient opened the chat later. The acknowledgement is repeated once the socket connects (the React Native MessageList does the same, since the delivery ack is dropped while the socket is closed), and receipt cursors reload after reconnect/resync so senders do not retain stale ticks.

Breaking (pre-launch) — the React kit's CSS prefix is now cariosan-: variables and class names that were --kabar-* / .kabar-* (a leftover of the old name) are now --cariosan-* / .cariosan-*, matching what the docs always said. The default primary colour is now the brand emerald #10B981 (it was blue #3B82F6, which the docs never listed), your own bubbles use --cariosan-own-surface (emerald tint), and the "read" tick has its own variable, --cariosan-receipt-read, so it stays blue. If you overrode --kabar-* variables or styled .kabar-* classes, rename them.

Fixed — useConnectionStatus stayed "offline" when the socket opened before the component mounted: the hook only listened for the connected event, so an app that awaits client.connect() and then renders its chat UI (like the support-widget example) showed "Connecting…" forever. Cariosan now has a connected getter for the current WebSocket state, and useConnectionStatus reads it (and re-reads on every connected / disconnected event).

Fixed — a database blip no longer looks like a rejected token: when the token revocation check cannot reach the database, REST requests with a user token and WebSocket upgrades now answer 503 with error.code AUTH_UNAVAILABLE and Retry-After (they previously answered 401, which clients treat as "refresh your credentials" and may turn into a sign-out). Requests are still refused (fail closed).

Fixed — outbox: a send that never answers now fails after 30 seconds (TIMEOUT) instead of blocking every later message; failed entries carry canRetry and retryAfterSeconds (from Retry-After), and the React and React Native kits show the reason and hide Retry for failures that cannot succeed (muted, frozen channel, moderation, not a member); a message confirmed from a history page is no longer appended to the end of the conversation; useOutgoing no longer shows the previous channel's rows after a channel switch and no longer keeps callbacks of discarded messages. CariosanError.retryAfterSeconds is new, and the sent outbox event gains source. isOwnMessage and describeOutgoingFailure are exported from @cariosan/react.

Fixed — realtime during a database outage: a channel.resync that cannot be authorised (the membership lookup failed) is retried up to three more times over about seven seconds instead of being re-signalled for as long as the outage lasts, and is no longer counted as a lost event in authz_unavailable.

Added — client_message_id in the message.created webhook: the message object includes the sender's idempotency key when the send carried one (omitted otherwise).

Changed — MessageList lays out like a messenger when it knows who you are: with currentUserId your messages (and your pending or failed ones) are aligned right with their own bubble colour (--cariosan-own-surface), no author name, and the time and delivery state together in the bottom-right corner, everyone else's left. currentUserId now accepts the internal id or the external_id (the React Native kit already did); before, only the internal id matched, so passing an external_id silently showed no ticks. Without currentUserId nothing changes. The marketplace-react example now passes it.

Changed — a send fails within 2 seconds when Redis does not answer: the rate-limit check that precedes every send is now bounded to 2 seconds, so a Redis that accepts connections but does not respond produces 503 SERVICE_UNAVAILABLE after about 2 seconds instead of waiting on the Redis client's own timeouts. Nothing is stored in that case.

Changed — support-widget-nextjs example uses the outbox: the widget now shows a sent message as "sending" (🕒) and a failed one with Retry and Delete, via useOutgoing.

Fixed — token endpoint in the examples: marketplace-react, support-widget-nextjs and the mobile-react-native README requested user tokens from /v1/users/{id}/tokens (a 401); the endpoint is /v1/users/{id}/token.

Added — client_message_id in message responses: a message sent with an idempotency key now returns it as client_message_id in the send response, the message.new frame, history and search (omitted when the message had no key, and on edit/unsend responses). @cariosan/client uses it to retire the matching outbox entry the moment its message arrives, live or from history: the 🕒 row no longer shows next to the real message when the live frame beats the REST response, and a "Not sent" row disappears if the server turns out to have stored the message. Message.client_message_id is added to the SDK types. No migration. A server that predates this still works with the new SDK; entries then retire when the REST response arrives, as before.

Added — idempotent sends (client_message_id): POST /v1/channels/{external_id}/messages accepts a client_message_id (a UUID you generate once per message and reuse when retrying). A retry returns the message already stored (200 with Idempotent-Replayed: true) instead of creating a second one, and does not publish, push, send a webhook or spend rate limit again; the first send is still 201. Keys are unique per sender; reusing one for a different channel is 409 IDEMPOTENCY_KEY_REUSED, a malformed one is 400. Concurrent sends with one key create exactly one message. The outbox in @cariosan/client generates the key (SendMessageInput.clientMessageId) and reuses it on every retry, so the React and React Native kits no longer warn that a retry "could send it twice". Database migration 20261004000027: a nullable messages.client_message_id column and a partial unique index, built on first boot with a plain CREATE UNIQUE INDEX that blocks writes to messages briefly (about 0.15 s for a million messages in our test); a runbook covers pre-building it on very large tables. Deploy the server before or together with the SDK: an older server ignores the key.

Added — sending and failed states in the React and React Native kits: a message you send now appears at once with a 🕒 while it is in flight, and a message that fails stays in the list as "Not sent" with Retry and Delete instead of the text being lost. The states live in a new per-channel channel.outbox in @cariosan/client (enqueue, retry, discard, change and sent events) and are exposed as useOutgoing() in @cariosan/react and @cariosan/react-native; MessageList accepts renderOutgoing to restyle them. Messages are sent one at a time in order. OutgoingMessage.maybeDelivered tells you whether the failure left the outcome unknown (no response or a 500/502/504) or the server definitely did not store it (4xx, or 503). MessageInput's onSend now fires when the server confirms the message, not when Send is pressed.

Breaking — search before removed: GET /v1/channels/{external_id}/messages/search and GET /v1/search/messages no longer accept before (it re-ranked older rows and could skip or repeat matches); sending it returns 400 VALIDATION_ERROR instead of being silently ignored. Search responses no longer carry next_cursor. Continue a search with page_token / next_page_token only. In @cariosan/client, SearchOptions.before is removed.

Changed — sending while Redis is down returns 503: POST /v1/channels/{external_id}/messages answers 503 SERVICE_UNAVAILABLE with Retry-After: 5 (previously an unclassified 500). Nothing was stored, so the same request can be retried without creating a duplicate; show the message as unsent.

Changed — add members requires at least one user: POST /v1/channels/{external_id}/members and POST /v1/me/channels/{external_id}/members return 400 VALIDATION_ERROR for an empty body, a misspelled field, an empty list or blank ids, instead of succeeding without adding anyone. Unknown extra fields are still ignored.

Fixed — typing indicators never reached SDK clients: the server sent typing.start / typing.stop with the sender's external_id in channel_id, while the SDK resolves channel_id as the channel UUID like every other event, so the frame was dropped. The server now sends the channel UUID. The SDK also accepts the old form for a known channel, so a new SDK works against an older server.

Changed — Redis publish and log noise: a Redis publish now fails after 1 second instead of waiting on the client's multi-second socket timeouts (the Redis client now honours context deadlines), and the "inbox full" warning is logged at most once per second with a suppressed-count; the drop metric still counts every frame.

Docs fixes: removed the non-existent --plan flag from workspace create examples, and the Go tabs for messaging methods the Go server SDK does not have (SendMessage, ListChannelMessages, MarkChannelRead, PresignAttachment).

Fixed — WebSocket connections dropped under heavy load: an open connection no longer runs a database revocation lookup before every frame it sends. Revocation is now checked at most every 5 seconds for outgoing frames (and still on every incoming frame), so a revoked token stops receiving within about 5 seconds. If the revocation store is briefly unreachable, an open connection is kept for up to 60 seconds instead of being closed (new connections and HTTP requests still fail closed). Previously a saturated database pool could close every healthy connection at once with code 4401.

Fixed — silently withheld events: when channel membership cannot be checked (database timeout), the event is now counted as cariosan_realtime_events_dropped_total{reason="authz_unavailable"} and the channel's clients receive a channel.resync so they catch up from history, instead of the event vanishing without a trace.

Realtime priority under backlog: when a channel's subscriber buffer is three-quarters full, typing, presence and receipt events are skipped before they are queued, keeping the remaining room for messages, edits and reactions. Skipped events are counted as cariosan_realtime_events_dropped_total{reason="inbox_shed"} and do not trigger a client resync. Servers publishing the new envelope flag interoperate with older servers in the same Redis (events without the flag are treated as essential). No configuration is required.

Receipt backpressure: delivery/read reports coalesce per member/channel/kind on each server (default CARIOS_RECEIPT_COALESCE_MS=1000, 0 disables). The hub skips typing, presence and receipt frames when a connection queue is at least three-quarters full, reserving room for essential events. Essential queue overflow still disconnects the client for reconnect/catch-up. The outbox remains 64 frames. New receipt-coalescing and backpressure metrics distinguish this from lost messages. Pending trailing updates are not durable across restarts.

Security breaking change — private attachments: attachment bucket bootstrap removes the legacy bucket policy and fails startup if that operation is denied. The Compose bootstrap also disables anonymous reads. public_url and message attachment_url remain stable references, but clients must request a five-minute download grant through POST /v1/channels/{external_id}/messages/{message_id}/attachment or channel.getAttachmentDownload(messageId). Grants require current workspace/channel access and a non-deleted message. Previously issued grants remain valid until expiry. Update custom clients before rollout; never restore public reads as an automatic rollback.

Fixed — Redis Pub/Sub concurrency: inbox delivery and closure share synchronization, including unsubscribe/resubscribe ordering. The previously skipped concurrency regression is enabled.

Fixed — search pagination no longer skips or repeats matches: search results are ordered by relevance, but the only continuation was ?before=<timestamp>, which re-ranks the older rows instead of continuing the list. In our tests this lost up to 60% of the matches (and repeated others) whenever relevance varied or timestamps tied, while a corpus with uniform relevance and distinct timestamps looked fine. Search responses now carry next_page_token while more matches exist; send it back as page_token (same q) for the next matches. The token is an exact position in the ranked order, so every match is returned exactly once, best first, for both GET /v1/channels/:id/messages/search and GET /v1/search/messages. It is opaque, bound to its query and scope (400 otherwise), and cannot be combined with before. Channel.searchMessages / Cariosan.searchMessages take pageToken; MessagesPage has next_page_token; useSearch gains hasMore, loadMore and loadingMore, and now drops responses superseded by a newer search. Compatible: before still works and next_cursor is still returned, but both keep the old lossy behavior, so move to page_token. Per-page cost is unchanged. Previously useSearch could only ever show the first page.

Fixed — message history no longer skips messages that share a timestamp: history was paged with the last message's created_at alone, so messages sharing the boundary timestamp (a bulk import, or a burst within one microsecond) could be skipped between pages. GET /v1/channels/:id/messages now returns next_cursor_id next to next_cursor and accepts before_id next to before; together they are an exact (created_at, id) position. Channel.getMessages takes beforeId, MessagesPage has next_cursor_id, and useMessages (@cariosan/react, @cariosan/react-native) uses the pair automatically. Compatible: before alone is still accepted with its old timestamp-only behavior, so existing clients keep working (and keep the old edge case until they send before_id). before_id without before is a 400. Search pagination is unchanged. A migration adds the idx_messages_channel_created_id index that keeps deep pages fast (see the upgrade note: it blocks writes to messages while it builds).

Added — lost realtime events are detected and repaired: previously a dropped live frame (a full per-channel buffer, or a failed Redis publish) was logged and forgotten, and useMessages only re-read the newest page on reconnect, so a gap of more than one page, or an edit, unsend or reaction change on an already-loaded message, was never corrected. Now the server sends a new channel.resync frame to connected members of the affected channel when it knows it lost an event, and counts losses in cariosan_realtime_events_dropped_total{reason} / cariosan_realtime_resync_signals_total. Channel emits a resync event, and useMessages pages history back to what it holds on every reconnect and every resync, then reconciles by message id (missed messages inserted without gaps or duplicates; edits, unsends and reactions converge; live messages that raced the fetch are kept; failures retry with backoff). New options: maxCatchUpPages, resyncRetryDelaysMs. Older SDKs ignore the new frame. Delivery remains at-most-once for clients on other servers while Redis itself is unreachable; they recover on reconnect. See Staying in sync.

Changed — MessageList windows long conversations: above 200 loaded messages (virtualizeAbove, default 200; Infinity disables) only the rows near the viewport are mounted, with the reader's position anchored so content never jumps, the bottom followed while you are there, and quote tap-through working for unmounted rows. In a 10,000-message test the page held ~135 DOM nodes instead of ~50,000. Conversations of 200 messages or fewer render exactly as before. @cariosan/react is now 8.8 KB gzipped (the previously published size figures were stale).

Performance — group delivery, history and busy channels: (1) fanning an event to a group now authorizes all local recipients with one membership query instead of one per recipient, still fail-closed and uncached, so a removed member stops receiving on the very next event; (2) a history page hydrates its authors with one query instead of one per distinct author; (3) sends to a single busy channel are ~2.6x faster in a test with 24 concurrent senders on one channel (one statement holds the channel row lock for one round trip instead of four), and edits/unsends/reactions no longer queue behind each other. A freeze still totally orders against concurrent writes, verified against Postgres commit timestamps. Reactions now also serialise per (message, user), which the old channel-wide lock used to do implicitly.

Post-MVP — channel avatars + group roles (RBAC, hybrid model): group channels gain an optional avatar_url (icon) and per-member roles (admin | member). On create, pass created_by (a user's external_id) — that user is added and made admin; everyone else is a member (direct channels have neither). Two management paths: the backend/API-key path stays god-mode (PATCH /v1/channels/:id to rename/set-avatar), while a new client/JWT path lets an admin manage the group from the app — PATCH /v1/me/channels/:id (name/avatar), POST/DELETE /v1/me/channels/:id/members, and PATCH /v1/me/channels/:id/members/:user (promote/demote). Non-admin members get 403; the last admin can't be demoted. GET /v1/channels/:id/presence now returns each member's role. @cariosan/client: Channel.avatarUrl + channel.updateChannel({name,avatarUrl}) / addMembers / removeMember / setMemberRole(extId, role); the mobile example renders the group avatar in its header.

Changed — reactions are now one-per-user-per-message (WhatsApp-style): a user can have at most one reaction on a given message. Reacting with a different emoji replaces the previous one (the server fans out a reaction.remove for the old emoji followed by a reaction.add for the new, so every client's counts stay correct); reacting with the same emoji again is a no-op. Previously a user could stack multiple distinct emoji on one message. This is enforced server-side in a single transaction, so it holds across the REST API, the WebSocket gateway, and all SDKs without any client changes. MessageList (@cariosan/react-native) now also passes currentUserId through to useMessages so the "reacted by me" highlight updates live.

Fixed — @cariosan/react/@cariosan/react-native now re-sync on reconnect: useMessages (and therefore MessageList) re-fetches the latest page on every WebSocket (re)connect and merges anything it missed. The WS doesn't replay frames sent while a client was disconnected — most commonly a mobile app that backgrounded (the SDK closes the socket) and then foregrounded — so a message sent during that gap used to never appear until a full reload, and the sender's ticks stayed grey (the recipient's read marker never advanced because the on-screen message list never changed). Live message.new is now also de-duplicated against the re-synced page. No API changes.

Fixed — live delivery for channels created/joined mid-connection: a WebSocket connection used to subscribe only to the channels you belonged to at connect time, so a channel created (or an add-members) while you were already connected delivered no live events — new messages and read/delivery receipts didn't appear until you reconnected. Now, when a channel is created or members are added, any affected member who is currently connected (on any server in the fleet) is subscribed to the channel's live topic immediately — no reconnect required. The signal rides each member's own topic over Redis pub/sub, so it works across a multi-server deployment. No API or SDK changes.

@cariosan/react-native — MessageList now marks the channel delivered + read while it's on screen (new markReadOnView prop, default true), so the other side's ✓✓ / blue ticks actually advance. Previously delivery/read were only marked on a live incoming message.new, so receipts never progressed for backlog messages or when the recipient opened the chat later.

@cariosan/client — Channel now exposes name (alongside type), refreshed from listChannels(). A UI can title a group by its channel name + member count and a direct chat by the other participant — the mobile example's header branches on channel.type (and shows sender names only in groups).

@cariosan/react-native attachments + emoji: MessageInput gains a 🙂 emoji inserter (tap to drop an emoji into the draft) and an optional 📎 attach button (onAttachPress — the host owns the picker + upload). MessageList now renders image attachments as thumbnails and other files as a tappable document card. The mobile example wires an attachment panel (Document / Camera / Gallery) on top of expo-image-picker + expo-document-picker. Attachments now accept documents (PDF, text, CSV, the Office formats, zip, + a generic fallback) alongside images, and a captionless photo overlays its time + ticks on the image (WhatsApp-style). Attachments stage a preview first and upload on send (MessageInput's attachmentPreview + onSendAttachment), with an upload progress % (uploadAttachment(file, meta, onProgress) reports 0..1 via XHR; the example renders a progress bar). Emoji-only messages render large + bubble-less (jumbomoji). SDK: channel.uploadAttachment(file, { filename, mimeType }) accepts explicit metadata for React Native (where a local-file Blob lacks a name), and sendMessage now routes attachments through the image message type so an empty caption is allowed.

@cariosan/react-native UI polish: MessageList now renders a familiar chat layout — day separators (Today / Yesterday / date), left/right bubbles with bottom-right time + read-receipt ticks, and MessageInput gains a rounded composer pill + circular send button (all in the Cariosan brand palette). New showSenderNames prop (default true) hides the sender name on received bubbles for 1:1 chats. New MessageSelectionProvider + useMessageSelection enable hold-to-select: long-pressing a message highlights it and floats a quick-reactions row above it, while your header reads the selection to show a contextual action bar (reply / copy / delete).

Fixed — realtime delivery for REST-sent messages: messages created via the REST API (POST /v1/channels/:id/messages, and therefore every @cariosan/client sendMessage) now broadcast message.new to the channel's WebSocket subscribers — including the sender's own devices — exactly like messages sent over the WS protocol. Previously only WS-native sends fanned out, so a message sent through the SDK was saved but never appeared live until a history refetch. The fan-out is now a single point inside the message service, shared by both send paths.

Post-MVP — Phase 21b (blocks & mutes): Block — an end-user safety control: client.blockUser(externalId) hides a user's messages from your history + search (workspace-wide), unblockUser reverses it, listBlocked() returns the list (filter the live WS stream client-side with it). Mute — a moderation/admin posting-mute: a muted user's sends are rejected (403 MUTED) until expiry; written automatically by the auto_mute moderation action. New internal/relations package (consumer-side message.RelationGuard — message stays decoupled), blocks + channel_mutes tables, and POST/DELETE/GET /v1/me/blocks. @cariosan/client adds blockUser/unblockUser/listBlocked.

Post-MVP — Phase 20 (push notifications): FCM push for new messages, opt-in + privacy-first. Clients register device tokens via client.registerDevice(token, platform) (POST /v1/me/devices); on a new message the server pushes a minimal notification (title only — "{sender} sent a message" + channel_id/message_id in data, never the body) to every other channel member's devices. FCM-only (Android + iOS-via-APNs + Web through one API), dispatched via gopackx/go-notification's FCM driver. BYO Firebase for self-host (CARIOS_FCM_SERVICE_ACCOUNT_FILE); push is off (registration still works, nothing delivered) until configured — Cariosan charges nothing. The push core is decoupled (a Sender interface; the FCM adapter is isolated) and the message service stays push-free via a Notifier hook. @cariosan/client adds registerDevice/unregisterDevice. Verified by unit + testcontainers tests with a mocked sender; real FCM delivery needs your Firebase project + device.

Post-MVP — Phase 19 (React Native SDK): a new @cariosan/react-native mobile UI kit for iOS + Android. The headless core is reused verbatim — @cariosan/client runs in RN unchanged, and the framework-agnostic hooks from @cariosan/react (all DOM-free) are re-exported — so engagement features (quoted reply, @-mentions, read receipts, search) behave identically to web. Native components: MessageList (FlatList with mention highlighting, quote previews, reactions, receipt ticks, Reply action), MessageInput (@-autocomplete + reply chip), ChannelList (unread + mention badges), TypingIndicator, PresenceBadge. The mobile-react-native example is rebuilt on the kit. Verified by TypeScript against real RN types + headless component tests (react-test-renderer); native device rendering is the integrator's check.

Post-MVP — Phase 21a (automatic moderation): in-core, non-AI content moderation, off by default (opt-in per workspace). Every send is evaluated before it persists; a matched rule applies the workspace's action — flag (deliver + record), mask (replace matched spans with ***), or reject (422 MESSAGE_REJECTED, no row). Rules: profanity (built-in Indonesian + English wordlist, whole-word, + custom blocklist), link (URLs), phone (contact-number leakage), flood (repeated messages). Every action is written to a moderation_actions audit trail and fans out a new message.moderated webhook. Settings live in a per-workspace moderation_settings table (Cloud dashboard wiring comes later). The message service stays decoupled from the moderation package (a Moderator interface + a cmd adapter). AI toxicity classification remains a separate, optional, later add-on.

All 16 MVP implementation phases have shipped:

  • Workspace + API key auth (Phase 2)
  • User management with JWT issuance (Phase 3)
  • Channels: direct + group with cascade delete (Phase 4)
  • Messaging REST: send, history, read markers (Phase 5)
  • WebSocket gateway with Redis pub/sub fanout (Phase 6)
  • Presence + typing (Phase 7)
  • Attachments via presigned S3 URLs (Phase 8)
  • Webhooks with retry + auto-disable (Phase 9)
  • TypeScript client SDK (Phase 10)
  • React components and hooks (Phase 11)
  • Go server SDK (Phase 12)
  • Production Docker image + Compose stack (Phase 13)
  • Documentation site (Phase 14)
  • Example applications (Phase 15)
  • Observability (Phase 16)

Post-MVP — Phase 18a (engagement): message reactions (add/remove, per-emoji aggregation with reacted_by_me), editing (author-only, edited_at), and unsend (author-only soft-delete tombstone, content blanked). Available across the REST API, the WebSocket gateway, @cariosan/client (addReaction/removeReaction/editMessage/deleteMessage + reaction.add/reaction.remove/message.updated/message.deleted events), and @cariosan/react (useMessageActions + live useMessages). New webhook events: message.updated, message.deleted, message.reaction.added, message.reaction.removed. The WebSocket envelope gains an optional v version field (forward-compatible: clients ignore unknown event types + fields).

Post-MVP — Phase 18c (quoted replies): send a message as a reply by setting parent_id to the message you're quoting. The server validates the parent lives in the same channel and embeds a truncated quoted_message preview (author, content, type, and a deleted flag for unsent parents) on the returned message, on history, and on the live message.new event. Available across the REST API (parent_id on send), the WebSocket gateway (parent_id on message.send), @cariosan/client (sendMessage({ text, parentId }) + Message.parent_id/Message.quoted_message), and @cariosan/react (MessageList renders the quote above the reply; wrap a room in the new ReplyDraftProvider for the built-in compose flow — per-bubble Reply button, quote chip, and tap-to-scroll — or drive it yourself with useReplyDraft()). No new webhook events — replies arrive as normal message.created payloads carrying parent_id and quoted_message.

Post-MVP — Phase 18b (read & delivery receipts): WhatsApp-style ticks, cursor-based (one last_delivered_at + last_read_at per member — no per-message rows). A message is ✓ sent, ✓✓ delivered when every other member's delivery cursor passed it, ✓✓ blue read when every other member's read cursor passed it. New inbound WS event channel.delivered (the SDK auto-acks on every inbound message); channel.read / channel.delivered broadcast as origin-filtered receipts. New REST GET /v1/channels/:id/receipts. Workspace privacy toggle read_receipts_enabled (default true) gates blue-tick broadcasts (delivery still flows). @cariosan/client adds getReceipts(), receiptState(message), markDelivered(), and a receipt.update event; @cariosan/react adds currentUserId ticks on MessageList + a useReadReceipts() hook.

Post-MVP — Phase 18e (message search): relevance-ranked full-text search over message content, backed by a generated tsvector column (Postgres simple config — language-agnostic, no stemming, sane for a mixed Indonesian + English corpus) + GIN index. New REST GET /v1/channels/:id/messages/search (one channel) and GET /v1/search/messages (across the caller's channels, member-scoped — never leaks non-member channels). Supports websearch syntax ("quoted", OR, -exclude), excludes deleted messages, and paginates like history. @cariosan/client adds channel.searchMessages(q, opts) + client.searchMessages(q, opts); @cariosan/react adds a useSearch(channelId?) hook (box styling left to consumers). This completes the Phase 18 engagement wave (reactions/edit/unsend, quoted reply, mentions, receipts, search).

Post-MVP — Phase 18d (mentions): @-mention channel members by passing their external_ids in mentions on send — explicit ids, no server-side text parsing. The server validates each is a member (a non-member rejects the send), resolves them to a mentions array ([{ user_id, external_id, name }]) on the message, and pings each mentioned member on their own WebSocket topic via notification.mention (delivered even when they're not viewing the channel). GET /v1/me/channels gains mention_unread_count per channel. New webhook event message.mentioned (and message.created now carries mentions). @cariosan/client adds sendMessage({ mentions }), Message.mentions, and a mention event on both the Cariosan instance and the Channel. @cariosan/react adds an @ autocomplete in MessageInput, mention-token highlighting in MessageList, and useMentions() + a ChannelList mention badge.

Currently in pre-launch: code-quality hardening and staging validation with design partners. Pre-1.0 minor versions may still include breaking changes until v1.0 freezes the public API and Docker image surface. Watch this page for updates.

How releases are cut

Release tags v*.*.* trigger a multi-arch image build (amd64 + arm64) pushed to GHCR under both latest and the semver tag. See operations for the upgrade workflow.

Was this page helpful?

On this page