diff --git a/crates/tranquil-api/src/server/app_password.rs b/crates/tranquil-api/src/server/app_password.rs index 291cca5..5a5f621 100644 --- a/crates/tranquil-api/src/server/app_password.rs +++ b/crates/tranquil-api/src/server/app_password.rs @@ -67,6 +67,13 @@ pub struct CreateAppPasswordInput { pub scopes: Option, } +fn with_required_atproto_scope(scopes: &str) -> String { + std::iter::once("atproto") + .chain(scopes.split_whitespace().filter(|scope| *scope != "atproto")) + .collect::>() + .join(" ") +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateAppPasswordOutput { @@ -121,8 +128,8 @@ pub async fn create_app_password( None => return Err(ApiError::InsufficientScope(None)), }; - let requested = input.scopes.as_deref().unwrap_or("atproto"); - let intersected = intersect_scopes(requested, granted_scopes.as_str()); + let requested = with_required_atproto_scope(input.scopes.as_deref().unwrap_or_default()); + let intersected = intersect_scopes(&requested, granted_scopes.as_str()); if intersected.is_empty() && !granted_scopes.is_empty() { return Err(ApiError::InsufficientScope(None)); @@ -142,7 +149,7 @@ pub async fn create_app_password( _ => "transition:generic transition:chat.bsky".to_string(), }, }; - (Some(scopes), None) + (Some(with_required_atproto_scope(&scopes)), None) }; let password = generate_app_password(); diff --git a/crates/tranquil-pds/tests/lifecycle_session.rs b/crates/tranquil-pds/tests/lifecycle_session.rs index ec46c7e..e461176 100644 --- a/crates/tranquil-pds/tests/lifecycle_session.rs +++ b/crates/tranquil-pds/tests/lifecycle_session.rs @@ -682,7 +682,7 @@ async fn test_app_password_non_privileged_blocks_chat() { .await; assert_eq!( create_body["scopes"].as_str().unwrap(), - "transition:generic", + "atproto transition:generic", "Non-privileged app password should not have chat scope" ); let status = try_chat_service_auth(&client, &app_jwt).await; @@ -707,7 +707,7 @@ async fn test_app_password_privileged_allows_chat() { .await; assert_eq!( create_body["scopes"].as_str().unwrap(), - "transition:generic transition:chat.bsky", + "atproto transition:generic transition:chat.bsky", "Privileged app password should have chat scope" ); let status = try_chat_service_auth(&client, &app_jwt).await; @@ -732,7 +732,7 @@ async fn test_app_password_no_privileged_field_allows_chat() { .await; assert_eq!( create_body["scopes"].as_str().unwrap(), - "transition:generic transition:chat.bsky", + "atproto transition:generic transition:chat.bsky", "App password without privileged field should default to full access" ); let status = try_chat_service_auth(&client, &app_jwt).await; @@ -757,8 +757,8 @@ async fn test_app_password_explicit_scopes_respected() { .await; assert_eq!( create_body["scopes"].as_str().unwrap(), - "transition:generic", - "Explicit scopes should be stored as-is" + "atproto transition:generic", + "Explicit scopes should include required AT Protocol access" ); let status = try_chat_service_auth(&client, &app_jwt).await; assert_eq!( diff --git a/frontend/src/components/dashboard/AppPasswordsContent.svelte b/frontend/src/components/dashboard/AppPasswordsContent.svelte index 326a747..013ce5a 100644 --- a/frontend/src/components/dashboard/AppPasswordsContent.svelte +++ b/frontend/src/components/dashboard/AppPasswordsContent.svelte @@ -5,6 +5,12 @@ import { toast } from '../../lib/toast.svelte' import { formatDate } from '../../lib/date' import type { Session } from '../../lib/types/api' + import { + classifyAppPasswordScopes, + normalizeAppPasswordScopes, + POST_ONLY_APP_PASSWORD_SCOPES, + READ_ONLY_APP_PASSWORD_SCOPES, + } from '../../lib/appPasswordScopes' interface Props { session: Session @@ -27,16 +33,14 @@ scopes?: string | null }> = [ { id: 'full', label: 'appPasswords.scopeFull', scopes: null }, - { id: 'readonly', label: 'appPasswords.scopeReadOnly', scopes: 'rpc:app.bsky.*?aud=* rpc:chat.bsky.*?aud=* account:status?action=read' }, - { id: 'post', label: 'appPasswords.scopePostOnly', scopes: 'repo:app.bsky.feed.post?action=create blob:*/*' }, + { id: 'readonly', label: 'appPasswords.scopeReadOnly', scopes: READ_ONLY_APP_PASSWORD_SCOPES }, + { id: 'post', label: 'appPasswords.scopePostOnly', scopes: POST_ONLY_APP_PASSWORD_SCOPES }, { id: 'custom', label: 'appPasswords.scopeCustom' }, ] function getScopeLabel(scopes: string | null | undefined): string { - if (!scopes) return $_('appPasswords.scopeFull') - const preset = SCOPE_PRESETS.find(p => p.scopes === scopes) - if (preset) return $_(preset.label) - return $_('appPasswords.scopeCustom') + const preset = SCOPE_PRESETS.find(p => p.id === classifyAppPasswordScopes(scopes)) + return $_(preset?.label ?? 'appPasswords.scopeCustom') } let appPasswords = $state([]) @@ -116,7 +120,7 @@ } function normalizeScopes(scopes: string): string { - return scopes.trim().split(/\s+/).filter(Boolean).join(' ') + return normalizeAppPasswordScopes(scopes) } function getSelectedScopes(): string | undefined { diff --git a/frontend/src/lib/appPasswordScopes.ts b/frontend/src/lib/appPasswordScopes.ts new file mode 100644 index 0000000..b5f6dde --- /dev/null +++ b/frontend/src/lib/appPasswordScopes.ts @@ -0,0 +1,34 @@ +export type AppPasswordScopePreset = "full" | "readonly" | "post" | "custom"; + +export const READ_ONLY_APP_PASSWORD_SCOPES = + "account:*?action=read"; +export const POST_ONLY_APP_PASSWORD_SCOPES = + "repo:*?action=create blob:*/*"; + +export function normalizeAppPasswordScopes(scopes: string): string { + return [...new Set(scopes.trim().split(/\s+/).filter(Boolean))] + .sort() + .join(" "); +} + +export function classifyAppPasswordScopes( + scopes: string | null | undefined, +): AppPasswordScopePreset { + if (!scopes) return "full"; + + const normalized = normalizeAppPasswordScopes(scopes); + if (normalized.split(" ").includes("transition:generic")) return "full"; + const presetScopes = normalizeAppPasswordScopes( + normalized + .split(" ") + .filter((scope) => scope !== "atproto") + .join(" "), + ); + if (presetScopes === normalizeAppPasswordScopes(READ_ONLY_APP_PASSWORD_SCOPES)) { + return "readonly"; + } + if (presetScopes === normalizeAppPasswordScopes(POST_ONLY_APP_PASSWORD_SCOPES)) { + return "post"; + } + return "custom"; +} \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 1487b8e..954351b 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -309,7 +309,7 @@ "scopePostOnly": "Post Only", "scopeCustom": "Custom", "scopeCustomLabel": "Scope string", - "scopeCustomPlaceholder": "atproto repo:app.bsky.feed.post?action=create blob:*/*", + "scopeCustomPlaceholder": "repo:app.example.record?action=create blob:*/*", "scopeCustomHelp": "Enter space-separated AT Protocol scopes. Only grant the permissions this app needs.", "byController": "By Controller" }, diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index e2a1e06..9229ae0 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -300,7 +300,7 @@ "scopePostOnly": "Vain julkaisu", "scopeCustom": "Mukautettu", "scopeCustomLabel": "Käyttöoikeusmerkkijono", - "scopeCustomPlaceholder": "atproto repo:app.bsky.feed.post?action=create blob:*/*", + "scopeCustomPlaceholder": "repo:app.example.record?action=create blob:*/*", "scopeCustomHelp": "Syötä välilyönnein erotetut AT Protocol -käyttöoikeudet. Myönnä vain sovelluksen tarvitsemat oikeudet.", "byController": "Hallinnoijan luoma", "create": "Luo", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 8551c07..a0c8047 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -309,7 +309,7 @@ "scopePostOnly": "Publication uniquement", "scopeCustom": "Personnalisé", "scopeCustomLabel": "Chaîne de portées", - "scopeCustomPlaceholder": "atproto repo:app.bsky.feed.post?action=create blob:*/*", + "scopeCustomPlaceholder": "repo:app.example.record?action=create blob:*/*", "scopeCustomHelp": "Saisissez des portées AT Protocol séparées par des espaces. N'accordez que les permissions nécessaires à cette application.", "byController": "Par contrôleur" }, diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 45e2417..785a679 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -300,7 +300,7 @@ "scopePostOnly": "投稿のみ", "scopeCustom": "カスタム", "scopeCustomLabel": "スコープ文字列", - "scopeCustomPlaceholder": "atproto repo:app.bsky.feed.post?action=create blob:*/*", + "scopeCustomPlaceholder": "repo:app.example.record?action=create blob:*/*", "scopeCustomHelp": "AT Protocol のスコープをスペース区切りで入力します。このアプリに必要な権限のみを付与してください。", "byController": "管理者作成", "create": "作成", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index dfb50ee..588d009 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -300,7 +300,7 @@ "scopePostOnly": "게시만", "scopeCustom": "사용자 지정", "scopeCustomLabel": "범위 문자열", - "scopeCustomPlaceholder": "atproto repo:app.bsky.feed.post?action=create blob:*/*", + "scopeCustomPlaceholder": "repo:app.example.record?action=create blob:*/*", "scopeCustomHelp": "공백으로 구분된 AT Protocol 범위를 입력하세요. 이 앱에 필요한 권한만 부여하세요.", "byController": "컨트롤러 생성", "create": "생성", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index ee2eae2..1d79416 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -300,7 +300,7 @@ "scopePostOnly": "Endast inlägg", "scopeCustom": "Anpassad", "scopeCustomLabel": "Scope-sträng", - "scopeCustomPlaceholder": "atproto repo:app.bsky.feed.post?action=create blob:*/*", + "scopeCustomPlaceholder": "repo:app.example.record?action=create blob:*/*", "scopeCustomHelp": "Ange blankstegsseparerade AT Protocol-scopes. Ge bara de behörigheter som appen behöver.", "byController": "Av controller", "create": "Skapa", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 5e09088..8fcc4ec 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -300,7 +300,7 @@ "scopePostOnly": "仅发帖", "scopeCustom": "自定义", "scopeCustomLabel": "权限范围字符串", - "scopeCustomPlaceholder": "atproto repo:app.bsky.feed.post?action=create blob:*/*", + "scopeCustomPlaceholder": "repo:app.example.record?action=create blob:*/*", "scopeCustomHelp": "请输入以空格分隔的 AT Protocol 权限范围。仅授予此应用所需的权限。", "byController": "由控制者创建", "create": "创建", diff --git a/frontend/src/tests/appPasswordScopes.test.ts b/frontend/src/tests/appPasswordScopes.test.ts new file mode 100644 index 0000000..c06060c --- /dev/null +++ b/frontend/src/tests/appPasswordScopes.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { classifyAppPasswordScopes } from "../lib/appPasswordScopes.ts"; + +describe("classifyAppPasswordScopes", () => { + it.each([ + ["atproto transition:generic transition:chat.bsky", "full"], + ["atproto transition:generic", "full"], + ["atproto account:*?action=read", "readonly"], + ["atproto blob:*/* repo:*?action=create", "post"], + ["atproto repo:custom.example.record", "custom"], + ] as const)("classifies %s as %s", (scopes, expected) => { + expect(classifyAppPasswordScopes(scopes)).toBe(expected); + }); +}); \ No newline at end of file