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.
1. System Overview
Section titled “1. System Overview”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.
1.1 Project structure
Section titled “1.1 Project structure”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
2. Service Boundaries
Section titled “2. Service Boundaries”Each package under src/ owns a router + service (+ models/schemas). They call each
other in-process.
2.1 Auth-adjacent (src/auth/)
Section titled “2.1 Auth-adjacent (src/auth/)”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_saltas the password envelope and a distinct AAD (umk-recovery-v1againstumk-envelope-v2), so the two can never be mistaken for one another. The server holds an opaque blob it cannot open, exactly likepw_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_idthat the client sends back asX-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).
2.2 Sync (src/sync/)
Section titled “2.2 Sync (src/sync/)”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:entryto Redis after a write - once to the author’suser:channel and once to eachspace:channel named in the entry’sspace_ids. There is nosync:delete: a tombstone is a normalsync:entrywithdeleted_atset. - Carries two server-visible routing/key columns it never interprets:
space_ids(fan-out targets) andwrapped_keys(the per-entry CEK envelope, section 7.2).
2.3 Settings (src/settings/)
Section titled “2.3 Settings (src/settings/)”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.
2.4 Blobs (src/blobs/)
Section titled “2.4 Blobs (src/blobs/)”Brokers direct-to-object-store uploads.
request-upload-> presigned PUT URL +blob_key. The server never buffers bytes. The URL is signed for the declaredsize_bytesasContent-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 withContent-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, andmax_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.
2.5 Spaces (src/spaces/)
Section titled “2.5 Spaces (src/spaces/)”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 liveinvite:*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.
2.6 Realtime (src/realtime.py)
Section titled “2.6 Realtime (src/realtime.py)”Single module: the WebSocket endpoint, an in-process connection hub, the Redis pub/sub bridge, the publish helpers, and device presence. See section 8.
2.7 Admin (src/admin/)
Section titled “2.7 Admin (src/admin/)”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 requireSUPABASE_SERVICE_ROLE_KEY.
2.8 Web pages (src/web/)
Section titled “2.8 Web pages (src/web/)”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 asorange://join?code=<code>, and links the latest release when the app is not installed.GET /reset- a 302 tosettings.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-inredirect_tostill 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.pyversions 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.
/joinvalidates 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.pytherefore sets it withsetdefault, and/joinsets 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 throughhtml.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.
3. Tech Stack
Section titled “3. Tech Stack”| 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 |
| 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-validatorThere 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.
4. Data Models
Section titled “4. Data Models”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.
4.1 profiles
Section titled “4.1 profiles”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.
4.2 devices
Section titled “4.2 devices”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).
4.3 sync_entries
Section titled “4.3 sync_entries”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.
4.4 sync_cursors
Section titled “4.4 sync_cursors”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.
4.5 user_settings
Section titled “4.5 user_settings”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);4.6 spaces
Section titled “4.6 spaces”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_codeis drawn fromABCDEFGHJKMNPQRSTUVWXYZ23456789(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 asKX7Q-2M4X. Anything longer than 8 characters is a legacytoken_urlsafecode, 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_approveis the only control over who may answer a join request: the owner alone (default), or the owner and any member.share_historyis resolved into the joining member’shistory_from_tsat join time. Flipping it later does not retroactively widen what an existing member can pull.key_fingerprintis 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_atis 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.
4.7 space_memberships
Section titled “4.7 space_memberships”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.
4.8 blobs
Section titled “4.8 blobs”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->profilesrename and the column drops that produced this shape are recorded inmigrations/versions/0006_supabase_migration.py.
Migration note (0011, spaces): the destructive groups -> spaces transition (and the addition of
sync_entries.wrapped_keys) is recorded inmigrations/versions/0011_spaces.py.
4.9 space_invites
Section titled “4.9 space_invites”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.
4.10 space_join_requests
Section titled “4.10 space_join_requests”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.
4.11 space_comments
Section titled “4.11 space_comments”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.
4.12 space_entry_removals
Section titled “4.12 space_entry_removals”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.
4.13 announcements
Section titled “4.13 announcements”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).
5. API Design
Section titled “5. API Design”Conventions
Section titled “Conventions”-
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 againstdevicesbefore trusting it, including as the origin device for fan-out:Answer When 400header 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:
429withRetry-Afterwhen 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 onRetry-Afterand 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.
5.0 Versioning
Section titled “5.0 Versioning”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_VERSIONbumps (v2, …) only on a backwards-incompatible contract change;v1andv2run side by side during a migration window. - Infra probes are intentionally unversioned:
/internal/healthzand/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 OpenAPIversion; it changes freely per release without implying a contract break.- Every HTTP response carries an
X-API-Versionheader (=API_VERSION). - Live schema: Swagger UI at
/api/docs, ReDoc at/api/redoc, raw spec at/api/openapi.json. All three are off unlessDOCS_ENABLED=true, and answer 404 when it is not set. The schema is a complete map of the surface,/internaladmin routes included, so it fails closed: a deployment that configures nothing keeps it private..env.examplealso shipsDOCS_ENABLED=false; set it totrueon a dev machine to get the UI. Every route declares a Pydanticresponse_modeland 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/wscontract is documented in section 5.8.
5.1 Auth Routes
Section titled “5.1 Auth Routes”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.
5.2 Sync Routes (require X-Device-Id)
Section titled “5.2 Sync Routes (require X-Device-Id)”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_idanother 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_idwith emptyspace_ids. That is a self-owned row under the deleter’s account that overlaps no space, so_belongs_to_someone_elsepermits it and fan-out reaches onlyuser:{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.
5.3 Settings Routes
Section titled “5.3 Settings Routes”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_atonly moves forward. A matching base with a bodyupdated_atat or below the stored one is refused, so a buggy client cannot move the version back.
5.4 Blob Routes
Section titled “5.4 Blob Routes”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 }5.5 Spaces Routes (require X-Device-Id)
Section titled “5.5 Spaces Routes (require X-Device-Id)”POST /api/v1/spaces Body: { name, share_history?: true } Returns: 201 { space_id, invite_code } -- caller becomes owner, full historyGET /api/v1/spaces Returns: [SpaceOut] -- every space the caller is inGET /api/v1/spaces/{space_id} Returns: SpaceOut -- 403 if not a memberGET /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 -> 204PATCH /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.6POST /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.
5.6 Invite Routes (addressed invites)
Section titled “5.6 Invite Routes (addressed invites)”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 visiblePOST /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: falsePOST /api/v1/invites/{id}/declineDELETE /api/v1/invites/{id} -- inviter revokes a pending invite; needs X-Device-IdPUT /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.
5.7 Internal Routes
Section titled “5.7 Internal Routes”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=HealthResponseGET /internal/metrics Prometheus text (X-Admin-Key) -- excluded from OpenAPI (text/plain)
-- Versioned admin API (require X-Admin-Key)GET /internal/v1/stats JSON aggregateGET /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 configuredPATCH /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 userGET /internal/v1/admin/email Returns: provider, configured, email_from, *_set flags -- no secretPOST /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 itEvery 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.
5.8 WebSocket Event Protocol
Section titled “5.8 WebSocket Event Protocol”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.
- 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>"}. - The token must verify (section 9) and the device must exist, belong
to
suband not be revoked. - 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:entryis published once touser:{author}and once perspace:channel in the entry’sspace_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 seesspace_idscut to the spaces it is in, andwrapped_keyscut to those spaces’ wraps, neverpersonal.- There is no
sync:delete. A delete arrives assync:entrywithdeleted_atset. device:online/device:offlineare per-device and go only to the user’s own channel.user:presenceis the per-user fact addressed to that user’s spaces, so other members’ lists stay current without polling REST.online: trueis published on every connect, not only on the offline->online edge.online: falsecomes 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 toPRESENCE_TTL. A client reconnecting inside that window looks like it never left, so an edge-triggered publish would say nothing.space:entry_removedcarries bothauthor_idandremoved_by.removed_byis the authoritative half: it is the account that acted.author_idis advisory. A client must not compute “did the author remove this” fromauthor_id == removed_by. It holds one copy and already knows who wrote that copy. Comparingremoved_byagainst 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 oneclient_idmay be clearing several authors’ rows, and only one of them is recorded (the remover’s own if present, else the lowest id).space:entry_removedis the fast path, not the guarantee. The durable record is aspace_entry_removalsrow, written in the same transaction that strips the space id and returned byGET /sync/pullasremovals. 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_changedis 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 /spacespublishesaction: "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_requestedgoes to the space channel whenmembers_can_approveis 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_decidedis addressed to the requester’s own channel: they are not on the space channel, and after a decline they never will be.space:rekeyis addressed to one member’s own channel and is fire-and-forget: nothing retries it. A client that was offline recovers the same keyring fromSpaceOut.my_wrapped_space_keyson its nextGET /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 pageGET /reset?code=<code> 302 - forwards to the reset page on the static siteBoth 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.
6. Sync Strategy
Section titled “6. Sync Strategy”6.1 Server-Timestamped Last-Write-Wins
Section titled “6.1 Server-Timestamped Last-Write-Wins”- Server assigns a millisecond
server_tson every accepted write; it is the sync cursor. - Conflict on
(user_id, client_id, entry_type): last writer wins. A push with anupdated_atnot newer than the stored row is rejected asstale_update. - Tombstone wins: an incoming delete beats a concurrent live update.
6.2 Push / Pull
Section titled “6.2 Push / Pull”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/cursorThe 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.
6.3 Size and Row Limits
Section titled “6.3 Size and Row Limits”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_bytesapplies toencrypted_contentandencrypted_metadataseparately. It is the only thing that boundsencrypted_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_bytesis the only one of the four settings not about a row. It is checked againstContent-Lengthwhen 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.BodySizeLimitMiddlewarecarries 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 asmax_push_batchmodels, 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 withdeleted_atnull 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.
6.4 Offline Operation
Section titled “6.4 Offline Operation”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.
7. E2E Encryption Design
Section titled “7. E2E Encryption Design”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 deviceauth_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 /userKEK = 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-sideUMK = AES-256-GCM-open(KEK, wrapped_umk) -- recovered on loginThe 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_umkfrombootstrap, 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, AADumk-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 withauth_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 /userwithauth_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.
UMK proof
Section titled “UMK proof”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.
Recovery ladder
Section titled “Recovery ladder”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 reusedencrypted_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_idbinds the ciphertext to its entry, so a row’s content cannot be moved onto a differentclient_idwithout 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.
- Try
wrapped_keys["personal"]against the UMK. - If that is absent or fails, walk the entry’s
space_idsand trial-decryptwrapped_keys[space_id]against each key in that space’s keyring, newest first. - If the CEK unwraps under no held key, skip the entry; do not drop it. A later
space:rekeyorGET /spacescan 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.
7.3 Multi-Device UMK Sharing (X25519)
Section titled “7.3 Multi-Device UMK Sharing (X25519)”- New device generates an X25519 keypair; registers it (
device_pubkey). - An existing device computes
shared_secret = X25519(my_priv, new_device_pubkey), wraps the UMK, and posts it toPOST /auth/devices/{new_device_id}/key-wrap. - The new device derives the same secret and unwraps the UMK. The server stores
wrapped_umkbut can never unwrap it (holds no private key).
7.4 Space Key Distribution and Rekey
Section titled “7.4 Space Key Distribution and Rekey”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_fingerprintwhen 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_keysis 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:
- Recover the local ring by unwrapping
my_wrapped_space_keysagainst the public keymy_wrapped_bynames. Verify it againstkey_fingerprint; on mismatch, refuse and report. An empty or absent wrap does not clear the in-memory ring. - Only the owner mints. An empty ring means a first key. A non-null
rekey_requested_atwhile the ring is non-empty means a new key prepended to it, published with a freshkey_fingerprint. - Wrap the full ring for every member with
has_space_key = false(or for everyone, if a key was just minted) andPOST /spaces/{id}/keys. Members with no registeredidentity_pubkeyare 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 = falsebut 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.
7.5 Server Visibility
Section titled “7.5 Server Visibility”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.
8. Realtime Architecture
Section titled “8. Realtime Architecture”src/realtime.py is one module: WebSocket endpoint + in-process hub + Redis bridge +
presence + publish helpers.
8.1 Fan-out (horizontally scalable)
Section titled “8.1 Fan-out (horizontally scalable)”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.
8.2 Presence (connection-driven)
Section titled “8.2 Presence (connection-driven)”- Connect ->
SADD user:{uid}:devices {did},SET presence:{uid}:{did} EX 300, publishdevice:onlineimmediately. - Clean disconnect ->
SREM,DEL presence:{uid}:{did}, publishdevice:offlineimmediately. - Heartbeat - server pings every 25 s; any client message/
pongrefreshes 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.
9. Auth Flow
Section titled “9. Auth Flow”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/HS256before any key is selected, sononeand 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. Akidmissing 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. HS256verifies againstSUPABASE_JWT_SECRET. When that secret is unset, the server answers an HS256 token with401, 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
expandsuband checks the audience, with 30 s of leeway for clock drift between Supabase and this host. WhenSUPABASE_URLis set it also requiresiss == {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.
10. Background Maintenance
Section titled “10. Background Maintenance”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 whosepresence:{uid}:{did}key has expired,SREMand publishdevice:offline. When that leaves the user with no device online, also publishuser:presenceoffline 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
blobstable. - Removal-record pruning (hourly) - delete
space_entry_removalsrows 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.
11. Deployment Model & Cost
Section titled “11. Deployment Model & Cost”Deployment topology, environment variables, and cost live in DEPLOY.md.
15. Spaces Design
Section titled “15. Spaces Design”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.
15.1 Personal Sync vs Spaces
Section titled “15.1 Personal Sync vs Spaces”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, overuser:{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.
15.2 Establishing a Space
Section titled “15.2 Establishing a Space”Owner → POST /api/v1/spaces { name, share_history } → { space_id, invite_code }; owner membership with history_from_ts = NULLInvitee → 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 timeOwner → 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.
15.3 Live Entry Fan-out
Section titled “15.3 Live Entry Fan-out”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.
15.4 Leaving, Removal, and Deletion
Section titled “15.4 Leaving, Removal, and Deletion”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 setspaces.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_changedwithaction: "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.
16. Announcements
Section titled “16. Announcements”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. |
17. Security Checklist
Section titled “17. Security Checklist”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/*anX-Admin-Key(section 9,docs/permissions.md). - Backend verifies, never signs, tokens; algorithm allowlisted,
exprequired (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_KEYis 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=(), andStrict-Transport-Security: max-age=31536000; includeSubDomainson HTTPS responses. A defaultContent-Security-Policyis set here too for JSON routes (value not repeated). - All SQL goes through SQLAlchemy parameterized queries.
-
X-Device-Idis 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.