See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add design spec for Bluesky OAuth sign-in feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+121
+121
docs/superpowers/specs/2026-04-13-bluesky-oauth-design.md
··· 1 + # Bluesky OAuth Sign-In 2 + 3 + ## Goal 4 + 5 + Allow users to sign in with their Bluesky account so they can quickly view their own top posts and like/repost posts inline when viewing other profiles. 6 + 7 + ## Packages 8 + 9 + - `@adonisjs/auth` — session guard, `ctx.auth`, auth middleware, `auth` in Edge templates 10 + - `@atproto/oauth-client-node` — handles DPoP, PAR, PKCE, token refresh 11 + 12 + ## OAuth Scopes (minimum required) 13 + 14 + - `atproto` (required base scope) 15 + - `repo:app.bsky.feed.like?action=create&action=delete` 16 + - `repo:app.bsky.feed.repost?action=create&action=delete` 17 + 18 + ## Database Changes 19 + 20 + ### Rename `users` → `tracked_profiles` 21 + 22 + Rename the table and update all references (model class becomes `TrackedProfile`, path alias, imports, queries, controllers, etc.). 23 + 24 + ### New `accounts` table (SQLite) 25 + 26 + | Column | Type | Notes | 27 + |--------|------|-------| 28 + | `did` | TEXT | Primary key | 29 + | `handle` | TEXT | | 30 + | `display_name` | TEXT | Nullable | 31 + | `avatar_url` | TEXT | Nullable | 32 + | `session_data` | TEXT | JSON blob — atproto SDK token/DPoP storage | 33 + | `created_at` | INTEGER | | 34 + | `updated_at` | INTEGER | | 35 + 36 + ### New `auth_states` table (SQLite) 37 + 38 + Ephemeral storage for in-flight OAuth flows. Rows are deleted after callback completes. 39 + 40 + | Column | Type | Notes | 41 + |--------|------|-------| 42 + | `key` | TEXT | Primary key | 43 + | `state_data` | TEXT | JSON blob | 44 + | `created_at` | INTEGER | | 45 + 46 + ## Auth Architecture 47 + 48 + ### AdonisJS Auth 49 + 50 + - Session guard configured against the `Account` model (primary key: `did`) 51 + - `initialize_auth_middleware` in the global middleware stack (so `ctx.auth` is always available, even on public pages) 52 + - Named `auth` middleware for protecting API routes 53 + 54 + ### Atproto OAuth Service 55 + 56 + Singleton AdonisJS service: `app/services/atproto_oauth_service.ts` 57 + 58 + Implements the SDK's `NodeSavedState` and `NodeSavedSession` storage interfaces backed by SQLite (`auth_states` table and `session_data` column on `accounts`). 59 + 60 + Key methods: 61 + - `authorize(handle)` → redirect URL 62 + - `callback(params)` → account DID 63 + - `getAgent(did)` → authenticated AT Protocol agent for API calls 64 + 65 + ### Client Metadata 66 + 67 + Served at `GET /oauth/client-metadata.json`. The `client_id` in the OAuth flow is this URL. Must be publicly accessible. 68 + 69 + ## OAuth Flow 70 + 71 + 1. User clicks "Sign in" → `GET /oauth/login` → service resolves their PDS and initiates PAR → redirect to Bluesky authorization server 72 + 2. User approves → callback to `GET /oauth/callback` → service exchanges code for tokens, stores session data on account → `auth.use('web').login(account)` → redirect to original page (or own profile if they were on landing page) 73 + 3. Subsequent requests: AdonisJS session cookie identifies the user. When authenticated API calls are needed (like/repost), the service restores an atproto agent from the stored session data. 74 + 4. Logout: `POST /oauth/logout` → `auth.use('web').logout()` + clear stored atproto session data. 75 + 76 + ## UI Changes 77 + 78 + ### Header 79 + 80 + - Signed out: "Sign in" link in the top right (alongside search bar and dark mode toggle) 81 + - Signed in: user's avatar + handle linking to their own profile, plus "Sign out" button 82 + 83 + ### Landing Page 84 + 85 + - When signed in and on the landing page, after sign-in redirect to their own profile 86 + - No special CTA — sign in lives in the header only 87 + 88 + ### Profile Page Post Cards 89 + 90 + - Signed out: no change 91 + - Signed in: each post card gets like (heart) and repost icons 92 + - Icons reflect current state: filled/highlighted if the viewer has already liked/reposted 93 + - Clicking toggles the action inline with optimistic UI (toggle immediately, revert on error) 94 + - Like/repost counts update accordingly (+1/-1) 95 + 96 + ## Fetching Viewer State 97 + 98 + When a signed-in user views a profile page, after fetching top-25 posts from ClickHouse, the controller calls `app.bsky.feed.getPosts` with the viewer's authenticated agent. The response includes `viewer.like` and `viewer.repost` fields. This data is passed to the template alongside existing post data. 99 + 100 + One API call, max 25 URIs (within the API limit), only for authenticated visitors. 101 + 102 + ## API Routes for Like/Repost 103 + 104 + All protected by auth middleware. 105 + 106 + | Method | Route | Request | Response | 107 + |--------|-------|---------|----------| 108 + | POST | `/api/posts/:uri/like` | — | `{ uri: "<like-record-uri>" }` | 109 + | DELETE | `/api/posts/:uri/like` | `{ uri: "<like-record-uri>" }` | `{}` | 110 + | POST | `/api/posts/:uri/repost` | — | `{ uri: "<repost-record-uri>" }` | 111 + | DELETE | `/api/posts/:uri/repost` | `{ uri: "<repost-record-uri>" }` | `{}` | 112 + 113 + The `:uri` param is the post's AT URI (e.g. `at://did:plc:abc/app.bsky.feed.post/123`). Since it contains slashes, it must be URL-encoded in the path, or alternatively we can use a query parameter or a wildcard route param (`*`). 114 + 115 + ## Alpine.js Interactions 116 + 117 + Each post card gets an `Alpine.data()` component (CSP build — no inline expressions) that manages: 118 + - Like/repost toggled state and record URIs 119 + - Optimistic count updates 120 + - API calls to the routes above 121 + - Revert on error