diff --git a/README.md b/README.md index 12878a2..df174e3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ FlockOff is an AT Protocol mass-block tool. Paste a post URL from an Atmosphere ### Prerequisites - Node.js v24+ -- An AT Protocol account (Bluesky or any compatible PDS) +- An AT Protocol account on Bluesky or another PDS that supports the current granular OAuth `repo:` permissions ### 1. Clone and install @@ -59,6 +59,8 @@ SESSION_SECRET=any-random-string-at-least-32-chars-long Login requests only the scopes needed for blocking. The first list action prompts for the additional list permissions through FlockOff's OAuth upgrade flow. +FlockOff does not fall back to the legacy `transition:generic` scope because it would grant write access to every record type in the user's repository. Older PDS installations must be updated before their accounts can use FlockOff. + ### 3. Run the dev server ```bash diff --git a/context.md b/context.md index 5767d20..d7f69fc 100644 --- a/context.md +++ b/context.md @@ -9,8 +9,8 @@ - Fedora repository: `/home/andrew/Projects/flockoff` - Active worktree: `.letta/worktrees/flockoff-predeploy-hardening` -- Branch: `letta/flockoff-predeploy-hardening-962c4508`, five 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 +- 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. @@ -280,6 +280,8 @@ LIST scopes (added via /api/oauth/upgrade when first needed): **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`): @@ -360,7 +362,7 @@ Post/list input parsing opened up to the whole Atmosphere; reviewed via two Lett - [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); granular-OAuth-scope fallback for older PDSs still an open decision. Planning doc lives in the HomeLab Docs vault: `planning/FlockOff - atproto Portability & Alt-AppView Support.md`. +- 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. diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 0574da9..bd8c11a 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -67,6 +67,10 @@ const FAQS = [ q: 'Does FlockOff work with other Atmosphere apps?', a: 'FlockOff accepts post and list links from supported Atmosphere clients as well as raw at:// URIs. Engagement reads still come from one configured AppView — Bluesky\'s public AppView by default — so FlockOff can only see content indexed by that AppView. The blocks themselves use the standard AT Protocol block lexicon (app.bsky.graph.block), so they apply in apps that respect it, though coverage can vary by client.', }, + { + q: 'Can I sign in from a custom PDS?', + a: 'Yes, if the PDS supports the current AT Protocol granular OAuth permissions. FlockOff deliberately does not fall back to the legacy transition:generic scope because that would grant write access to every record type in your repository. If sign-in reports unsupported permissions, your PDS operator will need to update the server.', + }, { q: 'What happens if my session expires while a block job is running?', a: 'If your sign-in session expires mid-job, the job will stop processing silently — no error is shown since the job runs in the background. Your progress up to that point is saved to your PDS, so no completed blocks are lost. To recover, sign back in, go to your block history, and use the Resume button on the interrupted event to pick up where it left off.', diff --git a/src/app/api/login/route.ts b/src/app/api/login/route.ts index 0fd2f97..831d594 100644 --- a/src/app/api/login/route.ts +++ b/src/app/api/login/route.ts @@ -27,7 +27,10 @@ export async function GET(request: Request) { return Response.redirect(url.toString(), 302) } catch (err) { console.error('[login] authorize failed:', err) - const message = err instanceof Error ? err.message : 'Authorization failed' + const rawMessage = err instanceof Error ? err.message : 'Authorization failed' + const message = /invalid[ _-]?scope|unsupported[^\n]*scope/i.test(rawMessage) + ? 'pds_scope_unsupported' + : rawMessage // Redirect back to login page with an error const loginUrl = new URL('/login', process.env.NEXT_PUBLIC_APP_URL!) loginUrl.searchParams.set('error', message) diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9aba127..1bf18c4 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -13,6 +13,8 @@ export default async function LoginPage({ if (session.did) redirect('/') const { error } = await searchParams + const isAccessRestricted = error === 'access_restricted' + const isUnsupportedPds = error === 'pds_scope_unsupported' return (
@@ -33,12 +35,14 @@ export default async function LoginPage({ {error && (
- {error === 'access_restricted' + {isAccessRestricted ? 'FlockOff is currently in limited beta. Your account hasn\'t been added to the access list yet.' + : isUnsupportedPds + ? 'Your account provider does not support the granular OAuth permissions FlockOff requires. Ask your PDS operator to update to a current AT Protocol release.' : decodeURIComponent(error)}
)}