A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
46
fork

Configure Feed

Select the types of activity you want to include in your feed.

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"