FlockOff is a mass-block tool for the Atmosphere flockoff.app
flockoff context.md
31 kB
Markdown

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 of origin/master and unpushed
  • Local commits: 2abbe40 hardening, d64c10d Atmosphere portability, e6bc78e context refresh, ce276e1 reply/quote text, 655b55b progressive OAuth scopes, 0a08b2b final 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:

  1. Paste a post link from any Atmosphere client (bsky.app, deer.social, witchsky.app, etc.) or an at:// URI
  2. Fetch everyone who engaged with that post (likes, reposts, quotes, replies, the OP)
  3. Filter by engagement type and follower relationship, select accounts to block
  4. Kick off a background batch block job with live SSE progress
  5. View history of past block events with full post previews
  6. Undo a block event (deletes all block records), redo it, or resume if interrupted
  7. Share a block event as a public AT Protocol block list (others can subscribe)
  8. Check for new engagement on a past block event and block new accounts
  9. 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
  • 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_URI is 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 primary oklch(0.545 0.165 35) (--fo-ember), all tokens in globals.css as --fo-* custom properties
  • Dark mode: Light-mode default. ThemeProvider reads localStorage('flockoff-theme'), toggles .dark class 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 — pendingDids in the PDS record enables recovery via Resume.
  • Concurrent job guard: activeEventRkeys Set in queue.ts prevents 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() supports swapRecord; 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.1 for loopback redirect URIs. Bridge token pattern (/api/oauth/bridge) handles cross-domain cookie setup in dev.
  • client.restore() → always wrap in Agent: restoreAgent(did) in oauth.ts wraps OAuthSession in new Agent(session). Never use the raw session for API calls.
  • globalThis HMR pattern: stateStore, sessionStore, __oauthClientPromise, queue state, auth bridge tokens, and simple rate-limit stores are anchored to globalThis. Do not move persistent in-process stores to module scope.
  • OAuth request lock: requestLock serializes token refreshes per OAuth session to avoid concurrent refresh replay failures.
  • Security headers: next.config.ts sets 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_URI restricts login to members of an AT Protocol list. Remove it to open launch access.
  • robustFetch workaround: Node.js 24 / undici v7.24.4 bug — fetch(request) throws "expected non-null body source" on DPoP nonce retry. Fixed in oauth.ts by buffering body to Uint8Array. Remove once node -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 skip resolveHandle. Inputs use type="text" + inputMode="url" — type="url" would reject at://did:plc:… (WHATWG parses DID colons as an invalid port).
  • Display-host allowlist: VERIFIED_DISPLAY_HOSTS in utils.ts gates what's persisted as sourcePostUrl (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.listRecords not AppView RPC: User lists fetched directly from PDS repo (covered by atproto scope). Do NOT use getLists AppView RPC.
  • shadcn v4 = Base UI: No asChild on Button. Use LinkButton.tsx for link-styled buttons.
  • fetchPostData vs fetchEngagement: engagement.ts has 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 with OAUTH_PRIVATE_KEY (ES256 JWK JSON string)
  • Client metadata served at /oauth-client-metadata.json, JWKS at /jwks.json — both need CORS headers (already in next.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 / 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:generic is intentionally not requested. Planning doc lives in the HomeLab Docs vault: planning/FlockOff - atproto Portability & Alt-AppView Support.md.

Quality #