DOCS
How Cabal works
Architecture, permissions, realtime and the encryption protocol, for people who want to read, run or change the code. Every claim links to the file that makes it true.
Links open main on GitHub · Checked against the code by tests/unit/docs.test.ts · Reviewed October 21, 2026
Overview
Cabal is a Next.js app, plus an Electron shell around it, backed by one Supabase project. The client talks to Postgres directly with the public anon key and the signed-in user’s JWT. There is no API layer making permission decisions: row-level security and security definer functions in the migrations do that, so a modified client gets exactly the same answers as ours.
Client
Next.js and React, in a browser tab or the Electron app.
Route handlers on Vercel
Server-only tasks the browser can’t do itself, like emailing a sign-in code.
Supabase
Postgres with RLS on every table, Auth, Realtime and Storage. Every read and write is checked here.
- Web app
Next.js 16 · React 19
App Router, Tailwind CSS 4 and shadcn/ui on Base UI. Client state lives in one provider,
components/cabal/store.tsx; data fetching goes through SWR.- Database
Supabase Postgres
Tables, RLS policies, triggers and RPCs are plain SQL in numbered migrations.
pnpm db:migrateapplies them in order and records each one, so re-running is safe.- Auth
Supabase Auth · email OTP
One-time email codes, no passwords.
proxy.tsrefreshes the session cookie on each request.- Live updates
Supabase Realtime
postgres_changesfor rows, which Realtime filters through the same RLS policies, and private broadcast topics for typing indicators.- Files
Supabase Storage
A public
avatarsbucket, where each user writes only inside their own folder, and a privateattachmentsbucket for photos and videos. Other people can read an attachment only once a message claims it, and only if they can read that conversation, through short-lived signed links. Photos are redrawn on the sender’s device first, which drops location and camera details.Resend
Sign-in codes. In development, codes print to the server log when Resend isn’t configured.
- Desktop
Electron
Loads the hosted app and adds a tray icon, an unread badge, a lock-in timer overlay, floating chat windows opened from its buddy list, and automatic updates from GitHub Releases on Windows and Linux. A chat window loads
/app/chat/[userId], which runs the client inpopoutmode: no presence, notifications or badge of its own, so one person never counts as two windows.
Run it locally
Node 24 and pnpm, plus a Supabase project.
git clone https://github.com/dance-hq/cabal-city.gitcd cabal-citypnpm installcp .env.example .env.local # then fill in your Supabase projectpnpm db:migrate # applies supabase/migrations/*.sql in orderpnpm db:seed # your community and its starting roomspnpm devnode scripts/dev-code.mjs you@example.com # print a sign-in codepnpm typecheckpnpm test # unit, plus integration if configuredpnpm desktop # run the Electron app- Environment
.env.examplelists every variable with a note on where to find it. OnlyNEXT_PUBLIC_*values are sent to the browser.- Supabase Auth
Turn off “Allow new users to sign up” and keep “Confirm email” on. Cabal creates accounts itself, so the sign-in route is the only way in.
- Your community
The slug, name, join policy and starting rooms live in
supabase/seed.sql. SetNEXT_PUBLIC_CABAL_COMMUNITYto the same slug. Nothing community-specific is hard-coded.
- app/
Routes: the marketing pages,
/app(the client),/login,/onboarding,/join/[code]and this page. - app/api/
Route handlers for the few things that have to run on a server.
- components/cabal/
The client UI.
store.tsxowns client state, the Realtime subscription and sending messages. - hooks/
Message pagination, typing indicators, lock-ins and buddy groups.
- lib/
Shared logic and types. Deployment settings and timing constants are in
lib/config.ts. - lib/e2ee/
Direct-message encryption: primitives, the per-user session and the IndexedDB key store.
- lib/supabase/
Supabase clients for the browser, server components and
proxy.ts. - supabase/migrations/
The schema and every permission rule. Read these to know what anyone can do.
- desktop/
The Electron app, with its own
package.jsonand lockfile. - tests/
Unit tests, plus
integration/tests against a real Supabase project. - scripts/
Migrations and seeding (
db.mjs), dev sign-in codes, brand assets. - docs/
Self-hosting, architecture and desktop release notes.
Permissions
RLS is on for every table. Reads are policies. Writes that need more than one check go through security definer functions that pin search_path = '' and check auth.uid() themselves. Columns members may edit are granted one by one, so nobody can update their own role.
- Rooms
Members of the community who aren’t banned.
- Public teams
Anyone in the community who joins, including history from before they joined. Any member can invite others; an invite just puts the team in front of them.
- Invite-only teams
Team members only. Only owners can invite, since the invite is what grants access. Community admins aren’t let in unless they’re on the team.
- Direct messages
The two participants. Posting also requires that neither has blocked the other. Message bodies are ciphertext, so the database can’t read them either.
- Profiles
People in your community: name, role, photo, what you’re building and up to four links. Members edit only the columns granted to them.
- Photos and videos
The people who can read the conversation they were sent in. Uploads go to
<channel>/<message>/<file>, and nobody but the uploader can open one until amessage_filesrow claims it.- Presence and lock-ins
People in your community.
invisibledeletes your presence rows instead of hiding them, so there is nothing to read. “Locked in now” also skips anyone who turned offlock_in_live.- Lock-in hours
Weekly totals appear on the board and on your profile only after you turn on
lock_in_board, which starts off. Session notes and your list of recent sessions are returned only to you.- Build Cabal board
Members of any Cabal community. Private feedback (“Send feedback”) stays visible to admins only.
- Public keys
People in your community, plus anyone you’ve had a direct message with, so old conversations stay readable after someone leaves.
- Email addresses
Never shown to other members. Included only in feedback you send, so the team can reply.
- Admin role
Granted on the server to emails listed in
CABAL_ADMIN_EMAILS. Being first to sign up grants nothing.
Tested against a real database. The integration suite signs in as several throwaway users and tries every read and write above that each shouldn't be allowed. The full user-facing version is on the privacy page.
Data model
All in the public schema. Grouped by what they’re for; the migrations are the source of truth.
- People
communities · community_members · profiles · user_settings · blocks
One deployment runs one community; membership carries the role and ban state.
- Conversations
channels · dm_participants · messages · channel_reads · reports
channels.kindisroom,teamordm.my_channels()returns your channels with unread counts. Links to channels are stored as<#channel-id>and mentions as<@user-id>, so renames never break them.- Attachments
message_files · messages.files · storage: attachments
One row per photo or video, with its path, kind and size.
messages.filesis the count, and a deferred trigger refuses a message whose rows don’t match it. A guard on each row reads the size from the stored object rather than trusting the client. In a DM the file name and type live inmessage_files.sealed.- Long messages
messages.long_text · message_texts
Anything too big to read inline is sent whole, exactly as typed. The message row keeps a short preview, so conversation lists and Realtime stay light; the full text lives in
message_textsand is fetched only when someone opens it.send_long_message()writes both in one transaction, and a deferred trigger refuses a long message without its text.- Teams
teams · team_members · team_invites
Public or invite-only, each with its own channel and an optional pinned welcome note.
- Presence
presence_sessions
One row per open window. The server stamps every heartbeat, so clients can’t fake being online.
- Lock-in
work_sessions
Focus sessions with server-owned start, pause and end times. At most one open session per person. Weekly hours, the opt-in board (
lock_in_board()) and profile numbers (lock_in_stats()) are computed on the server from finished sessions, in the community’stime_zone.- Buddy groups
buddy_groups · buddy_group_members
Your own AIM-style groups. Only you can see them.
- Build Cabal
feedback · idea_votes · idea_comments · ships · opportunities · opportunity_saves · opportunity_submissions
Ideas are
feedbackrows moved onto the public board; bug reports stay private. Opportunities are curated per city or for everyone.- Calendar
calendar_events
Upcoming events per community, entered and shown in the community’s
time_zone. Admins publish directly; members send events in and an admin checks them first.- Encryption
e2ee_keys · e2ee_backups · e2ee_device_requests · messages.sealed · message_files.sealed
Public keys, recovery-key and unlock-phrase backups, and in-flight device approvals. See End-to-end encryption below.
Realtime and polling
Rows that change often and matter right away are pushed. Everything else is polled with SWR.
| What | How it arrives | Latency |
|---|---|---|
| New and edited messages | postgres_changes on messages | Instant |
| Typing | Private broadcast on chan:<id>, never stored | Instant |
| Coming online, going offline | postgres_changes on presence_sessions | Instant, or 90s after a crash |
| Profiles, settings, invites, read markers | postgres_changes, filtered to your rows where it applies | Instant |
| Device unlock requests | postgres_changes on e2ee_device_requests | Instant |
| Who’s locked in | Broadcast on lock-ins:<community> says “look again”, then community_lock_ins(); SWR poll as a fallback | Instant, or 10s |
| A peer’s public key | SWR poll, and on window focus | 60s |
| Team members | SWR poll while a team is open | 60s |
| Community member list | 2 min | |
| New deploys | Poll /api/version, then show an update bar | 10 min |
| Desktop updates | electron-updater against GitHub Releases (Windows, Linux) | 6 h |
Each open window calls presence_heartbeat() every 25s and counts as online for 90s after the last one. Online means a window is open, not that someone is at the keyboard: Cabal doesn’t track input. Away and Do not disturb are set by hand.
Sign in
The client posts an email to
/api/auth/code. The handler rate-limits by email and by network, counting hashed identifiers that it deletes after 24 hours.It creates the account if needed, has Supabase Auth issue a one-time code, and sends it through Resend.
The client calls
supabase.auth.verifyOtp(). Supabase checks the code and sets the session cookie.On first sign-in,
/onboardingasks for a name and a role. The@usernameis derived from the name; on a unique violation (23505) it retries with a number.
Send a message
The client generates the message UUID and renders it straight away as pending.
In a DM, it seals the text on the device and sends
{ body: '', sealed: { v, sk, rk, iv, ct } }instead.It inserts the row through PostgREST. The insert policy requires
author_id = auth.uid()andcan_post_channel(), which covers membership, bans and blocks.A
BEFORE INSERTtrigger setscreated_atfrom the server clock, trims and length-checks plain text, rate-limits the author, and validates a sealed envelope against both people’s current keys.With photos or videos, the client uploads each file first (encrypted on the device in a DM), then calls
send_message_with_files(), which writes the message and its file rows in one transaction.Realtime delivers the
INSERTto everyone RLS lets read it. Recipients open sealed messages locally.A retry after a dropped connection reuses the UUID. If the first attempt landed, the duplicate fails with
23505and the client counts it as sent.
Lock in
lock_in()opens awork_sessionsrow with the server’s clock, so a refresh never resets the timer. Calling it again returns the open session (or resumes a paused one) instead of starting a second.An optional focus can be set while it runs with
edit_lock_in(). Ending goes throughfinish_lock_in(), which returns the session so the app can offernote_lock_in()for a short note.While Cabal is open, the client calls
lock_in_beat()once a minute.If beats stop for more than 5 minutes, the session pauses as of the last beat and the gap isn’t counted. It resumes on its own when Cabal is next open.
A session paused for more than 3 hours closes, saved with the time it actually ran. Any one session counts for at most 8 hours; a timer left running past that is saved at 8 and closed.
Hours count in the week a session ends, starting Monday at midnight in the community’s
time_zone. Sessions under a minute don’t count.
Close Cabal
On
pagehidethe client callspresence_leave(); the desktop app does the same when the machine suspends.If that call never arrives, the session expires 90s after its last heartbeat.
The last open conversation is kept in
localStorageundercabal:last-open:<user id>, on the device, not the server.
End-to-end encryption
Direct messages are end-to-end encrypted using only the Web Crypto API in browsers, Electron and Node; no third-party code touches keys. The server stores public keys and ciphertext and checks that envelopes are well formed. It never sees a private key or a plaintext message.
Primitives
- Identity key
ECDH P-256
One active key pair per person, generated on the device. The private key stays in IndexedDB (
cabal-e2ee); the raw public key goes ine2ee_keys, one active row per user. Keys are long-lived; resetting replaces them.- Conversation key
ECDH → HKDF base key
Each side derives the same 256 bits from its private key and the other’s public key. Nothing is exchanged.
- Message key
HKDF-SHA-256 → AES-256-GCM
Salt
cabal-dm-v1:<channel id>, infomessage:<message id>, random 96-bit IV. Every message has its own key, so disclosing one key discloses one message.- Associated data
channel | message | author | sk | rk
Binds the ciphertext to its conversation, message id, author and both key ids, so it can’t be moved to another message or attributed to someone else.
- Padding
64-byte buckets · 4 KB for long text
A 4-byte length prefix, then zeros up to the next multiple of 64 bytes, so “ok” and a full sentence look the same size. The full text of a long message is padded in 4 KB steps.
- Long messages
preview + full text
The message carries a sealed preview with the length inside it, so the server never learns the length. The full text is sealed separately under the same per-message key with its own label (
cabal-dm-long-v1), so the two can’t be swapped.- Files
HKDF → AES-256-GCM per file
Info
file:<message id>:<file id>under the conversation key, so each photo or video has its own key. The file is sealed whole and uploaded asapplication/octet-stream; its name, type and dimensions are padded and sealed separately (cabal-dm-file-meta-v1), and both are bound to the message, author and file id.- Envelope
{ v, sk, rk, iv, ct }
Stored in
messages.sealedwith an emptybody. The insert trigger checks its exact shape and thatskandrkare the sender’s and recipient’s current keys; a stale key is refused, and the client fetches the new one and resends.
Adding a device
The new device makes an ephemeral P-256 key and a 16-byte nonce, and publishes the key with
SHA-256(nonce)as a commitment.A device you already use answers with its own ephemeral key and nonce.
Only then does the new device reveal its nonce, and the database checks it against the commitment. Neither side can pick its nonce after seeing the other’s, so nobody in the middle, the server included, can grind for a matching code.
Both screens show six digits derived from SHA-256 over both keys and both nonces. You approve only if they match.
The approving device seals your whole keyring (every key you’ve had) with AES-GCM under HKDF of the two ephemeral keys, and the new device opens it.
Recovery, verification and reports
- Recovery key
120 bits · 24 characters
Crockford base32 with no I, L, O or U. Look-alike characters, case, spaces and dashes are normalised on input.
- Backup
AES-GCM under HKDF(recovery key)
Salt
cabal-recovery-v1, info your user id; associated data binds it to you. Stored ine2ee_backups, readable only by its owner, and published together with each new key in one call.- Unlock phrase
AES-GCM under PBKDF2-SHA256(phrase)
Optional. A second backup of your keyring, locked with a phrase you choose, so a new device can unlock without another one being on. 600,000 iterations and a random 16-byte salt; associated data
cabal-phrase-backup-v1plus your user id. Stored ine2ee_phrase_backups, readable only by its owner, saved only against your current key and dropped when that key is retired. Its strength is the phrase’s: anyone holding the backup can try guesses offline, so it asks for a long phrase.- Security code
12 × 5 digits · SHA-512
Over both user ids and public keys, in a fixed order, so both people see the same code. Clients remember each peer’s key and flag a change; a code you’ve checked is remembered for that key only.
- Reporting
The reporter’s client sends only that message’s key. The server opens the stored ciphertext with it before filing, so a report can’t put words in anyone’s mouth, and the rest of the conversation stays sealed. File keys aren’t shared, so a report carries the text only.
- Resetting
Starting over retires the old key and publishes a new one. Messages sealed to the old key stay unreadable unless some device still holds the old keyring, and your contacts see your security code change.
- Older messages
Direct messages sent before encryption shipped stay as they were and are labelled “sent before encryption”.
What it doesn't cover
- Rooms and team channels aren’t end-to-end encrypted. They’re protected by TLS and RLS, and the people who run a Cabal server can read them.
- Photos and videos in direct messages are encrypted on your device before they upload, each under its own key, with their name and type sealed alongside. Their size isn’t hidden.
- Metadata isn’t hidden: who talks to whom, when, and the padded size of each message all pass through the server so delivery and unread counts work.
- Keys live only on your devices, in your recovery-key backup and, if you set one, your unlock-phrase backup. Lose every device, the recovery key and the phrase, and nobody can recover those messages. Clearing a browser’s site data deletes its keys.
- The encrypting code is served by the Cabal server (the desktop app loads the same web app), so the protection assumes that server serves the published code. A compromised deployment could ship code that reads messages before they are sealed, as with any web-delivered encryption.
- Public keys come from the server. A swapped key shows a “security code changed” notice only if you already had their old key; comparing security codes in person is what rules out a swap on first contact.
- Unit
tests/unit/
Encryption round trips and tamper cases, recovery-key parsing, attachments, lock-in weeks, mentions, long messages, presence, usernames, text handling and the sign-in route. No database needed.
- Integration
tests/integration/
Creates throwaway users on a real Supabase project, then tries to read and post where each shouldn’t be able to. Runs when a test project is configured; files run one at a time.
- This page
tests/unit/docs.test.ts
Every path linked here must exist and every quoted snippet must still appear in its file. Change the code without updating
lib/docs.tsand CI fails.- CI
GitHub Actions
Typecheck, tests and a production build on every push to
mainand every pull request, plus a syntax and rules check for the desktop app. Pushing a version tag builds the Mac, Windows and Linux installers.
Self-hosting and contributing
Cabal is MIT-licensed. Running your own needs one Supabase project, a Resend account and a Next.js host. Upgrades are git pull, then pnpm db:migrate, then deploy; migrations only add things or replace functions.
- Self-hosting guidedocs/self-hosting.md
- Architecture notesdocs/architecture.md
- Desktop builds and releasesdocs/desktop.md
- ContributingCONTRIBUTING.md
- Security policySECURITY.md
- MIT licenseLICENSE
Found a security problem? Report it privately rather than in a public issue, and test against your own deployment, never against cabal.city.
Spotted something here that isn't true? Open an issue or send a pull request that fixes lib/docs.ts.