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:
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:
/**
* 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
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
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:
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
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
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
/**
* Parse scope string into display-friendly structure.
* @param {string} scope - Space-separated scope string
* @returns {{ hasAtproto: boolean, hasTransitionGeneric: boolean, repoPermissions: Map<string, {create: boolean, update: boolean, delete: boolean}>, 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
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
/**
* Render repo permissions as HTML table.
* @param {Map<string, {create: boolean, update: boolean, delete: boolean}>} 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 += `<tr>
<td>${escapeHtml(displayCollection)}</td>
<td class="check">${actions.create ? '✓' : ''}</td>
<td class="check">${actions.update ? '✓' : ''}</td>
<td class="check">${actions.delete ? '✓' : ''}</td>
</tr>`;
}
return `<div class="permissions-section">
<div class="section-label">Repository permissions:</div>
<table class="permissions-table">
<thead><tr><th>Collection</th><th>C</th><th>U</th><th>D</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
Step 2: Add renderBlobList helper
/**
* 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) => `<li>${escapeHtml(mime === '*/*' ? 'All file types' : mime)}</li>`)
.join('');
return `<div class="permissions-section">
<div class="section-label">Upload permissions:</div>
<ul class="blob-list">${items}</ul>
</div>`;
}
Step 3: Add renderPermissionsHtml helper
/**
* 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 `<div class="warning">⚠️ Full repository access requested<br>
<small>This app can create, update, and delete any data in your repository.</small></div>`;
}
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:
/**
* Escape HTML special characters.
* @param {string} s
* @returns {string}
*/
function escapeHtml(s) {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
Step 5: Run lint/format
Run: npm run format && npm run lint
Expected: PASS
Step 6: Commit
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 <style> block:
.permissions-section{margin:16px 0}
.section-label{color:#b0b0b0;font-size:13px;margin-bottom:8px}
.permissions-table{width:100%;border-collapse:collapse;font-size:13px}
.permissions-table th{color:#808080;font-weight:normal;text-align:left;padding:4px 8px;border-bottom:1px solid #333}
.permissions-table th:not(:first-child){text-align:center;width:32px}
.permissions-table td{padding:4px 8px;border-bottom:1px solid #2a2a2a}
.permissions-table td:not(:first-child){text-align:center}
.permissions-table .check{color:#4ade80}
.blob-list{margin:0;padding-left:20px;color:#e0e0e0;font-size:13px}
.blob-list li{margin:4px 0}
.warning{background:#3d2f00;border:1px solid #5c4a00;border-radius:6px;padding:12px;color:#fbbf24;margin:16px 0}
.warning small{color:#d4a000;display:block;margin-top:4px}
Step 2: Update body content
Replace the scope display line:
// Old:
<p>Scope: ${escHtml(scope)}</p>
// New:
const parsed = parseScopesForDisplay(scope);
const isIdentityOnly = parsed.repoPermissions.size === 0 &&
parsed.blobPermissions.length === 0 &&
!parsed.hasTransitionGeneric;
// In template:
<p><b>${escHtml(clientName)}</b> ${isIdentityOnly ?
'wants to uniquely identify you through your account.' :
'wants to access your account.'}</p>
${renderPermissionsHtml(parsed)}
Step 3: Run the app and test manually
Run: npm run dev
Test: Navigate to OAuth flow with different scope combinations
Step 4: Run all tests
Run: npm test
Expected: PASS
Step 5: Run format/lint/check
Run: npm run format && npm run lint && npm run check
Expected: PASS
Step 6: Commit
git add src/pds.js
git commit -m "feat(consent): display scopes as permissions table"
Task 6: Add E2E Test for Consent Page Display#
Files:
- Modify:
test/e2e.test.js
Step 1: Add test for consent page content
it('consent page shows permissions table for granular scopes', async () => {
// Create PAR request with granular scopes
const codeVerifier = 'test-verifier-' + randomBytes(16).toString('hex');
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
const parRes = await fetch(`${BASE}/oauth/par`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: `http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:3000/callback')}`,
redirect_uri: 'http://127.0.0.1:3000/callback',
response_type: 'code',
scope: 'atproto repo:app.bsky.feed.post?action=create&action=update blob:image/*',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: 'test-state',
}),
});
const { request_uri } = await parRes.json();
// GET the authorize page
const authorizeRes = await fetch(
`${BASE}/oauth/authorize?client_id=${encodeURIComponent(`http://localhost?redirect_uri=${encodeURIComponent('http://127.0.0.1:3000/callback')}`)}&request_uri=${encodeURIComponent(request_uri)}`,
);
const html = await authorizeRes.text();
// Verify permissions table is rendered
assert.ok(html.includes('Repository permissions:'), 'Should show repo permissions section');
assert.ok(html.includes('app.bsky.feed.post'), 'Should show collection name');
assert.ok(html.includes('Upload permissions:'), 'Should show upload permissions section');
assert.ok(html.includes('image/*'), 'Should show blob MIME type');
});
Step 2: Run E2E tests
Run: npm run test:e2e
Expected: PASS
Step 3: Commit
git add test/e2e.test.js
git commit -m "test(consent): add E2E test for permissions table display"
Task 7: Final Verification and Cleanup#
Step 1: Run full test suite
Run: npm test && npm run test:e2e
Expected: All tests pass
Step 2: Run all quality checks
Run: npm run format && npm run lint && npm run check && npm run typecheck
Expected: All pass
Step 3: Manual verification
- Start dev server:
npm run dev - Test consent page with various scopes:
atprotoonly → should show "uniquely identify you"atproto repo:app.bsky.feed.post?action=create→ should show tableatproto transition:generic→ should show warning banneratproto blob:image/*→ should show upload permissions
Step 4: Final commit if any fixes needed
git add -A
git commit -m "chore: final cleanup for consent permissions table"