chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# 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<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**
```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<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**
```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) => `<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**
```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 `<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:
```javascript
/**
* 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**
```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 `<style>` block:
```css
.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:
```javascript
// 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**
```bash
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**
```javascript
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**
```bash
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**
1. Start dev server: `npm run dev`
2. Test consent page with various scopes:
- `atproto` only → should show "uniquely identify you"
- `atproto repo:app.bsky.feed.post?action=create` → should show table
- `atproto transition:generic` → should show warning banner
- `atproto blob:image/*` → should show upload permissions
**Step 4: Final commit if any fixes needed**
```bash
git add -A
git commit -m "chore: final cleanup for consent permissions table"
```