AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

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

Merge branch 'try/granular-oauth-scopes': adopt granular OAuth scopes

+79 -1
+19
DECISIONS.md
··· 92 92 from the hash params on load. Dev uses atproto's loopback client via a Vite plugin (no hosted 93 93 metadata needed locally). 94 94 95 + 17b. **Granular OAuth scopes — no `transition:generic`.** The client requests exactly the writes it 96 + performs, nothing more: 97 + 98 + ``` 99 + atproto 100 + repo:bzh.herve.atmot.result?action=create 101 + repo:bzh.herve.atmot.stats?action=create&action=update 102 + repo:app.bsky.feed.post?action=create 103 + ``` 104 + 105 + This is least-privilege (the app can never touch any other collection in your repo) and gives a 106 + clearer consent screen than the broad `transition:generic`. Verified end-to-end against a 107 + self-hosted reference PDS (v0.4.5006): PAR → consent (rendered as "Repository: Publish changes" + 108 + "Bluesky") → token granted with the exact scope → `createRecord`/`putRecord` all succeed. `repo:` 109 + alone authorizes the `com.atproto.repo.*` write calls; no separate `rpc:` scope is needed. Reads 110 + (leaderboard, own records) need no scope at all. Trade-off: a PDS too old to parse granular scopes 111 + would reject authorization outright — acceptable, since `transition:*` scopes are themselves slated 112 + for deprecation and the reference implementation supports granular today. 113 + 95 114 ## Tooling 96 115 97 116 18. **Dev tooling (Vite/Vitest) bumped to latest majors** to clear all `npm audit` advisories. The
+4
README.md
··· 104 104 OAuth in dev uses atproto's loopback client (no hosted metadata needed). In production the SPA serves 105 105 `/client-metadata.json`, and `client_id` equals that exact URL. 106 106 107 + AT Mot requests **granular OAuth scopes** (least privilege) rather than the broad `transition:generic` 108 + — write access is limited to exactly the three collections it creates: `bzh.herve.atmot.result`, 109 + `bzh.herve.atmot.stats`, and `app.bsky.feed.post`. See DECISIONS.md (#17b). 110 + 107 111 ## Deploy (Cloudflare Pages) 108 112 109 113 The git remote lives on [tangled.org](https://tangled.org), so Cloudflare's git-connected builds
+1 -1
public/client-metadata.json
··· 6 6 "tos_uri": "https://atmot.herve.bzh/about", 7 7 "policy_uri": "https://atmot.herve.bzh/about", 8 8 "redirect_uris": ["https://atmot.herve.bzh/"], 9 - "scope": "atproto transition:generic", 9 + "scope": "atproto repo:bzh.herve.atmot.result?action=create repo:bzh.herve.atmot.stats?action=create&action=update repo:app.bsky.feed.post?action=create", 10 10 "grant_types": ["authorization_code", "refresh_token"], 11 11 "response_types": ["code"], 12 12 "token_endpoint_auth_method": "none",
+55
tests/oauth-scope.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { readFileSync } from 'node:fs'; 3 + import { fileURLToPath } from 'node:url'; 4 + import { COLLECTION } from '../src/config.js'; 5 + 6 + // Resolve paths relative to the repo root (this file is in tests/). 7 + const root = fileURLToPath(new URL('..', import.meta.url)); 8 + 9 + const metadata = JSON.parse(readFileSync(root + 'public/client-metadata.json', 'utf8')); 10 + const scopes: string[] = String(metadata.scope).split(/\s+/).filter(Boolean); 11 + 12 + /** Find the granted `repo:<nsid>` token for a collection, or undefined. */ 13 + const repoScope = (nsid: string) => 14 + scopes.find((s) => s === `repo:${nsid}` || s.startsWith(`repo:${nsid}?`)); 15 + 16 + describe('OAuth scope (client-metadata.json)', () => { 17 + it('requests the base atproto scope', () => { 18 + expect(scopes).toContain('atproto'); 19 + }); 20 + 21 + it('uses granular least-privilege scopes, never broad transition:generic', () => { 22 + // The whole point: AT Mot must not request blanket repo access. 23 + expect(scopes).not.toContain('transition:generic'); 24 + expect(scopes.some((s) => s.startsWith('transition:'))).toBe(false); 25 + }); 26 + 27 + it('grants create on the write-once result collection', () => { 28 + const s = repoScope(COLLECTION.result); 29 + expect(s, `missing repo scope for ${COLLECTION.result}`).toBeDefined(); 30 + expect(s).toContain('action=create'); 31 + }); 32 + 33 + it('grants create+update on the mutable stats collection', () => { 34 + const s = repoScope(COLLECTION.stats); 35 + expect(s, `missing repo scope for ${COLLECTION.stats}`).toBeDefined(); 36 + expect(s).toContain('action=create'); 37 + expect(s).toContain('action=update'); 38 + }); 39 + 40 + it('grants create on app.bsky.feed.post for the share', () => { 41 + const s = repoScope('app.bsky.feed.post'); 42 + expect(s, 'missing repo scope for app.bsky.feed.post').toBeDefined(); 43 + expect(s).toContain('action=create'); 44 + }); 45 + 46 + it('does not request write access to any other collection', () => { 47 + const allowed = new Set([COLLECTION.result, COLLECTION.stats, 'app.bsky.feed.post']); 48 + const repoNsids = scopes 49 + .filter((s) => s.startsWith('repo:')) 50 + .map((s) => s.slice('repo:'.length).split('?')[0]!); 51 + for (const nsid of repoNsids) { 52 + expect(allowed.has(nsid), `unexpected repo scope: ${nsid}`).toBe(true); 53 + } 54 + }); 55 + });