Our Personal Data Server from scratch!
0

Configure Feed

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

delegation: preset scopes grant identity & account

Lewis: May this revision serve well! <lu5a@proton.me>

Lewis (Jun 27, 2026, 11:11 PM +0300) ab4eba6d a1715182

+315 -70
+18 -4
crates/tranquil-api/src/delegation.rs
··· 174 174 .session 175 175 .delete_app_passwords_by_controller(&auth.did, &input.controller_did) 176 176 .await 177 - .unwrap_or(0) 178 - .try_into() 179 - .unwrap_or(0usize); 177 + .unwrap_or(0); 180 178 181 179 let revoked_oauth_tokens = state 182 180 .repos ··· 232 230 .await 233 231 { 234 232 Ok(true) => { 233 + let revoked_app_passwords = state 234 + .repos 235 + .session 236 + .delete_app_passwords_by_controller(&auth.did, &input.controller_did) 237 + .await 238 + .unwrap_or(0); 239 + 240 + let revoked_oauth_tokens = state 241 + .repos 242 + .oauth 243 + .revoke_tokens_for_controller(&auth.did, &input.controller_did) 244 + .await 245 + .unwrap_or(0); 246 + 235 247 let _ = state 236 248 .repos 237 249 .delegation ··· 241 253 Some(&input.controller_did), 242 254 DelegationActionType::ScopesModified, 243 255 Some(json!({ 244 - "new_scopes": input.granted_scopes.as_str() 256 + "new_scopes": input.granted_scopes.as_str(), 257 + "revoked_app_passwords": revoked_app_passwords, 258 + "revoked_oauth_tokens": revoked_oauth_tokens 245 259 })), 246 260 None, 247 261 None,
+4 -1
crates/tranquil-api/src/server/app_password.rs
··· 116 116 .await 117 117 .ok() 118 118 .flatten(); 119 - let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 119 + let granted_scopes = match grant { 120 + Some(g) => g.granted_scopes, 121 + None => return Err(ApiError::InsufficientScope(None)), 122 + }; 120 123 121 124 let requested = input.scopes.as_deref().unwrap_or("atproto"); 122 125 let intersected = intersect_scopes(requested, granted_scopes.as_str());
+2 -20
crates/tranquil-oauth-server/src/endpoints/authorize/consent.rs
··· 351 351 } else { 352 352 original_scope_str.to_string() 353 353 }; 354 - 355 354 let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); 356 - let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s)); 357 - let user_denied_some_granular = has_granular_scopes 358 - && requested_scopes 359 - .iter() 360 - .filter(|s| is_granular_scope(s)) 361 - .any(|s| !form.approved_scopes.contains(&s.to_string())); 362 355 let atproto_was_requested = requested_scopes.contains(&"atproto"); 363 - if atproto_was_requested 364 - && !has_granular_scopes 365 - && !form.approved_scopes.contains(&"atproto".to_string()) 366 - { 356 + if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) { 367 357 return json_error( 368 358 StatusCode::BAD_REQUEST, 369 359 "invalid_request", 370 360 "The atproto scope was requested and must be approved", 371 361 ); 372 362 } 373 - let final_approved: Vec<String> = if user_denied_some_granular { 374 - form.approved_scopes 375 - .iter() 376 - .filter(|s| *s != "atproto") 377 - .cloned() 378 - .collect() 379 - } else { 380 - form.approved_scopes.clone() 381 - }; 363 + let final_approved: Vec<String> = form.approved_scopes.clone(); 382 364 if final_approved.is_empty() { 383 365 return json_error( 384 366 StatusCode::BAD_REQUEST,
+8 -1
crates/tranquil-oauth-server/src/endpoints/token/grants.rs
··· 148 148 .await 149 149 .ok() 150 150 .flatten(); 151 - let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 151 + let granted_scopes = match grant { 152 + Some(g) => g.granted_scopes, 153 + None => { 154 + return Err(OAuthError::InvalidGrant( 155 + "Delegation grant not found or revoked".to_string(), 156 + )); 157 + } 158 + }; 152 159 let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto"); 153 160 let intersected = intersect_scopes(requested, granted_scopes.as_str()); 154 161 (Some(intersected), Some(controller.clone()))
+2 -2
crates/tranquil-pds/src/delegation/mod.rs
··· 5 5 CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_control_accounts, 6 6 }; 7 7 pub use scopes::{ 8 - InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope, 9 - intersect_scopes, 8 + EDITOR_FULL_SCOPES, InvalidDelegationScopeError, OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset, 9 + ValidatedDelegationScope, intersect_scopes, 10 10 }; 11 11 pub use tranquil_db_traits::DelegationActionType; 12 12
+102 -33
crates/tranquil-pds/src/delegation/scopes.rs
··· 12 12 pub scopes: &'static str, 13 13 } 14 14 15 + pub const OWNER_FULL_SCOPES: &str = 16 + "atproto repo:* blob:*/* identity:* account:*?action=manage"; 17 + 18 + pub const EDITOR_FULL_SCOPES: &str = 19 + "atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"; 20 + 15 21 pub const SCOPE_PRESETS: &[ScopePreset] = &[ 16 22 ScopePreset { 17 23 name: "owner", 18 24 label: "Owner", 19 25 description: "Full control including delegation management", 20 - scopes: "atproto", 26 + scopes: OWNER_FULL_SCOPES, 21 27 }, 22 28 ScopePreset { 23 29 name: "admin", ··· 29 35 name: "editor", 30 36 label: "Editor", 31 37 description: "Post content and upload media", 32 - scopes: "repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 38 + scopes: EDITOR_FULL_SCOPES, 33 39 }, 34 40 ScopePreset { 35 41 name: "viewer", ··· 40 46 ]; 41 47 42 48 pub fn intersect_scopes(requested: &str, granted: &str) -> String { 43 - if granted.is_empty() { 44 - return String::new(); 45 - } 46 - 47 49 let requested_set: HashSet<&str> = requested.split_whitespace().collect(); 48 50 let granted_set: HashSet<&str> = granted.split_whitespace().collect(); 49 51 50 - let granted_has_atproto = granted_set.contains("atproto"); 51 - let requested_has_atproto = requested_set.contains("atproto"); 52 - 53 - if granted_has_atproto { 54 - let mut scopes: Vec<&str> = requested_set.into_iter().collect(); 55 - scopes.sort(); 56 - return scopes.join(" "); 57 - } 58 - 59 - if requested_has_atproto { 60 - let mut scopes: Vec<&str> = granted_set.into_iter().collect(); 61 - scopes.sort(); 62 - return scopes.join(" "); 63 - } 64 - 65 - let mut result: Vec<&str> = requested_set 52 + let mut scopes: Vec<&str> = requested_set 66 53 .iter() 67 - .filter(|requested_scope| any_granted_covers(requested_scope, &granted_set)) 54 + .filter(|requested_scope| { 55 + **requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set) 56 + }) 68 57 .copied() 58 + .chain(requested_set.contains("atproto").then_some("atproto")) 69 59 .collect(); 70 - 71 - result.sort(); 72 - result.join(" ") 60 + scopes.sort(); 61 + scopes.join(" ") 73 62 } 74 63 75 64 fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool { ··· 159 148 } 160 149 161 150 #[test] 162 - fn test_intersect_granted_atproto() { 163 - let result = intersect_scopes("repo:* blob:*/*", "atproto"); 151 + fn test_intersect_owner_grant_covers_requested() { 152 + let result = intersect_scopes("repo:* blob:*/*", OWNER_FULL_SCOPES); 164 153 assert!(result.contains("repo:*")); 165 154 assert!(result.contains("blob:*/*")); 166 155 } 167 156 168 157 #[test] 169 - fn test_intersect_requested_atproto() { 170 - let result = intersect_scopes("atproto", "repo:* blob:*/*"); 171 - assert!(result.contains("repo:*")); 158 + fn test_intersect_bare_atproto_grant_is_auth_only() { 159 + let requested = "atproto repo:*?action=create blob:*/*"; 160 + assert_eq!(intersect_scopes(requested, "atproto"), "atproto"); 161 + } 162 + 163 + #[test] 164 + fn test_intersect_bare_atproto_request_is_auth_only() { 165 + assert_eq!(intersect_scopes("atproto", "repo:* blob:*/*"), "atproto"); 166 + } 167 + 168 + #[test] 169 + fn test_intersect_downscoped_request_keeps_atproto() { 170 + let approved = "atproto repo:*?action=create blob:*/* account:*?action=manage"; 171 + let result = intersect_scopes(approved, OWNER_FULL_SCOPES); 172 + assert!(result.split_whitespace().any(|s| s == "atproto")); 173 + assert!(result.contains("account:*?action=manage")); 174 + assert!(result.contains("repo:*?action=create")); 175 + assert!(result.contains("blob:*/*")); 176 + assert!(!result.contains("identity")); 177 + } 178 + 179 + #[test] 180 + fn test_intersect_owner_passes_through_identity() { 181 + let requested = "atproto repo:*?action=create identity:* account:*?action=manage"; 182 + let result = intersect_scopes(requested, OWNER_FULL_SCOPES); 183 + assert!(result.contains("identity:*")); 184 + assert!(result.contains("account:*?action=manage")); 185 + } 186 + 187 + #[test] 188 + fn test_intersect_admin_excludes_identity() { 189 + let requested = "atproto repo:*?action=create identity:* account:*?action=manage"; 190 + let granted = "atproto repo:* blob:*/* account:*?action=manage"; 191 + let result = intersect_scopes(requested, granted); 192 + assert!(!result.contains("identity")); 193 + assert!(result.contains("account:*?action=manage")); 194 + } 195 + 196 + #[test] 197 + fn test_intersect_admin_excludes_identity_coverage_path() { 198 + let requested = "repo:*?action=create identity:* account:*?action=manage"; 199 + let granted = "atproto repo:* blob:*/* account:*?action=manage"; 200 + let result = intersect_scopes(requested, granted); 201 + assert!(!result.contains("identity")); 202 + assert!(result.contains("account:*?action=manage")); 203 + assert!(result.contains("repo:*?action=create")); 204 + } 205 + 206 + #[test] 207 + fn test_intersect_editor_grant_keeps_atproto() { 208 + let editor = SCOPE_PRESETS 209 + .iter() 210 + .find(|p| p.name == "editor") 211 + .expect("editor preset") 212 + .scopes; 213 + let requested = 214 + "atproto repo:*?action=create identity:* account:*?action=manage blob:*/*"; 215 + let result = intersect_scopes(requested, editor); 216 + assert!(result.split_whitespace().any(|s| s == "atproto")); 217 + assert!(result.contains("repo:*?action=create")); 218 + assert!(result.contains("blob:*/*")); 219 + assert!(!result.contains("identity")); 220 + assert!(!result.contains("account")); 221 + } 222 + 223 + #[test] 224 + fn test_intersect_guarantees_atproto_for_custom_grant() { 225 + let result = intersect_scopes( 226 + "atproto repo:*?action=create blob:*/*", 227 + "repo:*?action=create blob:*/*", 228 + ); 229 + assert!(result.split_whitespace().any(|s| s == "atproto")); 172 230 assert!(result.contains("blob:*/*")); 231 + } 232 + 233 + #[test] 234 + fn test_intersect_no_atproto_request_stays_empty_when_uncovered() { 235 + assert_eq!(intersect_scopes("identity:*", "repo:* blob:*/*"), ""); 173 236 } 174 237 175 238 #[test] ··· 181 244 } 182 245 183 246 #[test] 184 - fn test_intersect_empty_granted() { 185 - assert_eq!(intersect_scopes("atproto", ""), ""); 247 + fn test_intersect_viewer_grant_keeps_atproto() { 248 + let requested = "atproto repo:*?action=create blob:*/* identity:*"; 249 + assert_eq!(intersect_scopes(requested, ""), "atproto"); 250 + } 251 + 252 + #[test] 253 + fn test_intersect_empty_grant_without_atproto_request_is_empty() { 254 + assert_eq!(intersect_scopes("repo:*?action=create", ""), ""); 186 255 } 187 256 188 257 #[test]
+42
crates/tranquil-pds/src/state.rs
··· 482 482 segments_dir: PathBuf, 483 483 } 484 484 485 + fn migrate_delegation_preset_scopes(metastore: &tranquil_store::metastore::Metastore) { 486 + const MARKER_KEY: &str = "migration:delegation_preset_scopes_v1"; 487 + const LEGACY_EDITOR_SCOPES: &str = 488 + "repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*"; 489 + 490 + let infra = metastore.infra_ops(); 491 + if infra 492 + .get_server_config(MARKER_KEY) 493 + .ok() 494 + .flatten() 495 + .is_some() 496 + { 497 + return; 498 + } 499 + 500 + let ops = metastore.delegation_ops(); 501 + let owners = match ops.remap_grant_scopes("atproto", crate::delegation::OWNER_FULL_SCOPES) { 502 + Ok(n) => n, 503 + Err(e) => { 504 + tracing::error!(error = ?e, "delegation owner-scope migration failed, will retry on next start"); 505 + return; 506 + } 507 + }; 508 + let editors = 509 + match ops.remap_grant_scopes(LEGACY_EDITOR_SCOPES, crate::delegation::EDITOR_FULL_SCOPES) { 510 + Ok(n) => n, 511 + Err(e) => { 512 + tracing::error!(error = ?e, "delegation editor-scope migration failed, will retry on next start"); 513 + return; 514 + } 515 + }; 516 + if owners + editors > 0 { 517 + tracing::info!(owners, editors, "upgraded legacy delegation grants to preset scopes"); 518 + } 519 + 520 + if let Err(e) = infra.upsert_server_config(MARKER_KEY, "done") { 521 + tracing::error!(error = ?e, "failed to record delegation scope migration marker, will retry"); 522 + } 523 + } 524 + 485 525 fn wire_tranquil_store( 486 526 store_cfg: &tranquil_config::TranquilStoreConfig, 487 527 shutdown: CancellationToken, ··· 618 658 Err(e) => tracing::error!(error = %e, "orphan repo purge failed"), 619 659 } 620 660 } 661 + 662 + migrate_delegation_preset_scopes(&metastore); 621 663 622 664 let notifier = bridge.notifier(); 623 665 let signal_db = metastore.database().clone();
+118
crates/tranquil-store/src/metastore/delegation_ops.rs
··· 187 187 } 188 188 } 189 189 190 + pub fn remap_grant_scopes(&self, from: &str, to: &str) -> Result<usize, MetastoreError> { 191 + let prefix = super::encoding::KeyBuilder::new() 192 + .tag(super::keys::KeyTag::DELEG_GRANT) 193 + .build(); 194 + 195 + let mut batch = self.db.batch(); 196 + let migrated = self.indexes.prefix(prefix.as_slice()).try_fold( 197 + 0usize, 198 + |count, guard| -> Result<usize, MetastoreError> { 199 + let (key_bytes, val_bytes) = guard.into_inner().map_err(MetastoreError::Fjall)?; 200 + match DelegationGrantValue::deserialize(&val_bytes) { 201 + Some(mut val) if val.granted_scopes == from => { 202 + val.granted_scopes = to.to_owned(); 203 + batch.insert(&self.indexes, key_bytes, val.serialize()); 204 + Ok(count + 1) 205 + } 206 + Some(_) => Ok(count), 207 + None => { 208 + tracing::warn!("skipping corrupt delegation grant during scope remap"); 209 + Ok(count) 210 + } 211 + } 212 + }, 213 + )?; 214 + 215 + match migrated { 216 + 0 => Ok(0), 217 + _ => { 218 + batch.commit().map_err(MetastoreError::Fjall)?; 219 + Ok(migrated) 220 + } 221 + } 222 + } 223 + 190 224 pub fn get_delegation( 191 225 &self, 192 226 delegated_did: &Did, ··· 410 444 }) 411 445 } 412 446 } 447 + 448 + #[cfg(test)] 449 + mod tests { 450 + use super::*; 451 + use crate::metastore::{Metastore, MetastoreConfig}; 452 + 453 + const OWNER_FULL: &str = "atproto repo:* blob:*/* identity:* account:*?action=manage"; 454 + 455 + fn fresh() -> (tempfile::TempDir, Metastore) { 456 + let dir = tempfile::tempdir().expect("tempdir"); 457 + let ms = Metastore::open(dir.path(), MetastoreConfig::default()).expect("open metastore"); 458 + (dir, ms) 459 + } 460 + 461 + fn did(s: &str) -> Did { 462 + Did::new(s.to_owned()).expect("valid did") 463 + } 464 + 465 + #[test] 466 + fn remap_upgrades_only_matching_grants() { 467 + let (_dir, ms) = fresh(); 468 + let ops = ms.delegation_ops(); 469 + 470 + let owner = did("did:plc:nel"); 471 + let ctrl_owner = did("did:plc:olaren"); 472 + let ctrl_editor = did("did:plc:teq"); 473 + 474 + ops.create_delegation(&owner, &ctrl_owner, &DbScope::new("atproto").unwrap(), &owner) 475 + .unwrap(); 476 + ops.create_delegation( 477 + &owner, 478 + &ctrl_editor, 479 + &DbScope::new("repo:* blob:*/*").unwrap(), 480 + &owner, 481 + ) 482 + .unwrap(); 483 + 484 + assert_eq!(ops.remap_grant_scopes("atproto", OWNER_FULL).unwrap(), 1); 485 + 486 + let upgraded = ops.get_delegation(&owner, &ctrl_owner).unwrap().unwrap(); 487 + assert_eq!(upgraded.granted_scopes.as_str(), OWNER_FULL); 488 + 489 + let untouched = ops.get_delegation(&owner, &ctrl_editor).unwrap().unwrap(); 490 + assert_eq!(untouched.granted_scopes.as_str(), "repo:* blob:*/*"); 491 + } 492 + 493 + #[test] 494 + fn remap_is_idempotent() { 495 + let (_dir, ms) = fresh(); 496 + let ops = ms.delegation_ops(); 497 + let owner = did("did:plc:limpet"); 498 + let ctrl = did("did:plc:whelk"); 499 + 500 + ops.create_delegation(&owner, &ctrl, &DbScope::new("atproto").unwrap(), &owner) 501 + .unwrap(); 502 + 503 + assert_eq!(ops.remap_grant_scopes("atproto", OWNER_FULL).unwrap(), 1); 504 + assert_eq!(ops.remap_grant_scopes("atproto", OWNER_FULL).unwrap(), 0); 505 + } 506 + 507 + #[test] 508 + fn remap_skips_corrupt_grant() { 509 + let (_dir, ms) = fresh(); 510 + let ops = ms.delegation_ops(); 511 + 512 + let owner = did("did:plc:nautilus"); 513 + let ctrl = did("did:plc:periwinkle"); 514 + ops.create_delegation(&owner, &ctrl, &DbScope::new("atproto").unwrap(), &owner) 515 + .unwrap(); 516 + 517 + let corrupt_key = grant_key( 518 + UserHash::from_did("did:plc:conch"), 519 + UserHash::from_did("did:plc:scallop"), 520 + ); 521 + let mut batch = ops.db.batch(); 522 + batch.insert(&ops.indexes, corrupt_key.as_slice(), b"not a grant".as_slice()); 523 + batch.commit().unwrap(); 524 + 525 + assert_eq!(ops.remap_grant_scopes("atproto", OWNER_FULL).unwrap(), 1); 526 + 527 + let upgraded = ops.get_delegation(&owner, &ctrl).unwrap().unwrap(); 528 + assert_eq!(upgraded.granted_scopes.as_str(), OWNER_FULL); 529 + } 530 + }
+8 -5
frontend/src/components/dashboard/ControllersContent.svelte
··· 42 42 let controllers = $state<Controller[]>([]) 43 43 let controlledAccounts = $state<ControlledAccount[]>([]) 44 44 let scopePresets = $state<ScopePreset[]>([]) 45 + let defaultScopes = $state('') 45 46 46 47 let hasControllers = $derived(controllers.length > 0) 47 48 let controlsAccounts = $derived(controlledAccounts.length > 0) ··· 50 51 51 52 let showAddController = $state(false) 52 53 let addControllerIdentifier = $state('') 53 - let addControllerScopes = $state('atproto') 54 + let addControllerScopes = $state('') 54 55 let addingController = $state(false) 55 56 let addControllerConfirmed = $state(false) 56 57 let resolvedController = $state<{ did: string; handle?: string; pdsUrl?: string; isLocal: boolean } | null>(null) ··· 119 120 let showCreateDelegated = $state(false) 120 121 let newDelegatedHandle = $state('') 121 122 let newDelegatedEmail = $state('') 122 - let newDelegatedScopes = $state('atproto') 123 + let newDelegatedScopes = $state('') 123 124 let creatingDelegated = $state(false) 124 125 125 126 onMount(async () => { ··· 168 169 description: p.description, 169 170 scopes: unsafeAsScopeSet(p.scopes) 170 171 })) 172 + defaultScopes = scopePresets.find(p => p.name === 'owner')?.scopes ?? scopePresets[0]?.scopes ?? '' 173 + addControllerScopes = defaultScopes 174 + newDelegatedScopes = defaultScopes 171 175 } 172 176 } 173 177 ··· 181 185 if (result.ok) { 182 186 toast.success($_('delegation.controllerAdded')) 183 187 addControllerIdentifier = '' 184 - addControllerScopes = 'atproto' 188 + addControllerScopes = defaultScopes 185 189 addControllerConfirmed = false 186 190 resolvedController = null 187 191 showAddController = false ··· 214 218 toast.success($_('delegation.accountCreated', { values: { handle: result.value.handle } })) 215 219 newDelegatedHandle = '' 216 220 newDelegatedEmail = '' 217 - newDelegatedScopes = 'atproto' 221 + newDelegatedScopes = defaultScopes 218 222 showCreateDelegated = false 219 223 await loadControlledAccounts() 220 224 } ··· 224 228 function getScopeLabel(scopes: ScopeSet): string { 225 229 const preset = scopePresets.find(p => p.scopes === scopes) 226 230 if (preset) return preset.label 227 - if ((scopes as string) === 'atproto') return $_('delegation.scopeOwner') 228 231 if ((scopes as string) === '') return $_('delegation.scopeViewer') 229 232 return $_('delegation.scopeCustom') 230 233 }
+2 -2
frontend/src/lib/oauth.ts
··· 3 3 const DPOP_KEY_STORE = "tranquil_pds_dpop_keys"; 4 4 const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce"; 5 5 6 - const SCOPES = [ 6 + export const SCOPES = [ 7 7 "atproto", 8 8 "repo:*?action=create", 9 9 "repo:*?action=update", ··· 16 16 const CLIENT_ID = 17 17 !(import.meta.env.DEV) || globalThis.location?.hostname !== 'localhost' 18 18 ? `${globalThis.location.origin}/oauth-client-metadata.json` 19 - : `http://localhost/?scope=${SCOPES}`; 19 + : `http://localhost/?scope=${encodeURIComponent(SCOPES)}`; 20 20 21 21 const REDIRECT_URI = `${globalThis.location.origin}/app/`; 22 22
+2 -2
frontend/src/routes/ActAs.svelte
··· 1 1 <script lang="ts"> 2 2 import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 - import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest, setDPoPNonce } from '../lib/oauth' 4 + import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest, setDPoPNonce, SCOPES } from '../lib/oauth' 5 5 import { _ } from '../lib/i18n' 6 6 import type { Session, DelegationControlledAccount } from '../lib/types/api' 7 7 import type { AuthenticatedClient } from '../lib/authenticated-client' ··· 54 54 client_id: `${hostname}/oauth-client-metadata.json`, 55 55 redirect_uri: `${hostname}/app/`, 56 56 response_type: 'code', 57 - scope: 'atproto', 57 + scope: SCOPES, 58 58 state: state, 59 59 code_challenge: codeChallenge, 60 60 code_challenge_method: 'S256',
+7
migrations/20260626_delegation_preset_scopes.sql
··· 1 + UPDATE account_delegations 2 + SET granted_scopes = 'atproto repo:* blob:*/* identity:* account:*?action=manage' 3 + WHERE granted_scopes = 'atproto'; 4 + 5 + UPDATE account_delegations 6 + SET granted_scopes = 'atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*' 7 + WHERE granted_scopes = 'repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*';