this repo has no description

OAuth Scope Validation Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement granular OAuth scope validation matching the official atproto PDS behavior for repo, blob, and transition scopes.

Architecture: Add a ScopePermissions class that parses scope strings and provides allowsRepo(collection, action) and allowsBlob(mime) methods. Replace hasRequiredScope() calls with permission checks at each write endpoint. Support atproto and transition:generic as full-access scopes.

Tech Stack: Pure JavaScript, no dependencies. Node.js test runner for TDD.


Task 1: Parse Repo Scopes#

Files:

  • Modify: src/pds.js (add after hasRequiredScope function ~line 4565)
  • Test: test/pds.test.js (add new describe block)

Step 1: Write the failing tests

Add to test/pds.test.js:

import {
  // ... existing imports ...
  parseRepoScope,
} from '../src/pds.js';

describe('Scope Parsing', () => {
  describe('parseRepoScope', () => {
    test('parses wildcard collection with single action', () => {
      const result = parseRepoScope('repo:*:create');
      assert.deepStrictEqual(result, {
        collections: ['*'],
        actions: ['create'],
      });
    });

    test('parses specific collection with single action', () => {
      const result = parseRepoScope('repo:app.bsky.feed.post:create');
      assert.deepStrictEqual(result, {
        collections: ['app.bsky.feed.post'],
        actions: ['create'],
      });
    });

    test('parses multiple actions', () => {
      const result = parseRepoScope('repo:*:create,update,delete');
      assert.deepStrictEqual(result, {
        collections: ['*'],
        actions: ['create', 'update', 'delete'],
      });
    });

    test('returns null for non-repo scope', () => {
      assert.strictEqual(parseRepoScope('atproto'), null);
      assert.strictEqual(parseRepoScope('blob:image/*'), null);
      assert.strictEqual(parseRepoScope('transition:generic'), null);
    });

    test('returns null for invalid repo scope', () => {
      assert.strictEqual(parseRepoScope('repo:'), null);
      assert.strictEqual(parseRepoScope('repo:foo'), null);
      assert.strictEqual(parseRepoScope('repo::create'), null);
    });
  });
});

Step 2: Run tests to verify they fail

Run: npm test Expected: FAIL with "parseRepoScope is not exported"

Step 3: Write minimal implementation

Add to src/pds.js after the hasRequiredScope function (~line 4565):

/**
 * Parse a repo scope string into its components.
 * Format: repo:<collection>:<action>[,<action>...]
 * @param {string} scope - The scope string to parse
 * @returns {{ collections: string[], actions: string[] } | null} Parsed scope or null if invalid
 */
function parseRepoScope(scope) {
  if (!scope.startsWith('repo:')) return null;

  const rest = scope.slice(5); // Remove 'repo:'
  const colonIdx = rest.lastIndexOf(':');
  if (colonIdx === -1 || colonIdx === 0 || colonIdx === rest.length - 1) {
    return null;
  }

  const collection = rest.slice(0, colonIdx);
  const actionsStr = rest.slice(colonIdx + 1);

  if (!collection || !actionsStr) return null;

  const actions = actionsStr.split(',').filter(a => a);
  if (actions.length === 0) return null;

  return {
    collections: [collection],
    actions,
  };
}

Add parseRepoScope to the exports at the end of the file.

Step 4: Run tests to verify they pass

Run: npm test Expected: PASS

Step 5: Commit

git add src/pds.js test/pds.test.js
git commit -m "feat(scope): add parseRepoScope function"

Task 2: Parse Blob Scopes with MIME Matching#

Files:

  • Modify: src/pds.js
  • Test: test/pds.test.js

Step 1: Write the failing tests

Add to test file:

import {
  // ... existing imports ...
  parseBlobScope,
  matchesMime,
} from '../src/pds.js';

describe('parseBlobScope', () => {
  test('parses wildcard MIME', () => {
    const result = parseBlobScope('blob:*/*');
    assert.deepStrictEqual(result, { accept: ['*/*'] });
  });

  test('parses type wildcard', () => {
    const result = parseBlobScope('blob:image/*');
    assert.deepStrictEqual(result, { accept: ['image/*'] });
  });

  test('parses specific MIME', () => {
    const result = parseBlobScope('blob:image/png');
    assert.deepStrictEqual(result, { accept: ['image/png'] });
  });

  test('parses multiple MIMEs', () => {
    const result = parseBlobScope('blob:image/png,image/jpeg');
    assert.deepStrictEqual(result, { accept: ['image/png', 'image/jpeg'] });
  });

  test('returns null for non-blob scope', () => {
    assert.strictEqual(parseBlobScope('atproto'), null);
    assert.strictEqual(parseBlobScope('repo:*:create'), null);
  });
});

describe('matchesMime', () => {
  test('wildcard matches everything', () => {
    assert.strictEqual(matchesMime('*/*', 'image/png'), true);
    assert.strictEqual(matchesMime('*/*', 'video/mp4'), true);
  });

  test('type wildcard matches same type', () => {
    assert.strictEqual(matchesMime('image/*', 'image/png'), true);
    assert.strictEqual(matchesMime('image/*', 'image/jpeg'), true);
    assert.strictEqual(matchesMime('image/*', 'video/mp4'), false);
  });

  test('exact match', () => {
    assert.strictEqual(matchesMime('image/png', 'image/png'), true);
    assert.strictEqual(matchesMime('image/png', 'image/jpeg'), false);
  });

  test('case insensitive', () => {
    assert.strictEqual(matchesMime('image/PNG', 'image/png'), true);
    assert.strictEqual(matchesMime('IMAGE/*', 'image/png'), true);
  });
});

Step 2: Run tests to verify they fail

Run: npm test Expected: FAIL

Step 3: Write minimal implementation

/**
 * Parse a blob scope string into its components.
 * Format: blob:<mime>[,<mime>...]
 * @param {string} scope - The scope string to parse
 * @returns {{ accept: string[] } | null} Parsed scope or null if invalid
 */
function parseBlobScope(scope) {
  if (!scope.startsWith('blob:')) return null;

  const mimeStr = scope.slice(5); // Remove 'blob:'
  if (!mimeStr) return null;

  const accept = mimeStr.split(',').filter(m => m);
  if (accept.length === 0) return null;

  return { accept };
}

/**
 * Check if a MIME pattern matches an actual MIME type.
 * @param {string} pattern - MIME pattern (e.g., 'image/*', '*/*', 'image/png')
 * @param {string} mime - Actual MIME type to check
 * @returns {boolean} Whether the pattern matches
 */
function matchesMime(pattern, mime) {
  const p = pattern.toLowerCase();
  const m = mime.toLowerCase();

  if (p === '*/*') return true;

  if (p.endsWith('/*')) {
    const pType = p.slice(0, -2);
    const mType = m.split('/')[0];
    return pType === mType;
  }

  return p === m;
}

Add exports.

Step 4: Run tests to verify they pass

Run: npm test Expected: PASS

Step 5: Commit

git add src/pds.js test/pds.test.js
git commit -m "feat(scope): add parseBlobScope and matchesMime functions"

Task 3: Create ScopePermissions Class#

Files:

  • Modify: src/pds.js
  • Test: test/pds.test.js

Step 1: Write the failing tests

import {
  // ... existing imports ...
  ScopePermissions,
} from '../src/pds.js';

describe('ScopePermissions', () => {
  describe('static scopes', () => {
    test('atproto grants full access', () => {
      const perms = new ScopePermissions('atproto');
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true);
      assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
      assert.strictEqual(perms.allowsBlob('image/png'), true);
      assert.strictEqual(perms.allowsBlob('video/mp4'), true);
    });

    test('transition:generic grants full repo/blob access', () => {
      const perms = new ScopePermissions('transition:generic');
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true);
      assert.strictEqual(perms.allowsRepo('any.collection', 'delete'), true);
      assert.strictEqual(perms.allowsBlob('image/png'), true);
    });
  });

  describe('repo scopes', () => {
    test('wildcard collection allows any collection', () => {
      const perms = new ScopePermissions('repo:*:create');
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true);
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), true);
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false);
    });

    test('specific collection restricts to that collection', () => {
      const perms = new ScopePermissions('repo:app.bsky.feed.post:create');
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true);
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'create'), false);
    });

    test('multiple actions', () => {
      const perms = new ScopePermissions('repo:*:create,update');
      assert.strictEqual(perms.allowsRepo('x', 'create'), true);
      assert.strictEqual(perms.allowsRepo('x', 'update'), true);
      assert.strictEqual(perms.allowsRepo('x', 'delete'), false);
    });

    test('multiple scopes combine', () => {
      const perms = new ScopePermissions('repo:app.bsky.feed.post:create repo:app.bsky.feed.like:delete');
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'create'), true);
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.like', 'delete'), true);
      assert.strictEqual(perms.allowsRepo('app.bsky.feed.post', 'delete'), false);
    });
  });

  describe('blob scopes', () => {
    test('wildcard allows any MIME', () => {
      const perms = new ScopePermissions('blob:*/*');
      assert.strictEqual(perms.allowsBlob('image/png'), true);
      assert.strictEqual(perms.allowsBlob('video/mp4'), true);
    });

    test('type wildcard restricts to type', () => {
      const perms = new ScopePermissions('blob:image/*');
      assert.strictEqual(perms.allowsBlob('image/png'), true);
      assert.strictEqual(perms.allowsBlob('image/jpeg'), true);
      assert.strictEqual(perms.allowsBlob('video/mp4'), false);
    });

    test('specific MIME restricts exactly', () => {
      const perms = new ScopePermissions('blob:image/png');
      assert.strictEqual(perms.allowsBlob('image/png'), true);
      assert.strictEqual(perms.allowsBlob('image/jpeg'), false);
    });
  });

  describe('empty/no scope', () => {
    test('no scope denies everything', () => {
      const perms = new ScopePermissions('');
      assert.strictEqual(perms.allowsRepo('x', 'create'), false);
      assert.strictEqual(perms.allowsBlob('image/png'), false);
    });

    test('undefined scope denies everything', () => {
      const perms = new ScopePermissions(undefined);
      assert.strictEqual(perms.allowsRepo('x', 'create'), false);
    });
  });

  describe('assertRepo', () => {
    test('throws ScopeMissingError when denied', () => {
      const perms = new ScopePermissions('repo:app.bsky.feed.post:create');
      assert.throws(
        () => perms.assertRepo('app.bsky.feed.like', 'create'),
        { message: /Missing required scope/ }
      );
    });

    test('does not throw when allowed', () => {
      const perms = new ScopePermissions('repo:app.bsky.feed.post:create');
      assert.doesNotThrow(() => perms.assertRepo('app.bsky.feed.post', 'create'));
    });
  });

  describe('assertBlob', () => {
    test('throws ScopeMissingError when denied', () => {
      const perms = new ScopePermissions('blob:image/*');
      assert.throws(
        () => perms.assertBlob('video/mp4'),
        { message: /Missing required scope/ }
      );
    });

    test('does not throw when allowed', () => {
      const perms = new ScopePermissions('blob:image/*');
      assert.doesNotThrow(() => perms.assertBlob('image/png'));
    });
  });
});

Step 2: Run tests to verify they fail

Run: npm test Expected: FAIL

Step 3: Write minimal implementation

/**
 * Error thrown when a required scope is missing.
 */
class ScopeMissingError extends Error {
  /**
   * @param {string} scope - The missing scope
   */
  constructor(scope) {
    super(`Missing required scope "${scope}"`);
    this.name = 'ScopeMissingError';
    this.scope = scope;
    this.status = 403;
  }
}

/**
 * Parses and checks OAuth scope permissions.
 */
class ScopePermissions {
  /**
   * @param {string | undefined} scopeString - Space-separated scope string
   */
  constructor(scopeString) {
    /** @type {Set<string>} */
    this.scopes = new Set(scopeString ? scopeString.split(' ').filter(s => s) : []);

    /** @type {Array<{ collections: string[], actions: string[] }>} */
    this.repoPermissions = [];

    /** @type {Array<{ accept: string[] }>} */
    this.blobPermissions = [];

    for (const scope of this.scopes) {
      const repo = parseRepoScope(scope);
      if (repo) this.repoPermissions.push(repo);

      const blob = parseBlobScope(scope);
      if (blob) this.blobPermissions.push(blob);
    }
  }

  /**
   * Check if full access is granted (atproto or transition:generic).
   * @returns {boolean}
   */
  hasFullAccess() {
    return this.scopes.has('atproto') || this.scopes.has('transition:generic');
  }

  /**
   * Check if a repo operation is allowed.
   * @param {string} collection - The collection NSID
   * @param {string} action - The action (create, update, delete)
   * @returns {boolean}
   */
  allowsRepo(collection, action) {
    if (this.hasFullAccess()) return true;

    for (const perm of this.repoPermissions) {
      const collectionMatch = perm.collections.includes('*') || perm.collections.includes(collection);
      const actionMatch = perm.actions.includes(action);
      if (collectionMatch && actionMatch) return true;
    }

    return false;
  }

  /**
   * Assert that a repo operation is allowed, throwing if not.
   * @param {string} collection - The collection NSID
   * @param {string} action - The action (create, update, delete)
   * @throws {ScopeMissingError}
   */
  assertRepo(collection, action) {
    if (!this.allowsRepo(collection, action)) {
      throw new ScopeMissingError(`repo:${collection}:${action}`);
    }
  }

  /**
   * Check if a blob operation is allowed.
   * @param {string} mime - The MIME type of the blob
   * @returns {boolean}
   */
  allowsBlob(mime) {
    if (this.hasFullAccess()) return true;

    for (const perm of this.blobPermissions) {
      for (const pattern of perm.accept) {
        if (matchesMime(pattern, mime)) return true;
      }
    }

    return false;
  }

  /**
   * Assert that a blob operation is allowed, throwing if not.
   * @param {string} mime - The MIME type of the blob
   * @throws {ScopeMissingError}
   */
  assertBlob(mime) {
    if (!this.allowsBlob(mime)) {
      throw new ScopeMissingError(`blob:${mime}`);
    }
  }
}

Add exports.

Step 4: Run tests to verify they pass

Run: npm test Expected: PASS

Step 5: Commit

git add src/pds.js test/pds.test.js
git commit -m "feat(scope): add ScopePermissions class with repo/blob checking"

Task 4: Integrate Scope Checking into createRecord#

Files:

  • Modify: src/pds.js (handleRepoWrite function and createRecord handler)
  • Test: test/e2e.test.js (add scope enforcement tests)

Step 1: Understand the current flow

The handleRepoWrite function at line ~4597 currently does:

if (!hasRequiredScope(auth.scope, 'atproto')) {
  return errorResponse('Forbidden', 'Insufficient scope for repo write', 403);
}

This needs to be replaced with per-endpoint scope checking. The collection is in body.collection.

Step 2: Modify handleRepoWrite to accept collection and action

Update handleRepoWrite in src/pds.js:

/**
 * @param {Request} request
 * @param {Env} env
 * @param {string} collection - The collection being written to
 * @param {string} action - The action being performed (create, update, delete)
 */
async function handleRepoWrite(request, env, collection, action) {
  const auth = await requireAuth(request, env);
  if ('error' in auth) return auth.error;

  // Validate scope for repo write using granular permissions
  if (auth.scope !== undefined) {
    const permissions = new ScopePermissions(auth.scope);
    if (!permissions.allowsRepo(collection, action)) {
      return errorResponse(
        'Forbidden',
        `Missing required scope "repo:${collection}:${action}"`,
        403,
      );
    }
  }
  // Legacy tokens without scope are trusted (backward compat)

  // ... rest of function
}

Step 3: Update createRecord to pass collection and action

Find the createRecord handler in the routes object and update it to extract collection before calling handleRepoWrite.

Since createRecord is POST, the collection comes from the body. We need to restructure slightly:

// In the route handler for com.atproto.repo.createRecord
async (request, env) => {
  const auth = await requireAuth(request, env);
  if ('error' in auth) return auth.error;

  const body = await request.json();
  const collection = body.collection;

  if (!collection) {
    return errorResponse('InvalidRequest', 'missing collection param', 400);
  }

  // Validate scope
  if (auth.scope !== undefined) {
    const permissions = new ScopePermissions(auth.scope);
    if (!permissions.allowsRepo(collection, 'create')) {
      return errorResponse(
        'Forbidden',
        `Missing required scope "repo:${collection}:create"`,
        403,
      );
    }
  }

  // Continue with existing logic...
}

Step 4: Write E2E test for scope enforcement

Add to test/e2e.test.js:

describe('Scope Enforcement', () => {
  test('createRecord denied with insufficient scope', async () => {
    // Create OAuth token with limited scope
    const limitedToken = await getOAuthToken('repo:app.bsky.feed.like:create');

    const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `DPoP ${limitedToken}`,
        'DPoP': dpopProof,
      },
      body: JSON.stringify({
        repo: TEST_DID,
        collection: 'app.bsky.feed.post', // Not allowed by scope
        record: { text: 'test', createdAt: new Date().toISOString() },
      }),
    });

    assert.strictEqual(response.status, 403);
    const body = await response.json();
    assert.ok(body.message.includes('Missing required scope'));
  });

  test('createRecord allowed with matching scope', async () => {
    const validToken = await getOAuthToken('repo:app.bsky.feed.post:create');

    const response = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.createRecord`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `DPoP ${validToken}`,
        'DPoP': dpopProof,
      },
      body: JSON.stringify({
        repo: TEST_DID,
        collection: 'app.bsky.feed.post',
        record: { text: 'test', createdAt: new Date().toISOString() },
      }),
    });

    assert.strictEqual(response.status, 200);
  });
});

Step 5: Run E2E tests

Run: npm run test:e2e Expected: PASS

Step 6: Commit

git add src/pds.js test/e2e.test.js
git commit -m "feat(scope): enforce granular scopes on createRecord"

Task 5: Integrate Scope Checking into putRecord#

Files:

  • Modify: src/pds.js

Step 1: Update putRecord handler

putRecord requires BOTH create AND update permissions (since it can do either):

// In putRecord handler
if (auth.scope !== undefined) {
  const permissions = new ScopePermissions(auth.scope);
  if (!permissions.allowsRepo(collection, 'create') || !permissions.allowsRepo(collection, 'update')) {
    const missing = !permissions.allowsRepo(collection, 'create') ? 'create' : 'update';
    return errorResponse(
      'Forbidden',
      `Missing required scope "repo:${collection}:${missing}"`,
      403,
    );
  }
}

Step 2: Run tests

Run: npm test && npm run test:e2e Expected: PASS

Step 3: Commit

git add src/pds.js
git commit -m "feat(scope): enforce granular scopes on putRecord"

Task 6: Integrate Scope Checking into deleteRecord#

Files:

  • Modify: src/pds.js

Step 1: Update deleteRecord handler

// In deleteRecord handler
if (auth.scope !== undefined) {
  const permissions = new ScopePermissions(auth.scope);
  if (!permissions.allowsRepo(collection, 'delete')) {
    return errorResponse(
      'Forbidden',
      `Missing required scope "repo:${collection}:delete"`,
      403,
    );
  }
}

Step 2: Run tests

Run: npm test && npm run test:e2e Expected: PASS

Step 3: Commit

git add src/pds.js
git commit -m "feat(scope): enforce granular scopes on deleteRecord"

Task 7: Integrate Scope Checking into applyWrites#

Files:

  • Modify: src/pds.js

Step 1: Update applyWrites handler

applyWrites must check each write operation individually:

// In applyWrites handler
if (auth.scope !== undefined) {
  const permissions = new ScopePermissions(auth.scope);

  for (const write of writes) {
    const collection = write.collection;
    let action;

    if (write.$type === 'com.atproto.repo.applyWrites#create') {
      action = 'create';
    } else if (write.$type === 'com.atproto.repo.applyWrites#update') {
      action = 'update';
    } else if (write.$type === 'com.atproto.repo.applyWrites#delete') {
      action = 'delete';
    } else {
      continue;
    }

    if (!permissions.allowsRepo(collection, action)) {
      return errorResponse(
        'Forbidden',
        `Missing required scope "repo:${collection}:${action}"`,
        403,
      );
    }
  }
}

Step 2: Run tests

Run: npm test && npm run test:e2e Expected: PASS

Step 3: Commit

git add src/pds.js
git commit -m "feat(scope): enforce granular scopes on applyWrites"

Task 8: Integrate Scope Checking into uploadBlob#

Files:

  • Modify: src/pds.js (handleBlobUpload function)

Step 1: Update handleBlobUpload

The MIME type comes from the Content-Type header:

async function handleBlobUpload(request, env) {
  const auth = await requireAuth(request, env);
  if ('error' in auth) return auth.error;

  const contentType = request.headers.get('content-type') || 'application/octet-stream';

  // Validate scope for blob upload
  if (auth.scope !== undefined) {
    const permissions = new ScopePermissions(auth.scope);
    if (!permissions.allowsBlob(contentType)) {
      return errorResponse(
        'Forbidden',
        `Missing required scope "blob:${contentType}"`,
        403,
      );
    }
  }

  // ... rest of function
}

Step 2: Run tests

Run: npm test && npm run test:e2e Expected: PASS

Step 3: Commit

git add src/pds.js
git commit -m "feat(scope): enforce granular scopes on uploadBlob with MIME matching"

Task 9: Remove Old hasRequiredScope Calls#

Files:

  • Modify: src/pds.js

Step 1: Search and remove old calls

Find all remaining uses of hasRequiredScope and either:

  • Remove them (if replaced by ScopePermissions)
  • Keep for legacy non-OAuth paths if needed

Step 2: Run all tests

Run: npm test && npm run test:e2e Expected: PASS

Step 3: Commit

git add src/pds.js
git commit -m "refactor(scope): remove deprecated hasRequiredScope function"

Task 10: Update scope-comparison.md#

Files:

  • Modify: docs/scope-comparison.md

Step 1: Update status in comparison doc

Change the pds.js column entries to reflect new implementation:

  • atproto: "Full access"
  • transition:generic: "Full access"
  • repo:<collection>:<action>: "Full parsing + enforcement"
  • blob:<mime>: "Full parsing + enforcement"

Step 2: Commit

git add docs/scope-comparison.md
git commit -m "docs: update scope comparison with implementation status"

Summary#

Task Description Est. Time
1 Parse repo scopes 5 min
2 Parse blob scopes + MIME matching 5 min
3 ScopePermissions class 10 min
4 Integrate into createRecord 10 min
5 Integrate into putRecord 5 min
6 Integrate into deleteRecord 5 min
7 Integrate into applyWrites 10 min
8 Integrate into uploadBlob 5 min
9 Remove old hasRequiredScope 5 min
10 Update docs 5 min

Total: ~65 minutes