# Consent Page Permissions Table Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Display OAuth scopes as a human-readable permissions table on the consent page, matching official atproto PDS behavior. **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). **Tech Stack:** Vanilla JavaScript, HTML/CSS (inline in template string) --- ### Task 1: Update parseRepoScope to Handle Query Parameters **Files:** - Modify: `src/pds.js:4558-4580` (parseRepoScope function) - Test: `test/pds.test.js` (parseRepoScope tests) **Step 1: Write failing tests for new format** Add to existing parseRepoScope test block in `test/pds.test.js`: ```javascript test('parses repo scope with query parameter action', () => { const result = parseRepoScope('repo:app.bsky.feed.post?action=create'); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create'], }); }); test('parses repo scope with multiple query parameter actions', () => { const result = parseRepoScope('repo:app.bsky.feed.post?action=create&action=update'); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create', 'update'], }); }); test('parses repo scope without actions as all actions', () => { const result = parseRepoScope('repo:app.bsky.feed.post'); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create', 'update', 'delete'], }); }); test('parses wildcard collection with action', () => { const result = parseRepoScope('repo:*?action=create'); assert.deepStrictEqual(result, { collection: '*', actions: ['create'], }); }); test('parses query-only format', () => { const result = parseRepoScope('repo?collection=app.bsky.feed.post&action=create'); assert.deepStrictEqual(result, { collection: 'app.bsky.feed.post', actions: ['create'], }); }); ``` **Step 2: Run tests to verify they fail** Run: `npm test 2>&1 | grep -A2 'parses repo scope with query'` Expected: FAIL - current parser doesn't handle query params **Step 3: Rewrite parseRepoScope implementation** Replace the existing `parseRepoScope` function in `src/pds.js`: ```javascript /** * Parse a repo scope string into collection and actions. * Official format: repo:collection?action=create&action=update * Or: repo?collection=foo&action=create * Without actions defaults to all: create, update, delete * @param {string} scope - The scope string to parse * @returns {{ collection: string, actions: string[] } | null} Parsed scope or null if invalid */ export function parseRepoScope(scope) { if (!scope.startsWith('repo:') && !scope.startsWith('repo?')) return null; const ALL_ACTIONS = ['create', 'update', 'delete']; let collection; let actions; const questionIdx = scope.indexOf('?'); if (questionIdx === -1) { // repo:collection (no query params = all actions) collection = scope.slice(5); actions = ALL_ACTIONS; } else { // Parse query parameters const queryString = scope.slice(questionIdx + 1); const params = new URLSearchParams(queryString); const pathPart = scope.startsWith('repo:') ? scope.slice(5, questionIdx) : ''; collection = pathPart || params.get('collection'); actions = params.getAll('action'); if (actions.length === 0) actions = ALL_ACTIONS; } if (!collection) return null; // Validate actions const validActions = actions.filter((a) => ALL_ACTIONS.includes(a)); if (validActions.length === 0) return null; return { collection, actions: validActions }; } ``` **Step 4: Run tests to verify they pass** Run: `npm test` Expected: All parseRepoScope tests pass **Step 5: Remove old format tests that no longer apply** Remove tests for colon-delimited action format (e.g., `repo:collection:create,update`) from test file. **Step 6: Run tests to verify still passing** Run: `npm test` Expected: PASS **Step 7: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "refactor(scope): update parseRepoScope to official query param format" ``` --- ### Task 2: Update ScopePermissions to Use New Parser **Files:** - Modify: `src/pds.js:4700-4710` (assertRepo method) - Test: `test/pds.test.js` (ScopePermissions tests) **Step 1: Update ScopePermissions.allowsRepo to handle new format** The `allowsRepo` method should still work since it iterates `repoPermissions` which now have new structure. Verify with test. **Step 2: Write test for new format compatibility** ```javascript test('allowsRepo with query param format scopes', () => { const perms = new ScopePermissions('atproto repo:app.bsky.feed.post?action=create'); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true); assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false); }); ``` **Step 3: Run test** Run: `npm test` Expected: PASS (existing logic should work) **Step 4: Update assertRepo error message format** In `assertRepo` method, update the error message to use official format: ```javascript assertRepo(collection, action) { if (!this.allowsRepo(collection, action)) { throw new ScopeMissingError(`repo:${collection}?action=${action}`); } } ``` **Step 5: Run tests** Run: `npm test` Expected: PASS **Step 6: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "refactor(scope): update ScopePermissions for query param format" ``` --- ### Task 3: Add parseScopesForDisplay Helper **Files:** - Modify: `src/pds.js` (add new function near renderConsentPage) - Test: `test/pds.test.js` **Step 1: Write failing test** ```javascript describe('parseScopesForDisplay', () => { test('parses identity-only scope', () => { const result = parseScopesForDisplay('atproto'); assert.strictEqual(result.hasAtproto, true); assert.strictEqual(result.hasTransitionGeneric, false); assert.strictEqual(result.repoPermissions.size, 0); assert.deepStrictEqual(result.blobPermissions, []); }); test('parses granular repo scopes', () => { const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create&action=update'); assert.strictEqual(result.repoPermissions.size, 1); const postPerms = result.repoPermissions.get('app.bsky.feed.post'); assert.deepStrictEqual(postPerms, { create: true, update: true, delete: false }); }); test('merges multiple scopes for same collection', () => { const result = parseScopesForDisplay('atproto repo:app.bsky.feed.post?action=create repo:app.bsky.feed.post?action=delete'); const postPerms = result.repoPermissions.get('app.bsky.feed.post'); assert.deepStrictEqual(postPerms, { create: true, update: false, delete: true }); }); test('parses blob scopes', () => { const result = parseScopesForDisplay('atproto blob:image/*'); assert.deepStrictEqual(result.blobPermissions, ['image/*']); }); test('detects transition:generic', () => { const result = parseScopesForDisplay('atproto transition:generic'); assert.strictEqual(result.hasTransitionGeneric, true); }); }); ``` **Step 2: Run tests to verify they fail** Run: `npm test 2>&1 | grep -A2 'parseScopesForDisplay'` Expected: FAIL - function doesn't exist **Step 3: Add export to pds.js and implement** ```javascript /** * Parse scope string into display-friendly structure. * @param {string} scope - Space-separated scope string * @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} */ export function parseScopesForDisplay(scope) { const scopes = scope.split(' ').filter((s) => s); const repoPermissions = new Map(); for (const s of scopes) { const repo = parseRepoScope(s); if (repo) { const existing = repoPermissions.get(repo.collection) || { create: false, update: false, delete: false, }; for (const action of repo.actions) { existing[action] = true; } repoPermissions.set(repo.collection, existing); } } const blobPermissions = []; for (const s of scopes) { const blob = parseBlobScope(s); if (blob) blobPermissions.push(...blob.accept); } return { hasAtproto: scopes.includes('atproto'), hasTransitionGeneric: scopes.includes('transition:generic'), repoPermissions, blobPermissions, }; } ``` **Step 4: Run tests** Run: `npm test` Expected: PASS **Step 5: Commit** ```bash git add src/pds.js test/pds.test.js git commit -m "feat(consent): add parseScopesForDisplay helper" ``` --- ### Task 4: Add Permission Rendering Helpers **Files:** - Modify: `src/pds.js` (add functions near renderConsentPage) **Step 1: Add renderRepoTable helper** ```javascript /** * Render repo permissions as HTML table. * @param {Map} repoPermissions * @returns {string} HTML string */ function renderRepoTable(repoPermissions) { if (repoPermissions.size === 0) return ''; let rows = ''; for (const [collection, actions] of repoPermissions) { const displayCollection = collection === '*' ? '* (any)' : collection; rows += ` ${escapeHtml(displayCollection)} ${actions.create ? '✓' : ''} ${actions.update ? '✓' : ''} ${actions.delete ? '✓' : ''} `; } return `
${rows}
CollectionCUD
`; } ``` **Step 2: Add renderBlobList helper** ```javascript /** * Render blob permissions as HTML list. * @param {string[]} blobPermissions * @returns {string} HTML string */ function renderBlobList(blobPermissions) { if (blobPermissions.length === 0) return ''; const items = blobPermissions .map((mime) => `
  • ${escapeHtml(mime === '*/*' ? 'All file types' : mime)}
  • `) .join(''); return `
      ${items}
    `; } ``` **Step 3: Add renderPermissionsHtml helper** ```javascript /** * Render full permissions display based on parsed scopes. * @param {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map, blobPermissions: string[] }} parsed * @returns {string} HTML string */ function renderPermissionsHtml(parsed) { if (parsed.hasTransitionGeneric) { return `
    ⚠️ Full repository access requested
    This app can create, update, and delete any data in your repository.
    `; } if (parsed.repoPermissions.size === 0 && parsed.blobPermissions.length === 0) { return ''; } return renderRepoTable(parsed.repoPermissions) + renderBlobList(parsed.blobPermissions); } ``` **Step 4: Add escapeHtml helper (if not exists)** Check if `escHtml` exists in renderConsentPage - rename to `escapeHtml` and move outside function for reuse, or create new one: ```javascript /** * Escape HTML special characters. * @param {string} s * @returns {string} */ function escapeHtml(s) { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } ``` **Step 5: Run lint/format** Run: `npm run format && npm run lint` Expected: PASS **Step 6: Commit** ```bash git add src/pds.js git commit -m "feat(consent): add permission rendering helpers" ``` --- ### Task 5: Update renderConsentPage **Files:** - Modify: `src/pds.js:583-628` (renderConsentPage function) **Step 1: Add new CSS to renderConsentPage** Add to the `