Skip to content

Backend architecture (wire contract)

Project: Orange Copy Paste backend Stack: FastAPI + Supabase (Postgres + Auth) + Redis + S3-compatible blob storage

Owns: the wire contract. Routes, payloads, DDL, socket events, the crypto envelope, and every rule the server itself enforces. If it crosses the network, its one true description is here, and a client doc that disagrees is wrong. Not here: client internals (orange-copy-paste-clipboard-app-rust/docs/architecture.md), who-may-do-what (root docs/permissions.md), cross-component invariants (root docs/architecture.md). Link to those rather than restating them - one fact, one home.


The backend is a stateless FastAPI application that leans on Supabase for Postgres + Auth and keeps everything else vendor-neutral. The API holds no per-connection state that other instances need, because Redis coordinates realtime fan-out and presence. It therefore scales horizontally: N replicas behind a load balancer, with no sticky sessions.

Two moving parts you operate (FastAPI + Redis); the rest is managed:

  • Supabase owns the database and the identity layer. It is the only intentional vendor lock-in.
  • FastAPI owns the app-specific logic: the sync engine, spaces + E2E key distribution, blob upload brokering, presence, and its own WebSocket.
  • Redis does exactly two things: realtime pub/sub fan-out and device presence.
  • R2 (or any S3-compatible store) holds encrypted binary blobs. Vendor-neutral.
  • No Celery / no worker service. Background jobs run in-process under a Postgres advisory lock (see section 10). Email is sent via FastAPI BackgroundTasks.

The desktop app remains fully functional offline; sync is opportunistic and resumes on reconnect.

Each package under src/ owns a router + service (+ models/schemas); section 2 walks through what each one does.

  • Directorysrc/
    • main.py App composition, router mounting, OpenAPI tags
    • version.py Single source for API/service versions + route prefixes
    • config.py Env-driven settings
    • database.py SQLAlchemy async engine + session
    • redis_client.py Redis connection (pub/sub + presence)
    • dependencies.py Shared JWT verification + X-Device-Id extraction
    • middleware.py Security headers + X-API-Version
    • limiter.py slowapi rate limiting
    • background.py In-process jobs under a Postgres advisory lock
    • email.py Transactional email via FastAPI BackgroundTasks
    • supabase_admin.py Supabase admin (service-role) calls
    • realtime.py WebSocket endpoint, in-process hub, Redis fan-out, presence
    • Directoryauth/ Profiles, devices, public-key registration, bootstrap
      • router.py HTTP routes
      • service.py Domain logic
      • models.py ORM models
      • schemas.py Pydantic request/response shapes
      • tokens.py Supabase token verification (verify only, never sign)
    • Directorysync/ Push/pull/cursor, last-write-wins
      • router.py
      • service.py
      • models.py sync_entries, sync_cursors
      • schemas.py
    • Directorysettings/ Encrypted settings blob
      • router.py
      • service.py
      • models.py
      • schemas.py
    • Directoryspaces/ Spaces, invites, and space-key distribution
      • router.py
      • service.py
      • invites.py Invite create/accept/revoke
      • join_requests.py Join request, owner approval, key wrap
      • models.py
      • schemas.py
    • Directoryblobs/ Presigned upload/download + quota
      • router.py
      • service.py
      • s3.py S3/R2 presign
      • models.py
      • schemas.py
    • Directoryannouncements/ Server-authored messages to users
      • router.py
      • service.py
      • models.py
      • schemas.py
    • Directoryadmin/ Internal stats/ops
      • router.py
      • service.py
      • schemas.py
    • Directoryweb/ Human-facing HTML pages
      • router.py
      • Directorytemplates/ shell.html, join.html, email_*.html
        • …
  • Directorymigrations/ Alembic
    • env.py
    • script.py.mako
    • Directoryversions/ Revisions through 0020
      • …
  • Directorytests/ pytest, one module per domain (test_auth, test_sync, etc.)
    • …
  • Directorycaddy/ Caddyfile (reverse proxy + TLS)
    • …
  • Directorydeploy/ On-box build-and-deploy, polled by a systemd timer
    • deploy.sh
    • rovertools-deploy.service
    • rovertools-deploy.timer
  • Directorynetdata/ Host metrics config
    • …
  • Directoryscripts/ One-off scripts (render_supabase_emails.py)
    • …
  • Directorydocs/ architecture.md (this file), DEPLOY.md, ANNOUNCEMENTS.md, supabase-email/
    • …
  • Dockerfile
  • docker-compose.yml Dev stack
  • docker-compose.prod.yml Production stack
  • alembic.ini
  • pyproject.toml uv-managed dependencies

Each package under src/ owns a router + service (+ models/schemas). They call each other in-process.

Registration, email verification, login, token refresh, and password reset are handled by Supabase Auth; the client talks to Supabase directly. This module owns only what the app itself must store:

  • Profile bootstrap - get-or-create the app profile for a Supabase user, and return the KDF salt and both UMK envelopes the client can unwrap to recover its key.
  • Recovery envelope - the same UMK wrapped a second time, under a key derived from a recovery code the user holds instead of a password they remember. It uses the same kdf_salt as the password envelope and a distinct AAD (umk-recovery-v1 against umk-envelope-v2), so the two can never be mistaken for one another. The server holds an opaque blob it cannot open, exactly like pw_wrapped_umk. There is no server-decryptable recovery path, and there must never be one.
  • Device registration - each install registers a device row, which carries the device public key and, later, the wrapped UMK. The route returns a device_id that the client sends back as X-Device-Id.
  • Public-key management - store the user’s X25519 identity key and the per-device wrapped UMK for multi-device E2E. The identity key is write-once: the server refuses a different value with 409. Device keys stay writable. Why: the identity key is derived from the UMK, so every device of an account derives the same one, and an honest client sends the same value forever. A different value means the caller holds a bearer token rather than the UMK. Accepting it would redirect every future Space Key wrap to a key the caller owns. A device key only ever opens that device’s own copy of the UMK.

The backend never issues tokens; it only verifies the Supabase JWT on protected routes (see section 9).

Owns sync_entries (clipboard + notes) and per-device sync_cursors.

  • Delta push (batches of new/mutated entries) with last-write-wins + tombstones.
  • Delta pull since a cursor, covering the caller’s own entries plus anything shared into a space they belong to (subject to that membership’s history floor).
  • Publishes sync:entry to Redis after a write - once to the author’s user: channel and once to each space: channel named in the entry’s space_ids. There is no sync:delete: a tombstone is a normal sync:entry with deleted_at set.
  • Carries two server-visible routing/key columns it never interprets: space_ids (fan-out targets) and wrapped_keys (the per-entry CEK envelope, section 7.2).

One encrypted blob per user (user_settings). A PUT that names the blob it was merged from (base_updated_at) is written only over that blob; a PUT without it is last-write-wins by updated_at. The write rules are in section 5.3. On a client-wins write, publishes settings:updated so other devices pull.

Brokers direct-to-object-store uploads.

  • request-upload -> presigned PUT URL + blob_key. The server never buffers bytes. The URL is signed for the declared size_bytes as Content-Length, so the store refuses a body of any other length. An unconfirmed row counts against the quota for as long as its PUT URL can still be used, so a burst of requests cannot reserve more than the quota.
  • confirm-upload -> reads the object’s size back from the store, and its SHA-256 where the store reports one, and compares them with what was declared. On a mismatch the server deletes the object and the row and answers 409. Quota is checked again here. This is where the meter starts: a confirmed blob counts against the quota whether or not an entry references it yet.
  • release -> un-confirms a blob whose entry never landed, which gives the bytes straight back. It answers 409 while a live entry still references the key, 404 if the key is not the caller’s, and a no-op 204 if the blob is already unconfirmed. Why: the client uploads and confirms before pushing the entry, so a refused or locally-failed push leaves an orphan. The 409 means a release can never take an image away from an entry that uses it.
  • {blob_key}/download-url -> presigned GET URL, always signed with Content-Disposition: attachment, so a stored object is never rendered inline.
  • quota -> usage (computed on demand: SUM(size_bytes) over confirmed blobs) and the per-user quota, plus the two sync ceilings the client cannot see on its own (entry_count / entry_limit, and max_entry_bytes; section 6.3).
  • 5 MB per-entry hard cap; per-user quota default 50 MB (configurable globally and per-user via the admin API).

Every byte is charged to whoever uploaded it, and to nobody else. _used_bytes sums blobs rows WHERE user_id = <caller> AND confirmed. Only request-upload creates a row, and only for the uploader. Receiving a shared image creates no row. download-url hands the reader a presigned GET on the owner’s key ({owner_id}/{hex}), after _shares_space_with_blob confirms that a live entry written by the blob’s owner carries it into a space the reader belongs to. A row another account wrote naming the key does not count, and push refuses to write one (invalid_blob, section 5.2). Everyone else gets 404 rather than 403, so the key’s existence is not confirmed. One bucket, namespaced by owner.

The deliberate consequence: the owner deleting the entry breaks it for every member. release_blob marks the blob unconfirmed, so quota stops counting it immediately. The hourly orphan sweep then deletes the object, and any member who had not already fetched it gets a 404.

A space is the single sharing primitive: a named, persistent, realtime room whose entries are encrypted under a Space Key the server never sees. There is no space type, no member cap, and no server-side scope. See section 15.

  • Spaces (router.py / service.py / models.py / schemas.py) - create, list, get, join by invite code, remove member / leave, delete, and per-member Space Key distribution.
  • Addressed invites (invites.py) - one persistent row per (space, invitee email) with accept / decline / revoke and live invite:* events, alongside the bearer invite code. Mounted separately under /api/v1/invites. An invite can carry the Space Key wrapped for the invitee, which the accept moves onto the new membership.

What flows into a space (send filters) and what a receiving client does with an incoming entry (auto-copy) are client-side choices stored in the encrypted settings blob. The server only routes ciphertext.

Single module: the WebSocket endpoint, an in-process connection hub, the Redis pub/sub bridge, the publish helpers, and device presence. See section 8.

Ops-only, gated by X-Admin-Key (disabled -> 503 if ADMIN_API_KEY unset), except the public /internal/healthz.

  • Health, Prometheus metrics, JSON stats (counts from our tables; online-device count from Redis presence; storage computed on demand).
  • User management over profiles (list/detail/quota). Account state (email, verification, suspension/ban, deletion) is owned by Supabase and delegated to the Supabase Admin API (src/supabase_admin.py); those calls require SUPABASE_SERVICE_ROLE_KEY.

The only HTML this service serves is /join: a space invite has to survive being pasted into an email or a chat window, where an orange:// URL is stripped or silently ignored.

  • GET /join/{code} - a space invite. Shows the code, hands it to the app as orange://join?code=<code>, and links the latest release when the app is not installed.
  • GET /reset - a 302 to settings.reset_page_url, query forwarded whole. The page a recovery mail lands on lives on the static site, so a reset does not depend on this service keeping its hostname or being up. This route exists only for installs whose compiled-in redirect_to still names this host, and can be dropped once those have aged out.

Four decisions worth keeping:

  • Unversioned, alongside the infra probes (see section 5.0). version.py versions the product API because the desktop app negotiates a contract with it. A URL a person clicks in an email cannot be re-versioned without breaking every link already sent.
  • No database call. /join validates the code’s shape only (spaces.service.normalize_invite_code). The page must paint on the first response, with nothing to wait on. A lookup would also confirm to anyone whether a given code exists.
  • CSP carve-out. The global header is default-src 'none'; connect-src 'self'; frame-ancestors 'none', which would blank a self-contained page. middleware.py therefore sets it with setdefault, and /join sets its own: inline style and script allowed, everything remote still denied. The header is now overridable, never absent.
  • HTML lives in templates/*.html, read once at import and rendered by replacing __PLACEHOLDER__ tokens. There is no Jinja2 dependency, and the pages stay openable and diffable as pages. Every interpolated value goes through html.escape.

settings.public_base_url is the single source for every link this service hands out, and web.join_url() the single builder. Pointing a domain at the service is an env change.


Concern Choice Reason
API framework FastAPI + uvicorn Async, Pydantic v2, native WebSocket, auto OpenAPI
Database Supabase Postgres 16 Managed; asyncpg + SQLAlchemy 2 async core; RLS available
Identity / Auth Supabase Auth (GoTrue) Managed signup, email verify, password reset, sessions, JWTs
JWT verification PyJWT - ES256/RS256 via JWKS, legacy HS256 We verify only; Supabase signs. Asymmetric by default, symmetric accepted when SUPABASE_JWT_SECRET is set
Cache / realtime Redis 7 Pub/sub fan-out + device presence (nothing else)
Blob storage Cloudflare R2 (S3-compatible) Direct presigned PUT/GET; zero egress; MinIO for local dev
Background jobs in-process asyncio + PG advisory lock Presence sweep + blob and removal-record cleanup; no Celery/broker
Email Brevo REST (default) or stdlib SMTP Space invites only; via BackgroundTasks
KDF (E2E) Argon2id + HKDF-SHA256 (client-side) Memory-hard stretch, then split: one half is the Supabase credential, the other wraps the random UMK
Content encryption AES-256-GCM (client-side) AEAD; per-entry content key, server stores ciphertext only
Key exchange X25519 (client-side) Multi-device UMK wrapping + Space Key wrapping

Core Python dependencies (pyproject.toml):

fastapi, uvicorn[standard], pydantic, pydantic-settings,
sqlalchemy[asyncio], asyncpg, alembic,
redis[hiredis], boto3, pyjwt[crypto],
python-multipart, httpx, slowapi, email-validator

There is no celery, python-jose, or passlib dependency. cryptography comes in only as pyjwt[crypto], which PyJWT needs to verify Supabase’s asymmetric (ES256/RS256) tokens - the server verifies tokens it never signs, and every content-encryption operation is client-side.


All server-assigned IDs are UUID v4. Timestamps are BIGINT milliseconds since the Unix epoch (to match the Tauri app’s u64). client_id is stored as TEXT and carries the app’s own entry ID - itself a UUID v4 - which is what dedup keys on.

The app-side record for a Supabase Auth user. id equals the Supabase auth.users.id (the JWT sub); linkage is application-level (no cross-schema FK). Email, password, and verification state live in Supabase - not here.

CREATE TABLE profiles (
id UUID PRIMARY KEY, -- = auth.users.id
display_name TEXT NOT NULL DEFAULT '',
email TEXT, -- lowercased mirror of the Supabase email claim; indexed, for resolving invites
avatar_url TEXT, -- mirror of the provider avatar URL (Google); no image data stored here
kdf_salt TEXT NOT NULL, -- base64; Argon2id salt for the wrapping key
identity_pubkey TEXT, -- base64 X25519 public key (E2E)
pw_wrapped_umk TEXT, -- base64; random UMK wrapped under the KEK
recovery_wrapped_umk TEXT, -- base64; the same UMK wrapped under the recovery code
umk_proof_hash TEXT, -- hex sha256 of the client's X-Umk-Proof; set on first use (0020)
pw_wrapped_umk_prev TEXT, -- the password envelope an account reset replaced (0020)
blob_bytes_quota BIGINT NOT NULL DEFAULT 52428800, -- 50 MB
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);

Storage used is not stored - it is computed on demand from confirmed blobs.

CREATE TABLE devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL, -- profiles.id
device_name TEXT NOT NULL DEFAULT '',
platform TEXT NOT NULL, -- 'windows' | 'linux' | 'macos'
app_version TEXT NOT NULL DEFAULT '',
device_pubkey TEXT, -- base64 X25519 public key
fingerprint VARCHAR(64), -- client-side salted hash of a machine id; a grouping hint, never proof of identity
wrapped_umk TEXT, -- AES-GCM(shared_secret, UMK); set by a peer device
revoked BOOLEAN NOT NULL DEFAULT false,
created_at BIGINT NOT NULL,
last_seen_at BIGINT NOT NULL
);
CREATE INDEX idx_devices_user_id ON devices(user_id);

Session/refresh lifecycle is owned by Supabase. revoked is a soft flag for the device-management UX (revoking also clears wrapped_umk).

CREATE TABLE sync_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id TEXT NOT NULL,
user_id UUID NOT NULL,
device_id UUID NOT NULL,
entry_type TEXT NOT NULL, -- 'clipboard' | 'note'
kind TEXT, -- 'text'|'image'|'html'|'file'
encrypted_content TEXT NOT NULL, -- base64 AES-256-GCM ciphertext (key = the entry's CEK)
encrypted_metadata TEXT, -- base64 AES-256-GCM: label/title, pinned, local group names
created_at BIGINT NOT NULL, -- client-originated
updated_at BIGINT NOT NULL, -- client-originated (notes order by this)
server_ts BIGINT NOT NULL, -- server-assigned; the sync cursor
deleted_at BIGINT, -- tombstone (NULL = alive)
pinned BOOLEAN NOT NULL DEFAULT false,
space_ids UUID[] NOT NULL DEFAULT '{}', -- server-visible; the fan-out targets
wrapped_keys TEXT NOT NULL DEFAULT '{}', -- CEK envelope; opaque JSON map (section 7.2)
blob_key TEXT, -- object key; NULL for text
blob_size BIGINT,
CONSTRAINT uniq_client_entry UNIQUE (user_id, client_id, entry_type)
);
CREATE INDEX idx_sync_entries_user_ts ON sync_entries(user_id, server_ts);
CREATE INDEX idx_sync_entries_device ON sync_entries(device_id);
CREATE INDEX idx_sync_entries_spaces ON sync_entries USING GIN(space_ids);

space_ids is the only sharing fact the server reads: it decides which space: channels a write fans out to and which memberships can pull the row. wrapped_keys is stored and echoed verbatim - the server cannot tell one wrap from another, and a row whose space_ids is empty is a personal entry. A tombstone keeps the space_ids the entry had, so a delete reaches the same members the entry did. It is stored stripped, whatever the push carried. The server writes encrypted_content = '', sets encrypted_metadata, blob_key and blob_size to NULL, and writes wrapped_keys = '{}'. A deleted row holds no ciphertext and no key wraps.

CREATE TABLE sync_cursors (
device_id UUID PRIMARY KEY,
user_id UUID NOT NULL,
last_server_ts BIGINT NOT NULL DEFAULT 0
);

The key is still device_id alone. POST /sync/cursor only moves a row whose user_id is the caller’s, so a device id belonging to another account is a no-op, never a write to their cursor.

CREATE TABLE user_settings (
user_id UUID PRIMARY KEY,
encrypted_blob TEXT NOT NULL, -- AES-256-GCM(UMK, JSON of synced preferences)
updated_at BIGINT NOT NULL -- client-originated; used for LWW
);
CREATE TABLE spaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID NOT NULL, -- profiles.id
name TEXT NOT NULL,
invite_code TEXT UNIQUE, -- bearer secret; 8 chars, 72 h TTL
invite_expires_at BIGINT,
share_history BOOLEAN NOT NULL DEFAULT true, -- may later joiners read older entries?
members_can_approve BOOLEAN NOT NULL DEFAULT false, -- may members decide join requests?
key_fingerprint TEXT, -- truncated hash of the newest Space Key (section 7.4)
rekey_requested_at BIGINT, -- set when a departure demands a new key (section 7.4)
created_at BIGINT NOT NULL
);
CREATE INDEX idx_spaces_owner_id ON spaces(owner_id);

There is no space type and no member cap. Per column:

  • invite_code is drawn from ABCDEFGHJKMNPQRSTUVWXYZ23456789 (no I/L/O/0/1), so it survives being read aloud or retyped. On join the API normalizes case and strips - and spaces; it displays the code as KX7Q-2M4X. Anything longer than 8 characters is a legacy token_urlsafe code, matched case-sensitively.
  • Redeeming a code does not join the space. It raises a row in space_join_requests (section 4.10) that somebody already inside must approve, so a leaked or forwarded code buys a knock, not a membership. Addressed invites (section 4.9) are unaffected: naming someone by email is the approval.
  • members_can_approve is the only control over who may answer a join request: the owner alone (default), or the owner and any member.
  • share_history is resolved into the joining member’s history_from_ts at join time. Flipping it later does not retroactively widen what an existing member can pull.
  • key_fingerprint is written by the owner alone, when it mints a key. It lets a recipient tell a genuine keyring from one a member made up. A hash of 32 random bytes reveals nothing about the key it names. Why: any member may hand a key over (section 7.4), and the server cannot open a wrap to check it. The fingerprint gives the newcomer its own check against a wrong key.
  • rekey_requested_at is the rekey signal. The owner’s wrap survives a departure (section 7.4). Why: the signal used to be implicit. A departure cleared every member’s wrap, and a client that saw an empty wrap while holding keys knew to mint. That destroyed the owner’s own recovery path, so the flag now carries the request instead.
CREATE TABLE space_memberships (
space_id UUID NOT NULL, -- CASCADE on space delete
user_id UUID NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'member'
wrapped_space_keys TEXT, -- JSON array of X25519-wrapped Space Keys, newest first (section 7.4)
wrapped_by UUID, -- who wrapped it; NULL means the owner did
history_from_ts BIGINT, -- pull floor; NULL = full history
joined_at BIGINT NOT NULL,
PRIMARY KEY (space_id, user_id)
);
CREATE INDEX idx_sm_user ON space_memberships(user_id);

role has exactly two values. There is no share_scope column: filtering what a device sends into a space is a client-side setting the server never learns.

wrapped_space_keys is a wrapped keyring, not a single key: a JSON array, newest key first, each element an X25519-wrapped copy for this member. NULL is meaningful. It is the signal that this member needs a (re)distribution (section 7.4). Why an array: a rekey must not make older entries unreadable. Previous Space Keys exist nowhere else, so a member who restarts after a rekey recovers the whole ring. It can then still decrypt entries written under earlier keys.

wrapped_by names the account whose public key the recipient must compute its shared secret against. NULL means “the owner”, so every row written before migration 0017 still opens. Why: distribution is no longer owner-only. With several possible writers, the recipient can no longer assume the counterparty.

CREATE TABLE blobs (
key TEXT PRIMARY KEY,
user_id UUID NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
checksum TEXT NOT NULL, -- SHA-256 hex
confirmed BOOLEAN NOT NULL DEFAULT false,
created_at BIGINT NOT NULL
);

Migration note (0006): the users -> profiles rename and the column drops that produced this shape are recorded in migrations/versions/0006_supabase_migration.py.

Migration note (0011, spaces): the destructive groups -> spaces transition (and the addition of sync_entries.wrapped_keys) is recorded in migrations/versions/0011_spaces.py.


Addressed counterpart to the bearer invite_code: one row per (space, invitee email), so an invitation survives the invitee being offline and the inviter can see its fate. profiles.email (added in migration 0009) is the lowercased mirror of the Supabase JWT email claim, captured at bootstrap, used to resolve invitees to user ids locally.


The anonymous counterpart to space_invites. An invite is addressed and starts with the inviter. A request starts with the joiner, whom nobody named, so it carries no inviter, no invitee email, and nothing to deliver. Why a separate table: overloading space_invites would leave half its columns null on every row and put a discriminator in every query.

CREATE TABLE space_join_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL, -- profiles.id of whoever pasted the code
status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | declined
wrapped_space_keys TEXT, -- the approver's wrap, handed to the membership
wrapped_by UUID, -- whose public key opens it
created_at BIGINT NOT NULL,
decided_at BIGINT,
decided_by UUID
);
CREATE UNIQUE INDEX ix_join_requests_space_user ON space_join_requests(space_id, user_id);
CREATE INDEX ix_join_requests_space_status ON space_join_requests(space_id, status);
CREATE INDEX ix_join_requests_user ON space_join_requests(user_id);

The unique index is the abuse cap: a leaked code lets strangers knock, and a knock must not stack. A declined row is kept rather than deleted. It stops the same person knocking again on a code they still hold, and it is the only record anyone has that somebody tried.

One exception: an approved row whose membership is gone, because the member left afterwards. request_join reopens that spent row instead of refusing or duplicating it. Refusing would lock the former member out of a code they still hold, with no way back.

The approval writes the wrap into wrapped_space_keys in the same call, and add_membership moves it onto the new membership, exactly as an accepted invite does. The joiner goes from pending straight to readable. Why: whoever approves is by definition online and holding the keyring at that moment, since they are the one clicking. Joining by code used to be instant and then unreadable for as long as it took somebody’s app to notice.

One comment written by a member on one entry shared into a space. Scoped to the space, not the entry: the same clipboard item can sit in two spaces, and a remark meant for one team must not surface in the other. The entry is addressed the way every other space route addresses it: by (client_id, entry_type), not by a foreign key. A sync_entries row is per-account, so there is no single row to point at.

CREATE TABLE space_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
client_id TEXT NOT NULL, -- the commented-on entry
entry_type VARCHAR(16) NOT NULL, -- 'clipboard' | 'note'
author_id UUID NOT NULL,
encrypted_body TEXT NOT NULL, -- ciphertext; mentions live inside it, invisible here
wrapped_key TEXT NOT NULL, -- per-comment content key, wrapped under the Space Key
created_at BIGINT NOT NULL
);
CREATE INDEX ix_space_comments_space_id ON space_comments(space_id);
CREATE INDEX ix_space_comments_author_id ON space_comments(author_id);

Encrypted the way entries are: the body is sealed under a random per-comment key, and that key is wrapped under the Space Key current at write time. The server stores both and can read neither. A member who joined after a rekey still holds the older key in their keyring, so every comment stays readable across rotations.

One row per (space, entry), recording that shared content was withdrawn from a space. A device that was offline during the removal learns about it on catch-up, instead of keeping the copy forever. It is not a tombstone for the entry: the author keeps their own copy, and what was withdrawn is the sharing. The same transaction that writes this row drops the entry’s wrapped key for this space. Re-sharing and re-removing the same entry updates the row in place and bumps server_ts, rather than accumulating history.

CREATE TABLE space_entry_removals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
space_id UUID NOT NULL,
client_id TEXT NOT NULL, -- the withdrawn entry, keyed as sync_entries is
entry_type VARCHAR(16) NOT NULL, -- 'clipboard' | 'note'
author_id UUID NOT NULL, -- who shared it
removed_by UUID NOT NULL, -- who took it down (author or moderator)
server_ts BIGINT NOT NULL -- same clock and meaning as sync_entries.server_ts
);
CREATE UNIQUE INDEX ix_space_entry_removals_entry ON space_entry_removals(space_id, client_id, entry_type);
CREATE INDEX ix_space_entry_removals_pull ON space_entry_removals(space_id, server_ts);

author_id and removed_by both travel to the client. The placeholder it shows depends on whether the author withdrew their own post or somebody moderated it, the same distinction the space:entry_removed event carries. A device pulls removals and entries against one cursor. The entry row always carries a newer server_ts than any removal that preceded it, so applying removals before entries still lands on the right final state.

Server-authored messages to users; delivery and read semantics are section 16. This is the one table that holds plaintext, deliberately. Nothing in it derives from an entry, a note, or a space name, so there is nothing the server would have had to decrypt to write it. A null user_id is a broadcast to every user; a uuid addresses one account.

CREATE TABLE announcements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID, -- null = every user; indexed
kind TEXT NOT NULL DEFAULT 'announcement', -- maps to a client NotificationKind
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
data JSONB NOT NULL DEFAULT '{}', -- opaque to the server; handed to the client as-is
created_at BIGINT NOT NULL, -- indexed
expires_at BIGINT -- null = never expires
);
CREATE INDEX ix_announcements_user_id ON announcements(user_id);
CREATE INDEX ix_announcements_created_at ON announcements(created_at);

kind is free-form, so the server can use a new value before every client knows it. Unknown values fall back to “announcement” on the client. data is opaque: a link, or an id to act on. After expires_at the row stops being served (null means it never expires).


  • Base URL: /api/v1

  • Auth: Authorization: Bearer <supabase access token> on all protected routes.

  • Device scope: X-Device-Id: <device_id> header on device-scoped routes (sync, settings, key registration). Bootstrap and device registration do not require it. The server checks it against devices before trusting it, including as the origin device for fan-out:

    Answer When
    400 header missing, or not a UUID
    403 {"detail": "device_unknown"} no such device, or it belongs to another account
    401 {"detail": "device_revoked"} the device was revoked; the client signs itself out

    A passing check is cached in Redis for 60 s under dev:{sub}:{device_id}; revoking the device deletes that key, so the refusal is immediate.

  • UMK proof: X-Umk-Proof: <base64 of 32 bytes> on the routes that change key material. Rule, trust-on-first-use and the reset exception: section 7.1.

  • Rate limits: 429 with Retry-After when called too often. Limits are per verified user where the route is authenticated, else per client IP. The limit on each route is in its decorator in the code. The client backs off on Retry-After and requeues a push that did not go, so a limit delays sync and never drops anything.

  • Errors: {"detail": "..."} + HTTP status.

  • Pagination: cursor-based - ?after_ts=<server_ts>&limit=200.

Source of truth: src/version.py (API_VERSION, SERVICE_VERSION).

  • Product API is versioned in the path: client-facing under /api/v1, admin under /internal/v1. API_VERSION bumps (v2, …) only on a backwards-incompatible contract change; v1 and v2 run side by side during a migration window.
  • Infra probes are intentionally unversioned: /internal/healthz and /internal/metrics. Load balancers and Prometheus hardcode these paths and must not track a version on each bump.
  • SERVICE_VERSION (semver, e.g. 2.0.0) is the deployable build version and the OpenAPI version; it changes freely per release without implying a contract break.
  • Every HTTP response carries an X-API-Version header (= API_VERSION).
  • Live schema: Swagger UI at /api/docs, ReDoc at /api/redoc, raw spec at /api/openapi.json. All three are off unless DOCS_ENABLED=true, and answer 404 when it is not set. The schema is a complete map of the surface, /internal admin routes included, so it fails closed: a deployment that configures nothing keeps it private. .env.example also ships DOCS_ENABLED=false; set it to true on a dev machine to get the UI. Every route declares a Pydantic response_model and a docstring, surfaced as the OpenAPI summary and description. Tags group the surface (auth, sync, settings, blobs, spaces, invites, announcements, realtime, ops, admin). FastAPI does not emit WebSockets into OpenAPI, so the /ws contract is documented in section 5.8.
POST /api/v1/auth/bootstrap
Body: { display_name? }
Returns: { user_id, kdf_salt, display_name, avatar_url?, wrapped_umk?, recovery_wrapped_umk? }
Idempotent: creates the profile on first call, generates the stable KDF salt,
and returns it plus both envelopes (null on a brand-new account).
Call right after Supabase login. A null recovery_wrapped_umk is what makes
the client ask the user to save a recovery code.
PUT /api/v1/auth/umk -- X-Umk-Proof
Body: { wrapped_umk, reset? = false } -- random UMK wrapped under the password-derived key
Stores the envelope on first setup (and on password change). Server holds only
the wrapped blob, never the key. reset: true starts the account over with a
new UMK; see "Account reset" in section 7.1.
PUT /api/v1/auth/umk/recovery -- X-Umk-Proof
Body: { recovery_wrapped_umk } -- the same UMK wrapped under the recovery code
Replacing it revokes the previous recovery code, which is what regenerating
one does. One code is live at a time.
DELETE /api/v1/auth/umk/recovery -- X-Umk-Proof
Drops the envelope. An account that starts over with a fresh UMK must clear
it, or the old code would hand a recovering client a dead key. Idempotent.
POST /api/v1/auth/devices
Body: { device_name?, platform?, app_version?, device_pubkey? }
Returns: 201 { device_id } -- store and send back as X-Device-Id
GET /api/v1/auth/devices
Returns: [{ id, device_name, platform, app_version, last_seen_at }]
DELETE /api/v1/auth/devices/{device_id} -- X-Umk-Proof; soft-revoke; clears wrapped_umk
Also drops the device's cached check, publishes device:revoked on the user's
channel and closes that device's sockets (section 5.8). The device's Supabase
session is not ended, by decision: GoTrue can log out a user, not one
session, and ending the user's sessions would sign out the device doing
the revoking. The revoked device's token stays valid until it expires,
but every device-scoped route and the socket refuse it, and the client
signs itself out (and out of Supabase) on the first `device_revoked`.
POST /api/v1/auth/keys/register -- requires X-Device-Id
Body: { identity_pubkey, device_pubkey } (base64 X25519)
409 when identity_pubkey differs from the one already stored: it is derived
from the UMK and never legitimately changes (section 2.1).
GET /api/v1/auth/umk/device -- requires X-Device-Id
Returns: { wrapped_umk } -- the UMK wrapped for the calling device;
the silent session-restore path. 404 when no wrap is stored or the device
was revoked, which is what makes revocation cut a device off for real.
That 404 carries X-Wrap-Absent: 1, and the header is part of the contract:
a client acts on it by ending the session and asking for a password, so it
must be able to tell this answer from a 404 produced by a proxy, a
rewritten path, or a deployment older than the route.
POST /api/v1/auth/devices/{device_id}/key-wrap -- X-Umk-Proof
Body: { wrapped_umk } -- an existing device wraps the UMK for another device
409 {"detail": "device_revoked"} when the target device was revoked.

Registration, email verification, login, refresh, and password reset are not here - the client performs them against Supabase Auth directly.

POST /api/v1/sync/push
Body: { entries: [{ client_id, entry_type, kind?, encrypted_content,
encrypted_metadata?, created_at, updated_at, pinned, deleted_at?,
blob_key?, blob_size?, space_ids?, wrapped_keys? }] }
Returns: { accepted: [{ client_id, server_id, server_ts }],
conflicts: [{ client_id, reason: 'stale_update' | 'not_your_entry'
| 'entry_too_large' | 'account_full' }] }
At most `max_push_batch` entries per call (422 beyond it, before any work).
space_ids — fan-out targets; default [], at most 32. wrapped_keys — the CEK
envelope as a JSON string, default "{}", at most 8 KB. Both are stored verbatim
and never interpreted.
Field rules (422 on the whole request):
client_id canonical lowercase UUID, 36 characters
kind 'text' | 'image' | 'html' | 'file' | 'note' | '' (tombstone)
created_at, updated_at, deleted_at 0 .. 2^63-1, and at most 5 minutes
ahead of the server clock
blob_key at most 128 characters, the server's own key shape
Whole-request refusals (422, nothing in the batch is written):
detail "not_a_member" an entry adds a space the caller is not a current
member of (or that does not exist), on insert or update
detail "invalid_blob" an entry names a blob_key that is not the caller's own
confirmed blob
A batch is one transaction under a per-account advisory lock, so two concurrent
pushes from one account cannot both pass the row limit.
GET /api/v1/sync/pull?after_ts=<ts>&limit=200&entry_type=all|clipboard|note
Returns: { entries: [...], removals: [...], next_cursor: <ts | null> }
Each entry echoes space_ids + wrapped_keys. Rows come from the caller's own
user_id OR any space they belong to, per-membership history floor applied.
On a row another account wrote, both are trimmed to the caller's view:
space_ids to the spaces the caller is in, wrapped_keys to those spaces' wraps
(never "personal"). The author's own rows come back whole.
after_ts is 0 .. 2^63-1 (422 otherwise). A page never ends partway through one
server_ts (section 6.2), so next_cursor stays a plain integer.
removals: [{ space_id, client_id, entry_type, author_id, removed_by, server_ts }]
Entries that LEFT one of the caller's spaces since their cursor. Same fields
as the space:entry_removed event, and for the same reason: apply both through
one path. Necessary because a removal strips the space id from space_ids, and
the entries query matches on exactly that array - so from that moment the row
is absent rather than changed, and a device that was offline when it happened
can learn it no other way. Apply removals BEFORE entries: an entry re-shared
after a withdrawal carries the newer server_ts, so that order converges and
the reverse drops a live entry. Same history floor as entries.
next_cursor is the LOWER of the two streams' truncation points - a watermark
may only advance past a point both are complete to, or a device steps over
removals it never received.
POST /api/v1/sync/cursor Body: { last_server_ts }
GET /api/v1/sync/breakdown
Returns: { clipboard, notes, total, text, image, file, html }
Live-row counts for the caller's own account, from one GROUP BY over
(entry_type, kind) on the user_id index - no ciphertext leaves the server, so
it answers in milliseconds where paging every row to count client-side did
not. Own rows only, tombstones excluded (matches "synced items"). kind is the
plaintext label the server already routes on; text is every clipboard row that
is not image/file/html (a legacy null-kind row included), so
text + image + file + html == clipboard. Coarser than the client's on-device
bar, which splits URL / document / folder out of content the server cannot read.

There is no GET /sync/status and no delete route. The cursor is client-held and advanced with POST /sync/cursor, and a delete is a push with deleted_at set.

not_your_entry - one entry, one author. Rows are keyed (user_id, client_id, entry_type). A push of an entry another account wrote would therefore insert a second row under the same client_id rather than updating theirs. Clients collapse the two into one item with the wrong attribution (client orange-copy-paste-clipboard-app-rust/docs/bugfix-history.md #8). The rule:

  • What is refused. Push refuses to write a row for a client_id another account already holds in a space this push adds (_belongs_to_someone_else). An update can add a space too, so the check runs on insert and on the update path, for the spaces the update adds.
  • Why the server enforces it. It is the rare rule the server can enforce without reading anything: it is about which account owns a key, not what the content says. It lives here, not only in the client, because old builds keep running and every one of them writes through this route.
  • Why only on space overlap. The overlap condition keeps it from refusing honest pushes. One person with two accounts and the same local history holds colliding client_ids by construction. Only when both rows land in the same space is one claiming to be the other.
  • “Remove from my devices” uses the gap. A member deletes a received entry off their own devices, without touching the space, by pushing a tombstone for the received client_id with empty space_ids. That is a self-owned row under the deleter’s account that overlaps no space, so _belongs_to_someone_else permits it and fan-out reaches only user:{deleter}. The author’s row is untouched and every other member keeps their copy. The deleter’s other devices apply it through a self-hide path in the client’s merge: they recognise a self-authored, space-less tombstone for an entry they hold as received.
  • Contrast the normal case. A tombstone for an entry you own keeps its space_ids, so the delete reaches the members who received it.

Only members write into a space. space_ids decides fan-out and who can pull the row. The server therefore refuses, as a whole with 422 not_a_member, a push that adds a space the caller is not a current member of. Only added spaces are checked: a space already on the row may stay there. That lets an author who has since left or been removed still push the tombstone for what they shared.

GET /api/v1/settings Returns: { encrypted_blob, updated_at } (404 if none)
PUT /api/v1/settings Body: { encrypted_blob, updated_at, base_updated_at? }
Returns: { updated_at, winner: 'client'|'server', encrypted_blob }
winner 'client' the body was stored. The reply echoes it, and settings:updated
is published to user:{id}.
winner 'server' nothing was written and nothing is published. The reply carries
the STORED encrypted_blob and updated_at.
When the body is stored:
no blob stored yet always, whatever base_updated_at holds
base_updated_at absent or null stored updated_at <= body updated_at
base_updated_at set stored updated_at == base_updated_at
AND body updated_at > stored updated_at
base_updated_at the updated_at of the stored blob the body was merged from, or 0
when the GET answered 404.
The condition is the upsert's own WHERE (INSERT ... ON CONFLICT DO UPDATE ...
WHERE), so the check and the write are one statement under the row lock.

Why base_updated_at. A client syncs settings in one round: GET, merge, PUT. When two devices’ rounds overlap, both merge from the same blob. Under last-write-wins the later PUT then replaces a blob it never saw, and the other device’s change is lost. With base_updated_at that PUT fails its condition instead, and the winner: 'server' reply hands it the blob that won, to merge into and PUT again. Of two PUTs that carry the same base_updated_at, exactly one is stored.

  • The field is optional on purpose. A client that does not send it keeps last-write-wins, lost-update window included. The one change for it: of two racing PUTs, an older one that lands second is answered winner: 'server' instead of overwriting the newer blob.
  • Stored updated_at only moves forward. A matching base with a body updated_at at or below the stored one is refused, so a buggy client cannot move the version back.
POST /api/v1/blobs/request-upload
Body: { mime_type, size_bytes, checksum }
Returns: { blob_key, presigned_put_url, expires_in_seconds }
422 if size_bytes is not 1 .. 5 MB, if mime_type is not one of image/png,
image/jpeg, image/jpg, image/webp, image/gif, image/bmp, application/zip,
application/octet-stream, or if checksum is not 64 hex characters (SHA-256).
402 if over quota, counting unexpired unconfirmed uploads.
The PUT must send Content-Length equal to size_bytes; it is part of the signature.
POST /api/v1/blobs/confirm-upload Body: { blob_key } → 204
(409 if the object is missing or its size
or SHA-256 differs from the declared one -
the object is then deleted; 402 if
confirming would exceed the quota)
POST /api/v1/blobs/release Body: { blob_key } → 204
(409 while a live entry references it,
404 if it is not the caller's)
GET /api/v1/blobs/{blob_key}/download-url Returns: { presigned_get_url, expires_in_seconds }
GET /api/v1/blobs/quota Returns: { used_bytes, quota_bytes,
entry_count, entry_limit,
max_entry_bytes }
POST /api/v1/spaces Body: { name, share_history?: true }
Returns: 201 { space_id, invite_code } -- caller becomes owner, full history
GET /api/v1/spaces Returns: [SpaceOut] -- every space the caller is in
GET /api/v1/spaces/{space_id} Returns: SpaceOut -- 403 if not a member
GET /api/v1/spaces/my-join-requests Returns: [{ space_id, space_name, created_at }]
The caller's own outstanding knocks. A pending request is not a membership, so
these spaces are absent from GET /spaces and the wait would otherwise be a blank
screen. Declared *above* /{space_id}, which takes a UUID and would shadow it
into a 422.
POST /api/v1/spaces/join Body: { invite_code }
→ { status: "pending" | "declined", space_name }
Raises a join request; does not join (section 4.10). The space name is the only
thing leaked, and the caller needs it to know what they asked for. 410 when the
code has expired; already a member returns the name and changes nothing;
knocking twice returns the same pending row rather than stacking one.
GET /api/v1/spaces/{space_id}/join-requests
Returns: [{ id, user_id, display_name, avatar_url, identity_pubkey, created_at }]
Pending rows only, for a caller who may approve. 403 otherwise.
POST /api/v1/spaces/{space_id}/join-requests/{request_id}/approve -> 204
Body: { wrapped_space_keys?: "[...]" } -- same shape as PUT /invites/{id}/key.
Handed to add_membership, so the joiner can read immediately. 409 if the request
was already decided.
POST /api/v1/spaces/{space_id}/join-requests/{request_id}/decline -> 204
PATCH /api/v1/spaces/{space_id} Body: { share_history?, members_can_approve? }
Owner only. Both fields optional and applied independently, so setting one
cannot clobber the other.
DELETE /api/v1/spaces/{space_id}/members/{member_user_id}
Owner removes a member, or a member removes themselves. 400 if the target is
the owner (delete the space instead). Leaving a space you are not in is a 204
that changes nothing and publishes nothing. Clears the remaining *non-owner* wraps
and stamps spaces.rekey_requested_at to ask for a new key (section 7.4).
DELETE /api/v1/spaces/{space_id} -- owner only; cascades memberships + invites
Ownership is checked before space:deleted is published, so a refused delete
(403, 404) announces nothing.
DELETE /api/v1/spaces/{space_id}/entries/{client_id}?entry_type=clipboard -> 204
Take a shared entry down from a space: the owner (any entry) or the member who
shared it (their own). Moderation, not deletion - the space id and its wrapped
key copy are dropped from the entry, and the author keeps their personal copy.
Emits space:entry_removed to the space channel. client_id must be a UUID (422);
a caller who is not a current member, an unknown space and an entry not in the
space all get the same 404, so the route reveals nothing about a space the
caller cannot see. A member removing someone else's entry gets 403.
POST /api/v1/spaces/{space_id}/invites Body: { email } -- owner only; see section 5.6
POST /api/v1/spaces/{space_id}/keys -- any member holding the keyring
Body: { wrapped_keyrings: [{ user_id, wrapped_space_keys }], key_fingerprint? }
wrapped_space_keys is a JSON array string, 1 .. 16 KB; at most 256 entries in
wrapped_keyrings. Stored verbatim on the membership, alongside
wrapped_by = the caller. The owner may write any row. Anyone else may only fill
a gap: a row that already holds a wrap, and the owner's row always, is skipped
without error. key_fingerprint is honoured from the space
owner only, and clears rekey_requested_at once every member holds a wrap.
space:rekey is published only to members actually written.
POST /api/v1/spaces/{space_id}/comments Returns: 201 CommentOut -- any member
Body: { client_id, entry_type?: "clipboard", encrypted_body, wrapped_key }
client_id must be a UUID (422), here and on the GET below.
Comment on an entry shared into the space. The body arrives encrypted and leaves
encrypted: stored as given, echoed to the space channel as given. Emits
space:comment (action "created").
GET /api/v1/spaces/{space_id}/comments?client_id=...&entry_type=clipboard
Returns: [CommentOut] -- one entry's thread, oldest first.
GET /api/v1/spaces/{space_id}/comments/counts Returns: [CommentCountOut]
Comment tallies for every commented-on entry in the space, so a feed draws all
its chips in one request. Each row: { client_id, entry_type, count, latest_at }.
DELETE /api/v1/spaces/{space_id}/comments/{comment_id} -> 204
The comment's author, or the space owner as moderator. Emits space:comment
(action "deleted").

SpaceOut:

{
"id": "...", "owner_id": "...", "name": "...",
"invite_code": "KX7Q2M4X", "invite_expires_at": 1234567, "share_history": true,
"created_at": 1234567,
"members_can_approve": false, // the owner's choice about who answers a knock
"i_can_approve": true, // derived by service.may_approve -- the one definition
"pending_join_requests": 0, // counted only for a caller who may act on it
"members": [{
"user_id": "...", "display_name": "...", "avatar_url": null,
"role": "owner|member", "joined_at": 1234567,
"identity_pubkey": "base64 X25519 | null", // null until that member registers keys
"has_space_key": true, // holds a keyring? presence only, never the bytes
"online": true // any device connected; a hint, see below
}],
"my_wrapped_space_keys": "[\"...\",\"...\"] | null", // only ever the caller's own keyring
"my_wrapped_by": "uuid | null", // whose public key opens it; null = the owner
"key_fingerprint": "base64 | null", // what a received keyring is checked against
"rekey_requested_at": 1234567 // non-null = this space is waiting for a new key
}

online is a snapshot and nothing more. It is read from Redis presence at request time. When the presence store is unreachable, every member reports false rather than the request failing; the rest of the response comes from Postgres and is still correct. So online may drive what a member list shows. It must never decide whether an action is allowed or whether a device has really gone away. The same applies to online on GET /auth/devices and to the user:presence event.

Six fields carry the key-distribution contract. A member’s keyring is never exposed to anyone else.

Field Role
identity_pubkey What a distributor wraps for.
has_space_key Lets a distributor wrap only for members who need one. Without it, every reconcile would re-distribute to everybody, the server would echo that back as space:rekey, and clients would loop.
my_wrapped_space_keys The restart recovery path: Space Keys live in client memory only, and space:rekey is fire-and-forget.
my_wrapped_by Whose public key opens my_wrapped_space_keys.
key_fingerprint What the recipient checks the unwrapped ring against.
rekey_requested_at The request for a new key.

i_can_approve and pending_join_requests are derived server-side rather than left to the client. The rule about who may approve therefore has exactly one definition (service.may_approve), and a client cannot drift from it. The count comes back as zero to anybody who could not act on it anyway.

CommentOut:

{
"id": "...", "space_id": "...",
"client_id": "...", "entry_type": "clipboard|note", // the entry the comment hangs on
"author_id": "...",
"encrypted_body": "...", // ciphertext; the server never sees the text or the mentions
"wrapped_key": "...", // the per-comment key wrapped under the Space Key; opaque here
"created_at": 1234567
}

There is no rotate-invite-code route: a space’s code is minted once at creation. Why: rotating it on approval was considered and dropped. The code is printed on links and sitting in mailboxes, so rotating it silently breaks every one of them. Approval already neutralises a leaked code, which is what rotation was for.

The bearer invite_code is complemented by persistent, per-email invites. Lifecycle: pending -> accepted | declined (invitee) | revoked (inviter); expiry (72 h) is judged at read/accept time, no sweeper.

POST /api/v1/spaces/{id}/invites Body: { email } -- owner only, requires X-Device-Id
Returns: 201 { id, space_id, space_name, inviter_id, inviter_name,
invitee_email, status, created_at, expires_at,
invitee_identity_pubkey, has_space_key }
The last two are returned to the inviter only, so it can pre-wrap the keyring;
the copy published to the invitee omits both.
400 if it's the caller's own email; 409 if already a member.
An address with no account gets the same 201 and the same fields as one with
an account (invitee_identity_pubkey is then null, as it is for an account that
has not registered keys), so the route cannot be used to test who is signed up.
The row is stored; no event is published and no email is sent for it.
Refreshes an existing pending invite instead of stacking duplicates.
Publishes invite:received to the invitee when resolvable; emails the code,
at most once per 10 minutes per invite (a re-invite inside that window
refreshes the row and the event but sends no second email).
GET /api/v1/invites Returns: { sent: [...], received: [...] }
received = pending, unexpired, matched by user id or the token's email claim
sent = the caller's 50 most recent, any status, so outcomes are visible
POST /api/v1/invites/{id}/accept Joins the space immediately -- no approval step,
because naming an email *is* the approval.
403 email_unverified when the token carries
email_verified: false
POST /api/v1/invites/{id}/decline
DELETE /api/v1/invites/{id} -- inviter revokes a pending invite; needs X-Device-Id
PUT /api/v1/invites/{id}/key Body: { wrapped_space_keys } -- inviter only, pending only
Attaches the keyring wrapped for invitee_identity_pubkey. Accept moves it onto
the new membership (wrapped_by = the inviter) and clears it from the invite, so
the member can read the space the moment they join with nobody else online.
Re-inviting drops a stale wrap rather than reusing it.

GET /invites and the accept / decline routes authenticate on the bearer token alone. They match the caller against the token’s email claim, are not device-scoped, and do not require X-Device-Id. Revoke does, since it goes through the shared device-scoped dependency.

Invitee resolution uses profiles.email, a lowercased mirror of the Supabase JWT email claim captured at bootstrap (section 4.9). It needs no Admin API round-trip.

Split into unversioned infra probes and the versioned admin API (see section 5.0).

-- Unversioned probes (paths are stable across API versions)
GET /internal/healthz Returns: { status, db, redis } -- public, response_model=HealthResponse
GET /internal/metrics Prometheus text (X-Admin-Key) -- excluded from OpenAPI (text/plain)
-- Versioned admin API (require X-Admin-Key)
GET /internal/v1/stats JSON aggregate
GET /internal/v1/admin/users?offset=&limit=&search=<display_name>
GET /internal/v1/admin/users/{user_id} -- enriched with email/verified/banned when Supabase admin is configured
PATCH /internal/v1/admin/users/{user_id}/quota Body: { blob_bytes_quota }
POST /internal/v1/admin/users/{user_id}/suspend Body: { suspend: bool } -- delegates to Supabase (ban/unban)
DELETE /internal/v1/admin/users/{user_id} -- deletes profile (cascade) + Supabase user
GET /internal/v1/admin/email Returns: provider, configured, email_from, *_set flags -- no secret
POST /internal/v1/admin/email/test Body: { to } Returns: { sent, provider, error? }
POST /internal/v1/admin/announcements -- post to one user or everyone (section 16)
DELETE /internal/v1/admin/announcements/{id} -- stop serving it

Every admin-key route is limited per client IP. Ten wrong keys from one IP lock that IP out (429) for 15 minutes, and the key is compared in constant time. In production Caddy answers 404 for every /internal/* path except /internal/healthz, so the admin API and /internal/metrics are reachable only from inside the box (see docs/DEPLOY.md).

Invite delivery is best-effort and its failures are swallowed (see email.send_sharing_invite), so a broken mail config is invisible from the client. The two email routes expose it. GET /admin/email reports what the deployment would use and whether its credentials are present. POST /admin/email/test sends one message and returns the real error. Neither returns a key or a password.

Metrics: orange_users_total, orange_devices_total, orange_devices_active_total, orange_devices_online, orange_sync_entries_total, orange_sync_entries_deleted_total, orange_blobs_total, orange_blobs_confirmed_total, orange_storage_bytes_used, orange_redis_memory_bytes, orange_realtime_events_dropped_total.

Connection: GET /ws, then a handshake message. Credentials are never taken from the URL, where proxies and access logs would keep them; a ?token= query is ignored.

  1. The server accepts the upgrade and waits up to 10 s for the first text frame: {"type": "auth", "token": "<supabase access token>", "device_id": "<uuid>"}.
  2. The token must verify (section 9) and the device must exist, belong to sub and not be revoked.
  3. The server answers {"type": "auth_ok"}, and only then subscribes the socket.

Close codes:

Code Means Client action
4401 Handshake failed or timed out, the token expired, or the device was revoked. Nothing else is sent before it. Refresh the token and reconnect; after device:revoked, sign out instead.
4429 The user already holds 8 sockets on this replica. Close a socket you no longer need.
1011 The socket stopped reading and was dropped (see _broadcast_local). Reconnect and pull.

A socket is closed with 4401 when its token’s exp passes, so a long-lived socket never outlives its credential; the client reconnects with a fresh token.

The connection is subscribed to user:<user_id> and every space:<space_id> the user belongs to. The server resolves the channel set at connect. It re-resolves it whenever a space:membership_changed for that user passes down their own channel, on every replica, for every socket that user has open. A client may also ask, with resubscribe (below), but nothing depends on it doing so. A socket that joined or created a space mid-connection is moved onto its channel either way.

Server -> Client events:

{ "event": "sync:entry", "payload": { ...SyncEntryOut } } // incl. tombstones (deleted_at set)
{ "event": "device:online", "payload": { "device_id": "..." } } // user: channel; own devices
{ "event": "device:offline", "payload": { "device_id": "..." } }
{ "event": "device:revoked", "payload": { "device_id": "..." } } // user: channel; the named device signs out, then its sockets are closed with 4401
{ "event": "user:presence", "payload": { "user_id": "...", "online": true } } // space: channels
{ "event": "space:entry_removed", "payload": { "space_id": "...", "client_id": "...", "entry_type": "clipboard|note", "author_id": "...", "removed_by": "..." } }
{ "event": "space:membership_changed", "payload": { "space_id": "...", "action": "joined|left|deleted", "user_id": "..." } }
{ "event": "space:rekey", "payload": { "space_id": "...", "wrapped_space_keys": "[...]" } }
{ "event": "space:join_requested", "payload": { "space_id": "...", "request_id": "...", "user_id": "..." } }
{ "event": "space:join_decided", "payload": { "space_id": "...", "status": "approved|declined" } }
{ "event": "space:comment", "payload": { "space_id": "...", "action": "created", "id": "...", "client_id": "...", "entry_type": "clipboard|note", "author_id": "...", "encrypted_body": "...", "wrapped_key": "...", "created_at": 1234567 } } // action "deleted" carries { space_id, action, id, client_id, entry_type, author_id, deleted_by }
{ "event": "space:history_opened", "payload": { "space_id": "..." } } // space: channel; owner opened the back catalogue, go re-pull older rows
{ "event": "announcement:new", "payload": { "id": "...", "kind": "...", "title": "...", "body": "...", "data": {}, "created_at": 1234567, "expires_at": 1234567 } } // one user's channel or broadcast
{ "event": "invite:received", "payload": { ...InviteOut } }
{ "event": "invite:updated", "payload": { "invite_id": "...", "status": "accepted|declined|revoked", "space_id": "..." } }
{ "event": "settings:updated", "payload": { "updated_at": 1234567 } }
{ "event": "ping", "payload": { "server_ts": 1234567 } }

Routing rules worth knowing when implementing a client:

  • sync:entry is published once to user:{author} and once per space: channel in the entry’s space_ids. Both carry the same payload, and the origin device is excluded from delivery, so a device never receives its own write back. A socket in a space it also authored into can therefore see the same entry twice; dedupe on (client_id, entry_type). The payload is trimmed per socket, the same view pull gives (section 5.2). The author’s sockets get the whole row on either channel. Every other socket sees space_ids cut to the spaces it is in, and wrapped_keys cut to those spaces’ wraps, never personal.
  • There is no sync:delete. A delete arrives as sync:entry with deleted_at set.
  • device:online / device:offline are per-device and go only to the user’s own channel. user:presence is the per-user fact addressed to that user’s spaces, so other members’ lists stay current without polling REST. online: true is published on every connect, not only on the offline->online edge. online: false comes from the clean-close path and, for sockets that died without one, from the presence sweeper. Repeats are expected; a client drops an update that changes nothing. Why every connect: a socket that dies without a close leaves its presence key alive for up to PRESENCE_TTL. A client reconnecting inside that window looks like it never left, so an edge-triggered publish would say nothing.
  • space:entry_removed carries both author_id and removed_by. removed_by is the authoritative half: it is the account that acted. author_id is advisory. A client must not compute “did the author remove this” from author_id == removed_by. It holds one copy and already knows who wrote that copy. Comparing removed_by against that author is the only answer that is true of the copy in front of the reader. Why advisory: the removal record has one row per (space, entry) and can name only one author, while rows are keyed (user_id, client_id, entry_type). An owner clearing every row under one client_id may be clearing several authors’ rows, and only one of them is recorded (the remover’s own if present, else the lowest id).
  • space:entry_removed is the fast path, not the guarantee. The durable record is a space_entry_removals row, written in the same transaction that strips the space id and returned by GET /sync/pull as removals. Anyone not connected at the time may miss the event, and pub/sub does not buffer for absent subscribers. A member whose device was closed learns about the withdrawal from the pull instead, so losing the event costs latency, not correctness.
  • space:membership_changed is published to the space channel and to the affected user’s own channel. The joiner is not on the space channel yet, and a removed member may already be off it. Creating a space is the same case with nobody else in it: POST /spaces publishes action: "joined" to the creator’s own channel alone. That event puts their open socket on the new space channel. Without it, the channel set resolved at connect never grows. The owner would then receive nothing published to their own space (entries, comments, removals, presence, or the next membership change) until the socket reconnects. action: "deleted" is published before the row is deleted, while the channel still has subscribers.
  • space:join_requested goes to the space channel when members_can_approve is set, and to the owner’s own channel when it is not. The fan-out therefore matches who may act on it, rather than being filtered client-side. space:join_decided is addressed to the requester’s own channel: they are not on the space channel, and after a decline they never will be.
  • space:rekey is addressed to one member’s own channel and is fire-and-forget: nothing retries it. A client that was offline recovers the same keyring from SpaceOut.my_wrapped_space_keys on its next GET /spaces.

Client -> Server: { "event": "pong" } / { "event": "ack" } (either refreshes the presence TTL), and { "event": "resubscribe" }, which re-resolves the socket’s channel set. The server already does this itself on any membership change (see the connection note above). resubscribe is a client saying it believes it is stale, not the mechanism fan-out depends on. It costs one query, so the server honours at most one per socket every 5 s and ignores the rest. Frames that are not a JSON object (arrays, numbers, invalid JSON, binary) are ignored.

5.9 Web Page Routes (unversioned, no auth)

Section titled “5.9 Web Page Routes (unversioned, no auth)”
GET /join/{code} HTML - space invite landing page
GET /reset?code=<code> 302 - forwards to the reset page on the static site

Both are excluded from OpenAPI. /join answers 200 for any well-formed code and sets its own Content-Security-Policy; /reset forwards whatever query it is given and reads nothing out of it. See section 2.8.


  • Server assigns a millisecond server_ts on every accepted write; it is the sync cursor.
  • Conflict on (user_id, client_id, entry_type): last writer wins. A push with an updated_at not newer than the stored row is rejected as stale_update.
  • Tombstone wins: an incoming delete beats a concurrent live update.
Push: client → POST /sync/push [batch] → upsert (client_id dedup), assign server_ts,
publish sync:entry to user:{author} + space:{id} for each space_id
→ { accepted, conflicts }
Pull: client → GET /sync/pull?after_ts=<cursor>&limit=200
server → rows WHERE server_ts > cursor
AND ( user_id = me
OR space_ids && ARRAY[<one space I'm in>] -- one arm per membership,
[AND server_ts >= that membership's history_from_ts] )
ORDER BY server_ts ASC (+1 for next_cursor)
+ removals WHERE server_ts > cursor
AND space_id IN (<spaces I'm in>) -- same history floor
ORDER BY server_ts ASC (+1 for its own cursor)
next_cursor = min(entry cursor, removal cursor)
repeat until next_cursor = null → POST /sync/cursor

The space arm is not redundant with the WebSocket. Without it, an entry another member pushed while this device was offline would never arrive at all, because live fan-out is the only other delivery path. The history floor is applied per membership, since the same caller may have full history in one space and post-join-only in another.

server_ts is assigned per accepted entry from wall-clock milliseconds, so a batch does not share one timestamp. Cursor comparisons are strict (>) on pull, and the cursor write only moves forward (POST /sync/cursor ignores a lower value).

Two rows can still share a server_ts (two accounts, one millisecond). A strict > cursor would step over the second if a page ended between them. So both streams order by (server_ts, id), and a page never ends partway through one server_ts. A page cut inside a run is trimmed back to the last complete timestamp. A page whose rows all share one timestamp is extended to return every row at it. The cursor stays a plain integer.

Four settings in src/config.py set five ceilings; the tombstone cap is derived from the live-row cap. The entry-size and row caps are enforced in push_entries, the batch cap by the request schema (src/sync/schemas.py), and the request-size cap in middleware, in front of all of them.

Setting Default Refusal
max_entry_bytes 512 KB entry_too_large, checked before the row lookup
max_entries_per_user 3,000 live rows account_full
3 x max_entries_per_user 9,000 tombstones account_full, new tombstones only
max_push_batch 200 entries 422 on the request body, before any work
max_request_bytes 8 MB 413, before the body is read at all

How each one behaves:

  • max_entry_bytes applies to encrypted_content and encrypted_metadata separately. It is the only thing that bounds encrypted_content, which lives in Postgres, not R2. A row cap bounds total bytes only at typical entry sizes. Why separately: both are ciphertext on the same row, and capping only the first left the second as a way around it. It is not a sum because the client derives its own local limit from this number. Charging metadata against the content budget would put that derivation a few bytes off and refuse rows that should have fit.
  • max_request_bytes is the only one of the four settings not about a row. It is checked against Content-Length when the client offers one, and against the running total either way, because a chunked request offers nothing. A body that goes over is answered 413 from inside the receive channel. Its read is then closed as a disconnect, so the route never runs. BodySizeLimitMiddleware carries the reason that answer cannot simply be an exception. Why: the other limits are read off an already-parsed body. By the time any of them runs, the request has been buffered and built into as many as max_push_batch models, a cost they cannot prevent whatever they decide.
  • account_full (live rows) counts live rows only (deleted_at IS NULL) and refuses only a new live row. It is checked after the update path, so a full account can still be edited and emptied. A new tombstone is let through too. This keeps “remove from my devices” for a received entry working at quota, because that hide is a brand-new tombstone row. Why: a tombstone adds nothing to a count that excludes tombstones, and it is the thing that frees space. A cap that blocks its own remedy is one the user cannot get out from under.
  • account_full (tombstones) caps tombstones at three times the live-row cap, so an account cannot grow the table without bound through deletes. It refuses only a brand-new tombstone row; turning a live row into a tombstone is always allowed. Reviving a tombstone (a push with deleted_at null onto a deleted row) makes a live row again, so it needs a free live slot, like an insert.
  • Whose rows count. Both per-account limits count rows by user_id. An entry somebody else shared into your space is their row on their account, and does not count against you. The storage quota follows the same rule (section 2.4): you are charged for what you uploaded, nothing else. The account screen shows both as bars side by side for that reason.

Local history.bin / notes.bin are the source of truth. The sync client pulls on startup/reconnect, queues pushes while offline, and applies WS events as they arrive.


The invariant: entry payloads reach the server (and Supabase) as ciphertext only, never as plaintext content, note titles, or labels. The client generates, wraps and unwraps all key material. Space names are the deliberate exception: spaces.name is plaintext. An invitee is shown the space name before they join, and therefore before they hold any key that could decrypt it. See section 7.5 for the full visibility list.

7.1 User Master Key (UMK) - envelope model

Section titled “7.1 User Master Key (UMK) - envelope model”

The UMK is a random 32-byte key, not derived from the password. The password is stretched once on the device and split in two: an auth key that is the only thing Supabase Auth ever receives, and a key-wrapping key (KEK) that wraps/unwraps the UMK and never leaves the device:

salt = SHA-256("orange-copy-paste/auth-salt/v2:" || lowercase(trim(email)))
master = Argon2id(password, salt, m=65536, t=3, p=4) → 32 bytes -- never leaves the device
auth_key = base64(HKDF-SHA256(ikm=master, salt=none, info="orange-copy-paste/auth-key/v2", 32))
-- sent to Supabase as the account "password": sign-up, sign-in, PUT /user
KEK = HKDF-SHA256(ikm=master, salt=kdf_salt, info="orange-copy-paste/kek/v2", 32)
wrapped_umk = AES-256-GCM(KEK, UMK, aad="umk-envelope-v2") -- the envelope, stored server-side
UMK = AES-256-GCM-open(KEK, wrapped_umk) -- recovered on login

The raw password is not sent anywhere. Supabase stores a bcrypt hash of auth_key. Recovering auth_key from that hash, or capturing it in transit, gives the ability to sign in and nothing else, because HKDF is one-way and the two halves are independent. The master is salted with the address rather than a server value. It has to exist before sign-in, because it produces the credential, and the address is the one thing known about the account at that point. The kdf_salt still binds the KEK to the account row.

Why the split. Before it, the same password was sent to Supabase and fed to Argon2id. Supabase Postgres also hosts these tables, so one database dump held bcrypt(password), kdf_salt and pw_wrapped_umk, and a bcrypt-speed guess against auth.users bypassed the memory-hard KDF for any guessable password. Anyone who could read sign-in traffic had the same shortcut.

  • First setup: the client generates a random UMK, wraps it under the KEK, and stores the envelope via PUT /auth/umk.
  • Return / new device: the client fetches wrapped_umk from bootstrap, derives the KEK from the entered password, and unwraps. A GCM auth failure = wrong password.
  • Accounts from before the split (Argon2id(password, kdf_salt) as the KEK, AAD umk-envelope-v1, raw password on file with Supabase) migrate on their next sign-in. The migration is client-side and needs no server change. Supabase refuses sign-in with auth_key, and the client retries once with the raw password. It opens the envelope with the legacy KEK and re-wraps the same UMK under the new KEK (PUT /auth/umk). Only then does it replace the Supabase credential (PUT /user with auth_key). Envelope first, credential second, so a failure between the two leaves the old sign-in working. An envelope in the old format behind a Google sign-in follows the same path. Nothing is re-encrypted; the UMK does not change.

Decoupling the key from the password means a password change only re-wraps the UMK (one PUT /auth/umk) instead of re-encrypting all data. The server holds only the wrapped envelope; the UMK lives in memory only.

The same random UMK is wrapped three ways, which is what makes it recoverable without ever being recoverable by the server:

Envelope Wrapped under AAD Stored
Password HKDF(Argon2id(password, email-salt), kdf_salt), the KEK above umk-envelope-v2 profiles.pw_wrapped_umk
Recovery code Argon2id(recovery_code, kdf_salt) umk-recovery-v1 profiles.recovery_wrapped_umk
Per device x25519(device_priv, device_pub) key-wrap, no AAD devices.wrapped_umk

The first two are account-wide and open on any machine; the third is local to one install and needs nothing typed. The recovery and password envelopes both take in kdf_salt deliberately - same account binding, different secret - and the distinct AADs are what stop one being fed to the other. The recovery code is typed into the app and sent nowhere, so it needs no split.

A bearer token alone is not enough to change key material. The routes that do take X-Umk-Proof: 32 bytes the client derives from the UMK itself and sends base64-encoded. Only a caller that has unlocked the account can produce it. The server stores sha256(proof) in profiles.umk_proof_hash, never the proof. The proof is not a key and opens nothing the server holds. Why: without it, a stolen access token could replace the password envelope with one under a key the thief chose, wrap the UMK for a device they control, or revoke the owner’s devices.

Routes: PUT /auth/umk, PUT /auth/umk/recovery, DELETE /auth/umk/recovery, POST /auth/devices/{id}/key-wrap, DELETE /auth/devices/{id}.

Stored hash Header Result
NULL absent Proceeds. An account no proof-carrying client has touched yet.
NULL present Proceeds, and sha256(header) is stored (trust on first use).
set matches (constant-time) Proceeds.
set absent or different 403 {"detail": "umk_proof_required"}
any not base64 of exactly 32 bytes 400 {"detail": "invalid_umk_proof"}

A password change keeps the proof, because the UMK does not change.

Derivation (client). proof = HKDF-SHA256(ikm = UMK, salt = none, info = "orange-copy-paste/umk-proof/v1", length = 32), sent as standard base64. It is a one-way function of the UMK, so holding the proof gives nothing back about the key.

Account reset. A user who has lost every way to open the old UMK starts over with a new one, so they cannot prove the old key. PUT /auth/umk with "reset": true is accepted without a matching proof only when the token’s amr claim has an entry with method == "recovery". Supabase sets that entry on a session opened from a password-recovery link. The header is still required and must carry the proof of the new UMK, which replaces the stored hash. The replaced envelope moves to profiles.pw_wrapped_umk_prev. A reset from any other session is judged like an ordinary write. After a reset the client clears the recovery envelope and re-wraps for its devices with the new proof.

What a client tries, cheapest first, when it needs the UMK:

Step Needs Prompt
In memory already signed in none
Device wrap this machine’s keychain key none, silent
Password envelope the password normal sign-in
Recovery envelope the recovery code only when the above cannot be used
Start over nothing explicit, and says plainly that older synced items become unreadable

Hard boundary: every step above needs a secret the user holds. A recovery path that needs none means the server can decrypt, which ends the end-to-end guarantee. That option does not exist here and must not be added.

7.2 Content Encryption - the per-entry CEK envelope

Section titled “7.2 Content Encryption - the per-entry CEK envelope”

Content is not encrypted under the UMK or under a Space Key. Every entry gets its own random 32-byte content encryption key (CEK). The content is encrypted exactly once under it, and the CEK is then wrapped once per reader:

CEK = 32 random bytes -- per entry, never reused
encrypted_content = base64( nonce(12B) || AES-256-GCM(key=CEK, content, aad=client_id) )
encrypted_metadata = same construction, same CEK
wrapped_keys = { -- JSON map, stored as TEXT
"personal": wrap(UMK, CEK), -- always present; the author's own copy
"<space_id>": wrap(SpaceKey_now, CEK), -- one per entry in space_ids
...
}
where wrap(k, key) = base64( nonce || AES-256-GCM(key=k, base64(key), aad="key-wrap") )

What the envelope buys:

  • Cheap, consistent fan-out. Sharing the same entry into three spaces adds three 60-odd-byte wraps, not three copies of the ciphertext, and every reader decrypts byte-identical content. Re-sharing an existing entry into a new space means re-pushing it with an extra wrap; the ciphertext does not change.
  • Bound ciphertext. aad=client_id binds the ciphertext to its entry, so a row’s content cannot be moved onto a different client_id without breaking the GCM tag.
  • Bound wraps. The "key-wrap" AAD does the same for the wraps: a wrapped CEK cannot be replayed as an entry payload, or the reverse.

Decrypt path.

  1. Try wrapped_keys["personal"] against the UMK.
  2. If that is absent or fails, walk the entry’s space_ids and trial-decrypt wrapped_keys[space_id] against each key in that space’s keyring, newest first.
  3. If the CEK unwraps under no held key, skip the entry; do not drop it. A later space:rekey or GET /spaces can make it readable.

An AES-GCM auth failure means “wrong key”, so trial decryption is safe, and no key epoch or version number has to be carried on the wire.

encrypted_metadata encodes { groups, pinned, label } for a clipboard entry and { title, groups, pinned } for a note, under the same CEK. groups here is the app’s local entry-grouping feature and has nothing to do with sharing. An entry’s space routing is deliberately not in there: the server has to read space_ids in the clear to fan out at all.

  1. New device generates an X25519 keypair; registers it (device_pubkey).
  2. An existing device computes shared_secret = X25519(my_priv, new_device_pubkey), wraps the UMK, and posts it to POST /auth/devices/{new_device_id}/key-wrap.
  3. The new device derives the same secret and unwraps the UMK. The server stores wrapped_umk but can never unwrap it (holds no private key).

Every space has a Space Key: a random 32-byte key held in client memory, minted by the owner and handed over by any member who holds it. It never encrypts content directly; it only wraps per-entry CEKs (section 7.2). A member’s copy is wrapped for their identity key:

shared = X25519(distributor_identity_priv, member_identity_pubkey)
wrapped = wrap(shared, SpaceKey)

X25519 is symmetric in the pair, so the recipient derives the same secret from X25519(their_priv, distributor_identity_pubkey). That is why SpaceOut.members[].identity_pubkey carries every member’s key, and why my_wrapped_by says which of them to use (null = the owner). A distributor also wraps for itself, since X25519(priv, own_pub) is a valid secret. That is how a member recovers its ring after a restart.

The rules:

  • Minting stays owner-only, so exactly one account decides what the current key is.
  • Distribution is open to any keyholder, but a non-owner may only fill a gap. The server skips a non-owner’s write onto a row that already holds a wrap, and always onto the owner’s row. Reconcile only ever wraps for members with has_space_key = false. Honest clients therefore lose nothing, and a member cannot overwrite someone’s working ring with a key that is not this space’s. Why distribution was widened from owner-only to any keyholder is recorded in the website design record.
  • Verifying a received ring. The owner writes spaces.key_fingerprint when it mints. A recipient checks the unwrapped ring against it before adopting it. A mismatch is surfaced as an error, not retried, since retrying cannot fix a wrong key. A self-write needs no check. Why: the server stores whatever wrap it is handed and cannot check it. With more than one possible writer, a member could hand a newcomer a key that is not this space’s. The unwrap would succeed, and the victim would silently decrypt nothing.
  • The keyring. space_memberships.wrapped_space_keys is a JSON array, newest key first, and every distribution sends the whole ring. Older keys must survive a rekey, or entries written under them become permanently unreadable. They exist nowhere but in client memory and these wraps.

Reconcile runs on login, on WebSocket connect, on space:rekey, and after any membership change. Every member runs it, for each space it is in, given GET /spaces:

  1. Recover the local ring by unwrapping my_wrapped_space_keys against the public key my_wrapped_by names. Verify it against key_fingerprint; on mismatch, refuse and report. An empty or absent wrap does not clear the in-memory ring.
  2. Only the owner mints. An empty ring means a first key. A non-null rekey_requested_at while the ring is non-empty means a new key prepended to it, published with a fresh key_fingerprint.
  3. Wrap the full ring for every member with has_space_key = false (or for everyone, if a key was just minted) and POST /spaces/{id}/keys. Members with no registered identity_pubkey are skipped and retried on the next reconcile.

Distribution only happens while someone lacks keys, so the space:rekey events the server echoes back cannot drive an endless reconcile loop. A member who cannot yet be helped is retried on a backoff. Any keyholder coming online is another chance, and in a space with more than one person, somebody nearly always is.

Rekey on departure. When a member is removed or leaves, the server deletes the membership, clears wrapped_space_keys for every remaining non-owner member, and sets spaces.rekey_requested_at. That is the entire server-side mechanism; the server never sees a key. The owner’s next reconcile prepends a fresh key and redistributes. Entries pushed from then on wrap their CEK under the new key, which the departed member never receives. Distribution clears the flag once every remaining member holds a wrap.

The owner’s own wrap is deliberately left alone. It is the owner’s only copy of the previous ring if their app restarts before redistributing, and that ring lives nowhere but in memory and these wraps.

Revocation is best-effort by construction, and the edges are real:

  • A member who already pulled or received an entry keeps its plaintext locally. A rekey changes what they can read next, never what they already read.
  • Between the removal and the owner’s reconcile, remaining members show has_space_key = false but keep decrypting with the ring already in memory. Nothing breaks; the state is transient.
  • A rekey needs the owner online, since only the owner mints. Until then no new key exists, and a removed member who still holds the old key could decrypt entries pushed under it.
  • A wrap is opened against a public key the server returned, unpinned. That is fine against a passive server, which is the threat model here. An active malicious server could substitute a key it owns. The fingerprint check defends against another member, not against the server that also serves the fingerprint.

Server sees: entry type/kind, timestamps, pinned, blob keys and sizes, which spaces an entry was shared into (space_ids), space membership (which user is in which space), space names, invitee emails, and public keys.

The one plaintext exception is announcements (section 16): rows the server itself wrote, so there was never a plaintext of the user’s to protect.

Server never sees: encrypted_content, encrypted_metadata, user_settings.encrypted_blob, the UMK, any CEK, or any Space Key. wrapped_keys and wrapped_space_keys pass through as opaque strings. The server stores and echoes them without parsing, and holds no private key that could open either. Send filters and auto-copy live in the encrypted settings blob, so the server cannot tell why a given entry was or was not shared.


src/realtime.py is one module: WebSocket endpoint + in-process hub + Redis bridge + presence + publish helpers.

Each API process keeps an in-memory hub of its local sockets and runs one Redis psubscribe("user:*", "space:*", "broadcast:*") listener. A write publishes an event to Redis, and every process forwards it to its own local sockets on that channel. No sticky sessions; add replicas freely.

Origin-device exclusion travels in-band. The publisher attaches _origin_device to the Redis message. Each process strips that field before delivering, and skips sockets whose device_id matches. That keeps a device from receiving its own push back as a sync:entry. The exclusion is per-device, not per-user, so the author’s other devices still get the event.

  • Connect -> SADD user:{uid}:devices {did}, SET presence:{uid}:{did} EX 300, publish device:online immediately.
  • Clean disconnect -> SREM, DEL presence:{uid}:{did}, publish device:offline immediately.
  • Heartbeat - server pings every 25 s; any client message/pong refreshes the presence TTL.
  • Crash backstop - a socket that dies without a clean close leaves its presence key to expire. The maintenance sweeper (section 10) then emits device:offline. The instant path is primary, and the sweep is a safety net, not a 60 s-latency primary.

Identity is Supabase’s; the app layers device + key state on top.

1. Sign up / log in / verify / reset ──► Supabase Auth (client SDK / GoTrue REST)
→ client holds a Supabase access token (JWT, sub = user id) + refresh token
2. POST /api/v1/auth/bootstrap { display_name? } (Authorization: Bearer <JWT>)
→ ensures a profile, returns kdf_salt + wrapped_umk
→ client derives KEK = HKDF(Argon2id(password, email-salt), kdf_salt); unwraps UMK from wrapped_umk
(step 1 used the other HKDF half, auth_key, as the Supabase credential - see section 7.1)
(or, if null, generates a random UMK, wraps it, and PUT /auth/umk)
3. POST /api/v1/auth/devices { device_name, platform, ... }
→ { device_id } (client stores it, sends X-Device-Id on subsequent calls)
4. All app calls: Authorization: Bearer <JWT> [+ X-Device-Id]
→ FastAPI verifies the JWT (ES256/RS256 via JWKS, or legacy HS256; aud='authenticated')

Token verification (src/auth/tokens.py):

  • Algorithm. Read from the token header and allowlisted to ES256/RS256/HS256 before any key is selected, so none and unknown algorithms are refused up front.
  • Asymmetric tokens verify against the project’s JWKS ({SUPABASE_URL}/auth/v1/.well-known/jwks.json), cached in-process for 300 s, so Supabase-side rotation needs no redeploy. The lookup runs in the threadpool, never on the event loop. A kid missing from the cached set forces at most one refetch per 60 s across the process. Inside that window, the token is judged against the set already held.
  • HS256 verifies against SUPABASE_JWT_SECRET. When that secret is unset, the server answers an HS256 token with 401, as a token this deployment does not accept. Algorithm confusion has nothing to forge against, since the two branches draw on unrelated key material.
  • Claims. Decode requires exp and sub and checks the audience, with 30 s of leeway for clock drift between Supabase and this host. When SUPABASE_URL is set it also requires iss == {SUPABASE_URL}/auth/v1, so a token from another project that shares a key is refused.
  • Revocation. No deny-list: Supabase owns session revocation. To cut off a user immediately, ban them via the admin suspend endpoint (Supabase).

A verification failure answers 401 only when a token was actually judged. Every authenticated route can therefore return one of two statuses on failure, and the difference is part of the contract:

Status Means What a client may do
401 The token was read and refused: bad signature, unknown kid in a key set we did fetch, expired, wrong audience, disallowed algorithm Treat the credential as dead. Re-authenticate.
503 + Retry-After We could not reach the JWKS endpoint, so nothing was judged (PyJWKClientConnectionError) Retry. The credential is untouched.

An unknown kid in a successfully fetched key set stays 401 on purpose. Why: collapsing the 503 into a 401 is not a cosmetic error. The desktop client’s silent session restore reads 401 as a verdict and ends the session with no retry. A single failed DNS lookup here would sign a user out and make them type a password. The unknown-kid case stays 401 because a forged random kid must not be a way to drive 5xx out of the service.


src/background.py replaces Celery + beat. It runs a single asyncio loop, started in the app lifespan and guarded by a Postgres advisory lock (pg_try_advisory_lock), so across N replicas exactly one runs it. If the leader dies, its connection drops, the lock releases, and another replica takes over on its next attempt (every 30 s).

Four jobs:

  • Presence sweep (every 60 s) - scan user:*:devices. For members whose presence:{uid}:{did} key has expired, SREM and publish device:offline. When that leaves the user with no device online, also publish user:presence offline to every space they belong to.
  • Unreferenced blob release (hourly) - un-confirm confirmed blobs older than 7 days that no live entry references, so they stop counting against the quota. The next orphan cleanup deletes them.
  • Orphan blob cleanup (hourly) - delete unconfirmed blobs older than 1 h from the object store and the blobs table.
  • Removal-record pruning (hourly) - delete space_entry_removals rows older than 90 days. A device offline longer than that keeps its copy of a withdrawn entry.

Email (space invites) is sent separately via FastAPI BackgroundTasks. It is best-effort: failures are logged, not surfaced to the request.

Every message is rendered from src/web/templates/email_shell.html plus a body fragment, so the invite and the admin test mail share one frame. Supabase sends account mail from templates held in its own dashboard, which cannot import from here. scripts/render_supabase_emails.py renders paste-ready copies off the same shell into docs/supabase-email/. It has to be re-run, and the output re-pasted, when the shell changes.


Deployment topology, environment variables, and cost live in DEPLOY.md.


A space is a named room any number of users can join, where every member sees the entries other members send in, in real time. It is the only sharing primitive. The earlier split between persistent pool groups and ephemeral Live Share sessions is gone, along with group_type, max_members, and server-side share_scope.

Why one primitive: the two used the same tables, the same key distribution, and the same fan-out. The only real differences were a member cap and a scope column the server stored but never enforced. Both were removed rather than kept as configuration.

They are separate concerns on the same pipe, distinguished by one field:

  • Personal sync - an entry with space_ids = []. Reaches only the author’s own devices, over user:{uid}. This is the baseline: everything a client captures syncs personally, whether or not any space exists.
  • A space - an entry with one or more space_ids. Still reaches the author’s own devices, and every member of each listed space. The same row serves both, because the CEK envelope carries a "personal" wrap alongside the per-space wraps (section 7.2).

Nothing enters a space implicitly. The client decides per entry, from its own send filters, and the server has no view into that decision.

Owner → POST /api/v1/spaces { name, share_history }
→ { space_id, invite_code }; owner membership with history_from_ts = NULL
Invitee → POST /api/v1/spaces/join { invite_code } -- bearer secret path
→ a join request; an approver posts .../approve with the wrapped ring
or POST /api/v1/invites/{id}/accept -- addressed path (section 5.6)
→ membership added, history floor resolved from share_history at join time
Owner → reconciles and posts the wrapped keyring (section 7.4)

Two invitation paths, one membership model. The invite code is a bearer secret usable by anyone holding it, and redeeming it raises a join request. An addressed invite targets one email, survives the invitee being offline, and reports its outcome back to the inviter.

A client sends an entry into a space by listing the space in space_ids, adding that space’s wrap to wrapped_keys, and pushing as normal. The server stores the row and publishes sync:entry to user:{author} and to each space:{id}. Every member’s socket receives it and unwraps the CEK locally. Members who were offline pick the same row up on their next GET /sync/pull through the space arm (section 6.2), subject to their history floor.

  • DELETE /spaces/{id}/members/{uid} - the owner removing a member, or a member removing themselves. Both clear every remaining non-owner member’s wrapped keyring and set spaces.rekey_requested_at, which is what drives the rekey (section 7.4). The owner cannot leave their own space.
  • DELETE /spaces/{id} - owner only. space:membership_changed with action: "deleted" is published first, while the channel still has subscribers. Then the row goes, and memberships and invites cascade.

Deleting a space does not delete its entries. Rows keep their space_ids, but with no memberships left the space arm of the pull query matches nobody. Clients drop keyrings for spaces that no longer come back from GET /spaces, zeroizing the key bytes. What members already decrypted stays in their local history. That is a deliberate consequence of client-side storage, not a gap to be closed server-side.


Operator’s guide, covering how to send one and how to word it: ANNOUNCEMENTS.md. Nothing sends one automatically yet; the machinery is in place and waiting for a reason to use it.

An announcement is the service talking: a maintenance window, a quota change, a note to one account. That is why its table may hold plaintext (section 4.13). Anything that would need decrypting is not an announcement, and belongs in an event the client can phrase itself.

One table, addressed or not:

user_id Means Published to
a uuid that account only user:<id>
null everybody broadcast:all, which every socket joins on connect

Delivery is doubled, because the interesting case is a user who is not looking. The row is written first, then pushed over the socket. A client with nothing connected still gets it from GET /api/v1/announcements, and one that is connected does not wait for its next refresh. The client keys both on announcement:<id>, making a double delivery a no-op.

Reads are watermarked, not marked read. There is no per-user read state here; the endpoint answers “what is newer than since”. A client dismisses a message by advancing its own watermark past whatever it was handed, and never asks for that window again, so the next refresh does not resurrect it. A first launch omits since and gets the last 30 days, capped at 100 rows newest first. A new device therefore arrives with what is current rather than a changelog.

Control Effect
expires_at The row stops being served after it. A notice about Tuesday’s maintenance is worse than useless on Friday, and without a stop date every new device would be told about every window the service ever had.
POST /internal/v1/admin/announcements (X-Admin-Key) Posting is an admin act.
DELETE /internal/v1/admin/announcements/{id} Stops the row being served. Devices already told keep their copy, since this is the server forgetting rather than a recall.

An at-a-glance audit of the backend’s security controls. Each line asserts a control and points to its home; the mechanism and any values live there, not here.

  • Route auth: user routes require a valid Supabase JWT, /internal/* an X-Admin-Key (section 9, docs/permissions.md).
  • Backend verifies, never signs, tokens; algorithm allowlisted, exp required (section 9).
  • Sessions, refresh, verification, reset, suspension, and deletion are owned by Supabase, not this service (section 9).
  • Device and identity private keys never leave the client; the server holds only public keys and opaque wrapped-key blobs (section 7.1, 7.3, 7.5).
  • Server stores only ciphertext for entry content, metadata, and blobs (section 7.5).
  • Per-entry content keys with an item-bound AAD, and a separate key-wrap AAD, so a wrap cannot be replayed as content or vice versa (section 7.2).
  • Blob downloads are member-scoped, and a miss returns 404 not 403 so a key’s existence is not confirmed (section 5.4, docs/permissions.md).
  • Space Key revocation is best-effort: a departed member keeps what they already decrypted until the owner is next online to rekey (section 7.4).
  • Presigned upload/download URLs are short-lived (section 5.4).
  • Per-entry size cap and per-user storage quota enforced server-side (section 6.3).
  • SUPABASE_SERVICE_ROLE_KEY is server-only and never returned to clients.
  • Security response headers set on every response via middleware (src/middleware.py): X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 1; mode=block, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: geolocation=(), camera=(), microphone=(), and Strict-Transport-Security: max-age=31536000; includeSubDomains on HTTPS responses. A default Content-Security-Policy is set here too for JSON routes (value not repeated).
  • All SQL goes through SQLAlchemy parameterized queries.
  • X-Device-Id is checked against the caller’s unrevoked devices before it is trusted (section 5 conventions).
  • Key-material writes need the UMK proof once an account has one (section 7.1).
  • Socket credentials travel in the first message, never the URL, and a socket closes when its token expires (section 5.8).
  • Revoking a device does not end its Supabase session; the device is refused by this service, but its token lives until it expires (section 5.1).
  • Asymmetric JWKS verification, so Supabase-side key rotation needs no redeploy; shared HS256 accepted only for legacy projects (section 9, section 5.0).

Related: who may call each route is in the root docs/permissions.md, client internals in orange-copy-paste-clipboard-app-rust/docs/architecture.md, and the host and deploy pipeline in DEPLOY.md.