[READ-ONLY] Mirror of https://github.com/flo-bit/contrail. atproto backend in a bottle flo-bit.dev/contrail/
0

Configure Feed

Select the types of activity you want to include in your feed.

README.md

sveltekit-group-chat#

A Discord-like demo built on top of contrail — SvelteKit + Cloudflare Workers + D1, OAuth, spaces, communities, and realtime (SSE).

What this exercises#

  • community — mint a fresh did:plc per server, tiered access levels (member / manager / admin / owner), space → space delegation.
  • spaces — permissioned per-channel record tables, membership pushed in by the community reconciler.
  • realtime — single SSE connection subscribed to community:<did>, fanning out per-channel events with unread dots derived client-side.
  • Three record collections — tools.atmo.chat.server, tools.atmo.chat.channel, tools.atmo.chat.message — with unified listRecords for "every server / channel I can see" in one call.

Setup#

pnpm install
cp .env.example .env
# fill in COMMUNITY_MASTER_KEY and REALTIME_TICKET_SECRET with
#   openssl rand -base64 32
pnpm generate:pull    # emits lexicons + src/lib/atproto/generated-methods.ts
pnpm dev

Re-run pnpm generate whenever you add/remove collections or toggle contrail modules in src/lib/contrail.config.ts: the OAuth scope list is derived from it, so the consent screen only asks for permissions you actually use.

Dev mode uses a loopback OAuth client — no Cloudflare setup needed. The realtime Durable Object runs locally via miniflare.

Auth in dev vs prod#

Contrail normally auths every write with an atproto service-auth JWT. The browser delegates minting those JWTs to the SvelteKit worker, which calls com.atproto.server.getServiceAuth on the user's PDS.

bsky.social refuses that call for loopback OAuth clients, so out of the box a plain pnpm dev couldn't talk to contrail without a cloudflared tunnel.

To skip the tunnel this example ships a dev auth bypass gated on DEV_AUTH=1 in .env:

  • Contrail's auth middleware trusts the HMAC-signed did session cookie the OAuth flow already sets, and uses its DID as the authenticated caller.
  • The cookie is HMAC-signed with COOKIE_SECRET, so only this worker can mint a valid one.
  • All access-level checks (admin+, manager+, etc.) still apply — the bypass only replaces the JWT step.

Never set DEV_AUTH=1 in production — that would make the cookie the sole auth signal for third-party clients.

With a tunnel#

If you want to exercise the real JWT path in dev, run a tunnel and drop DEV_AUTH:

pnpm tunnel            # prints https://xxx.trycloudflare.com
# in .env: set OAUTH_PUBLIC_URL to the tunnel URL and remove DEV_AUTH
pnpm dev
# open the tunnel URL (not localhost)

How the pieces connect#

┌────────────┐   service-auth JWT   ┌─────────────────┐   contrail XRPCs   ┌───────────┐
│  browser   │ ─────────────────► │ SvelteKit worker │ ─────────────────► │ contrail  │
└────────────┘                      └─────────────────┘                    └───────────┘
     │                                       │                                   │
     │ EventSource /xrpc/.../realtime.subscribe?ticket=... ◄──────── realtime DO │
     └───────────────────────────────────────────────────────────────────────────┘
  • The browser can't mint service-auth JWTs itself — it delegates to the SvelteKit worker, which uses the user's OAuth session to call com.atproto.server.getServiceAuth on their PDS. The resulting JWT is scoped to one lxm.
  • Realtime tickets are minted the same way, then the browser holds an EventSource open against the subscribe endpoint.

Data model#

Record Lives in Author
tools.atmo.chat.server community's members space, rkey self community DID (via community.space.putRecord)
tools.atmo.chat.channel channel's own space, rkey self community DID
tools.atmo.chat.message channel's own space, rkey = TID user DID

Only admins (admin+ in the target space) can write the server and channel records, and the community DID is the author. Reads filter by actor=<communityDid> so spoof records from random members are silently ignored.

Feature map#

Route What it does
/ "My servers" — unified server.listRecords over every space I'm in
/new Mint community, bootstrap members role-space, write server record, show recovery key once
/c/[communityDid] Redirects to the first channel you can see
/c/[communityDid]/[channelKey] Chat view — messages via space.listRecords + realtime
/c/[communityDid]/settings/members Add, promote, demote, revoke members

Private channels#

New-channel modal offers a visibility toggle:

  • Public — grants the members role-space member access on the new channel. Reconciler fans out to everyone.
  • Private — grants each picked DID directly. Also grants members (so they can see the server header) but not automatic access to other channels.

Deploy#

npx wrangler d1 create group-chat
# add the database_id to wrangler.jsonc
npx wrangler secret put COMMUNITY_MASTER_KEY
npx wrangler secret put REALTIME_TICKET_SECRET

pnpm build
npx wrangler deploy

Known gaps#

This is a demo. It deliberately skips:

  • adopt mode — only mint is wired up.
  • $publishers / community-authored public posts.
  • Threads, reactions, image uploads, rich text, typing indicators.

License#

MIT