A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

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"

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

  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

git add -A
git commit -m "chore: final cleanup for consent permissions table"