alf: the atproto Latency Fabric alf.fly.dev/
7
fork

Configure Feed

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

Use granular OAuth scope (repo:*?action=create blob:*/*) with ALLOWED_COLLECTIONS env var

Replaces transition:generic with the new ATProto granular scope system.
ALLOWED_COLLECTIONS defaults to * but can be narrowed to specific NSIDs
(e.g. app.bsky.feed.post) to restrict what record types ALF will accept.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+27 -7
+6
.env.example
··· 23 23 # Per-user draft limit (optional) 24 24 # Maximum number of active (draft/scheduled) posts per user. Unset = unlimited. 25 25 # MAX_DRAFTS_PER_USER=3 26 + 27 + # Allowed ATProto collections (optional) 28 + # Comma-separated NSIDs to restrict which record types ALF will accept. Defaults to * (all). 29 + # OAuth scope becomes: repo:<value>?action=create 30 + # Example: restrict to Bluesky posts only 31 + # ALLOWED_COLLECTIONS=app.bsky.feed.post
+3 -3
demo/client/index.ts
··· 667 667 if (isLocalhost) { 668 668 const port = window.location.port; 669 669 const redirectUri = `http://127.0.0.1${port ? `:${port}` : ''}/`; 670 - const clientId = `http://localhost?scope=${encodeURIComponent('atproto transition:generic')}&redirect_uri=${encodeURIComponent(redirectUri)}`; 670 + const clientId = `http://localhost?scope=${encodeURIComponent('atproto repo:*?action=create blob:*/*')}&redirect_uri=${encodeURIComponent(redirectUri)}`; 671 671 oauthClientMetadata = { 672 672 client_id: clientId, 673 673 client_name: 'ALF Demo', 674 674 redirect_uris: [redirectUri], 675 - scope: 'atproto transition:generic', 675 + scope: 'atproto repo:*?action=create blob:*/*', 676 676 grant_types: ['authorization_code', 'refresh_token'], 677 677 response_types: ['code'], 678 678 token_endpoint_auth_method: 'none', ··· 686 686 client_id: metadataUrl, 687 687 client_name: 'ALF Demo', 688 688 redirect_uris: [`${window.location.origin}/`], 689 - scope: 'atproto transition:generic', 689 + scope: 'atproto repo:*?action=create blob:*/*', 690 690 grant_types: ['authorization_code', 'refresh_token'], 691 691 response_types: ['code'], 692 692 token_endpoint_auth_method: 'none',
+1 -1
demo/server.ts
··· 22 22 client_name: 'ALF Demo', 23 23 client_uri: SERVICE_URL, 24 24 redirect_uris: [`${SERVICE_URL}/`], 25 - scope: 'atproto transition:generic', 25 + scope: 'atproto repo:*?action=create blob:*/*', 26 26 grant_types: ['authorization_code', 'refresh_token'], 27 27 response_types: ['code'], 28 28 token_endpoint_auth_method: 'none',
+1
src/__tests__/database.test.ts
··· 26 26 databasePath: ':memory:', 27 27 encryptionKey: 'a'.repeat(64), 28 28 maxDraftsPerUser: null, 29 + allowedCollections: '*', 29 30 ...overrides, 30 31 }); 31 32
+1
src/__tests__/oauth.test.ts
··· 36 36 databasePath: ':memory:', 37 37 encryptionKey: 'a'.repeat(64), 38 38 maxDraftsPerUser: null, 39 + allowedCollections: '*', 39 40 }); 40 41 41 42 describe('createOAuthClient', () => {
+1
src/__tests__/routes/oauth.test.ts
··· 21 21 databasePath: ':memory:', 22 22 encryptionKey: 'a'.repeat(64), 23 23 maxDraftsPerUser: null, 24 + allowedCollections: '*', 24 25 ...overrides, 25 26 }); 26 27
+1
src/__tests__/scheduler.test.ts
··· 47 47 databaseUrl: undefined, 48 48 encryptionKey: 'a'.repeat(64), 49 49 maxDraftsPerUser: null, 50 + allowedCollections: '*', 50 51 }); 51 52 52 53 /** Flush all pending microtasks and I/O callbacks */
+1
src/__tests__/server.test.ts
··· 79 79 databasePath: ':memory:', 80 80 encryptionKey: 'a'.repeat(64), 81 81 maxDraftsPerUser: null, 82 + allowedCollections: '*', 82 83 }; 83 84 84 85 const AUTH_HEADER = 'Bearer test-token';
+1
src/__tests__/storage.test.ts
··· 40 40 databaseUrl: undefined, 41 41 encryptionKey: 'a'.repeat(64), 42 42 maxDraftsPerUser: null, 43 + allowedCollections: '*', 43 44 }); 44 45 45 46 describe('storage', () => {
+7
src/config.ts
··· 19 19 postPublishWebhookUrl?: string; 20 20 /** Maximum number of active drafts per user. null = unlimited. */ 21 21 maxDraftsPerUser: number | null; 22 + /** 23 + * Comma-separated ATProto collection NSIDs to allow. Defaults to "*" (all collections). 24 + * Used to build the OAuth scope: `repo:<collection>?action=create`. 25 + * Example: "app.bsky.feed.post" to restrict to Bluesky posts only. 26 + */ 27 + allowedCollections: string; 22 28 } 23 29 24 30 export const getConfig = (): ServiceConfig => { ··· 38 44 encryptionKey: process.env.ENCRYPTION_KEY || '', 39 45 postPublishWebhookUrl: process.env.POST_PUBLISH_WEBHOOK_URL, 40 46 maxDraftsPerUser, 47 + allowedCollections: process.env.ALLOWED_COLLECTIONS || '*', 41 48 }; 42 49 43 50 if (config.databaseType === 'postgres' && !config.databaseUrl) {
+4 -3
src/oauth.ts
··· 123 123 */ 124 124 export function createOAuthClient(config: ServiceConfig): NodeOAuthClient { 125 125 const isHttps = config.serviceUrl.startsWith('https://'); 126 + const scope = `atproto repo:${config.allowedCollections}?action=create blob:*/*`; 126 127 127 128 // RFC 8252: loopback client pattern for HTTP (dev), discoverable for HTTPS (prod) 128 129 let clientMetadata: OAuthClientOptions['clientMetadata']; ··· 132 133 client_name: 'Scheduled Posts', 133 134 client_uri: config.serviceUrl, 134 135 redirect_uris: [`${config.serviceUrl}/oauth/callback`], 135 - scope: 'atproto transition:generic', 136 + scope, 136 137 grant_types: ['authorization_code', 'refresh_token'], 137 138 response_types: ['code'], 138 139 token_endpoint_auth_method: 'none', ··· 145 146 const port = new URL(config.serviceUrl).port; 146 147 const redirectUri = `http://127.0.0.1${port ? `:${port}` : ''}/oauth/callback`; 147 148 const loopbackParams = new URLSearchParams({ 148 - scope: 'atproto transition:generic', 149 + scope, 149 150 redirect_uri: redirectUri, 150 151 }); 151 152 clientMetadata = { 152 153 client_id: `http://localhost?${loopbackParams.toString()}`, 153 154 client_name: 'Scheduled Posts', 154 155 redirect_uris: [redirectUri], 155 - scope: 'atproto transition:generic', 156 + scope, 156 157 grant_types: ['authorization_code', 'refresh_token'], 157 158 response_types: ['code'], 158 159 token_endpoint_auth_method: 'none',