diff --git a/docs/plans/2026-01-07-scope-validation.md b/docs/plans/2026-01-07-scope-validation.md new file mode 100644 index 0000000..44e5ea4 --- /dev/null +++ b/docs/plans/2026-01-07-scope-validation.md @@ -0,0 +1,902 @@ +# 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`: + +```javascript +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): + +```javascript +/** + * Parse a repo scope string into its components. + * Format: repo::[,...] + * @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** + +```bash +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: + +```javascript +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** + +```javascript +/** + * Parse a blob scope string into its components. + * Format: blob:[,...] + * @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** + +```bash +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** + +```javascript +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** + +```javascript +/** + * 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} */ + 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** + +```bash +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: +```javascript +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`: + +```javascript +/** + * @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: + +```javascript +// 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`: + +```javascript +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** + +```bash +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): + +```javascript +// 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** + +```bash +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** + +```javascript +// 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** + +```bash +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: + +```javascript +// 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** + +```bash +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: + +```javascript +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** + +```bash +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** + +```bash +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::`: "Full parsing + enforcement" +- `blob:`: "Full parsing + enforcement" + +**Step 2: Commit** + +```bash +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**