···11+# Bluesky OAuth Sign-In
22+33+## Goal
44+55+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.
66+77+## Packages
88+99+- `@adonisjs/auth` — session guard, `ctx.auth`, auth middleware, `auth` in Edge templates
1010+- `@atproto/oauth-client-node` — handles DPoP, PAR, PKCE, token refresh
1111+1212+## OAuth Scopes (minimum required)
1313+1414+- `atproto` (required base scope)
1515+- `repo:app.bsky.feed.like?action=create&action=delete`
1616+- `repo:app.bsky.feed.repost?action=create&action=delete`
1717+1818+## Database Changes
1919+2020+### Rename `users` → `tracked_profiles`
2121+2222+Rename the table and update all references (model class becomes `TrackedProfile`, path alias, imports, queries, controllers, etc.).
2323+2424+### New `accounts` table (SQLite)
2525+2626+| Column | Type | Notes |
2727+|--------|------|-------|
2828+| `did` | TEXT | Primary key |
2929+| `handle` | TEXT | |
3030+| `display_name` | TEXT | Nullable |
3131+| `avatar_url` | TEXT | Nullable |
3232+| `session_data` | TEXT | JSON blob — atproto SDK token/DPoP storage |
3333+| `created_at` | INTEGER | |
3434+| `updated_at` | INTEGER | |
3535+3636+### New `auth_states` table (SQLite)
3737+3838+Ephemeral storage for in-flight OAuth flows. Rows are deleted after callback completes.
3939+4040+| Column | Type | Notes |
4141+|--------|------|-------|
4242+| `key` | TEXT | Primary key |
4343+| `state_data` | TEXT | JSON blob |
4444+| `created_at` | INTEGER | |
4545+4646+## Auth Architecture
4747+4848+### AdonisJS Auth
4949+5050+- Session guard configured against the `Account` model (primary key: `did`)
5151+- `initialize_auth_middleware` in the global middleware stack (so `ctx.auth` is always available, even on public pages)
5252+- Named `auth` middleware for protecting API routes
5353+5454+### Atproto OAuth Service
5555+5656+Singleton AdonisJS service: `app/services/atproto_oauth_service.ts`
5757+5858+Implements the SDK's `NodeSavedState` and `NodeSavedSession` storage interfaces backed by SQLite (`auth_states` table and `session_data` column on `accounts`).
5959+6060+Key methods:
6161+- `authorize(handle)` → redirect URL
6262+- `callback(params)` → account DID
6363+- `getAgent(did)` → authenticated AT Protocol agent for API calls
6464+6565+### Client Metadata
6666+6767+Served at `GET /oauth/client-metadata.json`. The `client_id` in the OAuth flow is this URL. Must be publicly accessible.
6868+6969+## OAuth Flow
7070+7171+1. User clicks "Sign in" → `GET /oauth/login` → service resolves their PDS and initiates PAR → redirect to Bluesky authorization server
7272+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)
7373+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.
7474+4. Logout: `POST /oauth/logout` → `auth.use('web').logout()` + clear stored atproto session data.
7575+7676+## UI Changes
7777+7878+### Header
7979+8080+- Signed out: "Sign in" link in the top right (alongside search bar and dark mode toggle)
8181+- Signed in: user's avatar + handle linking to their own profile, plus "Sign out" button
8282+8383+### Landing Page
8484+8585+- When signed in and on the landing page, after sign-in redirect to their own profile
8686+- No special CTA — sign in lives in the header only
8787+8888+### Profile Page Post Cards
8989+9090+- Signed out: no change
9191+- Signed in: each post card gets like (heart) and repost icons
9292+- Icons reflect current state: filled/highlighted if the viewer has already liked/reposted
9393+- Clicking toggles the action inline with optimistic UI (toggle immediately, revert on error)
9494+- Like/repost counts update accordingly (+1/-1)
9595+9696+## Fetching Viewer State
9797+9898+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.
9999+100100+One API call, max 25 URIs (within the API limit), only for authenticated visitors.
101101+102102+## API Routes for Like/Repost
103103+104104+All protected by auth middleware.
105105+106106+| Method | Route | Request | Response |
107107+|--------|-------|---------|----------|
108108+| POST | `/api/posts/:uri/like` | — | `{ uri: "<like-record-uri>" }` |
109109+| DELETE | `/api/posts/:uri/like` | `{ uri: "<like-record-uri>" }` | `{}` |
110110+| POST | `/api/posts/:uri/repost` | — | `{ uri: "<repost-record-uri>" }` |
111111+| DELETE | `/api/posts/:uri/repost` | `{ uri: "<repost-record-uri>" }` | `{}` |
112112+113113+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 (`*`).
114114+115115+## Alpine.js Interactions
116116+117117+Each post card gets an `Alpine.data()` component (CSP build — no inline expressions) that manages:
118118+- Like/repost toggled state and record URIs
119119+- Optimistic count updates
120120+- API calls to the routes above
121121+- Revert on error