Contacts & Shared Cards
The account-linked half of the CardMirror editor's real-time collaboration.
The engine's co-editing sessions (Loro CRDT, share codes, invite links,
version recovery — packages/debate-editor/src/editor/collab/) used to reach
a partner only over the clipboard: start a session, copy the
cmshare… code or #join=… link, paste it somewhere. Contacts key that to
better-auth user ids instead: a signed-in user has a contacts list, can
share the document they're editing straight to a contact's account, and the
shared card shows up as available on that account on every device they
sign in on.
- Route:
/contacts(tabs: Contacts, Shared cards;?tab=sharedlands on the second) - Nav: the dock's Settings menu ("Contacts", with a count of pending
requests); the Tools page's Prep & Practice group; the Reason Editor
sidebar's Documents row (the share glyph beside Upload) and
t contactsin Ctrl/Cmd-K's search palette - Package:
debate-team-collaboration(lib/contacts.tsrules,state/contacts.ts+state/cardShares.tsclients,useContacts/useCardShareshooks,ContactsPanel/SharedCardsPanel); the engine bridge isdebate-editor/collab-bridge
Contacts list
A typical friends list, all of it server-side and per account:
| Action | What happens |
|---|---|
Add by name/email search (/api/users/search) or by typing an email | A pending request. If that person already sent you one, it's accepted on the spot. |
| Accept / Decline (incoming) | Accept flips the pair to accepted; decline deletes it. |
| Cancel (outgoing) / Remove (accepted) | Deletes the row; either side can remove an accepted contact. |
| Block | Unilateral. Deletes any contact row and revokes every shared card between the two accounts, both directions. Blocked users can't send you requests or shares; their attempts get the same generic refusal as any other, so blocking is invisible to them. |
| Unblock | Lifts the block. Nothing severed by it is restored. |
Contacts show an online / away dot. There's no push channel in this repo,
so presence is a heartbeat: the contacts poll (GET /api/contacts, every
30 s while a tab is visible — run from the dock on every page, not just
/contacts, and shared by every mounted copy so a page sends one request per
tick) bumps the caller's user_presence row, and a contact is online
while their last poll is inside PRESENCE_ONLINE_WINDOW_MS (2 min).
Requests and accepts write ordinary account notifications (contact_request,
contact_accepted; see Notifications).
Organizations
An organization is a shared group, like a team or a school squad
(better-auth's organization plugin, registered in
apps/debate-ai.com/lib/auth/index.ts). The sidebar's account menu has an
Organizations submenu: Personal, every organization you belong to,
Members of … for the active one, and New organization. Creating one
makes you its owner and switches to it.
The organization you pick is stored on your session
(session.active_organization_id), and it changes what this page shows:
| Personal | An organization | |
|---|---|---|
| Contacts list | Your accepted contacts | The organization's other members (minus blocks either way); Remove is hidden, since membership is not a contact row |
| Shared cards | Every live share | Only shares with its members |
| Who you can share with | Accepted contacts | Accepted contacts and its members |
Members are added by an owner or admin from their own contacts (Members of
… → Add), so nobody lands in a group without a contact who trusts them; there
are no invitation emails. An id on the session is only honoured after the
caller is confirmed to be a member (lib/organizations/server.ts), so a stale
or forged id falls back to Personal.
Sharing a card
In the Reason Editor, Share with contacts opens a picker of your contacts plus an optional note. It is the share glyph in the sidebar's Documents row, beside New file / New folder / Upload — sharing is something done to the open file, so it sits with that file's other actions rather than in a header strip of its own. Shown only when you are signed in and a document is open, and only on the editor route: sharing starts a live co-editing session through the CardMirror engine, which that page is what mounts. On Share:
- If the open document has no live session, the engine's own Start Collaboration Session flow runs (same confirm dialog); the 'Enable collaboration' master toggle is switched on if it was off, since sharing is the opt-in.
- The session's share code and relay guest pass — exactly what the editor's
Copy Invite Link would put on the clipboard — are posted to
POST /api/card-sharesfor each picked contact. - Each recipient gets a
card_sharednotification linking to/contacts?tab=shared, and the card appears there marked Available (and New until first opened).
Shares only ever reach accepted contacts with no block in either
direction; anyone else in the picked list is reported back as skipped.
Re-sharing the same room to the same contact refreshes the row (new code/
pass, un-revoked, re-notified) rather than duplicating it — rows are unique
on (room_id, recipient_id). The owner can Stop sharing (revoke) from
the Shared cards tab; the recipient can Dismiss their copy.
The Shared cards tab also has a paste box: a cmshare… code or a full
invite link copied from the editor can be shared to contacts from there, for
hosts who started the session the old way.
On the trust model
The share code carries the room's end-to-end encryption key, which the collab relay itself never sees. Storing it in this app's database is a deliberate trade: a share that follows a person across devices has to live somewhere their account can read it, and the only alternative — the desktop pairing mailbox — seals to a per-browser key, not to an account. The database already holds the full content of every Reason Editor document, so the key adds no new class of exposure.
Opening a shared card
Open on a received share navigates to /reason-editor?share=<id>. The
editor page's SharedCardOpener then:
- creates a fresh document to hold the joined copy — the engine binds a session to whichever document is focused and the page autosaves it, so joining into the document that was open would overwrite it with the room's content;
- waits for the engine to boot, then joins with the code + guest pass through the same flow as pasting an invite link (the user confirms);
- marks the share opened.
A share that arrives while the editor is open toasts with its own Open action. Co-editing is a desktop-layout feature: on the mobile shell the engine's collab gate is closed and the open is refused with a message.
Presence name
The engine's partner cursor shows pairingDisplayName, defaulting to a
random "Guest NNN" on the web. The opener seeds it from the signed-in
account's name once, and never overwrites a name the user typed into the
editor's settings.
Data
apps/debate-ai.com/lib/database/schema.ts, migration
drizzle/0034_contacts_and_card_shares.sql:
| Table | Row |
|---|---|
contacts | One per unordered pair: requester_id, addressee_id, status (pending / accepted). Unique on the directed pair; the route checks both directions before inserting. |
user_blocks | One per (blocker_id, blocked_id). |
card_shares | One per (room_id, recipient_id): owner_id, share_code, guest_pass, title, message, opened_at, revoked_at. |
user_presence | user_id → last_seen_at. |
All four cascade-delete with the account. Until the migration has run
against an environment, the list endpoints degrade to empty (same "no such
table" handling as /api/notifications).
The organization plugin adds organization, member and invitation, and
session.active_organization_id. The app ships no migrations, and better-auth
names that session column in every sign-in insert, so getAuth() creates them
idempotently before it builds the auth instance
(lib/organizations/ensure-tables.ts, whose ORGANIZATION_DDL an operator
can also run with wrangler d1 execute).
API
| Endpoint | Body / query | Notes |
|---|---|---|
GET /api/contacts | — | { organization, contacts, incoming, outgoing, blocked }; bumps presence. With an active organization, contacts is its members and organization is { id, name, role }. |
POST /api/contacts | { userId } | { email } | { status: "requested" | "accepted" | "already-requested" | "already-contacts" } |
PATCH /api/contacts | { id, action: "accept" | "decline" } | Addressee only. |
DELETE /api/contacts?id= | — | Remove a contact / cancel your request. |
POST / DELETE /api/contacts/block | { userId } | Block / unblock. |
GET /api/card-shares | — | { received, sent }, live shares only. |
POST /api/card-shares | { shareCode, guestPass?, title?, message?, recipientIds } | { shared, skipped } |
PATCH /api/card-shares | { id, action: "opened" } | Recipient only. |
DELETE /api/card-shares?id= | — | Owner revokes; recipient dismisses. |
GET /api/organizations/members | — | { organization, members, candidates } for the active organization; candidates (your contacts who are not members) only for owners/admins. |
POST /api/organizations/members | { userId } | Owner/admin adds one of their shareable contacts. |
/api/auth/organization/* | — | better-auth's own create / list / set-active endpoints, called by the account menu. |
Every endpoint requires a session (401 otherwise).
Follow-ups
- Presence is poll-based; a real push channel would make "online" instant and let a share open without the recipient reloading.
- The
documentstable doesn't record which document holds a joined copy, so reopening a shared card always creates another fresh document; the engine's own IndexedDB session record does dedupe the room (a second join resumes instead of re-joining). - Groups/teams: a share is still one owner → one recipient; sharing to a whole organization at once would fan the same share out to its members.
- Organizations have no rename, leave, remove-member or role-change UI yet, and no invitation by email; better-auth's endpoints for all of these exist.
- The Reason Editor's documents are not scoped by organization.
