FlockOff — Project Context #
Updated: 2026-07-25 Purpose: Handoff reference for Claude Code (and Letta Code) working on pre-launch tasks.
Current Local State #
- Fedora repository:
/home/andrew/Projects/flockoff - Active worktree:
.letta/worktrees/flockoff-predeploy-hardening - Branch:
letta/flockoff-predeploy-hardening-962c4508, six commits ahead oforigin/masterand unpushed - Local commits:
2abbe40hardening,d64c10dAtmosphere portability,e6bc78econtext refresh,ce276e1reply/quote text,655b55bprogressive OAuth scopes,0a08b2bfinal dependency/list hardening - Progressive OAuth is committed after manual walkthroughs against the Bluesky PDS. Minimal login scopes, on-demand list upgrade, return-to-event behavior, share creation, logout/base-scope regression, gated undo, and cleanup all passed. Bluesky pins upgrades to the initiating DID, so its UI does not expose the account-switch scenario; the callback guard remains defense in depth.
- Fedora validation on Node 24.13.1/npm 11.11.0:
npx tsc --noEmit,npm run build,git diff --check, and the Alpine production-container build pass. - GitHub HTTPS and Tangled SSH authentication are configured and dry-run pushes pass. No migration-time commit, push, or deployment occurred.
- Production dependency audit is clean after updating Next.js to
16.2.12, moving the build-time shadcn CLI to dev dependencies, and overriding Next's not-yet-updated PostCSS/Sharp transitives to patched releases. The standalone runner does not bundle PostCSS or Sharp because FlockOff does not use Next image optimization.
1. What This App Does #
FlockOff (flockoff.app) is an AT Protocol mass-block tool. Any atproto account can log in (custom PDS, did:web, custom-domain handles), and post/list links from any Atmosphere client are accepted as input. Reads still go through a single AppView (Bluesky's by default, configurable via APPVIEW_URL) — swapping in an alternate AppView is a config change away once viable alternatives exist.
A logged-in user can:
- Paste a post link from any Atmosphere client (bsky.app, deer.social, witchsky.app, etc.) or an
at://URI - Fetch everyone who engaged with that post (likes, reposts, quotes, replies, the OP)
- Filter by engagement type and follower relationship, select accounts to block
- Kick off a background batch block job with live SSE progress
- View history of past block events with full post previews
- Undo a block event (deletes all block records), redo it, or resume if interrupted
- Share a block event as a public AT Protocol block list (others can subscribe)
- Check for new engagement on a past block event and block new accounts
- Import and manage AT Protocol block lists via the List Editor
All blocking is done directly on the user's PDS via authenticated AT Protocol API calls. No server-side database — all event state lives in the user's own PDS repo as app.flockoff.blockEvent lexicon records.
2. Tech Stack #
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| React | 19.2 |
| AT Protocol SDK | @atproto/api |
| AT Protocol OAuth | @atproto/oauth-client-node |
| Session store | iron-session v8 (encrypted cookie) |
| Styling | Tailwind CSS v4 + shadcn/ui (Base UI — not Radix, no asChild on Button) |
| Node.js | v24 |
| Deployment | Railway (Docker, single process) — live, beta-gated (BETA_LIST_URI restricts login) |
No database. No Prisma. No Redis. OAuth session/state stores are in-memory Maps anchored to globalThis. Block jobs and lightweight rate limits are also in-memory, matching the single Railway process assumption.
3. Pages #
/ — Landing / Home #
- Logged out: Full hero with murmuration boids animation, 2-col desktop layout (text + MockupCard), features grid below. CTA: "Sign In" button + "with your Atmosphere Account" label. Shows limited-beta notice while beta mode is active.
- Logged in: Two sections — "Block a post" (URL input →
/block) and "Import a list" (URL input →/list?url=...), each with description and secondary link.
/block — Block Flow #
- URL input + Preview button; empty state shows "Your FlockOff Blocks" (up to 5 recent events)
- Post preview card (author, text, engagement counts)
- CriteriaSelector (OP / Likes / Reposts / Quotes / Replies / ☢ Nuclear) — emoji chips, all selected = ember, Nuclear = red
- AccountList with relationship highlighting (mutual/following/follower/none); reply/quote accounts show their comment text inline (up to 3, line-clamped, "+N more") so tone can be judged before blocking
- Time estimate + Block button (sticky bottom bar on mobile)
- SSE progress bar during blocking (striped ember fill)
- Done state with share-as-list option
/events — Block History #
- Newest-first list of block events
- Each EventCard: compact post preview, status badge, date, counts (✓/✗/⋯), "View details" button
/events/[rkey] — Event Detail #
- Full post preview (fetched server-side); updates with amber "+N new" indicator after re-check
- 2×2 stat grid (total / blocked / failed / pending)
- Criteria chips (read-only)
- Shared block list link (if shared) + Unlink option
- Undo / redo / resume — Resume hidden while job is running (shows sky "⋯ Job is running" + "Check status" instead)
- Collapsible "Check for new engagement" drawer
/list — List Editor #
- Import a list section: paste a list URL, load members, add/remove
- Your Lists section: FlockOff-created + external lists, load/delete
- Accepts
?url=searchParam (set by LandingListForm on homepage)
/stats — Personal Stats #
- Block events run / accounts blocked / lists created (from PDS repo via
fetchPersonalStats()) - Global stats section present as dimmed placeholder — deferred indefinitely; the stats page as a whole is under reconsideration and may be removed (see checklist)
/about — About #
- "How it works" (5 steps) + 10-question FAQ
/contact — Contact #
- Bluesky link, help@flockoff.app, mailto-based feedback form
/legal — Terms & Privacy #
- Terms of Use + Privacy Policy one-pager, linked from footer
/login — Login #
- Wordmark + tagline ("Sign in with your Atmosphere Account to get started.")
- Handle input → AT Protocol OAuth flow
- Shows limited-beta notice and access-restricted message when
BETA_LIST_URIis active
4. Directory Structure #
src/
├── app/
│ ├── page.tsx # Landing (logged-out hero / logged-in URL input)
│ ├── layout.tsx # Root layout + metadata
│ ├── globals.css # Tailwind v4 + --fo-* brand tokens
│ ├── block/
│ │ ├── page.tsx
│ │ └── BlockClient.tsx # Main block flow (client component)
│ ├── events/
│ │ ├── page.tsx # History list (parallel post fetch)
│ │ └── [rkey]/
│ │ ├── page.tsx # Event detail (server-side post fetch)
│ │ └── EventDetailClient.tsx # All event detail interactions
│ ├── list/
│ │ ├── page.tsx
│ │ └── ListEditorClient.tsx
│ ├── login/page.tsx
│ ├── stats/page.tsx # Personal stats (fetchPersonalStats from lexicon.ts)
│ ├── about/page.tsx # How it works + FAQ
│ ├── contact/page.tsx # Contact + feedback form
│ ├── legal/page.tsx # Terms of Use + Privacy Policy
│ ├── opengraph-image.tsx # OG image (Next.js ImageResponse, edge runtime)
│ ├── icon.svg # Favicon (adaptive dark mode)
│ ├── favicon.ico
│ ├── apple-icon.png
│ └── api/
│ ├── blocks/
│ │ ├── route.ts # POST — start block job
│ │ ├── status/route.ts # GET — SSE progress stream
│ │ ├── validate/route.ts # GET — verify PDS state vs expected
│ │ ├── retry/route.ts # POST — retry failed DIDs
│ │ ├── undo/route.ts # POST — delete all blocks for event
│ │ ├── redo/route.ts # POST — re-block from undone event
│ │ ├── resume/route.ts # POST — resume interrupted job
│ │ ├── append/route.ts # POST — block new accounts on existing event
│ │ ├── refresh/route.ts # GET — re-fetch engagement for diff
│ │ ├── share/route.ts # POST — create/return block list
│ │ └── clear-list/route.ts # POST — clear stale listUri from event
│ ├── list/
│ │ ├── route.ts # GET/POST — list editor fetch + create
│ │ ├── mine/route.ts # GET — user's own lists (PDS repo read)
│ │ ├── delete/route.ts # DELETE — unsubscribe + delete list
│ │ ├── subscribe/route.ts
│ │ └── unsubscribe/route.ts
│ ├── post/route.ts # GET — fetch post + engagement (public API)
│ ├── login/route.ts
│ ├── oauth/callback/route.ts
│ ├── oauth/bridge/route.ts
│ └── oauth/logout/route.ts
│ ├── oauth-client-metadata.json/route.ts
│ └── jwks.json/route.ts
├── components/
│ ├── Nav.tsx # Sticky nav: wordmark, links, theme toggle, UserMenu
│ ├── UserMenu.tsx # Avatar dropdown (handle, sign out)
│ ├── ThemeProvider.tsx # localStorage theme toggle, toggles .dark on <html>
│ ├── Murmuration.tsx # Canvas boids animation (hero section only)
│ ├── MockupCard.tsx # Static fake-data hero mockup (landing only)
│ ├── PostPreview.tsx # Post card — full (counts) + compact (no counts) variants
│ ├── CriteriaSelector.tsx # Engagement type emoji chips + Nuclear toggle
│ ├── AccountList.tsx # Scrollable account picker with select/deselect controls
│ ├── AccountCard.tsx # Single account row with relationship badge
│ ├── BlockProgress.tsx # SSE-driven progress bar
│ ├── EventCard.tsx # History list card (compact PostPreview inside)
│ ├── LandingUrlForm.tsx # "Block a post" URL input on logged-in landing
│ ├── LandingListForm.tsx # "Import a list" URL input on logged-in landing
│ ├── LoginForm.tsx # Handle input + submit
│ ├── Footer.tsx # Site footer (Contact / About / Terms & Privacy links)
│ ├── ListPreview.tsx # Member list view in list editor
│ ├── ListResultClient.tsx # Post-create list result view
│ └── LinkButton.tsx # Next.js Link + buttonVariants (shadcn asChild workaround)
├── lib/
│ ├── oauth.ts # NodeOAuthClient + restoreAgent() + robustFetch workaround
│ ├── session.ts # iron-session helpers
│ ├── lexicon.ts # All PDS read/write typed wrappers
│ ├── queue.ts # In-memory job queue + SSE + activeEventRkeys guard
│ ├── engagement.ts # fetchEngagement, fetchEngagementByAtUri, fetchPostData
│ ├── appview.ts # APPVIEW_URL (env-overridable AppView base for all reads)
│ ├── rate-limit.ts # In-memory fixed-window rate limiter (login route)
│ ├── auth-bridge.ts # One-time token bridge for cross-domain session setup
│ └── utils.ts # Tiered post/list input parsers (at:// + any-client URLs), display-host allowlist, generateId, sleep
└── types/index.ts # All shared TypeScript types
5. Design System #
Direction: "Warm Editorial Utility" — purposeful, sharp, a little wry.
- Fonts: Playfair Display (display/headings) + DM Sans (body/UI) + DM Mono (labels/data/handles) via
next/font/google - Colors: Linen background
oklch(0.952 0.012 75), ember primaryoklch(0.545 0.165 35)(--fo-ember), all tokens inglobals.cssas--fo-*custom properties - Dark mode: Light-mode default.
ThemeProviderreadslocalStorage('flockoff-theme'), toggles.darkclass on<html>. - Relationship badges: forest green (mutual) / goldenrod (following) / dusk blue (follower) — all via
--fo-*vars - Wordmark:
Flock+Off(italic, ember) in Playfair Display — never use emoji or an icon as the brand mark - Murmuration: boids canvas, hero section only, IntersectionObserver-gated, ResizeObserver on hero element (not window)
- Design spec:
flockoff-design-spec.md(full token/component/layout reference)
6. Custom Lexicon: app.flockoff.blockEvent #
interface BlockEventRecord {
$type: 'app.flockoff.blockEvent'
sourcePostUri: string // AT URI of the original post
sourcePostUrl?: string // display URL — verified-client link or canonical bsky.app
createdAt: string
criteria: {
blockOp: boolean
blockLikes: boolean
blockReposts: boolean
blockQuotes: boolean
blockReplies: boolean
}
stats?: { total: number; completed: number; failed: number }
pendingDids: string[]
blockedDids: string[]
failedDids: string[]
blockedRkeys: string[] // rkeys of created block records, for undo
listUri?: string // set if shared as AT Protocol block list
undoneAt?: string // set when undo was run
}
7. Key Architecture Gotchas #
- In-memory queue: Block jobs live in RAM. Server restart loses active jobs —
pendingDidsin the PDS record enables recovery via Resume. - Concurrent job guard:
activeEventRkeysSet inqueue.tsprevents two jobs on the same event simultaneously. All job-starting routes (blocks, redo, resume, retry, append) check this and return 409 if already running. A per-user in-memory cap prevents too many concurrent jobs from one account. These in-memory guards are soft TOCTOU limits for the single Railway process assumption, not distributed locks. - PDS write concurrency:
updateBlockEvent()supportsswapRecord; queue persistence uses optimistic concurrency retry to avoid stomping concurrent event-record writes. Non-job event actions are also guarded while jobs are running. - OAuth loopback cookie split: AT Protocol requires
127.0.0.1for loopback redirect URIs. Bridge token pattern (/api/oauth/bridge) handles cross-domain cookie setup in dev. client.restore()→ always wrap in Agent:restoreAgent(did)inoauth.tswrapsOAuthSessioninnew Agent(session). Never use the raw session for API calls.globalThisHMR pattern:stateStore,sessionStore,__oauthClientPromise, queue state, auth bridge tokens, and simple rate-limit stores are anchored toglobalThis. Do not move persistent in-process stores to module scope.- OAuth request lock:
requestLockserializes token refreshes per OAuth session to avoid concurrent refresh replay failures. - Security headers:
next.config.tssets X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and a Content-Security-Policy globally; OAuth metadata/JWKS routes get CORS/cache headers. - Beta allowlist:
BETA_LIST_URIrestricts login to members of an AT Protocol list. Remove it to open launch access. robustFetchworkaround: Node.js 24 / undici v7.24.4 bug —fetch(request)throws "expected non-null body source" on DPoP nonce retry. Fixed inoauth.tsby buffering body toUint8Array. Remove oncenode -e "console.log(process.versions.undici)"shows ≥ 7.24.8.- Tiered input parsing: post/list inputs resolve via (1)
at://URI extraction — raw pastes plus URIs embedded in client links, raw or URL-encoded — then (2) path-shape parsing of/profile/<handle-or-did>/post/<rkey>(and/lists/) on ANY https host, with atproto-grammar segment validation. DIDs in the actor segment skipresolveHandle. Inputs usetype="text"+inputMode="url"—type="url"would rejectat://did:plc:…(WHATWG parses DID colons as an invalid port). - Display-host allowlist:
VERIFIED_DISPLAY_HOSTSinutils.tsgates what's persisted assourcePostUrl(rendered later + embedded in public list descriptions). Exact hostname match, https-only, normalized to host + pathname. Unverified hosts and all at://-tier inputs get canonical bsky.app URLs — parsing accepts any host, persistence only vouches for listed ones. Add new verified clients to this Set. com.atproto.repo.listRecordsnot AppView RPC: User lists fetched directly from PDS repo (covered byatprotoscope). Do NOT usegetListsAppView RPC.- shadcn v4 = Base UI: No
asChildon Button. UseLinkButton.tsxfor link-styled buttons. - fetchPostData vs fetchEngagement:
engagement.tshas two entrypoints —fetchPostData(atUri)for lightweight metadata-only fetch (used in history pages),fetchEngagementByAtUri(atUri)for full engagement lists (used in re-check).
8. OAuth Scopes & Production Setup #
Progressive authorization (2026-07-03): login requests BASE_SCOPES; list features request FULL_SCOPES on demand via /api/oauth/upgrade. Client metadata always declares FULL_SCOPES (requests must be a subset of metadata scope). Constants in src/lib/oauth.ts.
BASE_SCOPES (requested at login):
atproto
repo:app.bsky.graph.block?action=create&action=delete
repo:app.flockoff.blockEvent?action=create&action=update
LIST scopes (added via /api/oauth/upgrade when first needed):
repo:app.bsky.graph.list?action=create&action=delete
repo:app.bsky.graph.listitem?action=create
repo:app.bsky.graph.listblock?action=create&action=delete
(blockEvent delete and listitem delete were dropped — never used by any code path.)
Upgrade flow: gated API routes return 403 { error: 'insufficient_scope' } → frontend shows ScopeUpgradePrompt → browser navigates to /api/oauth/upgrade?return_to=<path> → PDS consent → callback verifies the typed app state's DID matches (confused-account guard, src/lib/oauth-state.ts) → auth bridge sets the cookie and redirects to return_to. atproto has no incremental-grant merging, so upgrade requests the full union; the new session replaces the old one per DID (client.callback() revokes-then-replaces — hence the upgrade route deflects with upgrade=job_running while the user has running jobs). No prompt: 'consent' — confidential clients may get silent re-approval, which is the desired fast path.
Gate matrix (requireListScopes in src/lib/scope-gate.ts): share always; /api/list POST/delete/subscribe/unsubscribe always; append/resume/retry only when the event has a listUri (their queue jobs write listitems); undo only when listUri && listAction !== 'none'. Gates run after job-state checks (409/429 take precedence) but before any mutation. No gate on reads (/api/list GET, /api/list/mine), redo (no listUri passed), or clear-list (event record only).
Scope matching: hasListScopes() compares repo-scope semantics (collection match + action superset), not raw string equality — legacy full-scope grants hold listitem?action=create&action=delete, a superset of the now-requested ?action=create, and must not be re-prompted.
PDS compatibility policy: FlockOff requires authorization servers that implement the current granular repo: permissions. It does not fall back to the legacy transition:generic scope, which would grant write access to every repository record type and undermine the least-privilege design. Login maps invalid_scope/unsupported-scope authorization failures to a user-facing PDS-update message.
Read operations (engagement, relationships, list members) go through the AppView at APPVIEW_URL (src/lib/appview.ts, defaults to https://public.api.bsky.app) — no auth needed.
Dev vs prod client (in oauth.ts):
NEXT_PUBLIC_APP_URL=http://localhost:*→ loopback public client (no key needed)NEXT_PUBLIC_APP_URL=https://flockoff.app→ confidential client withOAUTH_PRIVATE_KEY(ES256 JWK JSON string)- Client metadata served at
/oauth-client-metadata.json, JWKS at/jwks.json— both need CORS headers (already innext.config.ts)
Required env vars:
NEXT_PUBLIC_APP_URL=https://flockoff.app
OAUTH_PRIVATE_KEY={"kty":"EC","crv":"P-256",...}
SESSION_SECRET=<32+ char random string>
BETA_LIST_URI=<optional at://.../app.bsky.graph.list/... allowlist for beta>
APPVIEW_URL=<optional AppView override for reads; defaults to https://public.api.bsky.app>
9. Pre-Launch Checklist #
Some items handled by Letta Code, some by Claude Code to save API credits.
UI / UX Fixes #
New Features #
Infrastructure #
Content / Legal #
Content / Docs #
Language #
Claude follow-up review — 2026-06-29 #
Claude reviewed the pre-deploy hardening diff before commit. Follow-up items resolved before deploy:
Validation and dependency status — 2026-07-25 #
Latest checks after committing progressive OAuth and hardening dependencies:
atproto portability pass — 2026-07-02 (commit d64c10d) #
Post/list input parsing opened up to the whole Atmosphere; reviewed via two Letta adversarial passes, all findings addressed. See the commit message for detail. Highlights:
- Deferred by design: no aturi.to dependency (unsupported-link errors point users there). Older PDS fallback was rejected in favor of requiring current granular OAuth permissions;
transition:genericis intentionally not requested. Planning doc lives in the HomeLab Docs vault:planning/FlockOff - atproto Portability & Alt-AppView Support.md.