The AtmosphereConf talks your skyline missed
0
fork

Configure Feed

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

docs: replace transition:generic with granular AT Protocol scopes

Understory only needs two authenticated RPC calls (getFollows and
searchPosts), both read-only. Using transition:generic grants full write
access which is a negative trust signal for a read-only app. The AT
Protocol audience at ATmosphereConf will notice and appreciate minimal
scope requests.

New scope: 'atproto rpc:app.bsky.graph.getFollows?aud=*
rpc:app.bsky.feed.searchPosts?aud=*'

Also: add OAUTH_SCOPE constant to metadata.ts (shared by both client
metadata and the login route), and add login/route.ts to the plan's
files-changed table.

+94 -16
+80 -15
docs/superpowers/plans/2026-04-10-railway-deploy.md
··· 23 23 | `src/lib/auth/metadata.ts` | Create | Shared `buildClientMetadata(appUrl)` — single source of truth for OAuth metadata | 24 24 | `src/app/oauth/client-metadata.json/route.ts` | Create | Self-hosted AT Protocol OAuth client metadata endpoint | 25 25 | `src/lib/auth/client.ts` | Modify | Import `buildClientMetadata`, remove `OAUTH_CLIENT_ID` dependency | 26 + | `src/app/oauth/login/route.ts` | Modify | Update `client.authorize()` scope from `transition:generic` to granular scopes via `OAUTH_SCOPE` constant | 26 27 27 28 --- 28 29 ··· 178 179 179 180 export const CLIENT_METADATA_PATH = "/oauth/client-metadata.json"; 180 181 182 + /** 183 + * Granular OAuth scopes for Understory. Replaces the overpermissioned 184 + * `transition:generic` transitional scope with the minimum permissions 185 + * the app actually needs: 186 + * 187 + * - `atproto` — required for all AT Protocol OAuth sessions (identity) 188 + * - `rpc:app.bsky.graph.getFollows?aud=*` — read the user's follows 189 + * - `rpc:app.bsky.feed.searchPosts?aud=*` — search for conference posts 190 + * 191 + * Understory never writes records, so no write scopes are requested. 192 + * The `?aud=*` suffix allows the call to be proxied to any AppView service. 193 + */ 194 + export const OAUTH_SCOPE = [ 195 + "atproto", 196 + "rpc:app.bsky.graph.getFollows?aud=*", 197 + "rpc:app.bsky.feed.searchPosts?aud=*", 198 + ].join(" "); 199 + 181 200 export function buildClientMetadata(appUrl: string) { 182 201 const clientId = `${appUrl}${CLIENT_METADATA_PATH}`; 183 202 return { ··· 187 206 redirect_uris: [`${appUrl}/oauth/callback`], 188 207 grant_types: ["authorization_code", "refresh_token"], 189 208 response_types: ["code"], 190 - scope: "atproto transition:generic", 209 + scope: OAUTH_SCOPE, 191 210 application_type: "web" as const, 192 211 dpop_bound_access_tokens: true, 193 212 token_endpoint_auth_method: "none" as const, ··· 295 314 redirect_uris: [`${appUrl}/oauth/callback`], 296 315 grant_types: ["authorization_code", "refresh_token"], 297 316 response_types: ["code"], 298 - scope: "atproto transition:generic", 317 + scope: "atproto rpc:app.bsky.graph.getFollows?aud=* rpc:app.bsky.feed.searchPosts?aud=*", 299 318 application_type: "web", 300 319 dpop_bound_access_tokens: true, 301 320 token_endpoint_auth_method: "none", ··· 381 400 - Updated error message to reference only `APP_URL` 382 401 - `client_name` is now `"Understory"` (via the builder, not `"Understory (Development)"`) 383 402 384 - - [ ] **Step 3: Update local `.env`** 403 + - [ ] **Step 3: Update the login route to use the shared scope constant** 404 + 405 + Read `src/app/oauth/login/route.ts`. Line 18 has `scope: "atproto transition:generic"` hardcoded in the `client.authorize()` call. Replace it with the `OAUTH_SCOPE` constant from `metadata.ts`: 406 + 407 + ```ts 408 + import { NextRequest, NextResponse } from "next/server"; 409 + import { getOAuthClient } from "@/lib/auth/client"; 410 + import { OAUTH_SCOPE } from "@/lib/auth/metadata"; 411 + 412 + export async function POST(request: NextRequest) { 413 + try { 414 + const body = await request.json(); 415 + const handle = body.handle?.trim(); 416 + 417 + if (!handle) { 418 + return NextResponse.json( 419 + { error: "Handle is required" }, 420 + { status: 400 }, 421 + ); 422 + } 423 + 424 + const client = getOAuthClient(); 425 + const url = await client.authorize(handle, { 426 + scope: OAUTH_SCOPE, 427 + }); 428 + 429 + return NextResponse.json({ redirect: url.toString() }); 430 + } catch (error) { 431 + console.error("OAuth login error:", error); 432 + return NextResponse.json( 433 + { 434 + error: 435 + error instanceof Error 436 + ? error.message 437 + : "Failed to start authentication", 438 + }, 439 + { status: 400 }, 440 + ); 441 + } 442 + } 443 + ``` 444 + 445 + This ensures the scope requested at authorization time matches the scope declared in the client metadata (both read from the same `OAUTH_SCOPE` constant). 446 + 447 + - [ ] **Step 4: Update local `.env`** 385 448 386 449 Edit `.env` to remove `OAUTH_CLIENT_ID`: 387 450 ··· 392 455 393 456 > **Note:** `.env` is gitignored — this change is local only. 394 457 395 - - [ ] **Step 4: Verify the full OAuth flow locally** 458 + - [ ] **Step 5: Verify the full OAuth flow locally** 396 459 397 460 Start the dev server: `npm run dev` 398 461 ··· 410 473 411 474 Stop the dev server after verification. 412 475 413 - - [ ] **Step 5: Verify tsc, eslint, and tests** 476 + - [ ] **Step 6: Verify tsc, eslint, and tests** 414 477 415 478 Run in parallel: 416 479 - `npx tsc --noEmit` — Expected: clean 417 480 - `npx eslint src/` — Expected: clean 418 481 - `npm test` — Expected: 40/40 pass 419 482 420 - - [ ] **Step 6: Run a full production build and verify** 483 + - [ ] **Step 7: Run a full production build and verify** 421 484 422 485 Run: `npm run build` 423 486 ··· 432 495 ... 433 496 ``` 434 497 435 - - [ ] **Step 7: Commit all OAuth changes together** 498 + - [ ] **Step 8: Commit all OAuth changes together** 436 499 437 500 ```bash 438 501 git add src/lib/auth/metadata.ts \ 439 502 src/app/oauth/client-metadata.json/route.ts \ 440 - src/lib/auth/client.ts 441 - git commit -m "feat: self-hosted OAuth client metadata, drop OAUTH_CLIENT_ID 503 + src/lib/auth/client.ts \ 504 + src/app/oauth/login/route.ts 505 + git commit -m "feat: self-hosted OAuth metadata + granular scopes, drop OAUTH_CLIENT_ID 442 506 443 - - metadata.ts: shared buildClientMetadata(appUrl) function, single source 444 - of truth imported by both the metadata route and NodeOAuthClient. 445 - - /oauth/client-metadata.json route: serves metadata JSON to PDS servers 446 - during the OAuth authorization flow. 447 - - client.ts: derives client_id from APP_URL instead of reading a separate 448 - OAUTH_CLIENT_ID env var. client_name updated to 'Understory'. 507 + - metadata.ts: shared buildClientMetadata(appUrl) + OAUTH_SCOPE constant. 508 + Single source of truth for both the metadata route and NodeOAuthClient. 509 + - OAUTH_SCOPE: replace transition:generic with granular read-only scopes 510 + (atproto + rpc:getFollows + rpc:searchPosts). Understory never writes. 511 + - /oauth/client-metadata.json route: serves metadata to PDS servers. 512 + - client.ts: derives client_id from APP_URL (no OAUTH_CLIENT_ID env var). 513 + - login/route.ts: uses OAUTH_SCOPE constant instead of hardcoded scope. 449 514 - Eliminates the cimd-service.fly.dev dependency for auth." 450 515 ``` 451 516
+14 -1
docs/superpowers/specs/2026-04-10-railway-deploy.md
··· 115 115 ```ts 116 116 export const CLIENT_METADATA_PATH = "/oauth/client-metadata.json"; 117 117 118 + /** 119 + * Granular OAuth scopes — replaces the overpermissioned `transition:generic`. 120 + * Understory only reads follows and searches posts; it never writes records. 121 + */ 122 + export const OAUTH_SCOPE = [ 123 + "atproto", 124 + "rpc:app.bsky.graph.getFollows?aud=*", 125 + "rpc:app.bsky.feed.searchPosts?aud=*", 126 + ].join(" "); 127 + 118 128 export function buildClientMetadata(appUrl: string) { 119 129 const clientId = `${appUrl}${CLIENT_METADATA_PATH}`; 120 130 return { ··· 124 134 redirect_uris: [`${appUrl}/oauth/callback`], 125 135 grant_types: ["authorization_code", "refresh_token"], 126 136 response_types: ["code"], 127 - scope: "atproto transition:generic", 137 + scope: OAUTH_SCOPE, 128 138 application_type: "web", 129 139 dpop_bound_access_tokens: true, 130 140 token_endpoint_auth_method: "none", ··· 133 143 ``` 134 144 135 145 `buildClientMetadata` is a pure function: given `APP_URL`, it returns the exact metadata object. Both the route handler and `NodeOAuthClient` call it with the same `APP_URL`, making drift structurally impossible. 146 + 147 + `OAUTH_SCOPE` is also exported and used by `src/app/oauth/login/route.ts` in the `client.authorize()` call, so the scope requested at authorization time always matches the scope declared in the client metadata. 136 148 137 149 ### 3.3 `src/app/oauth/client-metadata.json/route.ts` — self-hosted metadata endpoint 138 150 ··· 376 388 | `src/lib/auth/metadata.ts` | Create | Shared `buildClientMetadata(appUrl)` function, single source of truth for OAuth metadata | 377 389 | `src/app/oauth/client-metadata.json/route.ts` | Create | Self-hosted AT Protocol OAuth client metadata endpoint | 378 390 | `src/lib/auth/client.ts` | Modify | Import `buildClientMetadata`, remove `OAUTH_CLIENT_ID` dependency | 391 + | `src/app/oauth/login/route.ts` | Modify | Update `client.authorize()` scope from `transition:generic` to granular scopes | 379 392 380 393 5 files touched in the codebase. The rest is Railway CLI/MCP configuration (not committed to git). 381 394