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, '&')
389 .replace(/</g, '<')
390 .replace(/>/g, '>')
391 .replace(/"/g, '"');
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```