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.

HTTPS

Route handlers on Vercel

Server-only tasks the browser can’t do itself, like emailing a sign-in code.

PostgREST · Realtime

Supabase

Postgres with RLS on every table, Auth, Realtime and Storage. Every read and write is checked here.

Resend delivers email. GitHub Actions builds the desktop installers and GitHub Releases hosts them. Any host that runs Next.js can stand in for Vercel.
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:migrate applies them in order and records each one, so re-running is safe.

Auth

Supabase Auth · email OTP

One-time email codes, no passwords. proxy.ts refreshes the session cookie on each request.

Live updates

Supabase Realtime

postgres_changes for rows, which Realtime filters through the same RLS policies, and private broadcast topics for typing indicators.

Files

Supabase Storage

A public avatars bucket, where each user writes only inside their own folder, and a private attachments bucket 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.

Email

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 in popout mode: 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.

setup
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 dev
while developing
node 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.example lists every variable with a note on where to find it. Only NEXT_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. Set NEXT_PUBLIC_CABAL_COMMUNITY to 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.tsx owns 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.json and 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 a message_files row claims it.

Presence and lock-ins

People in your community. invisible deletes your presence rows instead of hiding them, so there is nothing to read. “Locked in now” also skips anyone who turned off lock_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.kind is room, team or dm. 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.files is 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 in message_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_texts and 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’s time_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 feedback rows 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.

WhatHow it arrivesLatency
New and edited messagesInstant
Typing
Private broadcast on chan:<id>, never stored
Instant
Coming online, going offlineInstant, or 90s after a crash
Profiles, settings, invites, read markers
postgres_changes, filtered to your rows where it applies
Instant
Device unlock requestsInstant
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 key60s
Team members60s
Community member list2 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

  1. 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.

  2. It creates the account if needed, has Supabase Auth issue a one-time code, and sends it through Resend.

  3. The client calls supabase.auth.verifyOtp(). Supabase checks the code and sets the session cookie.

  4. On first sign-in, /onboarding asks for a name and a role. The @username is derived from the name; on a unique violation (23505) it retries with a number.

Send a message

  1. The client generates the message UUID and renders it straight away as pending.

  2. In a DM, it seals the text on the device and sends { body: '', sealed: { v, sk, rk, iv, ct } } instead.

  3. It inserts the row through PostgREST. The insert policy requires author_id = auth.uid() and can_post_channel(), which covers membership, bans and blocks.

  4. A BEFORE INSERT trigger sets created_at from the server clock, trims and length-checks plain text, rate-limits the author, and validates a sealed envelope against both people’s current keys.

  5. 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.

  6. Realtime delivers the INSERT to everyone RLS lets read it. Recipients open sealed messages locally.

  7. A retry after a dropped connection reuses the UUID. If the first attempt landed, the duplicate fails with 23505 and the client counts it as sent.

Lock in

  1. lock_in() opens a work_sessions row 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.

  2. An optional focus can be set while it runs with edit_lock_in(). Ending goes through finish_lock_in(), which returns the session so the app can offer note_lock_in() for a short note.

  3. While Cabal is open, the client calls lock_in_beat() once a minute.

  4. 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.

  5. 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.

  6. 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

  1. On pagehide the client calls presence_leave(); the desktop app does the same when the machine suspends.

  2. If that call never arrives, the session expires 90s after its last heartbeat.

  3. The last open conversation is kept in localStorage under cabal: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 in e2ee_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>, info message:<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 as application/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.sealed with an empty body. The insert trigger checks its exact shape and that sk and rk are the sender’s and recipient’s current keys; a stale key is refused, and the client fetches the new one and resends.

  1. 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.

  2. A device you already use answers with its own ephemeral key and nonce.

  3. 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.

  4. Both screens show six digits derived from SHA-256 over both keys and both nonces. You approve only if they match.

  5. 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 in e2ee_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-v1 plus your user id. Stored in e2ee_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.ts and CI fails.

CI

GitHub Actions

Typecheck, tests and a production build on every push to main and 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.

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.