this repo has no description
1# Consent Page Permissions Table Implementation Plan 2 3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 5**Goal:** Display OAuth scopes as a human-readable permissions table on the consent page, matching official atproto PDS behavior. 6 7**Architecture:** Update `parseRepoScope()` to handle official query parameter format, add display helpers to parse scopes into a permissions map, render as HTML table with Create/Update/Delete columns. Three display modes: identity-only (no table), granular scopes (table), full access (warning banner). 8 9**Tech Stack:** Vanilla JavaScript, HTML/CSS (inline in template string) 10 11--- 12 13### Task 1: Update parseRepoScope to Handle Query Parameters 14 15**Files:** 16- Modify: `src/pds.js:4558-4580` (parseRepoScope function) 17- Test: `test/pds.test.js` (parseRepoScope tests) 18 19**Step 1: Write failing tests for new format** 20 21Add to existing parseRepoScope test block in `test/pds.test.js`: 22 23```javascript 24test('parses repo scope with query parameter action', () => { 25 const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); 26 assert.deepStrictEqual(result, { 27 collection: 'app.bsky.feed.post', 28 actions: ['create'], 29 }); 30}); 31 32test('parses repo scope with multiple query parameter actions', () => { 33 const result = parseRepoScope('repo:app.bsky.feed.post?action=create&action=update'); 34 assert.deepStrictEqual(result, { 35 collection: 'app.bsky.feed.post', 36 actions: ['create', 'update'], 37 }); 38}); 39 40test('parses repo scope without actions as all actions', () => { 41 const result = parseRepoScope('repo:app.bsky.feed.post'); 42 assert.deepStrictEqual(result, { 43 collection: 'app.bsky.feed.post', 44 actions: ['create', 'update', 'delete'], 45 }); 46}); 47 48test('parses wildcard collection with action', () => { 49 const result = parseRepoScope('repo:*?action=create'); 50 assert.deepStrictEqual(result, { 51 collection: '*', 52 actions: ['create'], 53 }); 54}); 55 56test('parses query-only format', () => { 57 const result = parseRepoScope('repo?collection=app.bsky.feed.post&action=create'); 58 assert.deepStrictEqual(result, { 59 collection: 'app.bsky.feed.post', 60 actions: ['create'], 61 }); 62}); 63``` 64 65**Step 2: Run tests to verify they fail** 66 67Run: `npm test 2>&1 | grep -A2 'parses repo scope with query'` 68Expected: FAIL - current parser doesn't handle query params 69 70**Step 3: Rewrite parseRepoScope implementation** 71 72Replace the existing `parseRepoScope` function in `src/pds.js`: 73 74```javascript 75/** 76 * Parse a repo scope string into collection and actions. 77 * Official format: repo:collection?action=create&action=update 78 * Or: repo?collection=foo&action=create 79 * Without actions defaults to all: create, update, delete 80 * @param {string} scope - The scope string to parse 81 * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid 82 */ 83export function parseRepoScope(scope) { 84 if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; 85 86 const ALL_ACTIONS = ['create', 'update', 'delete']; 87 let collection; 88 let actions; 89 90 const questionIdx = scope.indexOf('?'); 91 if (questionIdx === -1) { 92 // repo:collection (no query params = all actions) 93 collection = scope.slice(5); 94 actions = ALL_ACTIONS; 95 } else { 96 // Parse query parameters 97 const queryString = scope.slice(questionIdx + 1); 98 const params = new URLSearchParams(queryString); 99 const pathPart = scope.startsWith('repo:') ? scope.slice(5, questionIdx) : ''; 100 101 collection = pathPart || params.get('collection'); 102 actions = params.getAll('action'); 103 if (actions.length === 0) actions = ALL_ACTIONS; 104 } 105 106 if (!collection) return null; 107 108 // Validate actions 109 const validActions = actions.filter((a) => ALL_ACTIONS.includes(a)); 110 if (validActions.length === 0) return null; 111 112 return { collection, actions: validActions }; 113} 114``` 115 116**Step 4: Run tests to verify they pass** 117 118Run: `npm test` 119Expected: All parseRepoScope tests pass 120 121**Step 5: Remove old format tests that no longer apply** 122 123Remove tests for colon-delimited action format (e.g., `repo:collection:create,update`) from test file. 124 125**Step 6: Run tests to verify still passing** 126 127Run: `npm test` 128Expected: PASS 129 130**Step 7: Commit** 131 132```bash 133git add src/pds.js test/pds.test.js 134git commit -m "refactor(scope): update parseRepoScope to official query param format" 135``` 136 137--- 138 139### Task 2: Update ScopePermissions to Use New Parser 140 141**Files:** 142- Modify: `src/pds.js:4700-4710` (assertRepo method) 143- Test: `test/pds.test.js` (ScopePermissions tests) 144 145**Step 1: Update ScopePermissions.allowsRepo to handle new format** 146 147The `allowsRepo` method should still work since it iterates `repoPermissions` which now have new structure. Verify with test. 148 149**Step 2: Write test for new format compatibility** 150 151```javascript 152test('allowsRepo with query param format scopes', () => { 153 const perms = new ScopePermissions('atproto repo:app.bsky.feed.post?action=create'); 154 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); 155 assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); 156}); 157``` 158 159**Step 3: Run test** 160 161Run: `npm test` 162Expected: PASS (existing logic should work) 163 164**Step 4: Update assertRepo error message format** 165 166In `assertRepo` method, update the error message to use official format: 167 168```javascript 169assertRepo(collection, action) { 170 if (!this.allowsRepo(collection, action)) { 171 throw new ScopeMissingError(`repo:${collection}?action=${action}`); 172 } 173} 174``` 175 176**Step 5: Run tests** 177 178Run: `npm test` 179Expected: PASS 180 181**Step 6: Commit** 182 183```bash 184git add src/pds.js test/pds.test.js 185git commit -m "refactor(scope): update ScopePermissions for query param format" 186``` 187 188--- 189 190### Task 3: Add parseScopesForDisplay Helper 191 192**Files:** 193- Modify: `src/pds.js` (add new function near renderConsentPage) 194- Test: `test/pds.test.js` 195 196**Step 1: Write failing test** 197 198```javascript 199describe('parseScopesForDisplay', () => { 200 test('parses identity-only scope', () => { 201 const result = parseScopesForDisplay('atproto'); 202 assert.strictEqual(result.hasAtproto, true); 203 assert.strictEqual(result.hasTransitionGeneric, false); 204 assert.strictEqual(result.repoPermissions.size, 0); 205 assert.deepStrictEqual(result.blobPermissions, []); 206 }); 207 208 test('parses granular repo scopes', () => { 209 const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create&action=update'); 210 assert.strictEqual(result.repoPermissions.size, 1); 211 const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 212 assert.deepStrictEqual(postPerms, { create: true, update: true, delete: false }); 213 }); 214 215 test('merges multiple scopes for same collection', () => { 216 const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete'); 217 const postPerms = result.repoPermissions.get('app.bsky.feed.post'); 218 assert.deepStrictEqual(postPerms, { create: true, update: false, delete: true }); 219 }); 220 221 test('parses blob scopes', () => { 222 const result = parseScopesForDisplay('atproto blob:image/*'); 223 assert.deepStrictEqual(result.blobPermissions, ['image/*']); 224 }); 225 226 test('detects transition:generic', () => { 227 const result = parseScopesForDisplay('atproto transition:generic'); 228 assert.strictEqual(result.hasTransitionGeneric, true); 229 }); 230}); 231``` 232 233**Step 2: Run tests to verify they fail** 234 235Run: `npm test 2>&1 | grep -A2 'parseScopesForDisplay'` 236Expected: FAIL - function doesn't exist 237 238**Step 3: Add export to pds.js and implement** 239 240```javascript 241/** 242 * Parse scope string into display-friendly structure. 243 * @param {string} scope - Space-separated scope string 244 * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, blobPermissions: string[] }} 245 */ 246export function parseScopesForDisplay(scope) { 247 const scopes = scope.split(' ').filter((s) => s); 248 249 const repoPermissions = new Map(); 250 251 for (const s of scopes) { 252 const repo = parseRepoScope(s); 253 if (repo) { 254 const existing = repoPermissions.get(repo.collection) || { 255 create: false, 256 update: false, 257 delete: false, 258 }; 259 for (const action of repo.actions) { 260 existing[action] = true; 261 } 262 repoPermissions.set(repo.collection, existing); 263 } 264 } 265 266 const blobPermissions = []; 267 for (const s of scopes) { 268 const blob = parseBlobScope(s); 269 if (blob) blobPermissions.push(...blob.accept); 270 } 271 272 return { 273 hasAtproto: scopes.includes('atproto'), 274 hasTransitionGeneric: scopes.includes('transition:generic'), 275 repoPermissions, 276 blobPermissions, 277 }; 278} 279``` 280 281**Step 4: Run tests** 282 283Run: `npm test` 284Expected: PASS 285 286**Step 5: Commit** 287 288```bash 289git add src/pds.js test/pds.test.js 290git commit -m "feat(consent): add parseScopesForDisplay helper" 291``` 292 293--- 294 295### Task 4: Add Permission Rendering Helpers 296 297**Files:** 298- Modify: `src/pds.js` (add functions near renderConsentPage) 299 300**Step 1: Add renderRepoTable helper** 301 302```javascript 303/** 304 * Render repo permissions as HTML table. 305 * @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} repoPermissions 306 * @returns {string} HTML string 307 */ 308function renderRepoTable(repoPermissions) { 309 if (repoPermissions.size === 0) return ''; 310 311 let rows = ''; 312 for (const [collection, actions] of repoPermissions) { 313 const displayCollection = collection === '*' ? '* (any)' : collection; 314 rows += `<tr> 315 <td>${escapeHtml(displayCollection)}</td> 316 <td class="check">${actions.create ? '✓' : ''}</td> 317 <td class="check">${actions.update ? '✓' : ''}</td> 318 <td class="check">${actions.delete ? '✓' : ''}</td> 319 </tr>`; 320 } 321 322 return `<div class="permissions-section"> 323 <div class="section-label">Repository permissions:</div> 324 <table class="permissions-table"> 325 <thead><tr><th>Collection</th><th>C</th><th>U</th><th>D</th></tr></thead> 326 <tbody>${rows}</tbody> 327 </table> 328 </div>`; 329} 330``` 331 332**Step 2: Add renderBlobList helper** 333 334```javascript 335/** 336 * Render blob permissions as HTML list. 337 * @param {string[]} blobPermissions 338 * @returns {string} HTML string 339 */ 340function renderBlobList(blobPermissions) { 341 if (blobPermissions.length === 0) return ''; 342 343 const items = blobPermissions 344 .map((mime) => `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`) 345 .join(''); 346 347 return `<div class="permissions-section"> 348 <div class="section-label">Upload permissions:</div> 349 <ul class="blob-list">${items}</ul> 350 </div>`; 351} 352``` 353 354**Step 3: Add renderPermissionsHtml helper** 355 356```javascript 357/** 358 * Render full permissions display based on parsed scopes. 359 * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} parsed 360 * @returns {string} HTML string 361 */ 362function renderPermissionsHtml(parsed) { 363 if (parsed.hasTransitionGeneric) { 364 return `<div class="warning">⚠️ Full repository access requested<br> 365 <small>This app can create, update, and delete any data in your repository.</small></div>`; 366 } 367 368 if (parsed.repoPermissions.size === 0 && parsed.blobPermissions.length === 0) { 369 return ''; 370 } 371 372 return renderRepoTable(parsed.repoPermissions) + renderBlobList(parsed.blobPermissions); 373} 374``` 375 376**Step 4: Add escapeHtml helper (if not exists)** 377 378Check if `escHtml` exists in renderConsentPage - rename to `escapeHtml` and move outside function for reuse, or create new one: 379 380```javascript 381/** 382 * Escape HTML special characters. 383 * @param {string} s 384 * @returns {string} 385 */ 386function escapeHtml(s) { 387 return s 388 .replace(/&/g, '&amp;') 389 .replace(/</g, '&lt;') 390 .replace(/>/g, '&gt;') 391 .replace(/"/g, '&quot;'); 392} 393``` 394 395**Step 5: Run lint/format** 396 397Run: `npm run format && npm run lint` 398Expected: PASS 399 400**Step 6: Commit** 401 402```bash 403git add src/pds.js 404git commit -m "feat(consent): add permission rendering helpers" 405``` 406 407--- 408 409### Task 5: Update renderConsentPage 410 411**Files:** 412- Modify: `src/pds.js:583-628` (renderConsentPage function) 413 414**Step 1: Add new CSS to renderConsentPage** 415 416Add to the `<style>` block: 417 418```css 419.permissions-section{margin:16px 0} 420.section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px} 421.permissions-table{width:100%;border-collapse:collapse;font-size:13px} 422.permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333} 423.permissions-table th:not(:first-child){text-align:center;width:32px} 424.permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a} 425.permissions-table td:not(:first-child){text-align:center} 426.permissions-table .check{color:#4ade80} 427.blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px} 428.blob-list li{margin:4px 0} 429.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0} 430.warning small{color:#d4a000;display:block;margin-top:4px} 431``` 432 433**Step 2: Update body content** 434 435Replace the scope display line: 436```javascript 437// Old: 438<p>Scope: ${escHtml(scope)}</p> 439 440// New: 441const parsed = parseScopesForDisplay(scope); 442const isIdentityOnly = parsed.repoPermissions.size === 0 && 443 parsed.blobPermissions.length === 0 && 444 !parsed.hasTransitionGeneric; 445 446// In template: 447<p><b>${escHtml(clientName)}</b> ${isIdentityOnly ? 448 'wants to uniquely identify you through your account.' : 449 'wants to access your account.'}</p> 450${renderPermissionsHtml(parsed)} 451``` 452 453**Step 3: Run the app and test manually** 454 455Run: `npm run dev` 456Test: Navigate to OAuth flow with different scope combinations 457 458**Step 4: Run all tests** 459 460Run: `npm test` 461Expected: PASS 462 463**Step 5: Run format/lint/check** 464 465Run: `npm run format && npm run lint && npm run check` 466Expected: PASS 467 468**Step 6: Commit** 469 470```bash 471git add src/pds.js 472git commit -m "feat(consent): display scopes as permissions table" 473``` 474 475--- 476 477### Task 6: Add E2E Test for Consent Page Display 478 479**Files:** 480- Modify: `test/e2e.test.js` 481 482**Step 1: Add test for consent page content** 483 484```javascript 485it('consent page shows permissions table for granular scopes', async () => { 486 // Create PAR request with granular scopes 487 const codeVerifier = 'test-verifier-' + randomBytes(16).toString('hex'); 488 const codeChallenge = createHash('sha256') 489 .update(codeVerifier) 490 .digest('base64url'); 491 492 const parRes = await fetch(`${BASE}/oauth/par`, { 493 method: 'POST', 494 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 495 body: new URLSearchParams({ 496 client_id: `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:3000/callback')}`, 497 redirect_uri: 'http://127.0.0.1:3000/callback', 498 response_type: 'code', 499 scope: 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*', 500 code_challenge: codeChallenge, 501 code_challenge_method: 'S256', 502 state: 'test-state', 503 }), 504 }); 505 506 const { request_uri } = await parRes.json(); 507 508 // GET the authorize page 509 const authorizeRes = await fetch( 510 `${BASE}/oauth/authorize?client_id=${encodeURIComponent(`http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:3000/callback')}`)}&request_uri=${encodeURIComponent(request_uri)}`, 511 ); 512 513 const html = await authorizeRes.text(); 514 515 // Verify permissions table is rendered 516 assert.ok(html.includes('Repository permissions:'), 'Should show repo permissions section'); 517 assert.ok(html.includes('app.bsky.feed.post'), 'Should show collection name'); 518 assert.ok(html.includes('Upload permissions:'), 'Should show upload permissions section'); 519 assert.ok(html.includes('image/*'), 'Should show blob MIME type'); 520}); 521``` 522 523**Step 2: Run E2E tests** 524 525Run: `npm run test:e2e` 526Expected: PASS 527 528**Step 3: Commit** 529 530```bash 531git add test/e2e.test.js 532git commit -m "test(consent): add E2E test for permissions table display" 533``` 534 535--- 536 537### Task 7: Final Verification and Cleanup 538 539**Step 1: Run full test suite** 540 541Run: `npm test && npm run test:e2e` 542Expected: All tests pass 543 544**Step 2: Run all quality checks** 545 546Run: `npm run format && npm run lint && npm run check && npm run typecheck` 547Expected: All pass 548 549**Step 3: Manual verification** 550 5511. Start dev server: `npm run dev` 5522. Test consent page with various scopes: 553 - `atproto` only → should show "uniquely identify you" 554 - `atproto repo:app.bsky.feed.post?action=create` → should show table 555 - `atproto transition:generic` → should show warning banner 556 - `atproto blob:image/*` → should show upload permissions 557 558**Step 4: Final commit if any fixes needed** 559 560```bash 561git add -A 562git commit -m "chore: final cleanup for consent permissions table" 563```