Retro Bulletin Board Systems on atproto. Web app and TUI. atbbs.xyz
python tui atproto bbs

atboards (React SPA)#

A static SPA reimplementation of the atboards web UI. No server, no database — all reads go directly to Slingshot/Constellation, all writes go directly to the user's PDS via atproto OAuth (DPoP). Designed to be hosted as static files on Cloudflare Pages or any static host.

Stack#

  • Vite + React 19 + TypeScript
  • react-router-dom v7 (history routing)
  • @atproto/oauth-client-browser for OAuth (same library red-dwarf uses)
  • @atproto/api Agent for authenticated XRPC writes
  • Tailwind CSS v4 (via @tailwindcss/vite)

All reads (boards, threads, replies, news, bans, hides, identity resolution) go through public Microcosm services:

  • slingshot.microcosm.blue — getRecord, listRecords, resolveMiniDoc
  • constellation.microcosm.blue — getBacklinks (used to find threads in a board, replies to a thread, news for a site, quotes of a reply)
  • ufos-api.microcosm.blue — random BBS discovery on the home page

All writes go to agent.com.atproto.repo.{createRecord, putRecord, deleteRecord, uploadBlob} against the user's PDS, using the OAuth/DPoP session held by @atproto/oauth-client-browser.

Layout#

react/
├── index.html
├── package.json
├── vite.config.ts
├── tsconfig.json
├── public/
│   ├── client-metadata.json    # OAuth client metadata for production (edit before deploy)
│   ├── _redirects              # Cloudflare Pages SPA fallback
│   ├── favicon.svg
│   └── hero.svg
└── src/
    ├── main.tsx                # Root, BrowserRouter + AuthProvider
    ├── App.tsx                 # Routes
    ├── index.css               # Tailwind entry
    ├── components/
    │   ├── Layout.tsx          # Header / footer / breadcrumb
    │   └── Localtime.tsx
    ├── lib/
    │   ├── lexicon.ts          # xyz.atboards.* collection IDs
    │   ├── util.ts             # date / AT-URI helpers
    │   ├── atproto.ts          # Slingshot + Constellation read wrappers
    │   ├── bbs.ts              # `resolveBBS()` — port of core/resolver.py
    │   ├── oauth.ts            # BrowserOAuthClient setup
    │   ├── auth.tsx            # AuthProvider / useAuth() hook
    │   └── writes.ts           # PDS write helpers (createThread, createReply, …)
    └── pages/
        ├── Home.tsx
        ├── Login.tsx
        ├── Callback.tsx        # /oauth/callback (no logic — provider handles it)
        ├── Site.tsx            # /bbs/:handle
        ├── Board.tsx           # /bbs/:handle/board/:slug
        ├── Thread.tsx          # /bbs/:handle/thread/:did/:tid
        ├── Account.tsx         # /account (inbox + BBS controls)
        ├── SysopCreate.tsx     # /account/create
        ├── SysopEdit.tsx       # /account/edit
        ├── SysopModerate.tsx   # /account/moderate
        └── NotFound.tsx

Routes#

Mirror the Python app exactly:

Route Page
/ Home
/login Login
/oauth/callback Callback
/account Account
/account/create SysopCreate
/account/edit SysopEdit
/account/moderate SysopModerate
/bbs/:handle Site
/bbs/:handle/board/:slug Board
/bbs/:handle/thread/:did/:tid Thread

The old /api/threads/... and /api/replies/... JSON endpoints are gone — pages do the same aggregation client-side via lib/atproto.ts.

Development#

cd react
npm install
npm run dev

For OAuth in dev, BrowserOAuthClient automatically falls back to a loopback client when no clientMetadata is provided. This works for http://localhost:5173 without any tunneling — the client_id becomes http://localhost/?... and atproto auth servers accept it.

Production deployment (Cloudflare Pages)#

  1. Edit public/client-metadata.json and replace every REPLACE_WITH_YOUR_DOMAIN with your deployed origin (e.g. https://atbbs.app).
  2. Set the build env var VITE_PUBLIC_URL=https://atbbs.app so lib/oauth.ts uses the production metadata path.
  3. npm run build — outputs static files to dist/.
  4. Deploy dist/ to Pages. The included public/_redirects makes Pages serve index.html for all routes (history routing).
  5. Verify https://your.domain/client-metadata.json is publicly fetchable — that URL is your client_id, atproto auth servers will fetch it during the OAuth handshake.

Auth flow#

  1. User hits /login, types handle, presses log in.
  2. useAuth().login(handle)BrowserOAuthClient.signIn(handle) → DPoP keypair generated, PAR pushed, browser redirected to the user's authserver.
  3. Authserver redirects back to /oauth/callback?code=…&state=….
  4. The AuthProvider runs client.init() on every mount; on the callback page that detects the code, exchanges it, and returns a OAuthSession.
  5. We wrap that session in an Agent and stash {did, handle, pdsUrl} in context.
  6. Session/refresh tokens are persisted by the OAuth client in IndexedDB; reloads silently restore the session.