# 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 ### `/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_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 │ ├── 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 ``. - **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` ```typescript 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//post/` (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=` → 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= APPVIEW_URL= ``` --- ## 9. Pre-Launch Checklist > Some items handled by Letta Code, some by Claude Code to save API credits. ### UI / UX Fixes - [x] **Engagement type selection colors** — CriteriaSelector has different colors per type (OP is forest-green, standard types are ember, caution types are amber). Simplify: all selected chips should use the same ember color. Only the Nuclear chip stays red/destructive. Remove the per-type color variation. - [x] **Block page empty state** — `/block` is boring before a URL is loaded. Make the empty state more inviting (better copy, visual interest). - [x] **Hero → features transition** — landing page flow between hero and features grid is awkward. Improve spacing and visual continuity. - [x] **Footer spacing** — bottom of landing page has inconsistent spacing, needs cleanup. - [x] **"Resume pending" button UX** — Resume button hidden when job is in-flight (replaced by sky-blue status + "Check status" button); Share and Undo disabled during active job; undo shows pulsing progress note with accurate "safe to close" messaging; fixed `activeEventRkeys`/`activeJobs` to use `globalThis` pattern so `isEventJobRunning` is reliable across Next.js module contexts. - [x] **"Check status" refresh button on block page** — resolved as part of Resume pending UX work. ### New Features - [x] **Stats page (`/stats`)** — personal stats live (paginated PDS read via `fetchPersonalStats` in lexicon.ts): block events run, accounts blocked, lists created. Global stats section present as a dimmed placeholder — needs Postgres post-Railway-setup. - [x] **Feedback form** — `/contact` page built with Bluesky link, help@flockoff.app, and mailto-based feedback form. Post-launch plan changed: migrate to **userinput.app** (atproto-native feedback service, https://userinput.app/) instead of Formspree/Resend. - [x] **Favicon** — `icon.svg` (adaptive dark mode), `favicon.ico`, `apple-icon.png` in `src/app/`; manifest PNGs in `public/`; `site.webmanifest` with brand colors wired via layout metadata. - [x] **Commenter text display (2026-07-02)** — reply and quote text shown inline on account cards in the block flow and re-check drawer, for judging supportive vs hostile commenters before blocking. The text was already in the `getPostThread`/`getQuotes` responses and previously discarded — no new API calls. Preview-time only: `comments` lives on `EngagementAccount`, never persisted to the blockEvent record. Comments are capped server-side at 3 per account (`MAX_COMMENTS_PER_ACCOUNT` in engagement.ts, deduped by URI for posts that are both a quote and a reply); `commentCount` carries the true total for the "+N more" UI. **Intentional behavior:** in the block flow, `/api/post` fetches all engagement types and the UI filters client-side — so an account visible via Likes still shows its reply text even with Replies toggled off. That's extra context for a visible account, not a leak; the re-check drawer uses the stored event criteria instead. Possible follow-on: keyword filter over comments ("select all whose comment contains X"). ### Infrastructure - [x] **Railway setup** — **deployed and live** (beta-gated via `BETA_LIST_URI`; only Andrew + test account can log in). Standalone output, multi-stage Dockerfile, env vars set. Reminder: add optional `APPVIEW_URL` to Railway env docs if ever overriding the default AppView. - [x] **Tangled publishing** — Tangled mirror is set up. ### Content / Legal - [x] **Terms of service + Privacy policy** — `/legal` one-pager: what's accessed (OAuth scopes), what's stored server-side (session cookie + in-memory job state only), how to revoke, contact info. Linked from footer. - [x] **OG image** — `src/app/opengraph-image.tsx` using Next.js `ImageResponse` (edge runtime). Auto-wired by Next.js — no manual metadata needed. Linen background, Flock*Off* wordmark, tagline, ember dot + `flockoff.app` URL. ### Content / Docs - [x] **README cleanup** — proper README: what FlockOff is, feature list, tech stack, local setup (env vars, dev server), OAuth private key generation via `jose`, architecture notes. - [x] **About page (`/about`)** — 4-step "How it works" section + 9-question FAQ covering data storage, permissions, undo/redo, Nuclear, list sharing, re-check, revocation. Linked from footer. ### Language - [x] **"Atmosphere" vs "AT Protocol"** — user-facing copy uses "Atmosphere" when referring to the network/apps. "AT Protocol" kept for technical references (lexicons, scopes, specs). (The old "Alternate AppView compatibility coming soon" tooltip is gone — alt-client link input shipped 2026-07-02; copy now says "any Atmosphere client.") ### Claude follow-up review — 2026-06-29 Claude reviewed the pre-deploy hardening diff before commit. Follow-up items resolved before deploy: - [x] Share retry reconciliation: if `record.listUri` exists after a partial population failure, `/api/blocks/share` repairs missing listitems and ensures listblock subscription before returning success. - [x] Undo partial failure semantics: synchronous undo does not clear all `blockedRkeys` if some deletes fail; failed rkeys remain retryable and pacing uses batched sleeps instead of per-item sleeps. - [ ] Large undo remains synchronous and may exceed an HTTP request timeout for very large events or prolonged 429 backoff. Accepted for the next beta deployment; move undo into a durable queued/SSE job before broad public launch. - [x] Rate limiter hardening: avoids spoofable leftmost `X-Forwarded-For` and sweeps/caps expired entries so the in-memory Map cannot grow without bound. - [x] List-import failure cleanup: curated list creation deletes the new list when population or final subscription fails; if cleanup itself fails, the response returns the list URI for manual removal. - [x] Small cleanup: `persistProgress` optimistic-concurrency retry is narrowed to `InvalidSwap`; Next/React pins are exact for reproducibility; in-memory guards are documented as soft TOCTOU limits. ### Validation and dependency status — 2026-07-25 Latest checks after committing progressive OAuth and hardening dependencies: - [x] `npx tsc --noEmit` - [x] `npm run build` - [x] `git diff --check` - [x] `npm audit --omit=dev` — 0 vulnerabilities after Next.js `16.2.12`, shadcn `4.15.0` as a build-time dev dependency, and scoped Next overrides for PostCSS `8.5.23` and Sharp `0.35.3` - [x] Alpine production image build (Node 24); standalone runner contains Next.js `16.2.12` and does not bundle build-only PostCSS or unused Sharp ### 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: - [x] Tiered input parsing: `at://` URI extraction (raw + embedded, incl. URL-encoded query params) → any-host `/profile/…/post|lists/` path-shape parsing with atproto-grammar segment validation - [x] Bug fix: DID-form bsky.app URLs (`/profile/did:plc:…/post/…`) no longer crash `resolveHandle` - [x] `APPVIEW_URL` env override (`src/lib/appview.ts`) replaces all hardcoded `public.api.bsky.app` references - [x] `VERIFIED_DISPLAY_HOSTS` allowlist gates persisted `sourcePostUrl` (exact-host, https-only, normalized); `/api/list` POST strictly validates `sourceListUri` as a list AT URI before embedding in public descriptions - [x] Inputs `type="url"` → `type="text"` + `inputMode="url"` (WHATWG URL parsing rejects `at://did:plc:…`) - [x] Verified: `tsc` clean, `next build` passes, 64/64 parser test cases - 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 - [x] **Pre-deploy hardening pass** — fixed queue 429 retry loop, job/event race guards, append persistence before queue start, safer list population, `undoneAt` lexicon schema, session secret validation, auth bridge avatar/cleanup, login rate limit, per-user job cap, Claude follow-up share/undo/rate-limit/list cleanup, and dependency updates. - [ ] **Full desktop + mobile walkthrough** — an initial pass was done post-deployment, but a complete re-test of the entire flow is needed (especially after the 2026-07-02 portability changes: paste alt-client URLs, raw at:// URIs, DID-form URLs). Known less-tested areas on mobile: re-check drawer, event detail stat grid, list editor. - [ ] **Beta gate testing** — `BETA_LIST_URI` stays enabled until the gate itself has had more real testing; public launch decision follows that. - [ ] **Global stats on `/stats`** — **deferred indefinitely.** Leaning toward not implementing any global stats (would require Postgres, breaking the no-database architecture); the `/stats` page as a whole may be removed. Revisit post-launch, if at all. - [x] **Session expiry during block job** — documented in About FAQ: job stops silently, progress saved to PDS, recover via Resume after signing back in.