Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

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

Delegated accounts

+6860 -253
+46
.sqlx/query-03e943475fd0af07d3e1ed5c14276c7841af9fc59076bd4017742844a91d29a1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n u.did,\n u.handle,\n d.granted_scopes,\n d.granted_at,\n (u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as \"is_active!\"\n FROM account_delegations d\n JOIN users u ON u.did = d.controller_did\n WHERE d.delegated_did = $1 AND d.revoked_at IS NULL\n ORDER BY d.granted_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "granted_scopes", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "granted_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "is_active!", 29 + "type_info": "Bool" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text" 35 + ] 36 + }, 37 + "nullable": [ 38 + false, 39 + false, 40 + false, 41 + false, 42 + null 43 + ] 44 + }, 45 + "hash": "03e943475fd0af07d3e1ed5c14276c7841af9fc59076bd4017742844a91d29a1" 46 + }
+15
.sqlx/query-045ba5a6ab497737d09367f57df825f7945bb317b76b770ef68aa3f53df284a2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_authorization_request\n SET controller_did = $2\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "045ba5a6ab497737d09367f57df825f7945bb317b76b770ef68aa3f53df284a2" 15 + }
+34
.sqlx/query-1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "password_hash", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "scopes", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "created_by_controller_did", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Uuid" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + true, 30 + true 31 + ] 32 + }, 33 + "hash": "1a156f5dd3deb0681f7f631321bae44c099eb2eb5d9d1337d22782fe73691a7b" 34 + }
+87
.sqlx/query-1f44c06434b913554e26ad1e2674c56701f43fe12907594325e885c6f256045e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n id,\n delegated_did,\n actor_did,\n controller_did,\n action_type as \"action_type: DelegationActionType\",\n action_details,\n ip_address,\n user_agent,\n created_at\n FROM delegation_audit_log\n WHERE controller_did = $1\n ORDER BY created_at DESC\n LIMIT $2 OFFSET $3\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "delegated_did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "actor_did", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "controller_did", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "action_type: DelegationActionType", 29 + "type_info": { 30 + "Custom": { 31 + "name": "delegation_action_type", 32 + "kind": { 33 + "Enum": [ 34 + "grant_created", 35 + "grant_revoked", 36 + "scopes_modified", 37 + "token_issued", 38 + "repo_write", 39 + "blob_upload", 40 + "account_action" 41 + ] 42 + } 43 + } 44 + } 45 + }, 46 + { 47 + "ordinal": 5, 48 + "name": "action_details", 49 + "type_info": "Jsonb" 50 + }, 51 + { 52 + "ordinal": 6, 53 + "name": "ip_address", 54 + "type_info": "Text" 55 + }, 56 + { 57 + "ordinal": 7, 58 + "name": "user_agent", 59 + "type_info": "Text" 60 + }, 61 + { 62 + "ordinal": 8, 63 + "name": "created_at", 64 + "type_info": "Timestamptz" 65 + } 66 + ], 67 + "parameters": { 68 + "Left": [ 69 + "Text", 70 + "Int8", 71 + "Int8" 72 + ] 73 + }, 74 + "nullable": [ 75 + false, 76 + false, 77 + false, 78 + true, 79 + false, 80 + true, 81 + true, 82 + true, 83 + false 84 + ] 85 + }, 86 + "hash": "1f44c06434b913554e26ad1e2674c56701f43fe12907594325e885c6f256045e" 87 + }
+22
.sqlx/query-33d3ad8e4668b029a3cccfac6dda6d4612e248886fd6290aa47253c6bb325c45.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT COUNT(*) as \"count!\"\n FROM account_delegations d\n JOIN users u ON u.did = d.controller_did\n WHERE d.delegated_did = $1\n AND d.revoked_at IS NULL\n AND u.deactivated_at IS NULL\n AND u.takedown_ref IS NULL\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count!", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "33d3ad8e4668b029a3cccfac6dda6d4612e248886fd6290aa47253c6bb325c45" 22 + }
+25
.sqlx/query-3781704482d019cbc5811ceab0ff26749d8fca1b13dfa7b2b2c42273ebb5beed.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)\n VALUES ($1, $2, $3, $4)\n RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text", 16 + "Text", 17 + "Text" 18 + ] 19 + }, 20 + "nullable": [ 21 + false 22 + ] 23 + }, 24 + "hash": "3781704482d019cbc5811ceab0ff26749d8fca1b13dfa7b2b2c42273ebb5beed" 25 + }
+22
.sqlx/query-38154ef1114e42ff2718ab5aa10a653f32d097976d2c4881676d27454ad1c2e5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT COUNT(*) as \"count!\" FROM delegation_audit_log WHERE delegated_did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "count!", 9 + "type_info": "Int8" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "38154ef1114e42ff2718ab5aa10a653f32d097976d2c4881676d27454ad1c2e5" 22 + }
-28
.sqlx/query-3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "password_hash", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "scopes", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - true 25 - ] 26 - }, 27 - "hash": "3d5ab47cdcb0d04b0a0d63c2d5a0cc45889ff4330b500ba7e77eac06ee9606c9" 28 - }
+22
.sqlx/query-40d42ed61a77074b298539e492d8fb6493174a7c49324e6f4f20b68bc30e95f4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT account_type::text = 'delegated' as \"is_delegated!\" FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "is_delegated!", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "40d42ed61a77074b298539e492d8fb6493174a7c49324e6f4f20b68bc30e95f4" 22 + }
+22
.sqlx/query-49e7d9a260209502aa79ef9f83bed78ec38b6f7c068fdf8433696082cfad91a8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT EXISTS(\n SELECT 1 FROM account_delegations\n WHERE controller_did = $1 AND revoked_at IS NULL\n ) as \"exists!\"", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "exists!", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "49e7d9a260209502aa79ef9f83bed78ec38b6f7c068fdf8433696082cfad91a8" 22 + }
+8 -2
.sqlx/query-53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765.json .sqlx/query-b474591bf3bd9359bd0d8af186f090a32c79a940771168d67160f3190da2eea4.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE did = $1\n ", 3 + "query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope, controller_did\n FROM oauth_token\n WHERE did = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 67 67 "ordinal": 12, 68 68 "name": "scope", 69 69 "type_info": "Text" 70 + }, 71 + { 72 + "ordinal": 13, 73 + "name": "controller_did", 74 + "type_info": "Text" 70 75 } 71 76 ], 72 77 "parameters": { ··· 87 92 true, 88 93 true, 89 94 true, 95 + true, 90 96 true 91 97 ] 92 98 }, 93 - "hash": "53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765" 99 + "hash": "b474591bf3bd9359bd0d8af186f090a32c79a940771168d67160f3190da2eea4" 94 100 }
-46
.sqlx/query-6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Int4" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "scope", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "key_bytes", 24 - "type_info": "Bytea" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "encryption_version", 29 - "type_info": "Int4" 30 - } 31 - ], 32 - "parameters": { 33 - "Left": [ 34 - "Text" 35 - ] 36 - }, 37 - "nullable": [ 38 - false, 39 - false, 40 - true, 41 - false, 42 - true 43 - ] 44 - }, 45 - "hash": "6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883" 46 - }
+3 -2
.sqlx/query-6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0.json .sqlx/query-cd3bc8199c3f9285f214ef091ad52dc881a19cf19fe27a2ba1f383ffb8e3fc0d.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n INSERT INTO oauth_token\n (did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\n RETURNING id\n ", 3 + "query": "\n INSERT INTO oauth_token\n (did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope, controller_did)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n RETURNING id\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 23 23 "Jsonb", 24 24 "Text", 25 25 "Text", 26 + "Text", 26 27 "Text" 27 28 ] 28 29 }, ··· 30 31 false 31 32 ] 32 33 }, 33 - "hash": "6b30d0a7dc0759c336334c2d34d3302b883795730c5dfa97925319dc998a43f0" 34 + "hash": "cd3bc8199c3f9285f214ef091ad52dc881a19cf19fe27a2ba1f383ffb8e3fc0d" 34 35 }
+15
.sqlx/query-7b4977eb51715a385cb00ee88dd3395fa28f9c0d2edc3dc1670c415ad983394f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE oauth_authorization_request\n SET did = $2\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "7b4977eb51715a385cb00ee88dd3395fa28f9c0d2edc3dc1670c415ad983394f" 15 + }
+22
.sqlx/query-8023c93fa18592cc5ebde7ae856effa70ef57e2801ecba999512f1b12000de9c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as \"exists!\"", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "exists!", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "8023c93fa18592cc5ebde7ae856effa70ef57e2801ecba999512f1b12000de9c" 22 + }
+52
.sqlx/query-80c029ff08ef3f7d19054fca573dee4037f38b7a7bf1473a0cae7887350de556.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT st.id, st.did, st.scope, st.controller_did, k.key_bytes, k.encryption_version\n FROM session_tokens st\n JOIN users u ON st.did = u.did\n JOIN user_keys k ON u.id = k.user_id\n WHERE st.refresh_jti = $1 AND st.refresh_expires_at > NOW()\n FOR UPDATE OF st", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Int4" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "scope", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "controller_did", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "key_bytes", 29 + "type_info": "Bytea" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "encryption_version", 34 + "type_info": "Int4" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + true, 46 + true, 47 + false, 48 + true 49 + ] 50 + }, 51 + "hash": "80c029ff08ef3f7d19054fca573dee4037f38b7a7bf1473a0cae7887350de556" 52 + }
+3 -2
.sqlx/query-8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40.json .sqlx/query-c3a0d5bbac7b0d33f79e61fd9790cd737b62628f5597489066228dd30af42c82.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)", 3 + "query": "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7)", 4 4 "describe": { 5 5 "columns": [], 6 6 "parameters": { ··· 10 10 "Text", 11 11 "Timestamptz", 12 12 "Bool", 13 + "Text", 13 14 "Text" 14 15 ] 15 16 }, 16 17 "nullable": [] 17 18 }, 18 - "hash": "8d634d6c3306424ed9239f078a4892245f4b73049037ea8f3cf23fc377b57a40" 19 + "hash": "c3a0d5bbac7b0d33f79e61fd9790cd737b62628f5597489066228dd30af42c82" 19 20 }
+70
.sqlx/query-90f46f595f418c306a9229e5c5379bb6e1a3f121a346dce565e6d3075b058f01.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, did, password_hash, deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "password_hash", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "deactivated_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "takedown_ref", 29 + "type_info": "Text" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "email_verified", 34 + "type_info": "Bool" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "discord_verified", 39 + "type_info": "Bool" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "telegram_verified", 44 + "type_info": "Bool" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "signal_verified", 49 + "type_info": "Bool" 50 + } 51 + ], 52 + "parameters": { 53 + "Left": [ 54 + "Text" 55 + ] 56 + }, 57 + "nullable": [ 58 + false, 59 + false, 60 + true, 61 + true, 62 + true, 63 + false, 64 + false, 65 + false, 66 + false 67 + ] 68 + }, 69 + "hash": "90f46f595f418c306a9229e5c5379bb6e1a3f121a346dce565e6d3075b058f01" 70 + }
+17
.sqlx/query-9182105a0f3cd4659e7c4bedb13c5670121fb25c351aa427a6b42a632c95e249.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by)\n VALUES ($1, $2, $3, $4)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Text" 12 + ] 13 + }, 14 + "nullable": [] 15 + }, 16 + "hash": "9182105a0f3cd4659e7c4bedb13c5670121fb25c351aa427a6b42a632c95e249" 17 + }
+22
.sqlx/query-a9bf34b436e0eecf3489cdabe9286b4ecb18905dc66e86a4084081f943b71d4c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT EXISTS(\n SELECT 1 FROM account_delegations\n WHERE delegated_did = $1 AND revoked_at IS NULL\n ) as \"exists!\"", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "exists!", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + null 19 + ] 20 + }, 21 + "hash": "a9bf34b436e0eecf3489cdabe9286b4ecb18905dc66e86a4084081f943b71d4c" 22 + }
+8 -2
.sqlx/query-b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8.json .sqlx/query-a886fcf853e54f3be88143b373f58a7fbf0881d19649c036660ef6cf52d14fa2.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE token_id = $1\n ", 3 + "query": "\n SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope, controller_did\n FROM oauth_token\n WHERE token_id = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 67 67 "ordinal": 12, 68 68 "name": "scope", 69 69 "type_info": "Text" 70 + }, 71 + { 72 + "ordinal": 13, 73 + "name": "controller_did", 74 + "type_info": "Text" 70 75 } 71 76 ], 72 77 "parameters": { ··· 87 92 true, 88 93 true, 89 94 true, 95 + true, 90 96 true 91 97 ] 92 98 }, 93 - "hash": "b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8" 99 + "hash": "a886fcf853e54f3be88143b373f58a7fbf0881d19649c036660ef6cf52d14fa2" 94 100 }
+16
.sqlx/query-bc0d078c738c6ebdaa19446608e96727c0f2f227e9fbcb06172e5c444bea6347.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE account_delegations\n SET granted_scopes = $1\n WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "bc0d078c738c6ebdaa19446608e96727c0f2f227e9fbcb06172e5c444bea6347" 16 + }
+22
.sqlx/query-bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Timestamptz", 12 + "Timestamptz", 13 + "Bool", 14 + "Bool", 15 + "Text", 16 + "Text" 17 + ] 18 + }, 19 + "nullable": [] 20 + }, 21 + "hash": "bc466b477a4ec8374078e9ba38cc735895a52babc75d7e8009baed8e5e843c38" 22 + }
+8 -2
.sqlx/query-bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95.json .sqlx/query-09cc26fbdc2d210146dccc3f9d1e6e82814596eadfd20d814e9f0d3f615127a8.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE current_refresh_token = $1\n ", 3 + "query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope, controller_did\n FROM oauth_token\n WHERE current_refresh_token = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 72 72 "ordinal": 13, 73 73 "name": "scope", 74 74 "type_info": "Text" 75 + }, 76 + { 77 + "ordinal": 14, 78 + "name": "controller_did", 79 + "type_info": "Text" 75 80 } 76 81 ], 77 82 "parameters": { ··· 93 98 true, 94 99 true, 95 100 true, 101 + true, 96 102 true 97 103 ] 98 104 }, 99 - "hash": "bc816a96fa2e186cd0ff279f98543bebd9a815677d86fa8852f51fe76f95ce95" 105 + "hash": "09cc26fbdc2d210146dccc3f9d1e6e82814596eadfd20d814e9f0d3f615127a8" 100 106 }
+23
.sqlx/query-c32aa6a95bf31d41eb2c60b97e6a90ae6a3ff84cc48e52459bc8657a7ce36413.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM app_passwords\n WHERE user_id = (SELECT id FROM users WHERE did = $1)\n AND created_by_controller_did = $2\n RETURNING id", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "c32aa6a95bf31d41eb2c60b97e6a90ae6a3ff84cc48e52459bc8657a7ce36413" 23 + }
+28
.sqlx/query-c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "password_hash", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + true 25 + ] 26 + }, 27 + "hash": "c4621f6a8a1ab78a6355b09fdfc2bf8999d276564e93015792ec07cb05e79038" 28 + }
+40
.sqlx/query-ceb51f40c33d99fc17c37d7cb685152c5f9d447bcbbedd47e8fb34d358e7669a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n u.did,\n u.handle,\n d.granted_scopes,\n d.granted_at\n FROM account_delegations d\n JOIN users u ON u.did = d.delegated_did\n WHERE d.controller_did = $1\n AND d.revoked_at IS NULL\n AND u.deactivated_at IS NULL\n AND u.takedown_ref IS NULL\n ORDER BY d.granted_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "granted_scopes", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "granted_at", 24 + "type_info": "Timestamptz" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Text" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + false, 36 + false 37 + ] 38 + }, 39 + "hash": "ceb51f40c33d99fc17c37d7cb685152c5f9d447bcbbedd47e8fb34d358e7669a" 40 + }
+8 -2
.sqlx/query-d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3.json .sqlx/query-00cb951e3b8fcb33fd16a4f1ebfc1a6298c7068d891e0c67816e3db077953736.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT did, device_id, client_id, client_auth, parameters, expires_at, code\n FROM oauth_authorization_request\n WHERE id = $1\n ", 3 + "query": "\n SELECT did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did\n FROM oauth_authorization_request\n WHERE id = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 37 37 "ordinal": 6, 38 38 "name": "code", 39 39 "type_info": "Text" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "controller_did", 44 + "type_info": "Text" 40 45 } 41 46 ], 42 47 "parameters": { ··· 51 56 true, 52 57 false, 53 58 false, 59 + true, 54 60 true 55 61 ] 56 62 }, 57 - "hash": "d5ec5d1952918c1d6ca035446cc5ffb805f271d621116b3ab314a1c57e3ba5c3" 63 + "hash": "00cb951e3b8fcb33fd16a4f1ebfc1a6298c7068d891e0c67816e3db077953736" 58 64 }
+46
.sqlx/query-d8e33a911d741e636d1f0efd81f8fc528d9af2716887d0d72b70ca7c7d7eb11a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n u.did,\n u.handle,\n d.granted_scopes,\n d.granted_at,\n true as \"is_active!\"\n FROM account_delegations d\n JOIN users u ON u.did = d.controller_did\n WHERE d.delegated_did = $1\n AND d.revoked_at IS NULL\n AND u.deactivated_at IS NULL\n AND u.takedown_ref IS NULL\n ORDER BY d.granted_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "granted_scopes", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "granted_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "is_active!", 29 + "type_info": "Bool" 30 + } 31 + ], 32 + "parameters": { 33 + "Left": [ 34 + "Text" 35 + ] 36 + }, 37 + "nullable": [ 38 + false, 39 + false, 40 + false, 41 + false, 42 + null 43 + ] 44 + }, 45 + "hash": "d8e33a911d741e636d1f0efd81f8fc528d9af2716887d0d72b70ca7c7d7eb11a" 46 + }
+65
.sqlx/query-ddd3e85a88d9a782c54bdc33072747dd5db70cf76432e50635e22343092eadeb.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, delegated_did, controller_did, granted_scopes,\n granted_at, granted_by, revoked_at, revoked_by\n FROM account_delegations\n WHERE delegated_did = $1 AND controller_did = $2 AND revoked_at IS NULL\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "delegated_did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "controller_did", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "granted_scopes", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "granted_at", 29 + "type_info": "Timestamptz" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "granted_by", 34 + "type_info": "Text" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "revoked_at", 39 + "type_info": "Timestamptz" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "revoked_by", 44 + "type_info": "Text" 45 + } 46 + ], 47 + "parameters": { 48 + "Left": [ 49 + "Text", 50 + "Text" 51 + ] 52 + }, 53 + "nullable": [ 54 + false, 55 + false, 56 + false, 57 + false, 58 + false, 59 + false, 60 + true, 61 + true 62 + ] 63 + }, 64 + "hash": "ddd3e85a88d9a782c54bdc33072747dd5db70cf76432e50635e22343092eadeb" 65 + }
+8 -2
.sqlx/query-df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4.json .sqlx/query-747a6f19cf9d6e971d359d8d269fe2e50e2ed3682c0bb746e7b2fbc5e493027a.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n DELETE FROM oauth_authorization_request\n WHERE code = $1\n RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code\n ", 3 + "query": "\n DELETE FROM oauth_authorization_request\n WHERE code = $1\n RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 37 37 "ordinal": 6, 38 38 "name": "code", 39 39 "type_info": "Text" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "controller_did", 44 + "type_info": "Text" 40 45 } 41 46 ], 42 47 "parameters": { ··· 51 56 true, 52 57 false, 53 58 false, 59 + true, 54 60 true 55 61 ] 56 62 }, 57 - "hash": "df7b49e30dd3388a7f0e6e8b531f0bf15f52cf6e943f7fe74382ac8090a3caf4" 63 + "hash": "747a6f19cf9d6e971d359d8d269fe2e50e2ed3682c0bb746e7b2fbc5e493027a" 58 64 }
+16
.sqlx/query-eb7fe20b8124f1e9ba0f1ba74a4640cae40d6d1b1ddd503080cb75385246d7e1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n UPDATE account_delegations\n SET revoked_at = NOW(), revoked_by = $1\n WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "eb7fe20b8124f1e9ba0f1ba74a4640cae40d6d1b1ddd503080cb75385246d7e1" 16 + }
+9 -3
.sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json .sqlx/query-c7353563d686b963723fb049b3a3f9f0162afef510b91926e29cf74ec05d25c6.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ", 3 + "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified,\n account_type::text as \"account_type!\"\n FROM users\n WHERE handle = $1 OR email = $1\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 79 79 "ordinal": 12, 80 80 "name": "signal_verified", 81 81 "type_info": "Bool" 82 + }, 83 + { 84 + "ordinal": 13, 85 + "name": "account_type!", 86 + "type_info": "Text" 82 87 } 83 88 ], 84 89 "parameters": { ··· 99 104 false, 100 105 false, 101 106 false, 102 - false 107 + false, 108 + null 103 109 ] 104 110 }, 105 - "hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f" 111 + "hash": "c7353563d686b963723fb049b3a3f9f0162afef510b91926e29cf74ec05d25c6" 106 112 }
+87
.sqlx/query-f18172e06c03978fb56a4e3acc9a926bdd0414f7883539113f7ec2d640ce184a.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n id,\n delegated_did,\n actor_did,\n controller_did,\n action_type as \"action_type: DelegationActionType\",\n action_details,\n ip_address,\n user_agent,\n created_at\n FROM delegation_audit_log\n WHERE delegated_did = $1\n ORDER BY created_at DESC\n LIMIT $2 OFFSET $3\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "delegated_did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "actor_did", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "controller_did", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "action_type: DelegationActionType", 29 + "type_info": { 30 + "Custom": { 31 + "name": "delegation_action_type", 32 + "kind": { 33 + "Enum": [ 34 + "grant_created", 35 + "grant_revoked", 36 + "scopes_modified", 37 + "token_issued", 38 + "repo_write", 39 + "blob_upload", 40 + "account_action" 41 + ] 42 + } 43 + } 44 + } 45 + }, 46 + { 47 + "ordinal": 5, 48 + "name": "action_details", 49 + "type_info": "Jsonb" 50 + }, 51 + { 52 + "ordinal": 6, 53 + "name": "ip_address", 54 + "type_info": "Text" 55 + }, 56 + { 57 + "ordinal": 7, 58 + "name": "user_agent", 59 + "type_info": "Text" 60 + }, 61 + { 62 + "ordinal": 8, 63 + "name": "created_at", 64 + "type_info": "Timestamptz" 65 + } 66 + ], 67 + "parameters": { 68 + "Left": [ 69 + "Text", 70 + "Int8", 71 + "Int8" 72 + ] 73 + }, 74 + "nullable": [ 75 + false, 76 + false, 77 + false, 78 + true, 79 + false, 80 + true, 81 + true, 82 + true, 83 + false 84 + ] 85 + }, 86 + "hash": "f18172e06c03978fb56a4e3acc9a926bdd0414f7883539113f7ec2d640ce184a" 87 + }
+43
.sqlx/query-f3cd43a21db350887127cd7e0cd24e95a70571cc5e9b2278dda49a2538d794ae.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO delegation_audit_log\n (delegated_did, actor_did, controller_did, action_type, action_details, ip_address, user_agent)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text", 15 + "Text", 16 + "Text", 17 + { 18 + "Custom": { 19 + "name": "delegation_action_type", 20 + "kind": { 21 + "Enum": [ 22 + "grant_created", 23 + "grant_revoked", 24 + "scopes_modified", 25 + "token_issued", 26 + "repo_write", 27 + "blob_upload", 28 + "account_action" 29 + ] 30 + } 31 + } 32 + }, 33 + "Jsonb", 34 + "Text", 35 + "Text" 36 + ] 37 + }, 38 + "nullable": [ 39 + false 40 + ] 41 + }, 42 + "hash": "f3cd43a21db350887127cd7e0cd24e95a70571cc5e9b2278dda49a2538d794ae" 43 + }
+8 -2
.sqlx/query-f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb.json .sqlx/query-bbd387655387724e97f819e78033682edffbd2463a65b2bb48ca73794dafdbcc.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 3 + "query": "SELECT name, created_at, privileged, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 22 22 "ordinal": 3, 23 23 "name": "scopes", 24 24 "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "created_by_controller_did", 29 + "type_info": "Text" 25 30 } 26 31 ], 27 32 "parameters": { ··· 33 38 false, 34 39 false, 35 40 false, 41 + true, 36 42 true 37 43 ] 38 44 }, 39 - "hash": "f47f2236dcc27bc203b0cd13cc022611492f0f82c572c5a536663e8d252cfafb" 45 + "hash": "bbd387655387724e97f819e78033682edffbd2463a65b2bb48ca73794dafdbcc" 40 46 }
+15
.sqlx/query-f631186890dc38141299d8ecf6feda13c4ab8bd6e3834c64b2cd508305bed3aa.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM oauth_token WHERE did = $1 AND controller_did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "f631186890dc38141299d8ecf6feda13c4ab8bd6e3834c64b2cd508305bed3aa" 15 + }
+8 -2
.sqlx/query-fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913.json .sqlx/query-06c00269b11c250e85bde385e18ae8df6b1cc122f584105a8ea98861ff89e1b9.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope\n FROM oauth_token\n WHERE previous_refresh_token = $1 AND rotated_at > $2\n ", 3 + "query": "\n SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,\n device_id, parameters, details, code, current_refresh_token, scope, controller_did\n FROM oauth_token\n WHERE previous_refresh_token = $1 AND rotated_at > $2\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 72 72 "ordinal": 13, 73 73 "name": "scope", 74 74 "type_info": "Text" 75 + }, 76 + { 77 + "ordinal": 14, 78 + "name": "controller_did", 79 + "type_info": "Text" 75 80 } 76 81 ], 77 82 "parameters": { ··· 94 99 true, 95 100 true, 96 101 true, 102 + true, 97 103 true 98 104 ] 99 105 }, 100 - "hash": "fd291f783059a00c2ac29920bcb5f12a0553148d8a216eb21dd0e63d5a4b1913" 106 + "hash": "06c00269b11c250e85bde385e18ae8df6b1cc122f584105a8ea98861ff89e1b9" 101 107 }
+1 -1
README.md
··· 14 14 15 15 This software isn't an afterthought by a company with limited resources. 16 16 17 - It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 17 + It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 18 18 19 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20 20
+2 -17
TODO.md
··· 2 2 3 3 ## Active development 4 4 5 - ### Delegated accounts 6 - Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. 7 - 8 - - [ ] Account type flag in actors table (personal | delegated) 9 - - [ ] account_delegations table (delegated_did, controller_did, granted_scopes[], granted_at, granted_by, revoked_at) 10 - - [ ] Detect delegated account during authorize flow 11 - - [ ] Redirect to "authenticate as controller" instead of password prompt 12 - - [ ] Validate controller has delegation grant for this account 13 - - [ ] Issue token with intersection of (requested scopes :intersection-emoji: granted scopes) 14 - - [ ] Token includes act_as claim indicating delegation 15 - - [ ] Define standard scope sets (owner, admin, editor, viewer) 16 - - [ ] Create delegated account flow (no password, must add initial controller) 17 - - [ ] Controller management page (add/remove controllers, modify scopes) 18 - - [ ] "Act as" account switcher for users with delegation grants 19 - - [ ] Log all actions with both actor DID and controller DID 20 - - [ ] Audit log view for delegated account owners 21 - 22 5 ### Migration tool 23 6 Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states. 24 7 ··· 85 68 Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods. 86 69 87 70 App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords. 71 + 72 + Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
+12
frontend/src/App.svelte
··· 25 25 import OAuth2FA from './routes/OAuth2FA.svelte' 26 26 import OAuthTotp from './routes/OAuthTotp.svelte' 27 27 import OAuthPasskey from './routes/OAuthPasskey.svelte' 28 + import OAuthDelegation from './routes/OAuthDelegation.svelte' 28 29 import OAuthError from './routes/OAuthError.svelte' 29 30 import Security from './routes/Security.svelte' 30 31 import TrustedDevices from './routes/TrustedDevices.svelte' 32 + import Controllers from './routes/Controllers.svelte' 33 + import DelegationAudit from './routes/DelegationAudit.svelte' 34 + import ActAs from './routes/ActAs.svelte' 31 35 import Home from './routes/Home.svelte' 32 36 33 37 initI18n() ··· 95 99 return OAuthTotp 96 100 case '/oauth/passkey': 97 101 return OAuthPasskey 102 + case '/oauth/delegation': 103 + return OAuthDelegation 98 104 case '/oauth/error': 99 105 return OAuthError 100 106 case '/security': 101 107 return Security 102 108 case '/trusted-devices': 103 109 return TrustedDevices 110 + case '/controllers': 111 + return Controllers 112 + case '/delegation-audit': 113 + return DelegationAudit 114 + case '/act-as': 115 + return ActAs 104 116 default: 105 117 return Home 106 118 }
+1
frontend/src/lib/api.ts
··· 94 94 name: string; 95 95 createdAt: string; 96 96 scopes?: string; 97 + createdByController?: string; 97 98 } 98 99 99 100 export interface InviteCode {
+4 -4
frontend/src/lib/oauth.ts
··· 44 44 ); 45 45 } 46 46 47 - async function generateCodeChallenge(verifier: string): Promise<string> { 47 + export async function generateCodeChallenge(verifier: string): Promise<string> { 48 48 const hash = await sha256(verifier); 49 49 return base64UrlEncode(hash); 50 50 } 51 51 52 - function generateState(): string { 52 + export function generateState(): string { 53 53 return generateRandomString(32); 54 54 } 55 55 56 - function generateCodeVerifier(): string { 56 + export function generateCodeVerifier(): string { 57 57 return generateRandomString(32); 58 58 } 59 59 60 - function saveOAuthState(state: OAuthState): void { 60 + export function saveOAuthState(state: OAuthState): void { 61 61 sessionStorage.setItem(OAUTH_STATE_KEY, state.state); 62 62 sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier); 63 63 }
+118 -1
frontend/src/locales/en.json
··· 6 6 "cancel": "Cancel", 7 7 "back": "Back", 8 8 "done": "Done", 9 + "continue": "Continue", 9 10 "refresh": "Refresh", 10 11 "create": "Create", 11 12 "delete": "Delete", ··· 271 272 "scopeFull": "Full Access", 272 273 "scopeReadOnly": "Read Only", 273 274 "scopePostOnly": "Post Only", 274 - "scopeCustom": "Custom" 275 + "scopeCustom": "Custom", 276 + "byController": "By Controller" 275 277 }, 276 278 "sessions": { 277 279 "title": "Active Sessions", ··· 852 854 "verify": "Verify", 853 855 "verifying": "Verifying...", 854 856 "cancel": "Cancel" 857 + }, 858 + "delegation": { 859 + "title": "Account Delegation", 860 + "loading": "Loading...", 861 + "controllers": "Controllers", 862 + "controllersDesc": "Accounts that can act on your behalf", 863 + "noControllers": "No controllers have been granted access to your account.", 864 + "inactive": "Inactive", 865 + "did": "DID", 866 + "granted": "Granted", 867 + "remove": "Remove", 868 + "removeConfirm": "Are you sure you want to remove this controller?", 869 + "cannotAddControllers": "You cannot add controllers because this account controls other accounts. An account can either have controllers or control other accounts, but not both.", 870 + "addController": "Add Controller", 871 + "controllerDid": "Controller DID", 872 + "accessLevel": "Access Level", 873 + "adding": "Adding...", 874 + "addControllerButton": "+ Add Controller", 875 + "controllerAdded": "Controller added successfully", 876 + "controllerRemoved": "Controller removed successfully", 877 + "failedToAddController": "Failed to add controller", 878 + "failedToRemoveController": "Failed to remove controller", 879 + "controlledAccounts": "Controlled Accounts", 880 + "controlledAccountsDesc": "Accounts you can act on behalf of", 881 + "noControlledAccounts": "You do not have access to any delegated accounts.", 882 + "actAs": "Act As", 883 + "cannotControlAccounts": "You cannot control other accounts because this account has controllers. An account can either have controllers or control other accounts, but not both.", 884 + "createDelegatedAccount": "Create Delegated Account", 885 + "handle": "Handle", 886 + "emailOptional": "Email (optional)", 887 + "yourAccessLevel": "Your Access Level", 888 + "creating": "Creating...", 889 + "createAccount": "Create Account", 890 + "createDelegatedAccountButton": "+ Create Delegated Account", 891 + "accountCreated": "Created delegated account: {handle}", 892 + "failedToCreateAccount": "Failed to create delegated account", 893 + "auditLog": "Audit Log", 894 + "auditLogDesc": "View all delegation activity", 895 + "viewAuditLog": "View Audit Log", 896 + "scopeOwner": "Owner", 897 + "scopeViewer": "Viewer", 898 + "scopeCustom": "Custom", 899 + "backToControllers": "Back to Controllers", 900 + "auditLogTitle": "Delegation Audit Log", 901 + "noActivity": "No delegation activity recorded.", 902 + "actor": "Actor", 903 + "controller": "Controller", 904 + "account": "Account", 905 + "details": "Details", 906 + "previous": "Previous", 907 + "next": "Next", 908 + "showing": "Showing {start} - {end} of {total}", 909 + "refresh": "Refresh", 910 + "failedToLoadAuditLog": "Failed to load audit log", 911 + "actionGrantCreated": "Grant Created", 912 + "actionGrantRevoked": "Grant Revoked", 913 + "actionScopesModified": "Scopes Modified", 914 + "actionTokenIssued": "Token Issued", 915 + "actionRepoWrite": "Repository Write", 916 + "actionBlobUpload": "Blob Upload", 917 + "actionAccountAction": "Account Action" 918 + }, 919 + "actAs": { 920 + "noAccountSpecified": "No account DID specified", 921 + "failedToVerify": "Failed to verify delegation access", 922 + "noAccess": "You do not have access to this account", 923 + "failedToInitiate": "Failed to initiate OAuth flow", 924 + "invalidResponse": "Invalid OAuth response", 925 + "failedError": "Failed to initiate act-as: {error}", 926 + "preparing": "Preparing to switch accounts...", 927 + "title": "Act As", 928 + "backToControllers": "Back to Controllers" 929 + }, 930 + "oauthDelegation": { 931 + "loading": "Loading...", 932 + "title": "Delegated Account", 933 + "isDelegated": "{handle} is a delegated account.", 934 + "enterControllerHandle": "Sign in with your controller account to access this account.", 935 + "controllerHandle": "Controller handle", 936 + "handlePlaceholder": "handle.example.com", 937 + "checking": "Checking...", 938 + "controllerNotFound": "Account not found or you don't have access to this delegated account", 939 + "missingParams": "Missing delegation parameters", 940 + "missingInfo": "Missing required information", 941 + "passkeyCancelled": "Passkey authentication cancelled", 942 + "passkeyFailed": "Passkey authentication failed", 943 + "failedPasskeyStart": "Failed to start passkey login", 944 + "authFailed": "Authentication failed", 945 + "unexpectedResponse": "Unexpected response from server", 946 + "signInAsController": "Sign In as Controller", 947 + "authenticateAs": "Authenticate as {controller} to act on behalf of {delegated}", 948 + "useDifferentController": "Use a different controller", 949 + "signInWithPasskey": "Sign in with Passkey", 950 + "authenticating": "Authenticating...", 951 + "usePasskey": "Use Passkey", 952 + "or": "or", 953 + "password": "Password", 954 + "enterPassword": "Enter password", 955 + "rememberDevice": "Remember this device", 956 + "signingIn": "Signing in...", 957 + "signIn": "Sign In", 958 + "goBack": "Go Back", 959 + "unableToLoad": "Unable to load delegation info" 960 + }, 961 + "oauthConsent": { 962 + "delegatedAccess": "Delegated Access", 963 + "actingAs": "Acting as", 964 + "controller": "Controller", 965 + "accessLevel": "Access Level", 966 + "readOnlyAccess": "Read-Only Access", 967 + "readOnlyDesc": "View public information only. No write access to this account.", 968 + "permissionsLimited": "Permissions Limited", 969 + "permissionsLimitedDesc": "Your actual permissions will be limited to your {level} access level, regardless of what the app requests.", 970 + "viewerLimitedDesc": "As a Viewer, you have read-only access. This app will not be able to create, update, or delete content on this account.", 971 + "editorLimitedDesc": "As an Editor, you can create and edit content but cannot manage account settings or security." 855 972 }, 856 973 "verifyChannel": { 857 974 "title": "Verify Channel",
+118 -1
frontend/src/locales/fi.json
··· 6 6 "cancel": "Peruuta", 7 7 "back": "Takaisin", 8 8 "done": "Valmis", 9 + "continue": "Jatka", 9 10 "refresh": "Päivitä", 10 11 "create": "Luo", 11 12 "delete": "Poista", ··· 271 272 "scopeFull": "Täydet oikeudet", 272 273 "scopeReadOnly": "Vain luku", 273 274 "scopePostOnly": "Vain julkaisut", 274 - "scopeCustom": "Mukautettu" 275 + "scopeCustom": "Mukautettu", 276 + "byController": "Hallinnoijan luoma" 275 277 }, 276 278 "sessions": { 277 279 "title": "Aktiiviset istunnot", ··· 888 890 "codeLabel": "Vahvistuskoodi", 889 891 "codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat.", 890 892 "verifyButton": "Vahvista" 893 + }, 894 + "delegation": { 895 + "title": "Tilin delegointi", 896 + "loading": "Ladataan...", 897 + "controllers": "Hallinnoijat", 898 + "controllersDesc": "Tilit, jotka voivat toimia puolestasi", 899 + "noControllers": "Tilillesi ei ole myönnetty hallinnoijia.", 900 + "inactive": "Ei käytössä", 901 + "did": "DID", 902 + "granted": "Myönnetty", 903 + "remove": "Poista", 904 + "removeConfirm": "Haluatko varmasti poistaa tämän hallinnoijan?", 905 + "cannotAddControllers": "Et voi lisätä hallinnoijia, koska tämä tili hallinnoi muita tilejä. Tili voi joko olla hallinnoija tai hallinnoidaan, mutta ei molempia.", 906 + "addController": "Lisää hallinnoija", 907 + "controllerDid": "Hallinnoijan DID", 908 + "accessLevel": "Käyttöoikeustaso", 909 + "adding": "Lisätään...", 910 + "addControllerButton": "+ Lisää hallinnoija", 911 + "controllerAdded": "Hallinnoija lisätty", 912 + "controllerRemoved": "Hallinnoija poistettu", 913 + "failedToAddController": "Hallinnoijan lisääminen epäonnistui", 914 + "failedToRemoveController": "Hallinnoijan poistaminen epäonnistui", 915 + "controlledAccounts": "Hallinnoidut tilit", 916 + "controlledAccountsDesc": "Tilit, joiden puolesta voit toimia", 917 + "noControlledAccounts": "Sinulla ei ole pääsyä delegoituihin tileihin.", 918 + "actAs": "Toimi käyttäjänä", 919 + "cannotControlAccounts": "Et voi hallinnoida muita tilejä, koska tällä tilillä on hallinnoijia. Tili voi joko olla hallinnoija tai hallinnoidaan, mutta ei molempia.", 920 + "createDelegatedAccount": "Luo delegoitu tili", 921 + "handle": "Käyttäjänimi", 922 + "emailOptional": "Sähköposti (valinnainen)", 923 + "yourAccessLevel": "Käyttöoikeustasosi", 924 + "creating": "Luodaan...", 925 + "createAccount": "Luo tili", 926 + "createDelegatedAccountButton": "+ Luo delegoitu tili", 927 + "accountCreated": "Delegoitu tili luotu: {handle}", 928 + "failedToCreateAccount": "Delegoidun tilin luominen epäonnistui", 929 + "auditLog": "Tapahtumaloki", 930 + "auditLogDesc": "Näytä kaikki delegointitoiminta", 931 + "viewAuditLog": "Näytä tapahtumaloki", 932 + "scopeOwner": "Omistaja", 933 + "scopeViewer": "Katsoja", 934 + "scopeCustom": "Mukautettu", 935 + "backToControllers": "Takaisin hallinnoijiin", 936 + "auditLogTitle": "Delegoinnin tapahtumaloki", 937 + "noActivity": "Delegointitoimintaa ei ole tallennettu.", 938 + "actor": "Toimija", 939 + "controller": "Hallinnoija", 940 + "account": "Tili", 941 + "details": "Tiedot", 942 + "previous": "Edellinen", 943 + "next": "Seuraava", 944 + "showing": "Näytetään {start} - {end} / {total}", 945 + "refresh": "Päivitä", 946 + "failedToLoadAuditLog": "Tapahtumalokin lataaminen epäonnistui", 947 + "actionGrantCreated": "Oikeus luotu", 948 + "actionGrantRevoked": "Oikeus peruttu", 949 + "actionScopesModified": "Oikeuksia muokattu", 950 + "actionTokenIssued": "Token myönnetty", 951 + "actionRepoWrite": "Tietovaraston kirjoitus", 952 + "actionBlobUpload": "Tiedoston lataus", 953 + "actionAccountAction": "Tilitoiminto" 954 + }, 955 + "actAs": { 956 + "noAccountSpecified": "Tilin DID:tä ei määritetty", 957 + "failedToVerify": "Delegointioikeuden tarkistus epäonnistui", 958 + "noAccess": "Sinulla ei ole pääsyä tähän tiliin", 959 + "failedToInitiate": "OAuth-kirjautumisen aloitus epäonnistui", 960 + "invalidResponse": "Virheellinen OAuth-vastaus", 961 + "failedError": "Toiminto epäonnistui: {error}", 962 + "preparing": "Valmistellaan tilin vaihtoa...", 963 + "title": "Toimi käyttäjänä", 964 + "backToControllers": "Takaisin hallinnoijiin" 965 + }, 966 + "oauthDelegation": { 967 + "loading": "Ladataan...", 968 + "title": "Delegoitu tili", 969 + "isDelegated": "{handle} on delegoitu tili.", 970 + "enterControllerHandle": "Kirjaudu hallinnoijatililläsi päästäksesi tähän tiliin.", 971 + "controllerHandle": "Hallinnoijan käyttäjätunnus", 972 + "handlePlaceholder": "tunnus.esimerkki.fi", 973 + "checking": "Tarkistetaan...", 974 + "controllerNotFound": "Tiliä ei löytynyt tai sinulla ei ole pääsyä tähän delegoituun tiliin", 975 + "missingParams": "Delegointiparametrit puuttuvat", 976 + "missingInfo": "Vaaditut tiedot puuttuvat", 977 + "passkeyCancelled": "Pääsyavaintunnistautuminen peruutettu", 978 + "passkeyFailed": "Pääsyavaintunnistautuminen epäonnistui", 979 + "failedPasskeyStart": "Pääsyavainkirjautumisen aloitus epäonnistui", 980 + "authFailed": "Tunnistautuminen epäonnistui", 981 + "unexpectedResponse": "Odottamaton vastaus palvelimelta", 982 + "signInAsController": "Kirjaudu hallinnoijana", 983 + "authenticateAs": "Tunnistaudu käyttäjänä {controller} toimiaksesi käyttäjän {delegated} puolesta", 984 + "useDifferentController": "Käytä toista hallinnoijaa", 985 + "signInWithPasskey": "Kirjaudu pääsyavaimella", 986 + "authenticating": "Tunnistaudutaan...", 987 + "usePasskey": "Käytä pääsyavainta", 988 + "or": "tai", 989 + "password": "Salasana", 990 + "enterPassword": "Syötä salasana", 991 + "rememberDevice": "Muista tämä laite", 992 + "signingIn": "Kirjaudutaan...", 993 + "signIn": "Kirjaudu", 994 + "goBack": "Palaa takaisin", 995 + "unableToLoad": "Delegointitietoja ei voitu ladata" 996 + }, 997 + "oauthConsent": { 998 + "delegatedAccess": "Delegoitu pääsy", 999 + "actingAs": "Toimii käyttäjänä", 1000 + "controller": "Hallinnoija", 1001 + "accessLevel": "Käyttöoikeustaso", 1002 + "readOnlyAccess": "Vain luku -oikeus", 1003 + "readOnlyDesc": "Näytä vain julkiset tiedot. Ei kirjoitusoikeutta tähän tiliin.", 1004 + "permissionsLimited": "Oikeudet rajoitettu", 1005 + "permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.", 1006 + "viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.", 1007 + "editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa." 891 1008 } 892 1009 }
+140 -1
frontend/src/locales/ja.json
··· 6 6 "cancel": "キャンセル", 7 7 "back": "戻る", 8 8 "done": "完了", 9 + "continue": "続行", 9 10 "refresh": "更新", 10 11 "create": "作成", 11 12 "delete": "削除", ··· 271 272 "scopeFull": "フルアクセス", 272 273 "scopeReadOnly": "読み取り専用", 273 274 "scopePostOnly": "投稿のみ", 274 - "scopeCustom": "カスタム" 275 + "scopeCustom": "カスタム", 276 + "byController": "管理者作成" 275 277 }, 276 278 "sessions": { 277 279 "title": "アクティブセッション", ··· 888 890 "codeLabel": "認証コード", 889 891 "codeHelp": "メッセージからハイフンを含む完全なコードをコピーしてください。", 890 892 "verifyButton": "認証" 893 + }, 894 + "delegation": { 895 + "title": "アカウント委任", 896 + "controllers": "コントローラー", 897 + "controllersDescription": "コントローラーはあなたのアカウントの管理者として行動できます。あなたが許可した操作を実行し、あなたの代わりに投稿を作成し、リポジトリを変更できます。", 898 + "controlledAccounts": "管理アカウント", 899 + "controlledAccountsDescription": "これらはあなたがコントローラーとして追加されているアカウントです。これらのアカウントで許可されたアクションを実行できます。", 900 + "noControllers": "コントローラーはまだいません", 901 + "noControlledAccounts": "管理アカウントはありません", 902 + "addController": "コントローラーを追加", 903 + "revokeAccess": "アクセスを取り消す", 904 + "revokeConfirm": "このコントローラーのアクセスを取り消しますか?あなたのアカウントで操作できなくなります。", 905 + "handle": "ハンドル", 906 + "handlePlaceholder": "@user.bsky.social", 907 + "did": "DID", 908 + "didPlaceholder": "did:plc:...", 909 + "scopes": "権限レベル", 910 + "scopeOwner": "オーナー", 911 + "scopeOwnerDesc": "完全な管理(すべてのアクションを実行可能)", 912 + "scopeAdmin": "管理者", 913 + "scopeAdminDesc": "投稿、アプリパスワード、設定の管理", 914 + "scopeEditor": "編集者", 915 + "scopeEditorDesc": "投稿、いいね、フォローの作成・管理", 916 + "scopeViewer": "閲覧者", 917 + "scopeViewerDesc": "リポジトリと設定の読み取り専用アクセス", 918 + "scopeCustom": "カスタム", 919 + "scopeCustomDesc": "個別の権限を選択", 920 + "grantedAt": "許可日時", 921 + "expiresAt": "有効期限", 922 + "noExpiration": "無期限", 923 + "actAs": "として行動", 924 + "auditLog": "監査ログ", 925 + "auditLogTitle": "委任監査ログ", 926 + "backToControllers": "← コントローラーに戻る", 927 + "loading": "読み込み中...", 928 + "noActivity": "アクティビティはまだありません", 929 + "actor": "アクター", 930 + "controller": "コントローラー", 931 + "account": "アカウント", 932 + "details": "詳細", 933 + "actionGrantCreated": "許可作成", 934 + "actionGrantRevoked": "許可取り消し", 935 + "actionScopesModified": "権限変更", 936 + "actionTokenIssued": "トークン発行", 937 + "actionRepoWrite": "リポジトリ書き込み", 938 + "actionBlobUpload": "Blobアップロード", 939 + "actionAccountAction": "アカウントアクション", 940 + "previous": "前へ", 941 + "next": "次へ", 942 + "showing": "{start}~{end} / {total}件", 943 + "refresh": "更新", 944 + "failedToLoadAuditLog": "監査ログの読み込みに失敗しました", 945 + "addControllerTitle": "コントローラーを追加", 946 + "addControllerDescription": "このアカウントに対して指定した権限で操作できるユーザーを追加します。", 947 + "controllerIdentifier": "コントローラーのハンドルまたはDID", 948 + "selectScopes": "権限レベルを選択", 949 + "add": "追加", 950 + "adding": "追加中...", 951 + "cancel": "キャンセル", 952 + "accessLevel": "アクセスレベル", 953 + "addControllerButton": "+ コントローラーを追加", 954 + "auditLogDesc": "すべての委任アクティビティを表示", 955 + "cannotAddControllers": "他のアカウントを管理しているため、コントローラーを追加できません。アカウントはコントローラーを持つか、他のアカウントを管理するかのいずれかのみ可能です。", 956 + "cannotControlAccounts": "このアカウントにはコントローラーがいるため、他のアカウントを管理できません。アカウントはコントローラーを持つか、他のアカウントを管理するかのいずれかのみ可能です。", 957 + "controlledAccountsDesc": "あなたが代わりに操作できるアカウント", 958 + "controllerAdded": "コントローラーを追加しました", 959 + "controllerDid": "コントローラーDID", 960 + "controllerRemoved": "コントローラーを削除しました", 961 + "controllersDesc": "あなたの代わりに操作できるアカウント", 962 + "createAccount": "アカウントを作成", 963 + "createDelegatedAccount": "委任アカウントを作成", 964 + "createDelegatedAccountButton": "+ 委任アカウントを作成", 965 + "creating": "作成中...", 966 + "emailOptional": "メール(任意)", 967 + "failedToAddController": "コントローラーの追加に失敗しました", 968 + "failedToCreateAccount": "委任アカウントの作成に失敗しました", 969 + "failedToRemoveController": "コントローラーの削除に失敗しました", 970 + "granted": "許可日", 971 + "inactive": "非アクティブ", 972 + "remove": "削除", 973 + "removeConfirm": "このコントローラーを削除しますか?", 974 + "viewAuditLog": "監査ログを表示", 975 + "yourAccessLevel": "あなたのアクセスレベル" 976 + }, 977 + "actAs": { 978 + "title": "として行動", 979 + "noAccountSpecified": "アカウントDIDが指定されていません", 980 + "failedToVerify": "アカウントへのアクセスを確認できませんでした", 981 + "noAccess": "このアカウントへのアクセス権がありません", 982 + "failedToInitiate": "認証の開始に失敗しました", 983 + "invalidResponse": "サーバーからの応答が無効です", 984 + "failedError": "失敗しました: {error}", 985 + "preparing": "委任アカウントへのログインを準備中...", 986 + "backToControllers": "コントローラーに戻る" 987 + }, 988 + "oauthDelegation": { 989 + "loading": "読み込み中...", 990 + "title": "委任アカウント", 991 + "isDelegated": "{handle} は委任アカウントです。", 992 + "enterControllerHandle": "このアカウントにアクセスするには、コントローラーアカウントでサインインしてください。", 993 + "controllerHandle": "コントローラーハンドル", 994 + "handlePlaceholder": "handle.example.com", 995 + "checking": "確認中...", 996 + "controllerNotFound": "アカウントが見つからないか、この委任アカウントへのアクセス権がありません", 997 + "missingParams": "委任パラメータが見つかりません", 998 + "missingInfo": "必要な情報がありません", 999 + "passkeyCancelled": "パスキー認証がキャンセルされました", 1000 + "passkeyFailed": "パスキー認証に失敗しました", 1001 + "failedPasskeyStart": "パスキーログインの開始に失敗しました", 1002 + "authFailed": "認証に失敗しました", 1003 + "unexpectedResponse": "サーバーから予期しない応答がありました", 1004 + "signInAsController": "コントローラーとしてサインイン", 1005 + "authenticateAs": "{controller} として認証して {delegated} の代わりに操作します", 1006 + "useDifferentController": "別のコントローラーを使用", 1007 + "signInWithPasskey": "パスキーでサインイン", 1008 + "authenticating": "認証中...", 1009 + "usePasskey": "パスキーを使用", 1010 + "or": "または", 1011 + "password": "パスワード", 1012 + "enterPassword": "パスワードを入力", 1013 + "rememberDevice": "このデバイスを記憶する", 1014 + "signingIn": "サインイン中...", 1015 + "signIn": "サインイン", 1016 + "goBack": "戻る", 1017 + "unableToLoad": "委任情報を読み込めませんでした" 1018 + }, 1019 + "oauthConsent": { 1020 + "delegatedAccess": "委任アクセス", 1021 + "actingAs": "次として行動中", 1022 + "controller": "コントローラー", 1023 + "accessLevel": "アクセスレベル", 1024 + "readOnlyAccess": "読み取り専用アクセス", 1025 + "readOnlyDesc": "公開情報のみ閲覧可能。このアカウントへの書き込みアクセスはありません。", 1026 + "permissionsLimited": "権限が制限されています", 1027 + "permissionsLimitedDesc": "アプリが何を要求しても、実際の権限は{level}アクセスレベルに制限されます。", 1028 + "viewerLimitedDesc": "閲覧者として、読み取り専用アクセスのみ可能です。このアプリはこのアカウントでコンテンツの作成、更新、削除ができません。", 1029 + "editorLimitedDesc": "編集者として、コンテンツの作成と編集が可能ですが、アカウント設定やセキュリティの管理はできません。" 891 1030 } 892 1031 }
+140 -1
frontend/src/locales/ko.json
··· 6 6 "cancel": "취소", 7 7 "back": "뒤로", 8 8 "done": "완료", 9 + "continue": "계속", 9 10 "refresh": "새로고침", 10 11 "create": "생성", 11 12 "delete": "삭제", ··· 271 272 "scopeFull": "전체 권한", 272 273 "scopeReadOnly": "읽기 전용", 273 274 "scopePostOnly": "게시만 가능", 274 - "scopeCustom": "사용자 지정" 275 + "scopeCustom": "사용자 지정", 276 + "byController": "컨트롤러 생성" 275 277 }, 276 278 "sessions": { 277 279 "title": "활성 세션", ··· 888 890 "codeLabel": "인증 코드", 889 891 "codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요.", 890 892 "verifyButton": "인증" 893 + }, 894 + "delegation": { 895 + "title": "계정 위임", 896 + "controllers": "컨트롤러", 897 + "controllersDescription": "컨트롤러는 귀하의 계정 관리자로서 행동할 수 있습니다. 귀하가 허용한 작업을 수행하고, 귀하를 대신하여 게시물을 생성하고, 저장소를 수정할 수 있습니다.", 898 + "controlledAccounts": "관리 계정", 899 + "controlledAccountsDescription": "귀하가 컨트롤러로 추가된 계정들입니다. 이 계정들에서 허용된 작업을 수행할 수 있습니다.", 900 + "noControllers": "아직 컨트롤러가 없습니다", 901 + "noControlledAccounts": "관리 계정이 없습니다", 902 + "addController": "컨트롤러 추가", 903 + "revokeAccess": "액세스 취소", 904 + "revokeConfirm": "이 컨트롤러의 액세스를 취소하시겠습니까? 귀하의 계정에서 더 이상 작업을 수행할 수 없습니다.", 905 + "handle": "핸들", 906 + "handlePlaceholder": "@user.bsky.social", 907 + "did": "DID", 908 + "didPlaceholder": "did:plc:...", 909 + "scopes": "권한 수준", 910 + "scopeOwner": "소유자", 911 + "scopeOwnerDesc": "전체 관리(모든 작업 수행 가능)", 912 + "scopeAdmin": "관리자", 913 + "scopeAdminDesc": "게시물, 앱 비밀번호, 설정 관리", 914 + "scopeEditor": "편집자", 915 + "scopeEditorDesc": "게시물, 좋아요, 팔로우 생성 및 관리", 916 + "scopeViewer": "뷰어", 917 + "scopeViewerDesc": "저장소 및 설정 읽기 전용 액세스", 918 + "scopeCustom": "사용자 정의", 919 + "scopeCustomDesc": "개별 권한 선택", 920 + "grantedAt": "허용 일시", 921 + "expiresAt": "만료", 922 + "noExpiration": "무기한", 923 + "actAs": "로 활동", 924 + "auditLog": "감사 로그", 925 + "auditLogTitle": "위임 감사 로그", 926 + "backToControllers": "← 컨트롤러로 돌아가기", 927 + "loading": "로딩 중...", 928 + "noActivity": "아직 활동이 없습니다", 929 + "actor": "액터", 930 + "controller": "컨트롤러", 931 + "account": "계정", 932 + "details": "세부정보", 933 + "actionGrantCreated": "권한 생성", 934 + "actionGrantRevoked": "권한 취소", 935 + "actionScopesModified": "권한 수정", 936 + "actionTokenIssued": "토큰 발급", 937 + "actionRepoWrite": "저장소 쓰기", 938 + "actionBlobUpload": "Blob 업로드", 939 + "actionAccountAction": "계정 작업", 940 + "previous": "이전", 941 + "next": "다음", 942 + "showing": "{start}~{end} / {total}개", 943 + "refresh": "새로고침", 944 + "failedToLoadAuditLog": "감사 로그를 불러오지 못했습니다", 945 + "addControllerTitle": "컨트롤러 추가", 946 + "addControllerDescription": "이 계정에서 지정된 권한으로 작업할 수 있는 사용자를 추가합니다.", 947 + "controllerIdentifier": "컨트롤러 핸들 또는 DID", 948 + "selectScopes": "권한 수준 선택", 949 + "add": "추가", 950 + "adding": "추가 중...", 951 + "cancel": "취소", 952 + "accessLevel": "액세스 수준", 953 + "addControllerButton": "+ 컨트롤러 추가", 954 + "auditLogDesc": "모든 위임 활동 보기", 955 + "cannotAddControllers": "다른 계정을 관리하고 있어 컨트롤러를 추가할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.", 956 + "cannotControlAccounts": "이 계정에 컨트롤러가 있어 다른 계정을 관리할 수 없습니다. 계정은 컨트롤러를 가지거나 다른 계정을 관리할 수 있지만 둘 다는 불가능합니다.", 957 + "controlledAccountsDesc": "귀하가 대신 작업할 수 있는 계정", 958 + "controllerAdded": "컨트롤러가 추가되었습니다", 959 + "controllerDid": "컨트롤러 DID", 960 + "controllerRemoved": "컨트롤러가 제거되었습니다", 961 + "controllersDesc": "귀하를 대신하여 작업할 수 있는 계정", 962 + "createAccount": "계정 생성", 963 + "createDelegatedAccount": "위임 계정 생성", 964 + "createDelegatedAccountButton": "+ 위임 계정 생성", 965 + "creating": "생성 중...", 966 + "emailOptional": "이메일 (선택사항)", 967 + "failedToAddController": "컨트롤러 추가에 실패했습니다", 968 + "failedToCreateAccount": "위임 계정 생성에 실패했습니다", 969 + "failedToRemoveController": "컨트롤러 제거에 실패했습니다", 970 + "granted": "허용일", 971 + "inactive": "비활성", 972 + "remove": "제거", 973 + "removeConfirm": "이 컨트롤러를 제거하시겠습니까?", 974 + "viewAuditLog": "감사 로그 보기", 975 + "yourAccessLevel": "귀하의 액세스 수준" 976 + }, 977 + "actAs": { 978 + "title": "로 활동", 979 + "noAccountSpecified": "계정 DID가 지정되지 않았습니다", 980 + "failedToVerify": "계정 액세스를 확인하지 못했습니다", 981 + "noAccess": "이 계정에 대한 액세스 권한이 없습니다", 982 + "failedToInitiate": "인증 시작에 실패했습니다", 983 + "invalidResponse": "서버에서 잘못된 응답을 받았습니다", 984 + "failedError": "실패: {error}", 985 + "preparing": "위임 계정 로그인 준비 중...", 986 + "backToControllers": "컨트롤러로 돌아가기" 987 + }, 988 + "oauthDelegation": { 989 + "loading": "로딩 중...", 990 + "title": "위임 계정", 991 + "isDelegated": "{handle}은(는) 위임 계정입니다.", 992 + "enterControllerHandle": "이 계정에 액세스하려면 컨트롤러 계정으로 로그인하세요.", 993 + "controllerHandle": "컨트롤러 핸들", 994 + "handlePlaceholder": "handle.example.com", 995 + "checking": "확인 중...", 996 + "controllerNotFound": "계정을 찾을 수 없거나 이 위임 계정에 대한 액세스 권한이 없습니다", 997 + "missingParams": "위임 매개변수가 없습니다", 998 + "missingInfo": "필요한 정보가 없습니다", 999 + "passkeyCancelled": "패스키 인증이 취소되었습니다", 1000 + "passkeyFailed": "패스키 인증에 실패했습니다", 1001 + "failedPasskeyStart": "패스키 로그인 시작에 실패했습니다", 1002 + "authFailed": "인증에 실패했습니다", 1003 + "unexpectedResponse": "서버에서 예기치 않은 응답을 받았습니다", 1004 + "signInAsController": "컨트롤러로 로그인", 1005 + "authenticateAs": "{controller}(으)로 인증하여 {delegated}를 대신합니다", 1006 + "useDifferentController": "다른 컨트롤러 사용", 1007 + "signInWithPasskey": "패스키로 로그인", 1008 + "authenticating": "인증 중...", 1009 + "usePasskey": "패스키 사용", 1010 + "or": "또는", 1011 + "password": "비밀번호", 1012 + "enterPassword": "비밀번호 입력", 1013 + "rememberDevice": "이 기기 기억하기", 1014 + "signingIn": "로그인 중...", 1015 + "signIn": "로그인", 1016 + "goBack": "뒤로", 1017 + "unableToLoad": "위임 정보를 로드할 수 없습니다" 1018 + }, 1019 + "oauthConsent": { 1020 + "delegatedAccess": "위임 액세스", 1021 + "actingAs": "활동 계정", 1022 + "controller": "컨트롤러", 1023 + "accessLevel": "액세스 수준", 1024 + "readOnlyAccess": "읽기 전용 액세스", 1025 + "readOnlyDesc": "공개 정보만 볼 수 있습니다. 이 계정에 대한 쓰기 권한이 없습니다.", 1026 + "permissionsLimited": "권한 제한됨", 1027 + "permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.", 1028 + "viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.", 1029 + "editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다." 891 1030 } 892 1031 }
+140 -1
frontend/src/locales/sv.json
··· 6 6 "cancel": "Avbryt", 7 7 "back": "Tillbaka", 8 8 "done": "Klar", 9 + "continue": "Fortsätt", 9 10 "refresh": "Uppdatera", 10 11 "create": "Skapa", 11 12 "delete": "Radera", ··· 271 272 "scopeFull": "Full åtkomst", 272 273 "scopeReadOnly": "Endast läsning", 273 274 "scopePostOnly": "Endast publicering", 274 - "scopeCustom": "Anpassad" 275 + "scopeCustom": "Anpassad", 276 + "byController": "Av controller" 275 277 }, 276 278 "sessions": { 277 279 "title": "Aktiva sessioner", ··· 888 890 "codeLabel": "Verifieringskod", 889 891 "codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck.", 890 892 "verifyButton": "Verifiera" 893 + }, 894 + "delegation": { 895 + "title": "Kontodelegering", 896 + "controllers": "Kontrollanter", 897 + "controllersDescription": "Kontrollanter kan agera som administratörer för ditt konto. De kan utföra åtgärder du tillåter, skapa inlägg för din räkning och modifiera din dataförvaring.", 898 + "controlledAccounts": "Kontrollerade konton", 899 + "controlledAccountsDescription": "Detta är konton där du har lagts till som kontrollant. Du kan utföra tillåtna åtgärder på dessa konton.", 900 + "noControllers": "Inga kontrollanter ännu", 901 + "noControlledAccounts": "Inga kontrollerade konton", 902 + "addController": "Lägg till kontrollant", 903 + "revokeAccess": "Återkalla åtkomst", 904 + "revokeConfirm": "Återkalla denna kontrollants åtkomst? De kommer inte längre kunna utföra åtgärder på ditt konto.", 905 + "handle": "Användarnamn", 906 + "handlePlaceholder": "@user.bsky.social", 907 + "did": "DID", 908 + "didPlaceholder": "did:plc:...", 909 + "scopes": "Behörighetsnivå", 910 + "scopeOwner": "Ägare", 911 + "scopeOwnerDesc": "Fullständig kontroll (kan utföra alla åtgärder)", 912 + "scopeAdmin": "Administratör", 913 + "scopeAdminDesc": "Hantera inlägg, applösenord, inställningar", 914 + "scopeEditor": "Redaktör", 915 + "scopeEditorDesc": "Skapa och hantera inlägg, gillningar, följningar", 916 + "scopeViewer": "Läsare", 917 + "scopeViewerDesc": "Endast läsåtkomst till dataförvaring och inställningar", 918 + "scopeCustom": "Anpassad", 919 + "scopeCustomDesc": "Välj individuella behörigheter", 920 + "grantedAt": "Beviljad", 921 + "expiresAt": "Upphör", 922 + "noExpiration": "Ingen utgång", 923 + "actAs": "Agera som", 924 + "auditLog": "Granskningslogg", 925 + "auditLogTitle": "Delegerings-granskningslogg", 926 + "backToControllers": "← Tillbaka till kontrollanter", 927 + "loading": "Laddar...", 928 + "noActivity": "Ingen aktivitet ännu", 929 + "actor": "Aktör", 930 + "controller": "Kontrollant", 931 + "account": "Konto", 932 + "details": "Detaljer", 933 + "actionGrantCreated": "Behörighet skapad", 934 + "actionGrantRevoked": "Behörighet återkallad", 935 + "actionScopesModified": "Behörigheter ändrade", 936 + "actionTokenIssued": "Token utfärdad", 937 + "actionRepoWrite": "Dataförvarsskrivning", 938 + "actionBlobUpload": "Blob-uppladdning", 939 + "actionAccountAction": "Kontoåtgärd", 940 + "previous": "Föregående", 941 + "next": "Nästa", 942 + "showing": "{start}–{end} av {total}", 943 + "refresh": "Uppdatera", 944 + "failedToLoadAuditLog": "Kunde inte ladda granskningsloggen", 945 + "addControllerTitle": "Lägg till kontrollant", 946 + "addControllerDescription": "Lägg till en användare som kan utföra åtgärder på detta konto med specificerade behörigheter.", 947 + "controllerIdentifier": "Kontrollantens användarnamn eller DID", 948 + "selectScopes": "Välj behörighetsnivå", 949 + "add": "Lägg till", 950 + "adding": "Lägger till...", 951 + "cancel": "Avbryt", 952 + "accessLevel": "Åtkomstnivå", 953 + "addControllerButton": "+ Lägg till kontrollant", 954 + "auditLogDesc": "Visa all delegeringsaktivitet", 955 + "cannotAddControllers": "Du kan inte lägga till kontrollanter eftersom detta konto kontrollerar andra konton. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.", 956 + "cannotControlAccounts": "Du kan inte kontrollera andra konton eftersom detta konto har kontrollanter. Ett konto kan antingen ha kontrollanter eller kontrollera andra konton, men inte båda.", 957 + "controlledAccountsDesc": "Konton du kan agera för", 958 + "controllerAdded": "Kontrollant tillagd", 959 + "controllerDid": "Kontrollant-DID", 960 + "controllerRemoved": "Kontrollant borttagen", 961 + "controllersDesc": "Konton som kan agera för dig", 962 + "createAccount": "Skapa konto", 963 + "createDelegatedAccount": "Skapa delegerat konto", 964 + "createDelegatedAccountButton": "+ Skapa delegerat konto", 965 + "creating": "Skapar...", 966 + "emailOptional": "E-post (valfritt)", 967 + "failedToAddController": "Kunde inte lägga till kontrollant", 968 + "failedToCreateAccount": "Kunde inte skapa delegerat konto", 969 + "failedToRemoveController": "Kunde inte ta bort kontrollant", 970 + "granted": "Beviljad", 971 + "inactive": "Inaktiv", 972 + "remove": "Ta bort", 973 + "removeConfirm": "Vill du ta bort denna kontrollant?", 974 + "viewAuditLog": "Visa granskningslogg", 975 + "yourAccessLevel": "Din åtkomstnivå" 976 + }, 977 + "actAs": { 978 + "title": "Agera som", 979 + "noAccountSpecified": "Inget konto-DID angivet", 980 + "failedToVerify": "Kunde inte verifiera kontoåtkomst", 981 + "noAccess": "Du har inte åtkomst till detta konto", 982 + "failedToInitiate": "Kunde inte initiera autentisering", 983 + "invalidResponse": "Ogiltigt svar från servern", 984 + "failedError": "Misslyckades: {error}", 985 + "preparing": "Förbereder inloggning till delegerat konto...", 986 + "backToControllers": "Tillbaka till kontrollanter" 987 + }, 988 + "oauthDelegation": { 989 + "loading": "Laddar...", 990 + "title": "Delegerat konto", 991 + "isDelegated": "{handle} är ett delegerat konto.", 992 + "enterControllerHandle": "Logga in med ditt kontrollantkonto för att komma åt detta konto.", 993 + "controllerHandle": "Kontrollantens användarnamn", 994 + "handlePlaceholder": "handle.example.com", 995 + "checking": "Kontrollerar...", 996 + "controllerNotFound": "Kontot hittades inte eller så har du inte åtkomst till detta delegerade konto", 997 + "missingParams": "Delegeringsparametrar saknas", 998 + "missingInfo": "Nödvändig information saknas", 999 + "passkeyCancelled": "Nyckelautentisering avbröts", 1000 + "passkeyFailed": "Nyckelautentisering misslyckades", 1001 + "failedPasskeyStart": "Kunde inte starta nyckelinloggning", 1002 + "authFailed": "Autentisering misslyckades", 1003 + "unexpectedResponse": "Oväntat svar från servern", 1004 + "signInAsController": "Logga in som kontrollant", 1005 + "authenticateAs": "Autentisera som {controller} för att agera på uppdrag av {delegated}", 1006 + "useDifferentController": "Använd en annan kontrollant", 1007 + "signInWithPasskey": "Logga in med nyckel", 1008 + "authenticating": "Autentiserar...", 1009 + "usePasskey": "Använd nyckel", 1010 + "or": "eller", 1011 + "password": "Lösenord", 1012 + "enterPassword": "Ange lösenord", 1013 + "rememberDevice": "Kom ihåg denna enhet", 1014 + "signingIn": "Loggar in...", 1015 + "signIn": "Logga in", 1016 + "goBack": "Gå tillbaka", 1017 + "unableToLoad": "Kunde inte ladda delegeringsinformation" 1018 + }, 1019 + "oauthConsent": { 1020 + "delegatedAccess": "Delegerad åtkomst", 1021 + "actingAs": "Agerar som", 1022 + "controller": "Kontrollant", 1023 + "accessLevel": "Åtkomstnivå", 1024 + "readOnlyAccess": "Endast läsåtkomst", 1025 + "readOnlyDesc": "Visa endast offentlig information. Ingen skrivåtkomst till detta konto.", 1026 + "permissionsLimited": "Behörigheter begränsade", 1027 + "permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.", 1028 + "viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.", 1029 + "editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet." 891 1030 } 892 1031 }
+140 -1
frontend/src/locales/zh.json
··· 6 6 "cancel": "取消", 7 7 "back": "返回", 8 8 "done": "完成", 9 + "continue": "继续", 9 10 "refresh": "刷新", 10 11 "create": "创建", 11 12 "delete": "删除", ··· 271 272 "scopeFull": "完全访问", 272 273 "scopeReadOnly": "只读", 273 274 "scopePostOnly": "仅发帖", 274 - "scopeCustom": "自定义" 275 + "scopeCustom": "自定义", 276 + "byController": "由控制者创建" 275 277 }, 276 278 "sessions": { 277 279 "title": "登录会话", ··· 871 873 "codeLabel": "验证码", 872 874 "codeHelp": "复制消息中的完整验证码,包括横线。", 873 875 "verifyButton": "验证" 876 + }, 877 + "delegation": { 878 + "title": "账户委托", 879 + "controllers": "控制者", 880 + "controllersDescription": "控制者可以作为您账户的管理员。他们可以执行您允许的操作,代表您发布帖子,以及修改您的数据仓库。", 881 + "controlledAccounts": "受控账户", 882 + "controlledAccountsDescription": "这些是您被添加为控制者的账户。您可以在这些账户上执行允许的操作。", 883 + "noControllers": "暂无控制者", 884 + "noControlledAccounts": "无受控账户", 885 + "addController": "添加控制者", 886 + "revokeAccess": "撤销访问", 887 + "revokeConfirm": "撤销此控制者的访问权限?他们将无法再在您的账户上执行操作。", 888 + "handle": "用户名", 889 + "handlePlaceholder": "@user.bsky.social", 890 + "did": "DID", 891 + "didPlaceholder": "did:plc:...", 892 + "scopes": "权限级别", 893 + "scopeOwner": "所有者", 894 + "scopeOwnerDesc": "完全控制(可执行所有操作)", 895 + "scopeAdmin": "管理员", 896 + "scopeAdminDesc": "管理帖子、应用专用密码、设置", 897 + "scopeEditor": "编辑者", 898 + "scopeEditorDesc": "创建和管理帖子、点赞、关注", 899 + "scopeViewer": "查看者", 900 + "scopeViewerDesc": "只读访问数据仓库和设置", 901 + "scopeCustom": "自定义", 902 + "scopeCustomDesc": "选择单独的权限", 903 + "grantedAt": "授权时间", 904 + "expiresAt": "过期时间", 905 + "noExpiration": "永不过期", 906 + "actAs": "代理操作", 907 + "auditLog": "审计日志", 908 + "auditLogTitle": "委托审计日志", 909 + "backToControllers": "← 返回控制者", 910 + "loading": "加载中...", 911 + "noActivity": "暂无活动", 912 + "actor": "执行者", 913 + "controller": "控制者", 914 + "account": "账户", 915 + "details": "详情", 916 + "actionGrantCreated": "授权创建", 917 + "actionGrantRevoked": "授权撤销", 918 + "actionScopesModified": "权限修改", 919 + "actionTokenIssued": "令牌发放", 920 + "actionRepoWrite": "仓库写入", 921 + "actionBlobUpload": "Blob上传", 922 + "actionAccountAction": "账户操作", 923 + "previous": "上一页", 924 + "next": "下一页", 925 + "showing": "{start}–{end} / 共{total}条", 926 + "refresh": "刷新", 927 + "failedToLoadAuditLog": "加载审计日志失败", 928 + "addControllerTitle": "添加控制者", 929 + "addControllerDescription": "添加一个可以在此账户上执行指定权限操作的用户。", 930 + "controllerIdentifier": "控制者用户名或 DID", 931 + "selectScopes": "选择权限级别", 932 + "add": "添加", 933 + "adding": "添加中...", 934 + "cancel": "取消", 935 + "accessLevel": "访问级别", 936 + "addControllerButton": "+ 添加控制者", 937 + "auditLogDesc": "查看所有委托活动", 938 + "cannotAddControllers": "因为此账户正在控制其他账户,所以无法添加控制者。账户只能拥有控制者或控制其他账户,不能同时两者兼备。", 939 + "cannotControlAccounts": "因为此账户有控制者,所以无法控制其他账户。账户只能拥有控制者或控制其他账户,不能同时两者兼备。", 940 + "controlledAccountsDesc": "您可以代理操作的账户", 941 + "controllerAdded": "控制者已添加", 942 + "controllerDid": "控制者 DID", 943 + "controllerRemoved": "控制者已移除", 944 + "controllersDesc": "可以代理操作您账户的账户", 945 + "createAccount": "创建账户", 946 + "createDelegatedAccount": "创建委托账户", 947 + "createDelegatedAccountButton": "+ 创建委托账户", 948 + "creating": "创建中...", 949 + "emailOptional": "邮箱(可选)", 950 + "failedToAddController": "添加控制者失败", 951 + "failedToCreateAccount": "创建委托账户失败", 952 + "failedToRemoveController": "移除控制者失败", 953 + "granted": "授权日期", 954 + "inactive": "未激活", 955 + "remove": "移除", 956 + "removeConfirm": "确定要移除此控制者吗?", 957 + "viewAuditLog": "查看审计日志", 958 + "yourAccessLevel": "您的访问级别" 959 + }, 960 + "actAs": { 961 + "title": "代理操作", 962 + "noAccountSpecified": "未指定账户 DID", 963 + "failedToVerify": "无法验证账户访问权限", 964 + "noAccess": "您没有此账户的访问权限", 965 + "failedToInitiate": "无法启动认证", 966 + "invalidResponse": "服务器返回无效响应", 967 + "failedError": "失败: {error}", 968 + "preparing": "正在准备登录委托账户...", 969 + "backToControllers": "返回控制者" 970 + }, 971 + "oauthDelegation": { 972 + "loading": "加载中...", 973 + "title": "委托账户", 974 + "isDelegated": "{handle} 是一个委托账户。", 975 + "enterControllerHandle": "请使用您的控制者账户登录以访问此账户。", 976 + "controllerHandle": "控制者用户名", 977 + "handlePlaceholder": "handle.example.com", 978 + "checking": "检查中...", 979 + "controllerNotFound": "账户未找到或您没有权限访问此委托账户", 980 + "missingParams": "缺少委托参数", 981 + "missingInfo": "缺少必要信息", 982 + "passkeyCancelled": "通行密钥认证已取消", 983 + "passkeyFailed": "通行密钥认证失败", 984 + "failedPasskeyStart": "无法启动通行密钥登录", 985 + "authFailed": "认证失败", 986 + "unexpectedResponse": "服务器返回意外响应", 987 + "signInAsController": "以控制者身份登录", 988 + "authenticateAs": "以 {controller} 身份认证以代表 {delegated} 操作", 989 + "useDifferentController": "使用其他控制者", 990 + "signInWithPasskey": "使用通行密钥登录", 991 + "authenticating": "认证中...", 992 + "usePasskey": "使用通行密钥", 993 + "or": "或", 994 + "password": "密码", 995 + "enterPassword": "输入密码", 996 + "rememberDevice": "记住此设备", 997 + "signingIn": "登录中...", 998 + "signIn": "登录", 999 + "goBack": "返回", 1000 + "unableToLoad": "无法加载委托信息" 1001 + }, 1002 + "oauthConsent": { 1003 + "delegatedAccess": "委托访问", 1004 + "actingAs": "代理操作", 1005 + "controller": "控制者", 1006 + "accessLevel": "访问级别", 1007 + "readOnlyAccess": "只读访问", 1008 + "readOnlyDesc": "仅查看公开信息。无法对此账户进行写入操作。", 1009 + "permissionsLimited": "权限受限", 1010 + "permissionsLimitedDesc": "无论应用请求什么权限,您的实际权限将限制在{level}访问级别。", 1011 + "viewerLimitedDesc": "作为查看者,您只有只读权限。此应用无法在此账户上创建、更新或删除内容。", 1012 + "editorLimitedDesc": "作为编辑者,您可以创建和编辑内容,但无法管理账户设置或安全选项。" 874 1013 } 875 1014 }
+179
frontend/src/routes/ActAs.svelte
··· 1 + <script lang="ts"> 2 + import { getAuthState, logout } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState } from '../lib/oauth' 5 + import { _ } from '../lib/i18n' 6 + 7 + const auth = getAuthState() 8 + let error = $state<string | null>(null) 9 + let loading = $state(true) 10 + let actAsInProgress = $state(false) 11 + 12 + function getDid(): string | null { 13 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 14 + return params.get('did') 15 + } 16 + 17 + $effect(() => { 18 + if (!auth.loading && !auth.session && !actAsInProgress) { 19 + navigate('/login') 20 + } 21 + }) 22 + 23 + $effect(() => { 24 + if (auth.session && !actAsInProgress) { 25 + actAsInProgress = true 26 + initiateActAs() 27 + } 28 + }) 29 + 30 + async function initiateActAs() { 31 + const did = getDid() 32 + if (!did) { 33 + error = $_('actAs.noAccountSpecified') 34 + loading = false 35 + return 36 + } 37 + 38 + try { 39 + const response = await fetch( 40 + `/xrpc/com.tranquil.delegation.listControlledAccounts`, 41 + { 42 + headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` } 43 + } 44 + ) 45 + 46 + if (!response.ok) { 47 + error = $_('actAs.failedToVerify') 48 + loading = false 49 + return 50 + } 51 + 52 + const data = await response.json() 53 + const account = data.accounts?.find((a: { did: string }) => a.did === did) 54 + 55 + if (!account) { 56 + error = $_('actAs.noAccess') 57 + loading = false 58 + return 59 + } 60 + 61 + await logout() 62 + 63 + const hostname = window.location.origin 64 + const state = generateState() 65 + const codeVerifier = generateCodeVerifier() 66 + const codeChallenge = await generateCodeChallenge(codeVerifier) 67 + saveOAuthState({ state, codeVerifier }) 68 + 69 + const parResponse = await fetch('/oauth/par', { 70 + method: 'POST', 71 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 72 + body: new URLSearchParams({ 73 + client_id: `${hostname}/oauth/client-metadata.json`, 74 + redirect_uri: `${hostname}/`, 75 + response_type: 'code', 76 + scope: 'atproto', 77 + state: state, 78 + code_challenge: codeChallenge, 79 + code_challenge_method: 'S256', 80 + login_hint: account.handle 81 + }) 82 + }) 83 + 84 + if (!parResponse.ok) { 85 + error = $_('actAs.failedToInitiate') 86 + loading = false 87 + return 88 + } 89 + 90 + const parData = await parResponse.json() 91 + if (parData.request_uri) { 92 + window.location.href = `/#/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}` 93 + } else { 94 + error = $_('actAs.invalidResponse') 95 + loading = false 96 + } 97 + } catch (e) { 98 + error = $_('actAs.failedError', { values: { error: e instanceof Error ? e.message : String(e) } }) 99 + loading = false 100 + } 101 + } 102 + 103 + function goBack() { 104 + navigate('/controllers') 105 + } 106 + </script> 107 + 108 + <div class="page"> 109 + {#if loading} 110 + <div class="loading"> 111 + <p>{$_('actAs.preparing')}</p> 112 + </div> 113 + {:else} 114 + <header> 115 + <h1>{$_('actAs.title')}</h1> 116 + </header> 117 + 118 + {#if error} 119 + <div class="message error">{error}</div> 120 + {/if} 121 + 122 + <div class="actions"> 123 + <button class="back-btn" onclick={goBack}> 124 + {$_('actAs.backToControllers')} 125 + </button> 126 + </div> 127 + {/if} 128 + </div> 129 + 130 + <style> 131 + .page { 132 + max-width: var(--width-md); 133 + margin: var(--space-9) auto; 134 + padding: var(--space-7); 135 + } 136 + 137 + .loading { 138 + display: flex; 139 + align-items: center; 140 + justify-content: center; 141 + min-height: 200px; 142 + color: var(--text-secondary); 143 + } 144 + 145 + header { 146 + margin-bottom: var(--space-6); 147 + } 148 + 149 + h1 { 150 + margin: 0; 151 + } 152 + 153 + .message.error { 154 + padding: var(--space-3); 155 + background: var(--error-bg); 156 + border: 1px solid var(--error-border); 157 + border-radius: var(--radius-md); 158 + color: var(--error-text); 159 + margin-bottom: var(--space-4); 160 + } 161 + 162 + .actions { 163 + margin-top: var(--space-4); 164 + } 165 + 166 + .back-btn { 167 + padding: var(--space-3) var(--space-5); 168 + border: 1px solid var(--border-color); 169 + border-radius: var(--radius-md); 170 + background: transparent; 171 + color: var(--text-primary); 172 + cursor: pointer; 173 + } 174 + 175 + .back-btn:hover { 176 + background: var(--bg-card); 177 + border-color: var(--accent); 178 + } 179 + </style>
+13
frontend/src/routes/AppPasswords.svelte
··· 173 173 <span class="name">{pw.name}</span> 174 174 <span class="meta"> 175 175 <span class="scope-badge" class:full={!pw.scopes}>{getScopeLabel(pw.scopes)}</span> 176 + {#if pw.createdByController} 177 + <span class="controller-badge" title={pw.createdByController}>{$_('appPasswords.byController')}</span> 178 + {/if} 176 179 <span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span> 177 180 </span> 178 181 </div> ··· 416 419 background: var(--success-bg); 417 420 border-color: var(--success-border); 418 421 color: var(--success-text); 422 + } 423 + 424 + .controller-badge { 425 + font-size: var(--text-xs); 426 + padding: var(--space-1) var(--space-2); 427 + background: var(--info-bg, #e3f2fd); 428 + border: 1px solid var(--info-border, #90caf9); 429 + border-radius: var(--radius-sm); 430 + color: var(--info-text, #1565c0); 431 + cursor: help; 419 432 } 420 433 421 434 .date {
+680
frontend/src/routes/Controllers.svelte
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { _ } from '../lib/i18n' 5 + import { formatDateTime } from '../lib/date' 6 + 7 + interface Controller { 8 + did: string 9 + handle: string 10 + grantedScopes: string 11 + grantedAt: string 12 + isActive: boolean 13 + } 14 + 15 + interface ControlledAccount { 16 + did: string 17 + handle: string 18 + grantedScopes: string 19 + grantedAt: string 20 + } 21 + 22 + interface ScopePreset { 23 + name: string 24 + label: string 25 + description: string 26 + scopes: string 27 + } 28 + 29 + const auth = getAuthState() 30 + let loading = $state(true) 31 + let error = $state<string | null>(null) 32 + let success = $state<string | null>(null) 33 + let controllers = $state<Controller[]>([]) 34 + let controlledAccounts = $state<ControlledAccount[]>([]) 35 + let scopePresets = $state<ScopePreset[]>([]) 36 + 37 + let hasControllers = $derived(controllers.length > 0) 38 + let controlsAccounts = $derived(controlledAccounts.length > 0) 39 + let canAddControllers = $derived(!controlsAccounts) 40 + let canControlAccounts = $derived(!hasControllers) 41 + 42 + let showAddController = $state(false) 43 + let addControllerDid = $state('') 44 + let addControllerScopes = $state('atproto') 45 + let addingController = $state(false) 46 + 47 + let showCreateDelegated = $state(false) 48 + let newDelegatedHandle = $state('') 49 + let newDelegatedEmail = $state('') 50 + let newDelegatedScopes = $state('atproto') 51 + let creatingDelegated = $state(false) 52 + 53 + $effect(() => { 54 + if (!auth.loading && !auth.session) { 55 + navigate('/login') 56 + } 57 + }) 58 + 59 + $effect(() => { 60 + if (auth.session) { 61 + loadData() 62 + } 63 + }) 64 + 65 + async function loadData() { 66 + loading = true 67 + error = null 68 + try { 69 + await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()]) 70 + } finally { 71 + loading = false 72 + } 73 + } 74 + 75 + async function loadControllers() { 76 + if (!auth.session) return 77 + try { 78 + const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', { 79 + headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 80 + }) 81 + if (response.ok) { 82 + const data = await response.json() 83 + controllers = data.controllers || [] 84 + } 85 + } catch (e) { 86 + console.error('Failed to load controllers:', e) 87 + } 88 + } 89 + 90 + async function loadControlledAccounts() { 91 + if (!auth.session) return 92 + try { 93 + const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', { 94 + headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 95 + }) 96 + if (response.ok) { 97 + const data = await response.json() 98 + controlledAccounts = data.accounts || [] 99 + } 100 + } catch (e) { 101 + console.error('Failed to load controlled accounts:', e) 102 + } 103 + } 104 + 105 + async function loadScopePresets() { 106 + try { 107 + const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets') 108 + if (response.ok) { 109 + const data = await response.json() 110 + scopePresets = data.presets || [] 111 + } 112 + } catch (e) { 113 + console.error('Failed to load scope presets:', e) 114 + } 115 + } 116 + 117 + async function addController() { 118 + if (!auth.session || !addControllerDid.trim()) return 119 + addingController = true 120 + error = null 121 + success = null 122 + 123 + try { 124 + const response = await fetch('/xrpc/com.tranquil.delegation.addController', { 125 + method: 'POST', 126 + headers: { 127 + 'Authorization': `Bearer ${auth.session.accessJwt}`, 128 + 'Content-Type': 'application/json' 129 + }, 130 + body: JSON.stringify({ 131 + controller_did: addControllerDid.trim(), 132 + granted_scopes: addControllerScopes 133 + }) 134 + }) 135 + 136 + if (!response.ok) { 137 + const data = await response.json() 138 + error = data.message || data.error || $_('delegation.failedToAddController') 139 + return 140 + } 141 + 142 + success = $_('delegation.controllerAdded') 143 + addControllerDid = '' 144 + addControllerScopes = 'atproto' 145 + showAddController = false 146 + await loadControllers() 147 + } catch (e) { 148 + error = $_('delegation.failedToAddController') 149 + } finally { 150 + addingController = false 151 + } 152 + } 153 + 154 + async function removeController(controllerDid: string) { 155 + if (!auth.session) return 156 + if (!confirm($_('delegation.removeConfirm'))) return 157 + 158 + error = null 159 + success = null 160 + 161 + try { 162 + const response = await fetch('/xrpc/com.tranquil.delegation.removeController', { 163 + method: 'POST', 164 + headers: { 165 + 'Authorization': `Bearer ${auth.session.accessJwt}`, 166 + 'Content-Type': 'application/json' 167 + }, 168 + body: JSON.stringify({ controller_did: controllerDid }) 169 + }) 170 + 171 + if (!response.ok) { 172 + const data = await response.json() 173 + error = data.message || data.error || $_('delegation.failedToRemoveController') 174 + return 175 + } 176 + 177 + success = $_('delegation.controllerRemoved') 178 + await loadControllers() 179 + } catch (e) { 180 + error = $_('delegation.failedToRemoveController') 181 + } 182 + } 183 + 184 + async function createDelegatedAccount() { 185 + if (!auth.session || !newDelegatedHandle.trim()) return 186 + creatingDelegated = true 187 + error = null 188 + success = null 189 + 190 + try { 191 + const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', { 192 + method: 'POST', 193 + headers: { 194 + 'Authorization': `Bearer ${auth.session.accessJwt}`, 195 + 'Content-Type': 'application/json' 196 + }, 197 + body: JSON.stringify({ 198 + handle: newDelegatedHandle.trim(), 199 + email: newDelegatedEmail.trim() || undefined, 200 + controllerScopes: newDelegatedScopes 201 + }) 202 + }) 203 + 204 + if (!response.ok) { 205 + const data = await response.json() 206 + error = data.message || data.error || $_('delegation.failedToCreateAccount') 207 + return 208 + } 209 + 210 + const data = await response.json() 211 + success = $_('delegation.accountCreated', { values: { handle: data.handle } }) 212 + newDelegatedHandle = '' 213 + newDelegatedEmail = '' 214 + newDelegatedScopes = 'atproto' 215 + showCreateDelegated = false 216 + await loadControlledAccounts() 217 + } catch (e) { 218 + error = $_('delegation.failedToCreateAccount') 219 + } finally { 220 + creatingDelegated = false 221 + } 222 + } 223 + 224 + function getScopeLabel(scopes: string): string { 225 + const preset = scopePresets.find(p => p.scopes === scopes) 226 + if (preset) return preset.label 227 + if (scopes === 'atproto') return $_('delegation.scopeOwner') 228 + if (scopes === '') return $_('delegation.scopeViewer') 229 + return $_('delegation.scopeCustom') 230 + } 231 + </script> 232 + 233 + <div class="page"> 234 + <header> 235 + <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 236 + <h1>{$_('delegation.title')}</h1> 237 + </header> 238 + 239 + {#if loading} 240 + <p class="loading">{$_('delegation.loading')}</p> 241 + {:else} 242 + {#if error} 243 + <div class="message error">{error}</div> 244 + {/if} 245 + 246 + {#if success} 247 + <div class="message success">{success}</div> 248 + {/if} 249 + 250 + <section class="section"> 251 + <div class="section-header"> 252 + <h2>{$_('delegation.controllers')}</h2> 253 + <p class="section-description">{$_('delegation.controllersDesc')}</p> 254 + </div> 255 + 256 + {#if controllers.length === 0} 257 + <p class="empty">{$_('delegation.noControllers')}</p> 258 + {:else} 259 + <div class="items-list"> 260 + {#each controllers as controller} 261 + <div class="item-card" class:inactive={!controller.isActive}> 262 + <div class="item-info"> 263 + <div class="item-header"> 264 + <span class="item-handle">@{controller.handle}</span> 265 + <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span> 266 + {#if !controller.isActive} 267 + <span class="badge inactive">{$_('delegation.inactive')}</span> 268 + {/if} 269 + </div> 270 + <div class="item-details"> 271 + <div class="detail"> 272 + <span class="label">{$_('delegation.did')}</span> 273 + <span class="value did">{controller.did}</span> 274 + </div> 275 + <div class="detail"> 276 + <span class="label">{$_('delegation.granted')}</span> 277 + <span class="value">{formatDateTime(controller.grantedAt)}</span> 278 + </div> 279 + </div> 280 + </div> 281 + <div class="item-actions"> 282 + <button class="danger-outline" onclick={() => removeController(controller.did)}> 283 + {$_('delegation.remove')} 284 + </button> 285 + </div> 286 + </div> 287 + {/each} 288 + </div> 289 + {/if} 290 + 291 + {#if !canAddControllers} 292 + <div class="constraint-notice"> 293 + <p>{$_('delegation.cannotAddControllers')}</p> 294 + </div> 295 + {:else if showAddController} 296 + <div class="form-card"> 297 + <h3>{$_('delegation.addController')}</h3> 298 + <div class="field"> 299 + <label for="controllerDid">{$_('delegation.controllerDid')}</label> 300 + <input 301 + id="controllerDid" 302 + type="text" 303 + bind:value={addControllerDid} 304 + placeholder="did:plc:..." 305 + disabled={addingController} 306 + /> 307 + </div> 308 + <div class="field"> 309 + <label for="controllerScopes">{$_('delegation.accessLevel')}</label> 310 + <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}> 311 + {#each scopePresets as preset} 312 + <option value={preset.scopes}>{preset.label} - {preset.description}</option> 313 + {/each} 314 + </select> 315 + </div> 316 + <div class="form-actions"> 317 + <button class="ghost" onclick={() => showAddController = false} disabled={addingController}> 318 + {$_('common.cancel')} 319 + </button> 320 + <button onclick={addController} disabled={addingController || !addControllerDid.trim()}> 321 + {addingController ? $_('delegation.adding') : $_('delegation.addController')} 322 + </button> 323 + </div> 324 + </div> 325 + {:else} 326 + <button class="ghost full-width" onclick={() => showAddController = true}> 327 + {$_('delegation.addControllerButton')} 328 + </button> 329 + {/if} 330 + </section> 331 + 332 + <section class="section"> 333 + <div class="section-header"> 334 + <h2>{$_('delegation.controlledAccounts')}</h2> 335 + <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p> 336 + </div> 337 + 338 + {#if controlledAccounts.length === 0} 339 + <p class="empty">{$_('delegation.noControlledAccounts')}</p> 340 + {:else} 341 + <div class="items-list"> 342 + {#each controlledAccounts as account} 343 + <div class="item-card"> 344 + <div class="item-info"> 345 + <div class="item-header"> 346 + <span class="item-handle">@{account.handle}</span> 347 + <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span> 348 + </div> 349 + <div class="item-details"> 350 + <div class="detail"> 351 + <span class="label">{$_('delegation.did')}</span> 352 + <span class="value did">{account.did}</span> 353 + </div> 354 + <div class="detail"> 355 + <span class="label">{$_('delegation.granted')}</span> 356 + <span class="value">{formatDateTime(account.grantedAt)}</span> 357 + </div> 358 + </div> 359 + </div> 360 + <div class="item-actions"> 361 + <a href="/#/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 362 + {$_('delegation.actAs')} 363 + </a> 364 + </div> 365 + </div> 366 + {/each} 367 + </div> 368 + {/if} 369 + 370 + {#if !canControlAccounts} 371 + <div class="constraint-notice"> 372 + <p>{$_('delegation.cannotControlAccounts')}</p> 373 + </div> 374 + {:else if showCreateDelegated} 375 + <div class="form-card"> 376 + <h3>{$_('delegation.createDelegatedAccount')}</h3> 377 + <div class="field"> 378 + <label for="delegatedHandle">{$_('delegation.handle')}</label> 379 + <input 380 + id="delegatedHandle" 381 + type="text" 382 + bind:value={newDelegatedHandle} 383 + placeholder="username" 384 + disabled={creatingDelegated} 385 + /> 386 + </div> 387 + <div class="field"> 388 + <label for="delegatedEmail">{$_('delegation.emailOptional')}</label> 389 + <input 390 + id="delegatedEmail" 391 + type="email" 392 + bind:value={newDelegatedEmail} 393 + placeholder="email@example.com" 394 + disabled={creatingDelegated} 395 + /> 396 + </div> 397 + <div class="field"> 398 + <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label> 399 + <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}> 400 + {#each scopePresets as preset} 401 + <option value={preset.scopes}>{preset.label} - {preset.description}</option> 402 + {/each} 403 + </select> 404 + </div> 405 + <div class="form-actions"> 406 + <button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}> 407 + {$_('common.cancel')} 408 + </button> 409 + <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 410 + {creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')} 411 + </button> 412 + </div> 413 + </div> 414 + {:else} 415 + <button class="ghost full-width" onclick={() => showCreateDelegated = true}> 416 + {$_('delegation.createDelegatedAccountButton')} 417 + </button> 418 + {/if} 419 + </section> 420 + 421 + <section class="section"> 422 + <div class="section-header"> 423 + <h2>{$_('delegation.auditLog')}</h2> 424 + <p class="section-description">{$_('delegation.auditLogDesc')}</p> 425 + </div> 426 + <a href="#/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 427 + </section> 428 + {/if} 429 + </div> 430 + 431 + <style> 432 + .page { 433 + max-width: var(--width-lg); 434 + margin: 0 auto; 435 + padding: var(--space-7); 436 + } 437 + 438 + header { 439 + margin-bottom: var(--space-7); 440 + } 441 + 442 + .back { 443 + color: var(--text-secondary); 444 + text-decoration: none; 445 + font-size: var(--text-sm); 446 + } 447 + 448 + .back:hover { 449 + color: var(--accent); 450 + } 451 + 452 + h1 { 453 + margin: var(--space-2) 0 0 0; 454 + } 455 + 456 + .loading, 457 + .empty { 458 + text-align: center; 459 + color: var(--text-secondary); 460 + padding: var(--space-4); 461 + } 462 + 463 + .message { 464 + padding: var(--space-3); 465 + border-radius: var(--radius-md); 466 + margin-bottom: var(--space-4); 467 + } 468 + 469 + .message.error { 470 + background: var(--error-bg); 471 + border: 1px solid var(--error-border); 472 + color: var(--error-text); 473 + } 474 + 475 + .message.success { 476 + background: var(--success-bg); 477 + border: 1px solid var(--success-border); 478 + color: var(--success-text); 479 + } 480 + 481 + .constraint-notice { 482 + background: var(--bg-tertiary); 483 + border: 1px solid var(--border-color); 484 + border-radius: var(--radius-md); 485 + padding: var(--space-4); 486 + } 487 + 488 + .constraint-notice p { 489 + margin: 0; 490 + color: var(--text-secondary); 491 + font-size: var(--text-sm); 492 + } 493 + 494 + .section { 495 + margin-bottom: var(--space-8); 496 + } 497 + 498 + .section-header { 499 + margin-bottom: var(--space-4); 500 + } 501 + 502 + .section-header h2 { 503 + margin: 0 0 var(--space-1) 0; 504 + font-size: var(--text-lg); 505 + } 506 + 507 + .section-description { 508 + color: var(--text-secondary); 509 + margin: 0; 510 + font-size: var(--text-sm); 511 + } 512 + 513 + .items-list { 514 + display: flex; 515 + flex-direction: column; 516 + gap: var(--space-4); 517 + margin-bottom: var(--space-4); 518 + } 519 + 520 + .item-card { 521 + background: var(--bg-secondary); 522 + border: 1px solid var(--border-color); 523 + border-radius: var(--radius-xl); 524 + padding: var(--space-4); 525 + display: flex; 526 + justify-content: space-between; 527 + align-items: center; 528 + gap: var(--space-4); 529 + flex-wrap: wrap; 530 + } 531 + 532 + .item-card.inactive { 533 + opacity: 0.6; 534 + } 535 + 536 + .item-info { 537 + flex: 1; 538 + min-width: 200px; 539 + } 540 + 541 + .item-header { 542 + margin-bottom: var(--space-2); 543 + display: flex; 544 + align-items: center; 545 + gap: var(--space-2); 546 + flex-wrap: wrap; 547 + } 548 + 549 + .item-handle { 550 + font-weight: var(--font-semibold); 551 + color: var(--text-primary); 552 + } 553 + 554 + .badge { 555 + display: inline-block; 556 + padding: var(--space-1) var(--space-2); 557 + border-radius: var(--radius-md); 558 + font-size: var(--text-xs); 559 + font-weight: var(--font-medium); 560 + } 561 + 562 + .badge.scope { 563 + background: var(--accent); 564 + color: var(--text-inverse); 565 + } 566 + 567 + .badge.inactive { 568 + background: var(--error-bg); 569 + color: var(--error-text); 570 + border: 1px solid var(--error-border); 571 + } 572 + 573 + .item-details { 574 + display: flex; 575 + flex-direction: column; 576 + gap: var(--space-1); 577 + } 578 + 579 + .detail { 580 + font-size: var(--text-sm); 581 + } 582 + 583 + .detail .label { 584 + color: var(--text-secondary); 585 + margin-right: var(--space-2); 586 + } 587 + 588 + .detail .value { 589 + color: var(--text-primary); 590 + } 591 + 592 + .detail .value.did { 593 + font-family: var(--font-mono); 594 + font-size: var(--text-xs); 595 + word-break: break-all; 596 + } 597 + 598 + .item-actions { 599 + display: flex; 600 + gap: var(--space-2); 601 + } 602 + 603 + .item-actions button { 604 + padding: var(--space-2) var(--space-4); 605 + font-size: var(--text-sm); 606 + } 607 + 608 + .btn-link { 609 + display: inline-block; 610 + padding: var(--space-2) var(--space-4); 611 + border: 1px solid var(--accent); 612 + border-radius: var(--radius-md); 613 + background: transparent; 614 + color: var(--accent); 615 + font-size: var(--text-sm); 616 + font-weight: var(--font-medium); 617 + text-decoration: none; 618 + transition: background var(--transition-normal), color var(--transition-normal); 619 + } 620 + 621 + .btn-link:hover { 622 + background: var(--accent); 623 + color: var(--text-inverse); 624 + } 625 + 626 + .full-width { 627 + width: 100%; 628 + } 629 + 630 + .form-card { 631 + background: var(--bg-secondary); 632 + border: 1px solid var(--border-color); 633 + border-radius: var(--radius-xl); 634 + padding: var(--space-5); 635 + margin-top: var(--space-4); 636 + } 637 + 638 + .form-card h3 { 639 + margin: 0 0 var(--space-4) 0; 640 + } 641 + 642 + .field { 643 + margin-bottom: var(--space-4); 644 + } 645 + 646 + .field label { 647 + display: block; 648 + font-size: var(--text-sm); 649 + font-weight: var(--font-medium); 650 + margin-bottom: var(--space-1); 651 + } 652 + 653 + .field input, 654 + .field select { 655 + width: 100%; 656 + padding: var(--space-3); 657 + border: 1px solid var(--border-color); 658 + border-radius: var(--radius-md); 659 + font-size: var(--text-base); 660 + background: var(--bg-input); 661 + color: var(--text-primary); 662 + } 663 + 664 + .field input:focus, 665 + .field select:focus { 666 + outline: none; 667 + border-color: var(--accent); 668 + } 669 + 670 + .form-actions { 671 + display: flex; 672 + gap: var(--space-3); 673 + justify-content: flex-end; 674 + } 675 + 676 + .form-actions button { 677 + padding: var(--space-2) var(--space-4); 678 + font-size: var(--text-sm); 679 + } 680 + </style>
+4
frontend/src/routes/Dashboard.svelte
··· 186 186 <h3>{$_('dashboard.navRepo')}</h3> 187 187 <p>{$_('dashboard.navRepoDesc')}</p> 188 188 </a> 189 + <a href="#/controllers" class="nav-card"> 190 + <h3>Delegation</h3> 191 + <p>Manage account controllers and delegated accounts</p> 192 + </a> 189 193 {#if auth.session.isAdmin} 190 194 <a href="#/admin" class="nav-card admin-card"> 191 195 <h3>{$_('dashboard.navAdmin')}</h3>
+322
frontend/src/routes/DelegationAudit.svelte
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { _ } from '../lib/i18n' 5 + import { formatDateTime } from '../lib/date' 6 + 7 + interface AuditEntry { 8 + id: string 9 + delegatedDid: string 10 + actorDid: string 11 + controllerDid: string | null 12 + actionType: string 13 + actionDetails: Record<string, unknown> | null 14 + createdAt: string 15 + } 16 + 17 + const auth = getAuthState() 18 + let loading = $state(true) 19 + let error = $state<string | null>(null) 20 + let entries = $state<AuditEntry[]>([]) 21 + let total = $state(0) 22 + let offset = $state(0) 23 + const limit = 20 24 + 25 + $effect(() => { 26 + if (!auth.loading && !auth.session) { 27 + navigate('/login') 28 + } 29 + }) 30 + 31 + $effect(() => { 32 + if (auth.session) { 33 + loadAuditLog() 34 + } 35 + }) 36 + 37 + async function loadAuditLog() { 38 + if (!auth.session) return 39 + loading = true 40 + error = null 41 + 42 + try { 43 + const response = await fetch( 44 + `/xrpc/com.tranquil.delegation.getAuditLog?limit=${limit}&offset=${offset}`, 45 + { 46 + headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 47 + } 48 + ) 49 + 50 + if (!response.ok) { 51 + const data = await response.json() 52 + error = data.message || data.error || $_('delegation.failedToLoadAuditLog') 53 + return 54 + } 55 + 56 + const data = await response.json() 57 + entries = data.entries || [] 58 + total = data.total || 0 59 + } catch (e) { 60 + error = $_('delegation.failedToLoadAuditLog') 61 + } finally { 62 + loading = false 63 + } 64 + } 65 + 66 + function prevPage() { 67 + if (offset > 0) { 68 + offset = Math.max(0, offset - limit) 69 + loadAuditLog() 70 + } 71 + } 72 + 73 + function nextPage() { 74 + if (offset + limit < total) { 75 + offset = offset + limit 76 + loadAuditLog() 77 + } 78 + } 79 + 80 + function formatActionType(type: string): string { 81 + const labels: Record<string, string> = { 82 + 'GrantCreated': $_('delegation.actionGrantCreated'), 83 + 'GrantRevoked': $_('delegation.actionGrantRevoked'), 84 + 'ScopesModified': $_('delegation.actionScopesModified'), 85 + 'TokenIssued': $_('delegation.actionTokenIssued'), 86 + 'RepoWrite': $_('delegation.actionRepoWrite'), 87 + 'BlobUpload': $_('delegation.actionBlobUpload'), 88 + 'AccountAction': $_('delegation.actionAccountAction') 89 + } 90 + return labels[type] || type 91 + } 92 + 93 + function formatActionDetails(details: Record<string, unknown> | null): string { 94 + if (!details) return '' 95 + const parts: string[] = [] 96 + for (const [key, value] of Object.entries(details)) { 97 + const formattedKey = key.replace(/_/g, ' ') 98 + parts.push(`${formattedKey}: ${JSON.stringify(value)}`) 99 + } 100 + return parts.join(', ') 101 + } 102 + 103 + function truncateDid(did: string): string { 104 + if (did.length <= 30) return did 105 + return did.substring(0, 20) + '...' + did.substring(did.length - 6) 106 + } 107 + </script> 108 + 109 + <div class="page"> 110 + <header> 111 + <a href="#/controllers" class="back">{$_('delegation.backToControllers')}</a> 112 + <h1>{$_('delegation.auditLogTitle')}</h1> 113 + </header> 114 + 115 + {#if loading} 116 + <p class="loading">{$_('delegation.loading')}</p> 117 + {:else} 118 + {#if error} 119 + <div class="message error">{error}</div> 120 + {/if} 121 + 122 + {#if entries.length === 0} 123 + <p class="empty">{$_('delegation.noActivity')}</p> 124 + {:else} 125 + <div class="audit-list"> 126 + {#each entries as entry} 127 + <div class="audit-entry"> 128 + <div class="entry-header"> 129 + <span class="action-type">{formatActionType(entry.actionType)}</span> 130 + <span class="timestamp">{formatDateTime(entry.createdAt)}</span> 131 + </div> 132 + <div class="entry-details"> 133 + <div class="detail"> 134 + <span class="label">{$_('delegation.actor')}</span> 135 + <span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span> 136 + </div> 137 + {#if entry.controllerDid} 138 + <div class="detail"> 139 + <span class="label">{$_('delegation.controller')}</span> 140 + <span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span> 141 + </div> 142 + {/if} 143 + <div class="detail"> 144 + <span class="label">{$_('delegation.account')}</span> 145 + <span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span> 146 + </div> 147 + {#if entry.actionDetails} 148 + <div class="detail"> 149 + <span class="label">{$_('delegation.details')}</span> 150 + <span class="value details">{formatActionDetails(entry.actionDetails)}</span> 151 + </div> 152 + {/if} 153 + </div> 154 + </div> 155 + {/each} 156 + </div> 157 + 158 + <div class="pagination"> 159 + <button 160 + class="ghost" 161 + onclick={prevPage} 162 + disabled={offset === 0} 163 + > 164 + {$_('delegation.previous')} 165 + </button> 166 + <span class="page-info"> 167 + {$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })} 168 + </span> 169 + <button 170 + class="ghost" 171 + onclick={nextPage} 172 + disabled={offset + limit >= total} 173 + > 174 + {$_('delegation.next')} 175 + </button> 176 + </div> 177 + {/if} 178 + 179 + <div class="actions-bar"> 180 + <button class="ghost" onclick={loadAuditLog}>{$_('delegation.refresh')}</button> 181 + </div> 182 + {/if} 183 + </div> 184 + 185 + <style> 186 + .page { 187 + max-width: var(--width-lg); 188 + margin: 0 auto; 189 + padding: var(--space-7); 190 + } 191 + 192 + header { 193 + margin-bottom: var(--space-7); 194 + } 195 + 196 + .back { 197 + color: var(--text-secondary); 198 + text-decoration: none; 199 + font-size: var(--text-sm); 200 + } 201 + 202 + .back:hover { 203 + color: var(--accent); 204 + } 205 + 206 + h1 { 207 + margin: var(--space-2) 0 0 0; 208 + } 209 + 210 + .loading, 211 + .empty { 212 + text-align: center; 213 + color: var(--text-secondary); 214 + padding: var(--space-7); 215 + } 216 + 217 + .message.error { 218 + padding: var(--space-3); 219 + background: var(--error-bg); 220 + border: 1px solid var(--error-border); 221 + border-radius: var(--radius-md); 222 + color: var(--error-text); 223 + margin-bottom: var(--space-4); 224 + } 225 + 226 + .audit-list { 227 + display: flex; 228 + flex-direction: column; 229 + gap: var(--space-3); 230 + margin-bottom: var(--space-4); 231 + } 232 + 233 + .audit-entry { 234 + background: var(--bg-secondary); 235 + border: 1px solid var(--border-color); 236 + border-radius: var(--radius-lg); 237 + padding: var(--space-4); 238 + } 239 + 240 + .entry-header { 241 + display: flex; 242 + justify-content: space-between; 243 + align-items: center; 244 + margin-bottom: var(--space-3); 245 + flex-wrap: wrap; 246 + gap: var(--space-2); 247 + } 248 + 249 + .action-type { 250 + font-weight: var(--font-semibold); 251 + color: var(--text-primary); 252 + } 253 + 254 + .timestamp { 255 + font-size: var(--text-sm); 256 + color: var(--text-muted); 257 + } 258 + 259 + .entry-details { 260 + display: flex; 261 + flex-direction: column; 262 + gap: var(--space-2); 263 + } 264 + 265 + .detail { 266 + font-size: var(--text-sm); 267 + display: flex; 268 + gap: var(--space-2); 269 + align-items: baseline; 270 + flex-wrap: wrap; 271 + } 272 + 273 + .detail .label { 274 + color: var(--text-secondary); 275 + min-width: 80px; 276 + } 277 + 278 + .detail .value { 279 + color: var(--text-primary); 280 + } 281 + 282 + .detail .value.did { 283 + font-family: var(--font-mono); 284 + font-size: var(--text-xs); 285 + word-break: break-all; 286 + } 287 + 288 + .detail .value.details { 289 + font-size: var(--text-xs); 290 + color: var(--text-muted); 291 + word-break: break-word; 292 + } 293 + 294 + .pagination { 295 + display: flex; 296 + justify-content: center; 297 + align-items: center; 298 + gap: var(--space-4); 299 + margin: var(--space-5) 0; 300 + } 301 + 302 + .pagination button { 303 + padding: var(--space-2) var(--space-4); 304 + font-size: var(--text-sm); 305 + } 306 + 307 + .page-info { 308 + font-size: var(--text-sm); 309 + color: var(--text-secondary); 310 + } 311 + 312 + .actions-bar { 313 + display: flex; 314 + gap: var(--space-2); 315 + flex-wrap: wrap; 316 + } 317 + 318 + .actions-bar button { 319 + padding: var(--space-2) var(--space-4); 320 + font-size: var(--text-sm); 321 + } 322 + </style>
+5
frontend/src/routes/Home.svelte
··· 178 178 <h3>App passwords with guardrails</h3> 179 179 <p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p> 180 180 </div> 181 + 182 + <div class="feature"> 183 + <h3>Delegate without sharing passwords</h3> 184 + <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p> 185 + </div> 181 186 </div> 182 187 183 188 <h2>Everything in one place</h2>
+171 -24
frontend/src/routes/OAuthConsent.svelte
··· 20 20 scopes: ScopeInfo[] 21 21 show_consent: boolean 22 22 did: string 23 + is_delegation?: boolean 24 + controller_did?: string 25 + controller_handle?: string 26 + delegation_level?: string 23 27 } 24 28 25 29 let loading = $state(true) ··· 77 81 if (!consentData) return 78 82 79 83 submitting = true 80 - const approvedScopes = Object.entries(scopeSelections) 84 + let approvedScopes = Object.entries(scopeSelections) 81 85 .filter(([_, approved]) => approved) 82 86 .map(([scope]) => scope) 87 + 88 + if (approvedScopes.length === 0 && consentData.scopes.length === 0) { 89 + approvedScopes = ['atproto'] 90 + } 83 91 84 92 try { 85 93 const response = await fetch('/oauth/authorize/consent', { ··· 183 191 </div> 184 192 185 193 <div class="account-info"> 186 - <span class="label">{$_('oauth.consent.signingInAs')}</span> 187 - <span class="did">{consentData.did}</span> 194 + {#if consentData.is_delegation} 195 + <div class="delegation-badge">{$_('oauthConsent.delegatedAccess')}</div> 196 + <div class="delegation-info"> 197 + <div class="info-row"> 198 + <span class="label">{$_('oauthConsent.actingAs')}</span> 199 + <span class="did">{consentData.did}</span> 200 + </div> 201 + <div class="info-row"> 202 + <span class="label">{$_('oauthConsent.controller')}</span> 203 + <span class="handle">@{consentData.controller_handle || consentData.controller_did}</span> 204 + </div> 205 + <div class="info-row"> 206 + <span class="label">{$_('oauthConsent.accessLevel')}</span> 207 + <span class="level-badge level-{consentData.delegation_level?.toLowerCase()}">{consentData.delegation_level}</span> 208 + </div> 209 + </div> 210 + {#if consentData.delegation_level && consentData.delegation_level !== 'Owner'} 211 + <div class="permissions-notice"> 212 + <div class="notice-header"> 213 + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg> 214 + <span>{$_('oauthConsent.permissionsLimited')}</span> 215 + </div> 216 + <p class="notice-text"> 217 + {#if consentData.delegation_level === 'Viewer'} 218 + {$_('oauthConsent.viewerLimitedDesc')} 219 + {:else if consentData.delegation_level === 'Editor'} 220 + {$_('oauthConsent.editorLimitedDesc')} 221 + {:else} 222 + {$_('oauthConsent.permissionsLimitedDesc', { values: { level: consentData.delegation_level } })} 223 + {/if} 224 + </p> 225 + </div> 226 + {/if} 227 + {:else} 228 + <span class="label">{$_('oauth.consent.signingInAs')}</span> 229 + <span class="did">{consentData.did}</span> 230 + {/if} 188 231 </div> 189 232 </div> 190 233 191 234 <div class="permissions-panel"> 192 235 <div class="scopes-section"> 193 236 <h2>{$_('oauth.consent.permissionsRequested')}</h2> 194 - {#each Object.entries(scopeGroups) as [category, scopes]} 195 - <div class="scope-group"> 196 - <h3 class="category-title">{category}</h3> 197 - {#each scopes as scope} 198 - <label class="scope-item" class:required={scope.required}> 199 - <input 200 - type="checkbox" 201 - checked={scopeSelections[scope.scope]} 202 - disabled={scope.required || submitting} 203 - onchange={() => handleScopeToggle(scope.scope)} 204 - /> 205 - <div class="scope-info"> 206 - <span class="scope-name">{scope.display_name}</span> 207 - <span class="scope-description">{scope.description}</span> 208 - {#if scope.required} 209 - <span class="required-badge">{$_('oauth.consent.required')}</span> 210 - {/if} 211 - </div> 212 - </label> 213 - {/each} 237 + {#if consentData.scopes.length === 0} 238 + <div class="read-only-notice"> 239 + <div class="scope-item read-only"> 240 + <div class="scope-info"> 241 + <span class="scope-name">{$_('oauthConsent.readOnlyAccess')}</span> 242 + <span class="scope-description">{$_('oauthConsent.readOnlyDesc')}</span> 243 + </div> 244 + </div> 214 245 </div> 215 - {/each} 246 + {:else} 247 + {#each Object.entries(scopeGroups) as [category, scopes]} 248 + <div class="scope-group"> 249 + <h3 class="category-title">{category}</h3> 250 + {#each scopes as scope} 251 + <label class="scope-item" class:required={scope.required}> 252 + <input 253 + type="checkbox" 254 + checked={scopeSelections[scope.scope]} 255 + disabled={scope.required || submitting} 256 + onchange={() => handleScopeToggle(scope.scope)} 257 + /> 258 + <div class="scope-info"> 259 + <span class="scope-name">{scope.display_name}</span> 260 + <span class="scope-description">{scope.description}</span> 261 + {#if scope.required} 262 + <span class="required-badge">{$_('oauth.consent.required')}</span> 263 + {/if} 264 + </div> 265 + </label> 266 + {/each} 267 + </div> 268 + {/each} 269 + {/if} 216 270 </div> 217 271 218 272 <label class="remember-choice"> ··· 339 393 word-break: break-all; 340 394 } 341 395 396 + .delegation-badge { 397 + display: inline-block; 398 + padding: var(--space-1) var(--space-2); 399 + background: var(--accent); 400 + color: var(--text-inverse); 401 + border-radius: var(--radius-md); 402 + font-size: var(--text-xs); 403 + font-weight: var(--font-semibold); 404 + text-transform: uppercase; 405 + letter-spacing: 0.05em; 406 + margin-bottom: var(--space-3); 407 + } 408 + 409 + .delegation-info { 410 + display: flex; 411 + flex-direction: column; 412 + gap: var(--space-2); 413 + } 414 + 415 + .delegation-info .info-row { 416 + display: flex; 417 + flex-direction: column; 418 + gap: 2px; 419 + } 420 + 421 + .delegation-info .handle { 422 + font-weight: var(--font-medium); 423 + color: var(--text-primary); 424 + } 425 + 426 + .level-badge { 427 + display: inline-block; 428 + padding: 2px var(--space-2); 429 + background: var(--bg-tertiary); 430 + color: var(--text-primary); 431 + border-radius: var(--radius-sm); 432 + font-size: var(--text-sm); 433 + font-weight: var(--font-medium); 434 + } 435 + 436 + .level-badge.level-owner { 437 + background: var(--success-bg); 438 + color: var(--success-text); 439 + } 440 + 441 + .level-badge.level-admin { 442 + background: var(--accent); 443 + color: var(--text-inverse); 444 + } 445 + 446 + .level-badge.level-editor { 447 + background: var(--warning-bg); 448 + color: var(--warning-text); 449 + } 450 + 451 + .level-badge.level-viewer { 452 + background: var(--bg-tertiary); 453 + color: var(--text-secondary); 454 + } 455 + 456 + .permissions-notice { 457 + margin-top: var(--space-3); 458 + padding: var(--space-3); 459 + background: var(--warning-bg); 460 + border: 1px solid var(--warning-border); 461 + border-radius: var(--radius-md); 462 + } 463 + 464 + .notice-header { 465 + display: flex; 466 + align-items: center; 467 + gap: var(--space-2); 468 + font-weight: var(--font-semibold); 469 + color: var(--warning-text); 470 + margin-bottom: var(--space-2); 471 + } 472 + 473 + .notice-header svg { 474 + flex-shrink: 0; 475 + } 476 + 477 + .notice-text { 478 + margin: 0; 479 + font-size: var(--text-sm); 480 + color: var(--warning-text); 481 + line-height: 1.5; 482 + } 483 + 342 484 .scopes-section { 343 485 margin-bottom: var(--space-6); 344 486 } ··· 380 522 381 523 .scope-item.required { 382 524 background: var(--bg-secondary); 525 + } 526 + 527 + .scope-item.read-only { 528 + background: var(--bg-secondary); 529 + border-style: dashed; 383 530 } 384 531 385 532 .scope-item input[type="checkbox"] {
+738
frontend/src/routes/OAuthDelegation.svelte
··· 1 + <script lang="ts"> 2 + import { navigate } from '../lib/router.svelte' 3 + import { _ } from '../lib/i18n' 4 + 5 + let delegatedDid = $state<string | null>(null) 6 + let delegatedHandle = $state<string | null>(null) 7 + let controllerIdentifier = $state('') 8 + let controllerDid = $state<string | null>(null) 9 + let password = $state('') 10 + let rememberDevice = $state(false) 11 + let submitting = $state(false) 12 + let loading = $state(true) 13 + let error = $state<string | null>(null) 14 + let hasPasskeys = $state(false) 15 + let hasTotp = $state(false) 16 + let passkeySupported = $state(false) 17 + let step = $state<'identifier' | 'password'>('identifier') 18 + 19 + $effect(() => { 20 + passkeySupported = window.PublicKeyCredential !== undefined 21 + }) 22 + 23 + function getRequestUri(): string | null { 24 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 25 + return params.get('request_uri') 26 + } 27 + 28 + function getDelegatedDid(): string | null { 29 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') 30 + return params.get('delegated_did') 31 + } 32 + 33 + $effect(() => { 34 + loadDelegationInfo() 35 + }) 36 + 37 + async function loadDelegationInfo() { 38 + const requestUri = getRequestUri() 39 + delegatedDid = getDelegatedDid() 40 + 41 + if (!requestUri || !delegatedDid) { 42 + error = $_('oauthDelegation.missingParams') 43 + loading = false 44 + return 45 + } 46 + 47 + try { 48 + const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(delegatedDid.replace('did:', ''))}`) 49 + if (response.ok) { 50 + const data = await response.json() 51 + delegatedHandle = data.handle || delegatedDid 52 + } else { 53 + const handleResponse = await fetch(`/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(delegatedDid)}`) 54 + if (handleResponse.ok) { 55 + const data = await handleResponse.json() 56 + delegatedHandle = data.handle || delegatedDid 57 + } else { 58 + delegatedHandle = delegatedDid 59 + } 60 + } 61 + } catch { 62 + delegatedHandle = delegatedDid 63 + } finally { 64 + loading = false 65 + } 66 + } 67 + 68 + async function handleIdentifierSubmit(e: Event) { 69 + e.preventDefault() 70 + if (!controllerIdentifier.trim()) return 71 + 72 + submitting = true 73 + error = null 74 + 75 + try { 76 + let resolvedDid = controllerIdentifier.trim() 77 + if (!resolvedDid.startsWith('did:')) { 78 + resolvedDid = resolvedDid.replace(/^@/, '') 79 + const response = await fetch(`/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(resolvedDid)}`) 80 + if (!response.ok) { 81 + error = $_('oauthDelegation.controllerNotFound') 82 + submitting = false 83 + return 84 + } 85 + const data = await response.json() 86 + resolvedDid = data.did 87 + } 88 + 89 + controllerDid = resolvedDid 90 + 91 + const securityResponse = await fetch(`/oauth/security-status?identifier=${encodeURIComponent(controllerIdentifier.trim().replace(/^@/, ''))}`) 92 + if (securityResponse.ok) { 93 + const data = await securityResponse.json() 94 + hasPasskeys = passkeySupported && data.hasPasskeys === true 95 + hasTotp = data.hasTotp === true 96 + } 97 + 98 + step = 'password' 99 + } catch { 100 + error = $_('oauthDelegation.controllerNotFound') 101 + } finally { 102 + submitting = false 103 + } 104 + } 105 + 106 + function arrayBufferToBase64Url(buffer: ArrayBuffer): string { 107 + const bytes = new Uint8Array(buffer) 108 + let binary = '' 109 + for (let i = 0; i < bytes.byteLength; i++) { 110 + binary += String.fromCharCode(bytes[i]) 111 + } 112 + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 113 + } 114 + 115 + function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { 116 + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') 117 + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) 118 + const binary = atob(padded) 119 + const bytes = new Uint8Array(binary.length) 120 + for (let i = 0; i < binary.length; i++) { 121 + bytes[i] = binary.charCodeAt(i) 122 + } 123 + return bytes.buffer 124 + } 125 + 126 + function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions { 127 + return { 128 + ...options, 129 + challenge: base64UrlToArrayBuffer(options.challenge), 130 + allowCredentials: options.allowCredentials?.map((cred: any) => ({ 131 + ...cred, 132 + id: base64UrlToArrayBuffer(cred.id) 133 + })) || [] 134 + } 135 + } 136 + 137 + async function handlePasskeyLogin() { 138 + const requestUri = getRequestUri() 139 + if (!requestUri || !controllerDid || !delegatedDid) { 140 + error = $_('oauthDelegation.missingInfo') 141 + return 142 + } 143 + 144 + submitting = true 145 + error = null 146 + 147 + try { 148 + const startResponse = await fetch('/oauth/passkey/start', { 149 + method: 'POST', 150 + headers: { 151 + 'Content-Type': 'application/json', 152 + 'Accept': 'application/json' 153 + }, 154 + body: JSON.stringify({ 155 + request_uri: requestUri, 156 + identifier: controllerIdentifier.trim().replace(/^@/, '') 157 + }) 158 + }) 159 + 160 + if (!startResponse.ok) { 161 + const data = await startResponse.json() 162 + error = data.error_description || data.error || $_('oauthDelegation.failedPasskeyStart') 163 + submitting = false 164 + return 165 + } 166 + 167 + const { options } = await startResponse.json() 168 + 169 + const credential = await navigator.credentials.get({ 170 + publicKey: prepareCredentialRequestOptions(options.publicKey) 171 + }) as PublicKeyCredential | null 172 + 173 + if (!credential) { 174 + error = $_('oauthDelegation.passkeyCancelled') 175 + submitting = false 176 + return 177 + } 178 + 179 + const assertionResponse = credential.response as AuthenticatorAssertionResponse 180 + const credentialData = { 181 + id: credential.id, 182 + type: credential.type, 183 + rawId: arrayBufferToBase64Url(credential.rawId), 184 + response: { 185 + clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON), 186 + authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), 187 + signature: arrayBufferToBase64Url(assertionResponse.signature), 188 + userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null 189 + } 190 + } 191 + 192 + const finishResponse = await fetch('/oauth/passkey/finish', { 193 + method: 'POST', 194 + headers: { 195 + 'Content-Type': 'application/json', 196 + 'Accept': 'application/json' 197 + }, 198 + body: JSON.stringify({ 199 + request_uri: requestUri, 200 + identifier: controllerIdentifier.trim().replace(/^@/, ''), 201 + credential: credentialData, 202 + delegated_did: delegatedDid, 203 + controller_did: controllerDid 204 + }) 205 + }) 206 + 207 + const data = await finishResponse.json() 208 + 209 + if (!finishResponse.ok || data.success === false || data.error) { 210 + error = data.error_description || data.error || $_('oauthDelegation.passkeyFailed') 211 + submitting = false 212 + return 213 + } 214 + 215 + if (data.needs_totp) { 216 + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 217 + return 218 + } 219 + 220 + if (data.needs_2fa) { 221 + navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 222 + return 223 + } 224 + 225 + if (data.redirect_uri) { 226 + window.location.href = data.redirect_uri 227 + return 228 + } 229 + 230 + error = $_('oauthDelegation.unexpectedResponse') 231 + submitting = false 232 + } catch (e) { 233 + console.error('Passkey login error:', e) 234 + error = $_('oauthDelegation.authFailed') 235 + submitting = false 236 + } 237 + } 238 + 239 + async function handlePasswordSubmit(e: Event) { 240 + e.preventDefault() 241 + const requestUri = getRequestUri() 242 + if (!requestUri || !controllerDid || !delegatedDid) { 243 + error = $_('oauthDelegation.missingInfo') 244 + return 245 + } 246 + 247 + submitting = true 248 + error = null 249 + 250 + try { 251 + const response = await fetch('/oauth/delegation/auth', { 252 + method: 'POST', 253 + headers: { 254 + 'Content-Type': 'application/json', 255 + 'Accept': 'application/json' 256 + }, 257 + body: JSON.stringify({ 258 + request_uri: requestUri, 259 + delegated_did: delegatedDid, 260 + controller_did: controllerDid, 261 + password, 262 + remember_device: rememberDevice 263 + }) 264 + }) 265 + 266 + const data = await response.json() 267 + 268 + if (!response.ok || data.success === false || data.error) { 269 + error = data.error_description || data.error || $_('oauthDelegation.authFailed') 270 + submitting = false 271 + return 272 + } 273 + 274 + if (data.needs_totp) { 275 + navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`) 276 + return 277 + } 278 + 279 + if (data.needs_2fa) { 280 + navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`) 281 + return 282 + } 283 + 284 + if (data.redirect_uri) { 285 + window.location.href = data.redirect_uri 286 + return 287 + } 288 + 289 + error = $_('oauthDelegation.unexpectedResponse') 290 + submitting = false 291 + } catch { 292 + error = $_('oauthDelegation.authFailed') 293 + submitting = false 294 + } 295 + } 296 + 297 + async function handleCancel() { 298 + const requestUri = getRequestUri() 299 + if (!requestUri) { 300 + window.history.back() 301 + return 302 + } 303 + 304 + submitting = true 305 + try { 306 + const response = await fetch('/oauth/authorize/deny', { 307 + method: 'POST', 308 + headers: { 309 + 'Content-Type': 'application/json', 310 + 'Accept': 'application/json' 311 + }, 312 + body: JSON.stringify({ request_uri: requestUri }) 313 + }) 314 + 315 + const data = await response.json() 316 + if (data.redirect_uri) { 317 + window.location.href = data.redirect_uri 318 + } 319 + } catch { 320 + window.history.back() 321 + } 322 + } 323 + 324 + function goBack() { 325 + step = 'identifier' 326 + password = '' 327 + error = null 328 + } 329 + </script> 330 + 331 + <div class="delegation-container"> 332 + {#if loading} 333 + <div class="loading"> 334 + <p>{$_('oauthDelegation.loading')}</p> 335 + </div> 336 + {:else if step === 'identifier'} 337 + <header class="page-header"> 338 + <h1>{$_('oauthDelegation.title')}</h1> 339 + <p class="subtitle"> 340 + {$_('oauthDelegation.isDelegated', { values: { handle: delegatedHandle } })} 341 + <br />{$_('oauthDelegation.enterControllerHandle')} 342 + </p> 343 + </header> 344 + 345 + {#if error} 346 + <div class="error">{error}</div> 347 + {/if} 348 + 349 + <form onsubmit={handleIdentifierSubmit}> 350 + <div class="field"> 351 + <label for="controller-identifier">{$_('oauthDelegation.controllerHandle')}</label> 352 + <input 353 + id="controller-identifier" 354 + type="text" 355 + bind:value={controllerIdentifier} 356 + disabled={submitting} 357 + required 358 + autocomplete="username" 359 + placeholder={$_('oauthDelegation.handlePlaceholder')} 360 + /> 361 + </div> 362 + 363 + <div class="actions"> 364 + <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 365 + {$_('common.cancel')} 366 + </button> 367 + <button type="submit" class="submit-btn" disabled={submitting || !controllerIdentifier.trim()}> 368 + {submitting ? $_('oauthDelegation.checking') : $_('common.continue')} 369 + </button> 370 + </div> 371 + </form> 372 + {:else if step === 'password'} 373 + <header class="page-header"> 374 + <h1>{$_('oauthDelegation.signInAsController')}</h1> 375 + <p class="subtitle"> 376 + {$_('oauthDelegation.authenticateAs', { values: { controller: '@' + controllerIdentifier.replace(/^@/, ''), delegated: delegatedHandle } })} 377 + </p> 378 + </header> 379 + 380 + {#if error} 381 + <div class="error">{error}</div> 382 + {/if} 383 + 384 + <button class="back-link" onclick={goBack} disabled={submitting}> 385 + &larr; {$_('oauthDelegation.useDifferentController')} 386 + </button> 387 + 388 + <form onsubmit={handlePasswordSubmit}> 389 + {#if passkeySupported && hasPasskeys} 390 + <div class="auth-methods"> 391 + <div class="passkey-method"> 392 + <h3>{$_('oauthDelegation.signInWithPasskey')}</h3> 393 + <button 394 + type="button" 395 + class="passkey-btn" 396 + onclick={handlePasskeyLogin} 397 + disabled={submitting} 398 + > 399 + <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 400 + <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 401 + <path d="M17 17v4l3-2-3-2z" /> 402 + <path d="M12 11c-4 0-6 2-6 4v4h9" /> 403 + </svg> 404 + <span class="passkey-text"> 405 + {submitting ? $_('oauthDelegation.authenticating') : $_('oauthDelegation.usePasskey')} 406 + </span> 407 + </button> 408 + </div> 409 + 410 + <div class="method-divider"> 411 + <span>{$_('oauthDelegation.or')}</span> 412 + </div> 413 + 414 + <div class="password-method"> 415 + <h3>{$_('oauthDelegation.password')}</h3> 416 + <div class="field"> 417 + <input 418 + type="password" 419 + bind:value={password} 420 + disabled={submitting} 421 + required 422 + autocomplete="current-password" 423 + placeholder={$_('oauthDelegation.enterPassword')} 424 + /> 425 + </div> 426 + 427 + <label class="remember-device"> 428 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 429 + <span>{$_('oauthDelegation.rememberDevice')}</span> 430 + </label> 431 + 432 + <button type="submit" class="submit-btn" disabled={submitting || !password}> 433 + {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')} 434 + </button> 435 + </div> 436 + </div> 437 + {:else} 438 + <div class="field"> 439 + <label for="password">{$_('oauthDelegation.password')}</label> 440 + <input 441 + id="password" 442 + type="password" 443 + bind:value={password} 444 + disabled={submitting} 445 + required 446 + autocomplete="current-password" 447 + /> 448 + </div> 449 + 450 + <label class="remember-device"> 451 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 452 + <span>{$_('oauthDelegation.rememberDevice')}</span> 453 + </label> 454 + 455 + <div class="actions"> 456 + <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 457 + {$_('common.cancel')} 458 + </button> 459 + <button type="submit" class="submit-btn" disabled={submitting || !password}> 460 + {submitting ? $_('oauthDelegation.signingIn') : $_('oauthDelegation.signIn')} 461 + </button> 462 + </div> 463 + {/if} 464 + </form> 465 + {:else} 466 + <header class="page-header"> 467 + <h1>{$_('oauthDelegation.title')}</h1> 468 + </header> 469 + <div class="error">{error || $_('oauthDelegation.unableToLoad')}</div> 470 + <div class="actions"> 471 + <button type="button" class="cancel-btn" onclick={handleCancel}> 472 + {$_('oauthDelegation.goBack')} 473 + </button> 474 + </div> 475 + {/if} 476 + </div> 477 + 478 + <style> 479 + .delegation-container { 480 + max-width: var(--width-md); 481 + margin: var(--space-9) auto; 482 + padding: var(--space-7); 483 + } 484 + 485 + .loading { 486 + display: flex; 487 + align-items: center; 488 + justify-content: center; 489 + min-height: 200px; 490 + color: var(--text-secondary); 491 + } 492 + 493 + .page-header { 494 + margin-bottom: var(--space-6); 495 + } 496 + 497 + h1 { 498 + margin: 0 0 var(--space-2) 0; 499 + } 500 + 501 + .subtitle { 502 + color: var(--text-secondary); 503 + margin: 0; 504 + line-height: 1.6; 505 + } 506 + 507 + .back-link { 508 + display: inline-flex; 509 + align-items: center; 510 + padding: var(--space-2) 0; 511 + background: none; 512 + border: none; 513 + color: var(--accent); 514 + font-size: var(--text-sm); 515 + cursor: pointer; 516 + margin-bottom: var(--space-4); 517 + } 518 + 519 + .back-link:hover:not(:disabled) { 520 + text-decoration: underline; 521 + } 522 + 523 + .back-link:disabled { 524 + opacity: 0.6; 525 + cursor: not-allowed; 526 + } 527 + 528 + form { 529 + display: flex; 530 + flex-direction: column; 531 + gap: var(--space-4); 532 + } 533 + 534 + .auth-methods { 535 + display: grid; 536 + grid-template-columns: 1fr; 537 + gap: var(--space-5); 538 + margin-top: var(--space-4); 539 + } 540 + 541 + @media (min-width: 600px) { 542 + .auth-methods { 543 + grid-template-columns: 1fr auto 1fr; 544 + align-items: start; 545 + } 546 + } 547 + 548 + .passkey-method, 549 + .password-method { 550 + display: flex; 551 + flex-direction: column; 552 + gap: var(--space-4); 553 + padding: var(--space-5); 554 + background: var(--bg-secondary); 555 + border-radius: var(--radius-xl); 556 + } 557 + 558 + .passkey-method h3, 559 + .password-method h3 { 560 + margin: 0; 561 + font-size: var(--text-sm); 562 + font-weight: var(--font-semibold); 563 + color: var(--text-secondary); 564 + text-transform: uppercase; 565 + letter-spacing: 0.05em; 566 + } 567 + 568 + .method-divider { 569 + display: flex; 570 + align-items: center; 571 + justify-content: center; 572 + color: var(--text-muted); 573 + font-size: var(--text-sm); 574 + } 575 + 576 + @media (min-width: 600px) { 577 + .method-divider { 578 + flex-direction: column; 579 + padding: 0 var(--space-3); 580 + } 581 + 582 + .method-divider::before, 583 + .method-divider::after { 584 + content: ''; 585 + width: 1px; 586 + height: var(--space-6); 587 + background: var(--border-color); 588 + } 589 + 590 + .method-divider span { 591 + writing-mode: vertical-rl; 592 + text-orientation: mixed; 593 + transform: rotate(180deg); 594 + padding: var(--space-2) 0; 595 + } 596 + } 597 + 598 + @media (max-width: 599px) { 599 + .method-divider { 600 + gap: var(--space-4); 601 + } 602 + 603 + .method-divider::before, 604 + .method-divider::after { 605 + content: ''; 606 + flex: 1; 607 + height: 1px; 608 + background: var(--border-color); 609 + } 610 + } 611 + 612 + .field { 613 + display: flex; 614 + flex-direction: column; 615 + gap: var(--space-1); 616 + } 617 + 618 + label { 619 + font-size: var(--text-sm); 620 + font-weight: var(--font-medium); 621 + } 622 + 623 + input[type="password"], 624 + input[type="text"] { 625 + padding: var(--space-3); 626 + border: 1px solid var(--border-color); 627 + border-radius: var(--radius-md); 628 + font-size: var(--text-base); 629 + background: var(--bg-input); 630 + color: var(--text-primary); 631 + } 632 + 633 + input:focus { 634 + outline: none; 635 + border-color: var(--accent); 636 + } 637 + 638 + .remember-device { 639 + display: flex; 640 + align-items: center; 641 + gap: var(--space-2); 642 + cursor: pointer; 643 + color: var(--text-secondary); 644 + font-size: var(--text-sm); 645 + } 646 + 647 + .remember-device input { 648 + width: 16px; 649 + height: 16px; 650 + } 651 + 652 + .error { 653 + padding: var(--space-3); 654 + background: var(--error-bg); 655 + border: 1px solid var(--error-border); 656 + border-radius: var(--radius-md); 657 + color: var(--error-text); 658 + margin-bottom: var(--space-4); 659 + } 660 + 661 + .actions { 662 + display: flex; 663 + gap: var(--space-4); 664 + margin-top: var(--space-2); 665 + } 666 + 667 + .actions button { 668 + flex: 1; 669 + padding: var(--space-3); 670 + border: none; 671 + border-radius: var(--radius-md); 672 + font-size: var(--text-base); 673 + cursor: pointer; 674 + transition: background-color var(--transition-fast); 675 + } 676 + 677 + .actions button:disabled { 678 + opacity: 0.6; 679 + cursor: not-allowed; 680 + } 681 + 682 + .cancel-btn { 683 + background: var(--bg-secondary); 684 + color: var(--text-primary); 685 + border: 1px solid var(--border-color); 686 + } 687 + 688 + .cancel-btn:hover:not(:disabled) { 689 + background: var(--error-bg); 690 + border-color: var(--error-border); 691 + color: var(--error-text); 692 + } 693 + 694 + .submit-btn { 695 + background: var(--accent); 696 + color: var(--text-inverse); 697 + } 698 + 699 + .submit-btn:hover:not(:disabled) { 700 + background: var(--accent-hover); 701 + } 702 + 703 + .passkey-btn { 704 + display: flex; 705 + align-items: center; 706 + justify-content: center; 707 + gap: var(--space-2); 708 + width: 100%; 709 + padding: var(--space-3); 710 + background: var(--accent); 711 + color: var(--text-inverse); 712 + border: 1px solid var(--accent); 713 + border-radius: var(--radius-md); 714 + font-size: var(--text-base); 715 + cursor: pointer; 716 + transition: background-color var(--transition-fast), border-color var(--transition-fast); 717 + } 718 + 719 + .passkey-btn:hover:not(:disabled) { 720 + background: var(--accent-hover); 721 + border-color: var(--accent-hover); 722 + } 723 + 724 + .passkey-btn:disabled { 725 + opacity: 0.6; 726 + cursor: not-allowed; 727 + } 728 + 729 + .passkey-icon { 730 + width: 20px; 731 + height: 20px; 732 + } 733 + 734 + .passkey-text { 735 + flex: 1; 736 + text-align: left; 737 + } 738 + </style>
+16
frontend/src/routes/OAuthLogin.svelte
··· 9 9 let error = $state<string | null>(null) 10 10 let hasPasskeys = $state(false) 11 11 let hasTotp = $state(false) 12 + let hasPassword = $state(true) 13 + let isDelegated = $state(false) 14 + let userDid = $state<string | null>(null) 12 15 let checkingSecurityStatus = $state(false) 13 16 let securityStatusChecked = $state(false) 14 17 let passkeySupported = $state(false) ··· 84 87 const data = await response.json() 85 88 hasPasskeys = passkeySupported && data.hasPasskeys === true 86 89 hasTotp = data.hasTotp === true 90 + hasPassword = data.hasPassword !== false 91 + isDelegated = data.isDelegated === true 92 + userDid = data.did || null 87 93 securityStatusChecked = true 94 + 95 + if (!hasPassword && !hasPasskeys && isDelegated && data.did) { 96 + const requestUri = getRequestUri() 97 + if (requestUri) { 98 + navigate(`/oauth/delegation?request_uri=${encodeURIComponent(requestUri)}&delegated_did=${encodeURIComponent(data.did)}`) 99 + return 100 + } 101 + } 88 102 } 89 103 } catch { 90 104 hasPasskeys = false 91 105 hasTotp = false 106 + hasPassword = true 107 + isDelegated = false 92 108 } finally { 93 109 checkingSecurityStatus = false 94 110 }
+11
frontend/src/styles/base.css
··· 171 171 background: #900; 172 172 } 173 173 174 + button.danger-outline { 175 + background: transparent; 176 + border: 1px solid var(--error-border); 177 + color: var(--error-text); 178 + } 179 + 180 + button.danger-outline:hover:not(:disabled) { 181 + background: var(--error-bg); 182 + border-color: var(--error-text); 183 + } 184 + 174 185 button.ghost { 175 186 background: transparent; 176 187 color: var(--text-secondary);
+55
migrations/20251237_account_delegation.sql
··· 1 + CREATE TYPE account_type AS ENUM ('personal', 'delegated'); 2 + 3 + ALTER TABLE users ADD COLUMN account_type account_type NOT NULL DEFAULT 'personal'; 4 + 5 + CREATE TYPE delegation_action_type AS ENUM ( 6 + 'grant_created', 7 + 'grant_revoked', 8 + 'scopes_modified', 9 + 'token_issued', 10 + 'repo_write', 11 + 'blob_upload', 12 + 'account_action' 13 + ); 14 + 15 + CREATE TABLE account_delegations ( 16 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 17 + delegated_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 18 + controller_did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 19 + granted_scopes TEXT NOT NULL, 20 + granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 21 + granted_by TEXT NOT NULL REFERENCES users(did), 22 + revoked_at TIMESTAMPTZ, 23 + revoked_by TEXT REFERENCES users(did), 24 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 25 + ); 26 + 27 + CREATE UNIQUE INDEX unique_active_delegation ON account_delegations(delegated_did, controller_did) 28 + WHERE revoked_at IS NULL; 29 + CREATE INDEX idx_delegations_delegated ON account_delegations(delegated_did) WHERE revoked_at IS NULL; 30 + CREATE INDEX idx_delegations_controller ON account_delegations(controller_did) WHERE revoked_at IS NULL; 31 + 32 + CREATE TABLE delegation_audit_log ( 33 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 34 + delegated_did TEXT NOT NULL, 35 + actor_did TEXT NOT NULL, 36 + controller_did TEXT, 37 + action_type delegation_action_type NOT NULL, 38 + action_details JSONB, 39 + ip_address TEXT, 40 + user_agent TEXT, 41 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 42 + ); 43 + 44 + CREATE INDEX idx_delegation_audit_delegated ON delegation_audit_log(delegated_did, created_at DESC); 45 + CREATE INDEX idx_delegation_audit_controller ON delegation_audit_log(controller_did, created_at DESC) WHERE controller_did IS NOT NULL; 46 + 47 + ALTER TABLE oauth_authorization_request ADD COLUMN controller_did TEXT; 48 + 49 + ALTER TABLE oauth_token ADD COLUMN controller_did TEXT; 50 + CREATE INDEX idx_oauth_token_controller ON oauth_token(controller_did) WHERE controller_did IS NOT NULL; 51 + 52 + ALTER TABLE app_passwords ADD COLUMN created_by_controller_did TEXT REFERENCES users(did) ON DELETE SET NULL; 53 + CREATE INDEX idx_app_passwords_controller ON app_passwords(created_by_controller_did) WHERE created_by_controller_did IS NOT NULL; 54 + 55 + ALTER TABLE session_tokens ADD COLUMN controller_did TEXT;
+976
src/api/delegation.rs
··· 1 + use crate::api::repo::record::utils::create_signed_commit; 2 + use crate::auth::BearerAuth; 3 + use crate::delegation::{self, DelegationActionType}; 4 + use crate::oauth::db as oauth_db; 5 + use crate::state::{AppState, RateLimitKind}; 6 + use crate::util::extract_client_ip; 7 + use crate::validation::is_valid_did; 8 + use axum::{ 9 + Json, 10 + extract::{Query, State}, 11 + http::{HeaderMap, StatusCode}, 12 + response::{IntoResponse, Response}, 13 + }; 14 + use jacquard::types::{integer::LimitedU32, string::Tid}; 15 + use jacquard_repo::{mst::Mst, storage::BlockStore}; 16 + use serde::{Deserialize, Serialize}; 17 + use serde_json::json; 18 + use std::sync::Arc; 19 + use tracing::{error, info, warn}; 20 + 21 + #[derive(Debug, Serialize)] 22 + #[serde(rename_all = "camelCase")] 23 + pub struct ControllerInfo { 24 + pub did: String, 25 + pub handle: String, 26 + pub granted_scopes: String, 27 + pub granted_at: chrono::DateTime<chrono::Utc>, 28 + pub is_active: bool, 29 + } 30 + 31 + #[derive(Debug, Serialize)] 32 + pub struct ListControllersResponse { 33 + pub controllers: Vec<ControllerInfo>, 34 + } 35 + 36 + pub async fn list_controllers(State(state): State<AppState>, auth: BearerAuth) -> Response { 37 + let controllers = match delegation::get_delegations_for_account(&state.db, &auth.0.did).await { 38 + Ok(c) => c, 39 + Err(e) => { 40 + tracing::error!("Failed to list controllers: {:?}", e); 41 + return ( 42 + StatusCode::INTERNAL_SERVER_ERROR, 43 + Json(serde_json::json!({ 44 + "error": "ServerError", 45 + "message": "Failed to list controllers" 46 + })), 47 + ) 48 + .into_response(); 49 + } 50 + }; 51 + 52 + Json(ListControllersResponse { 53 + controllers: controllers 54 + .into_iter() 55 + .map(|c| ControllerInfo { 56 + did: c.did, 57 + handle: c.handle, 58 + granted_scopes: c.granted_scopes, 59 + granted_at: c.granted_at, 60 + is_active: c.is_active, 61 + }) 62 + .collect(), 63 + }) 64 + .into_response() 65 + } 66 + 67 + #[derive(Debug, Deserialize)] 68 + pub struct AddControllerInput { 69 + pub controller_did: String, 70 + pub granted_scopes: String, 71 + } 72 + 73 + pub async fn add_controller( 74 + State(state): State<AppState>, 75 + auth: BearerAuth, 76 + Json(input): Json<AddControllerInput>, 77 + ) -> Response { 78 + if !is_valid_did(&input.controller_did) { 79 + return ( 80 + StatusCode::BAD_REQUEST, 81 + Json(serde_json::json!({ 82 + "error": "InvalidRequest", 83 + "message": "Invalid DID format" 84 + })), 85 + ) 86 + .into_response(); 87 + } 88 + 89 + if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) { 90 + return ( 91 + StatusCode::BAD_REQUEST, 92 + Json(serde_json::json!({ 93 + "error": "InvalidScopes", 94 + "message": e 95 + })), 96 + ) 97 + .into_response(); 98 + } 99 + 100 + let controller_exists: bool = sqlx::query_scalar!( 101 + r#"SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as "exists!""#, 102 + input.controller_did 103 + ) 104 + .fetch_one(&state.db) 105 + .await 106 + .unwrap_or(false); 107 + 108 + if !controller_exists { 109 + return ( 110 + StatusCode::NOT_FOUND, 111 + Json(serde_json::json!({ 112 + "error": "ControllerNotFound", 113 + "message": "Controller account not found" 114 + })), 115 + ) 116 + .into_response(); 117 + } 118 + 119 + match delegation::controls_any_accounts(&state.db, &auth.0.did).await { 120 + Ok(true) => { 121 + return ( 122 + StatusCode::BAD_REQUEST, 123 + Json(serde_json::json!({ 124 + "error": "InvalidDelegation", 125 + "message": "Cannot add controllers to an account that controls other accounts" 126 + })), 127 + ) 128 + .into_response(); 129 + } 130 + Err(e) => { 131 + tracing::error!("Failed to check delegation status: {:?}", e); 132 + return ( 133 + StatusCode::INTERNAL_SERVER_ERROR, 134 + Json(serde_json::json!({ 135 + "error": "ServerError", 136 + "message": "Failed to verify delegation status" 137 + })), 138 + ) 139 + .into_response(); 140 + } 141 + Ok(false) => {} 142 + } 143 + 144 + match delegation::has_any_controllers(&state.db, &input.controller_did).await { 145 + Ok(true) => { 146 + return ( 147 + StatusCode::BAD_REQUEST, 148 + Json(serde_json::json!({ 149 + "error": "InvalidDelegation", 150 + "message": "Cannot add a controlled account as a controller" 151 + })), 152 + ) 153 + .into_response(); 154 + } 155 + Err(e) => { 156 + tracing::error!("Failed to check controller status: {:?}", e); 157 + return ( 158 + StatusCode::INTERNAL_SERVER_ERROR, 159 + Json(serde_json::json!({ 160 + "error": "ServerError", 161 + "message": "Failed to verify controller status" 162 + })), 163 + ) 164 + .into_response(); 165 + } 166 + Ok(false) => {} 167 + } 168 + 169 + match delegation::create_delegation( 170 + &state.db, 171 + &auth.0.did, 172 + &input.controller_did, 173 + &input.granted_scopes, 174 + &auth.0.did, 175 + ) 176 + .await 177 + { 178 + Ok(_) => { 179 + let _ = delegation::log_delegation_action( 180 + &state.db, 181 + &auth.0.did, 182 + &auth.0.did, 183 + Some(&input.controller_did), 184 + DelegationActionType::GrantCreated, 185 + Some(serde_json::json!({ 186 + "granted_scopes": input.granted_scopes 187 + })), 188 + None, 189 + None, 190 + ) 191 + .await; 192 + 193 + ( 194 + StatusCode::OK, 195 + Json(serde_json::json!({ 196 + "success": true 197 + })), 198 + ) 199 + .into_response() 200 + } 201 + Err(e) => { 202 + tracing::error!("Failed to add controller: {:?}", e); 203 + ( 204 + StatusCode::INTERNAL_SERVER_ERROR, 205 + Json(serde_json::json!({ 206 + "error": "ServerError", 207 + "message": "Failed to add controller" 208 + })), 209 + ) 210 + .into_response() 211 + } 212 + } 213 + } 214 + 215 + #[derive(Debug, Deserialize)] 216 + pub struct RemoveControllerInput { 217 + pub controller_did: String, 218 + } 219 + 220 + pub async fn remove_controller( 221 + State(state): State<AppState>, 222 + auth: BearerAuth, 223 + Json(input): Json<RemoveControllerInput>, 224 + ) -> Response { 225 + if !is_valid_did(&input.controller_did) { 226 + return ( 227 + StatusCode::BAD_REQUEST, 228 + Json(serde_json::json!({ 229 + "error": "InvalidRequest", 230 + "message": "Invalid DID format" 231 + })), 232 + ) 233 + .into_response(); 234 + } 235 + 236 + match delegation::revoke_delegation(&state.db, &auth.0.did, &input.controller_did, &auth.0.did) 237 + .await 238 + { 239 + Ok(true) => { 240 + let revoked_app_passwords = sqlx::query_scalar!( 241 + r#"DELETE FROM app_passwords 242 + WHERE user_id = (SELECT id FROM users WHERE did = $1) 243 + AND created_by_controller_did = $2 244 + RETURNING id"#, 245 + auth.0.did, 246 + input.controller_did 247 + ) 248 + .fetch_all(&state.db) 249 + .await 250 + .map(|r| r.len()) 251 + .unwrap_or(0); 252 + 253 + let revoked_oauth_tokens = oauth_db::revoke_tokens_for_controller( 254 + &state.db, 255 + &auth.0.did, 256 + &input.controller_did, 257 + ) 258 + .await 259 + .unwrap_or(0); 260 + 261 + let _ = delegation::log_delegation_action( 262 + &state.db, 263 + &auth.0.did, 264 + &auth.0.did, 265 + Some(&input.controller_did), 266 + DelegationActionType::GrantRevoked, 267 + Some(serde_json::json!({ 268 + "revoked_app_passwords": revoked_app_passwords, 269 + "revoked_oauth_tokens": revoked_oauth_tokens 270 + })), 271 + None, 272 + None, 273 + ) 274 + .await; 275 + 276 + ( 277 + StatusCode::OK, 278 + Json(serde_json::json!({ 279 + "success": true 280 + })), 281 + ) 282 + .into_response() 283 + } 284 + Ok(false) => ( 285 + StatusCode::NOT_FOUND, 286 + Json(serde_json::json!({ 287 + "error": "DelegationNotFound", 288 + "message": "No active delegation found for this controller" 289 + })), 290 + ) 291 + .into_response(), 292 + Err(e) => { 293 + tracing::error!("Failed to remove controller: {:?}", e); 294 + ( 295 + StatusCode::INTERNAL_SERVER_ERROR, 296 + Json(serde_json::json!({ 297 + "error": "ServerError", 298 + "message": "Failed to remove controller" 299 + })), 300 + ) 301 + .into_response() 302 + } 303 + } 304 + } 305 + 306 + #[derive(Debug, Deserialize)] 307 + pub struct UpdateControllerScopesInput { 308 + pub controller_did: String, 309 + pub granted_scopes: String, 310 + } 311 + 312 + pub async fn update_controller_scopes( 313 + State(state): State<AppState>, 314 + auth: BearerAuth, 315 + Json(input): Json<UpdateControllerScopesInput>, 316 + ) -> Response { 317 + if !is_valid_did(&input.controller_did) { 318 + return ( 319 + StatusCode::BAD_REQUEST, 320 + Json(serde_json::json!({ 321 + "error": "InvalidRequest", 322 + "message": "Invalid DID format" 323 + })), 324 + ) 325 + .into_response(); 326 + } 327 + 328 + if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) { 329 + return ( 330 + StatusCode::BAD_REQUEST, 331 + Json(serde_json::json!({ 332 + "error": "InvalidScopes", 333 + "message": e 334 + })), 335 + ) 336 + .into_response(); 337 + } 338 + 339 + match delegation::update_delegation_scopes( 340 + &state.db, 341 + &auth.0.did, 342 + &input.controller_did, 343 + &input.granted_scopes, 344 + ) 345 + .await 346 + { 347 + Ok(true) => { 348 + let _ = delegation::log_delegation_action( 349 + &state.db, 350 + &auth.0.did, 351 + &auth.0.did, 352 + Some(&input.controller_did), 353 + DelegationActionType::ScopesModified, 354 + Some(serde_json::json!({ 355 + "new_scopes": input.granted_scopes 356 + })), 357 + None, 358 + None, 359 + ) 360 + .await; 361 + 362 + ( 363 + StatusCode::OK, 364 + Json(serde_json::json!({ 365 + "success": true 366 + })), 367 + ) 368 + .into_response() 369 + } 370 + Ok(false) => ( 371 + StatusCode::NOT_FOUND, 372 + Json(serde_json::json!({ 373 + "error": "DelegationNotFound", 374 + "message": "No active delegation found for this controller" 375 + })), 376 + ) 377 + .into_response(), 378 + Err(e) => { 379 + tracing::error!("Failed to update controller scopes: {:?}", e); 380 + ( 381 + StatusCode::INTERNAL_SERVER_ERROR, 382 + Json(serde_json::json!({ 383 + "error": "ServerError", 384 + "message": "Failed to update controller scopes" 385 + })), 386 + ) 387 + .into_response() 388 + } 389 + } 390 + } 391 + 392 + #[derive(Debug, Serialize)] 393 + #[serde(rename_all = "camelCase")] 394 + pub struct DelegatedAccountInfo { 395 + pub did: String, 396 + pub handle: String, 397 + pub granted_scopes: String, 398 + pub granted_at: chrono::DateTime<chrono::Utc>, 399 + } 400 + 401 + #[derive(Debug, Serialize)] 402 + pub struct ListControlledAccountsResponse { 403 + pub accounts: Vec<DelegatedAccountInfo>, 404 + } 405 + 406 + pub async fn list_controlled_accounts(State(state): State<AppState>, auth: BearerAuth) -> Response { 407 + let accounts = match delegation::get_accounts_controlled_by(&state.db, &auth.0.did).await { 408 + Ok(a) => a, 409 + Err(e) => { 410 + tracing::error!("Failed to list controlled accounts: {:?}", e); 411 + return ( 412 + StatusCode::INTERNAL_SERVER_ERROR, 413 + Json(serde_json::json!({ 414 + "error": "ServerError", 415 + "message": "Failed to list controlled accounts" 416 + })), 417 + ) 418 + .into_response(); 419 + } 420 + }; 421 + 422 + Json(ListControlledAccountsResponse { 423 + accounts: accounts 424 + .into_iter() 425 + .map(|a| DelegatedAccountInfo { 426 + did: a.did, 427 + handle: a.handle, 428 + granted_scopes: a.granted_scopes, 429 + granted_at: a.granted_at, 430 + }) 431 + .collect(), 432 + }) 433 + .into_response() 434 + } 435 + 436 + #[derive(Debug, Deserialize)] 437 + pub struct AuditLogParams { 438 + #[serde(default = "default_limit")] 439 + pub limit: i64, 440 + #[serde(default)] 441 + pub offset: i64, 442 + } 443 + 444 + fn default_limit() -> i64 { 445 + 50 446 + } 447 + 448 + #[derive(Debug, Serialize)] 449 + #[serde(rename_all = "camelCase")] 450 + pub struct AuditLogEntry { 451 + pub id: String, 452 + pub delegated_did: String, 453 + pub actor_did: String, 454 + pub controller_did: Option<String>, 455 + pub action_type: String, 456 + pub action_details: Option<serde_json::Value>, 457 + pub created_at: chrono::DateTime<chrono::Utc>, 458 + } 459 + 460 + #[derive(Debug, Serialize)] 461 + pub struct GetAuditLogResponse { 462 + pub entries: Vec<AuditLogEntry>, 463 + pub total: i64, 464 + } 465 + 466 + pub async fn get_audit_log( 467 + State(state): State<AppState>, 468 + auth: BearerAuth, 469 + Query(params): Query<AuditLogParams>, 470 + ) -> Response { 471 + let limit = params.limit.min(100).max(1); 472 + let offset = params.offset.max(0); 473 + 474 + let entries = 475 + match delegation::audit::get_audit_log_for_account(&state.db, &auth.0.did, limit, offset) 476 + .await 477 + { 478 + Ok(e) => e, 479 + Err(e) => { 480 + tracing::error!("Failed to get audit log: {:?}", e); 481 + return ( 482 + StatusCode::INTERNAL_SERVER_ERROR, 483 + Json(serde_json::json!({ 484 + "error": "ServerError", 485 + "message": "Failed to get audit log" 486 + })), 487 + ) 488 + .into_response(); 489 + } 490 + }; 491 + 492 + let total = match delegation::audit::count_audit_log_entries(&state.db, &auth.0.did).await { 493 + Ok(t) => t, 494 + Err(_) => 0, 495 + }; 496 + 497 + Json(GetAuditLogResponse { 498 + entries: entries 499 + .into_iter() 500 + .map(|e| AuditLogEntry { 501 + id: e.id.to_string(), 502 + delegated_did: e.delegated_did, 503 + actor_did: e.actor_did, 504 + controller_did: e.controller_did, 505 + action_type: format!("{:?}", e.action_type), 506 + action_details: e.action_details, 507 + created_at: e.created_at, 508 + }) 509 + .collect(), 510 + total, 511 + }) 512 + .into_response() 513 + } 514 + 515 + #[derive(Debug, Serialize)] 516 + pub struct ScopePresetInfo { 517 + pub name: &'static str, 518 + pub label: &'static str, 519 + pub description: &'static str, 520 + pub scopes: &'static str, 521 + } 522 + 523 + #[derive(Debug, Serialize)] 524 + pub struct GetScopePresetsResponse { 525 + pub presets: Vec<ScopePresetInfo>, 526 + } 527 + 528 + pub async fn get_scope_presets() -> Response { 529 + Json(GetScopePresetsResponse { 530 + presets: delegation::SCOPE_PRESETS 531 + .iter() 532 + .map(|p| ScopePresetInfo { 533 + name: p.name, 534 + label: p.label, 535 + description: p.description, 536 + scopes: p.scopes, 537 + }) 538 + .collect(), 539 + }) 540 + .into_response() 541 + } 542 + 543 + #[derive(Debug, Deserialize)] 544 + #[serde(rename_all = "camelCase")] 545 + pub struct CreateDelegatedAccountInput { 546 + pub handle: String, 547 + pub email: Option<String>, 548 + pub controller_scopes: String, 549 + pub invite_code: Option<String>, 550 + } 551 + 552 + #[derive(Debug, Serialize)] 553 + #[serde(rename_all = "camelCase")] 554 + pub struct CreateDelegatedAccountResponse { 555 + pub did: String, 556 + pub handle: String, 557 + } 558 + 559 + pub async fn create_delegated_account( 560 + State(state): State<AppState>, 561 + headers: HeaderMap, 562 + auth: BearerAuth, 563 + Json(input): Json<CreateDelegatedAccountInput>, 564 + ) -> Response { 565 + let client_ip = extract_client_ip(&headers); 566 + if !state 567 + .check_rate_limit(RateLimitKind::AccountCreation, &client_ip) 568 + .await 569 + { 570 + warn!(ip = %client_ip, "Delegated account creation rate limit exceeded"); 571 + return ( 572 + StatusCode::TOO_MANY_REQUESTS, 573 + Json(json!({ 574 + "error": "RateLimitExceeded", 575 + "message": "Too many account creation attempts. Please try again later." 576 + })), 577 + ) 578 + .into_response(); 579 + } 580 + 581 + if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.controller_scopes) { 582 + return ( 583 + StatusCode::BAD_REQUEST, 584 + Json(json!({ 585 + "error": "InvalidScopes", 586 + "message": e 587 + })), 588 + ) 589 + .into_response(); 590 + } 591 + 592 + match delegation::has_any_controllers(&state.db, &auth.0.did).await { 593 + Ok(true) => { 594 + return ( 595 + StatusCode::BAD_REQUEST, 596 + Json(json!({ 597 + "error": "InvalidDelegation", 598 + "message": "Cannot create delegated accounts from a controlled account" 599 + })), 600 + ) 601 + .into_response(); 602 + } 603 + Err(e) => { 604 + tracing::error!("Failed to check controller status: {:?}", e); 605 + return ( 606 + StatusCode::INTERNAL_SERVER_ERROR, 607 + Json(json!({ 608 + "error": "ServerError", 609 + "message": "Failed to verify controller status" 610 + })), 611 + ) 612 + .into_response(); 613 + } 614 + Ok(false) => {} 615 + } 616 + 617 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 618 + let pds_suffix = format!(".{}", hostname); 619 + 620 + let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 621 + let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 622 + input 623 + .handle 624 + .strip_suffix(&pds_suffix) 625 + .unwrap_or(&input.handle) 626 + } else { 627 + &input.handle 628 + }; 629 + match crate::api::validation::validate_short_handle(handle_to_validate) { 630 + Ok(h) => format!("{}.{}", h, hostname), 631 + Err(e) => { 632 + return ( 633 + StatusCode::BAD_REQUEST, 634 + Json(json!({"error": "InvalidHandle", "message": e.to_string()})), 635 + ) 636 + .into_response(); 637 + } 638 + } 639 + } else { 640 + input.handle.to_lowercase() 641 + }; 642 + 643 + let email = input 644 + .email 645 + .as_ref() 646 + .map(|e| e.trim().to_string()) 647 + .filter(|e| !e.is_empty()); 648 + if let Some(ref email) = email 649 + && !crate::api::validation::is_valid_email(email) 650 + { 651 + return ( 652 + StatusCode::BAD_REQUEST, 653 + Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), 654 + ) 655 + .into_response(); 656 + } 657 + 658 + if let Some(ref code) = input.invite_code { 659 + let valid = sqlx::query_scalar!( 660 + "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1", 661 + code 662 + ) 663 + .fetch_optional(&state.db) 664 + .await 665 + .ok() 666 + .flatten() 667 + .unwrap_or(Some(false)); 668 + 669 + if valid != Some(true) { 670 + return ( 671 + StatusCode::BAD_REQUEST, 672 + Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})), 673 + ) 674 + .into_response(); 675 + } 676 + } else { 677 + let invite_required = std::env::var("INVITE_CODE_REQUIRED") 678 + .map(|v| v == "true" || v == "1") 679 + .unwrap_or(false); 680 + if invite_required { 681 + return ( 682 + StatusCode::BAD_REQUEST, 683 + Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})), 684 + ) 685 + .into_response(); 686 + } 687 + } 688 + 689 + use k256::ecdsa::SigningKey; 690 + use rand::rngs::OsRng; 691 + 692 + let pds_endpoint = format!("https://{}", hostname); 693 + let secret_key = k256::SecretKey::random(&mut OsRng); 694 + let secret_key_bytes = secret_key.to_bytes().to_vec(); 695 + 696 + let signing_key = match SigningKey::from_slice(&secret_key_bytes) { 697 + Ok(k) => k, 698 + Err(e) => { 699 + error!("Error creating signing key: {:?}", e); 700 + return ( 701 + StatusCode::INTERNAL_SERVER_ERROR, 702 + Json(json!({"error": "InternalError"})), 703 + ) 704 + .into_response(); 705 + } 706 + }; 707 + 708 + let rotation_key = std::env::var("PLC_ROTATION_KEY") 709 + .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&signing_key)); 710 + 711 + let genesis_result = match crate::plc::create_genesis_operation( 712 + &signing_key, 713 + &rotation_key, 714 + &handle, 715 + &pds_endpoint, 716 + ) { 717 + Ok(r) => r, 718 + Err(e) => { 719 + error!("Error creating PLC genesis operation: {:?}", e); 720 + return ( 721 + StatusCode::INTERNAL_SERVER_ERROR, 722 + Json( 723 + json!({"error": "InternalError", "message": "Failed to create PLC operation"}), 724 + ), 725 + ) 726 + .into_response(); 727 + } 728 + }; 729 + 730 + let plc_client = crate::plc::PlcClient::new(None); 731 + if let Err(e) = plc_client 732 + .send_operation(&genesis_result.did, &genesis_result.signed_operation) 733 + .await 734 + { 735 + error!("Failed to submit PLC genesis operation: {:?}", e); 736 + return ( 737 + StatusCode::BAD_GATEWAY, 738 + Json(json!({ 739 + "error": "UpstreamError", 740 + "message": format!("Failed to register DID with PLC directory: {}", e) 741 + })), 742 + ) 743 + .into_response(); 744 + } 745 + 746 + let did = genesis_result.did; 747 + info!(did = %did, handle = %handle, controller = %auth.0.did, "Created DID for delegated account"); 748 + 749 + let mut tx = match state.db.begin().await { 750 + Ok(tx) => tx, 751 + Err(e) => { 752 + error!("Error starting transaction: {:?}", e); 753 + return ( 754 + StatusCode::INTERNAL_SERVER_ERROR, 755 + Json(json!({"error": "InternalError"})), 756 + ) 757 + .into_response(); 758 + } 759 + }; 760 + 761 + let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as( 762 + r#"INSERT INTO users ( 763 + handle, email, did, password_hash, password_required, 764 + account_type, preferred_comms_channel 765 + ) VALUES ($1, $2, $3, NULL, FALSE, 'delegated'::account_type, 'email'::comms_channel) RETURNING id"#, 766 + ) 767 + .bind(&handle) 768 + .bind(&email) 769 + .bind(&did) 770 + .fetch_one(&mut *tx) 771 + .await; 772 + 773 + let user_id = match user_insert { 774 + Ok((id,)) => id, 775 + Err(e) => { 776 + if let Some(db_err) = e.as_database_error() 777 + && db_err.code().as_deref() == Some("23505") 778 + { 779 + let constraint = db_err.constraint().unwrap_or(""); 780 + if constraint.contains("handle") { 781 + return ( 782 + StatusCode::BAD_REQUEST, 783 + Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})), 784 + ) 785 + .into_response(); 786 + } else if constraint.contains("email") { 787 + return ( 788 + StatusCode::BAD_REQUEST, 789 + Json( 790 + json!({"error": "InvalidEmail", "message": "Email already registered"}), 791 + ), 792 + ) 793 + .into_response(); 794 + } 795 + } 796 + error!("Error inserting user: {:?}", e); 797 + return ( 798 + StatusCode::INTERNAL_SERVER_ERROR, 799 + Json(json!({"error": "InternalError"})), 800 + ) 801 + .into_response(); 802 + } 803 + }; 804 + 805 + let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) { 806 + Ok(bytes) => bytes, 807 + Err(e) => { 808 + error!("Error encrypting signing key: {:?}", e); 809 + return ( 810 + StatusCode::INTERNAL_SERVER_ERROR, 811 + Json(json!({"error": "InternalError"})), 812 + ) 813 + .into_response(); 814 + } 815 + }; 816 + 817 + if let Err(e) = sqlx::query!( 818 + "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())", 819 + user_id, 820 + &encrypted_key_bytes[..], 821 + crate::config::ENCRYPTION_VERSION 822 + ) 823 + .execute(&mut *tx) 824 + .await 825 + { 826 + error!("Error inserting user key: {:?}", e); 827 + return ( 828 + StatusCode::INTERNAL_SERVER_ERROR, 829 + Json(json!({"error": "InternalError"})), 830 + ) 831 + .into_response(); 832 + } 833 + 834 + if let Err(e) = sqlx::query!( 835 + r#"INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by) 836 + VALUES ($1, $2, $3, $4)"#, 837 + did, 838 + auth.0.did, 839 + input.controller_scopes, 840 + auth.0.did 841 + ) 842 + .execute(&mut *tx) 843 + .await 844 + { 845 + error!("Error creating initial delegation: {:?}", e); 846 + return ( 847 + StatusCode::INTERNAL_SERVER_ERROR, 848 + Json(json!({"error": "InternalError"})), 849 + ) 850 + .into_response(); 851 + } 852 + 853 + let mst = Mst::new(Arc::new(state.block_store.clone())); 854 + let mst_root = match mst.persist().await { 855 + Ok(c) => c, 856 + Err(e) => { 857 + error!("Error persisting MST: {:?}", e); 858 + return ( 859 + StatusCode::INTERNAL_SERVER_ERROR, 860 + Json(json!({"error": "InternalError"})), 861 + ) 862 + .into_response(); 863 + } 864 + }; 865 + let rev = Tid::now(LimitedU32::MIN); 866 + let (commit_bytes, _sig) = 867 + match create_signed_commit(&did, mst_root, rev.as_ref(), None, &signing_key) { 868 + Ok(result) => result, 869 + Err(e) => { 870 + error!("Error creating genesis commit: {:?}", e); 871 + return ( 872 + StatusCode::INTERNAL_SERVER_ERROR, 873 + Json(json!({"error": "InternalError"})), 874 + ) 875 + .into_response(); 876 + } 877 + }; 878 + let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { 879 + Ok(c) => c, 880 + Err(e) => { 881 + error!("Error saving genesis commit: {:?}", e); 882 + return ( 883 + StatusCode::INTERNAL_SERVER_ERROR, 884 + Json(json!({"error": "InternalError"})), 885 + ) 886 + .into_response(); 887 + } 888 + }; 889 + let commit_cid_str = commit_cid.to_string(); 890 + if let Err(e) = sqlx::query!( 891 + "INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", 892 + user_id, 893 + commit_cid_str 894 + ) 895 + .execute(&mut *tx) 896 + .await 897 + { 898 + error!("Error inserting repo: {:?}", e); 899 + return ( 900 + StatusCode::INTERNAL_SERVER_ERROR, 901 + Json(json!({"error": "InternalError"})), 902 + ) 903 + .into_response(); 904 + } 905 + 906 + if let Some(ref code) = input.invite_code { 907 + let _ = sqlx::query!( 908 + "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", 909 + code 910 + ) 911 + .execute(&mut *tx) 912 + .await; 913 + 914 + let _ = sqlx::query!( 915 + "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", 916 + code, 917 + user_id 918 + ) 919 + .execute(&mut *tx) 920 + .await; 921 + } 922 + 923 + if let Err(e) = tx.commit().await { 924 + error!("Error committing transaction: {:?}", e); 925 + return ( 926 + StatusCode::INTERNAL_SERVER_ERROR, 927 + Json(json!({"error": "InternalError"})), 928 + ) 929 + .into_response(); 930 + } 931 + 932 + if let Err(e) = 933 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await 934 + { 935 + warn!("Failed to sequence identity event for {}: {}", did, e); 936 + } 937 + if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await 938 + { 939 + warn!("Failed to sequence account event for {}: {}", did, e); 940 + } 941 + 942 + let profile_record = json!({ 943 + "$type": "app.bsky.actor.profile", 944 + "displayName": handle 945 + }); 946 + if let Err(e) = crate::api::repo::record::create_record_internal( 947 + &state, 948 + &did, 949 + "app.bsky.actor.profile", 950 + "self", 951 + &profile_record, 952 + ) 953 + .await 954 + { 955 + warn!("Failed to create default profile for {}: {}", did, e); 956 + } 957 + 958 + let _ = delegation::log_delegation_action( 959 + &state.db, 960 + &did, 961 + &auth.0.did, 962 + Some(&auth.0.did), 963 + DelegationActionType::GrantCreated, 964 + Some(json!({ 965 + "account_created": true, 966 + "granted_scopes": input.controller_scopes 967 + })), 968 + None, 969 + None, 970 + ) 971 + .await; 972 + 973 + info!(did = %did, handle = %handle, controller = %auth.0.did, "Delegated account created"); 974 + 975 + Json(CreateDelegatedAccountResponse { did, handle }).into_response() 976 + }
+5 -1
src/api/error.rs
··· 42 42 AppPasswordNotFound, 43 43 InvalidSwap, 44 44 Forbidden, 45 + InsufficientScope, 45 46 InvitesDisabled, 46 47 DatabaseError, 47 48 UpstreamFailure, ··· 72 73 | Self::TokenRequired 73 74 | Self::AccountDeactivated 74 75 | Self::AccountTakedown => StatusCode::UNAUTHORIZED, 75 - Self::Forbidden | Self::InvitesDisabled => StatusCode::FORBIDDEN, 76 + Self::Forbidden | Self::InsufficientScope | Self::InvitesDisabled => { 77 + StatusCode::FORBIDDEN 78 + } 76 79 Self::AccountNotFound 77 80 | Self::RepoNotFound 78 81 | Self::RepoNotFoundMsg(_) ··· 114 117 Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 115 118 Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), 116 119 Self::Forbidden => Cow::Borrowed("Forbidden"), 120 + Self::InsufficientScope => Cow::Borrowed("InsufficientScope"), 117 121 Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"), 118 122 Self::AccountNotFound => Cow::Borrowed("AccountNotFound"), 119 123 Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"),
+1
src/api/mod.rs
··· 1 1 pub mod actor; 2 2 pub mod admin; 3 + pub mod delegation; 3 4 pub mod error; 4 5 pub mod identity; 5 6 pub mod moderation;
+24 -3
src/api/repo/blob.rs
··· 1 1 use crate::auth::{ServiceTokenVerifier, is_service_token}; 2 + use crate::delegation::{self, DelegationActionType}; 2 3 use crate::state::AppState; 3 4 use axum::body::Bytes; 4 5 use axum::{ ··· 39 40 40 41 let is_service_auth = is_service_token(&token); 41 42 42 - let (did, is_migration) = if is_service_auth { 43 + let (did, is_migration, controller_did) = if is_service_auth { 43 44 debug!("Verifying service token for blob upload"); 44 45 let verifier = ServiceTokenVerifier::new(); 45 46 match verifier ··· 48 49 { 49 50 Ok(claims) => { 50 51 debug!("Service token verified for DID: {}", claims.iss); 51 - (claims.iss, false) 52 + (claims.iss, false, None) 52 53 } 53 54 Err(e) => { 54 55 error!("Service token verification failed: {:?}", e); ··· 82 83 .ok() 83 84 .flatten() 84 85 .flatten(); 85 - (user.did, deactivated.is_some()) 86 + let ctrl_did = user.controller_did.clone(); 87 + (user.did, deactivated.is_some(), ctrl_did) 86 88 } 87 89 Err(_) => { 88 90 return ( ··· 204 206 ) 205 207 .into_response(); 206 208 } 209 + 210 + if let Some(ref controller) = controller_did { 211 + let _ = delegation::log_delegation_action( 212 + &state.db, 213 + &did, 214 + controller, 215 + Some(controller), 216 + DelegationActionType::BlobUpload, 217 + Some(json!({ 218 + "cid": cid_str, 219 + "mime_type": mime_type, 220 + "size": size 221 + })), 222 + None, 223 + None, 224 + ) 225 + .await; 226 + } 227 + 207 228 Json(json!({ 208 229 "blob": { 209 230 "$type": "blob",
+62 -20
src/api/repo/record/batch.rs
··· 1 1 use super::validation::validate_record; 2 2 use super::write::has_verified_comms_channel; 3 3 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 4 + use crate::delegation::{self, DelegationActionType}; 4 5 use crate::repo::tracking::TrackingBlockStore; 5 6 use crate::state::AppState; 6 7 use axum::{ ··· 109 110 let did = auth_user.did.clone(); 110 111 let is_oauth = auth_user.is_oauth; 111 112 let scope = auth_user.scope; 113 + let controller_did = auth_user.controller_did.clone(); 112 114 if input.repo != did { 113 115 return ( 114 116 StatusCode::FORBIDDEN, ··· 116 118 ) 117 119 .into_response(); 118 120 } 119 - match has_verified_comms_channel(&state.db, &did).await { 120 - Ok(true) => {} 121 - Ok(false) => { 122 - return ( 123 - StatusCode::FORBIDDEN, 124 - Json(json!({ 125 - "error": "AccountNotVerified", 126 - "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" 127 - })), 128 - ) 129 - .into_response(); 130 - } 131 - Err(e) => { 132 - error!("DB error checking notification channels: {}", e); 133 - return ( 134 - StatusCode::INTERNAL_SERVER_ERROR, 135 - Json(json!({"error": "InternalError"})), 136 - ) 137 - .into_response(); 138 - } 121 + let is_verified = has_verified_comms_channel(&state.db, &did) 122 + .await 123 + .unwrap_or(false); 124 + let is_delegated = crate::delegation::is_delegated_account(&state.db, &did) 125 + .await 126 + .unwrap_or(false); 127 + if !is_verified && !is_delegated { 128 + return ( 129 + StatusCode::FORBIDDEN, 130 + Json(json!({ 131 + "error": "AccountNotVerified", 132 + "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" 133 + })), 134 + ) 135 + .into_response(); 139 136 } 140 137 if input.writes.is_empty() { 141 138 return ( ··· 485 482 .into_response(); 486 483 } 487 484 }; 485 + 486 + if let Some(ref controller) = controller_did { 487 + let write_summary: Vec<serde_json::Value> = input 488 + .writes 489 + .iter() 490 + .map(|w| match w { 491 + WriteOp::Create { 492 + collection, rkey, .. 493 + } => json!({ 494 + "action": "create", 495 + "collection": collection, 496 + "rkey": rkey 497 + }), 498 + WriteOp::Update { 499 + collection, rkey, .. 500 + } => json!({ 501 + "action": "update", 502 + "collection": collection, 503 + "rkey": rkey 504 + }), 505 + WriteOp::Delete { collection, rkey } => json!({ 506 + "action": "delete", 507 + "collection": collection, 508 + "rkey": rkey 509 + }), 510 + }) 511 + .collect(); 512 + 513 + let _ = delegation::log_delegation_action( 514 + &state.db, 515 + &did, 516 + controller, 517 + Some(controller), 518 + DelegationActionType::RepoWrite, 519 + Some(json!({ 520 + "action": "apply_writes", 521 + "count": input.writes.len(), 522 + "writes": write_summary 523 + })), 524 + None, 525 + None, 526 + ) 527 + .await; 528 + } 529 + 488 530 ( 489 531 StatusCode::OK, 490 532 Json(ApplyWritesOutput {
+23
src/api/repo/record/delete.rs
··· 1 1 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 2 2 use crate::api::repo::record::write::prepare_repo_write; 3 + use crate::delegation::{self, DelegationActionType}; 3 4 use crate::repo::tracking::TrackingBlockStore; 4 5 use crate::state::AppState; 5 6 use axum::{ ··· 52 53 let did = auth.did; 53 54 let user_id = auth.user_id; 54 55 let current_root_cid = auth.current_root_cid; 56 + let controller_did = auth.controller_did; 55 57 56 58 if let Some(swap_commit) = &input.swap_commit 57 59 && Cid::from_str(swap_commit).ok() != Some(current_root_cid) ··· 124 126 .into_response(); 125 127 } 126 128 }; 129 + let collection_for_audit = input.collection.clone(); 130 + let rkey_for_audit = input.rkey.clone(); 127 131 let op = RecordOp::Delete { 128 132 collection: input.collection, 129 133 rkey: input.rkey, ··· 174 178 ) 175 179 .into_response(); 176 180 }; 181 + 182 + if let Some(ref controller) = controller_did { 183 + let _ = delegation::log_delegation_action( 184 + &state.db, 185 + &did, 186 + controller, 187 + Some(controller), 188 + DelegationActionType::RepoWrite, 189 + Some(json!({ 190 + "action": "delete", 191 + "collection": collection_for_audit, 192 + "rkey": rkey_for_audit 193 + })), 194 + None, 195 + None, 196 + ) 197 + .await; 198 + } 199 + 177 200 (StatusCode::OK, Json(json!({}))).into_response() 178 201 }
+59 -20
src/api/repo/record/write.rs
··· 1 1 use super::validation::validate_record; 2 2 use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; 3 + use crate::delegation::{self, DelegationActionType}; 3 4 use crate::repo::tracking::TrackingBlockStore; 4 5 use crate::state::AppState; 5 6 use axum::{ ··· 55 56 pub current_root_cid: Cid, 56 57 pub is_oauth: bool, 57 58 pub scope: Option<String>, 59 + pub controller_did: Option<String>, 58 60 } 59 61 60 62 pub async fn prepare_repo_write( ··· 99 101 ) 100 102 .into_response()); 101 103 } 102 - match has_verified_comms_channel(&state.db, &auth_user.did).await { 103 - Ok(true) => {} 104 - Ok(false) => { 105 - return Err(( 106 - StatusCode::FORBIDDEN, 107 - Json(json!({ 108 - "error": "AccountNotVerified", 109 - "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" 110 - })), 111 - ) 112 - .into_response()); 113 - } 114 - Err(e) => { 115 - error!("DB error checking notification channels: {}", e); 116 - return Err(( 117 - StatusCode::INTERNAL_SERVER_ERROR, 118 - Json(json!({"error": "InternalError"})), 119 - ) 120 - .into_response()); 121 - } 104 + let is_verified = has_verified_comms_channel(&state.db, &auth_user.did) 105 + .await 106 + .unwrap_or(false); 107 + let is_delegated = crate::delegation::is_delegated_account(&state.db, &auth_user.did) 108 + .await 109 + .unwrap_or(false); 110 + if !is_verified && !is_delegated { 111 + return Err(( 112 + StatusCode::FORBIDDEN, 113 + Json(json!({ 114 + "error": "AccountNotVerified", 115 + "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" 116 + })), 117 + ) 118 + .into_response()); 122 119 } 123 120 let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) 124 121 .fetch_optional(&state.db) ··· 172 169 current_root_cid, 173 170 is_oauth: auth_user.is_oauth, 174 171 scope: auth_user.scope, 172 + controller_did: auth_user.controller_did, 175 173 }) 176 174 } 177 175 #[derive(Deserialize)] ··· 215 213 let did = auth.did; 216 214 let user_id = auth.user_id; 217 215 let current_root_cid = auth.current_root_cid; 216 + let controller_did = auth.controller_did; 218 217 219 218 if let Some(swap_commit) = &input.swap_commit 220 219 && Cid::from_str(swap_commit).ok() != Some(current_root_cid) ··· 355 354 ) 356 355 .into_response(); 357 356 }; 357 + 358 + if let Some(ref controller) = controller_did { 359 + let _ = delegation::log_delegation_action( 360 + &state.db, 361 + &did, 362 + controller, 363 + Some(controller), 364 + DelegationActionType::RepoWrite, 365 + Some(json!({ 366 + "action": "create", 367 + "collection": input.collection, 368 + "rkey": rkey 369 + })), 370 + None, 371 + None, 372 + ) 373 + .await; 374 + } 375 + 358 376 ( 359 377 StatusCode::OK, 360 378 Json(CreateRecordOutput { ··· 415 433 let did = auth.did; 416 434 let user_id = auth.user_id; 417 435 let current_root_cid = auth.current_root_cid; 436 + let controller_did = auth.controller_did; 418 437 419 438 if let Some(swap_commit) = &input.swap_commit 420 439 && Cid::from_str(swap_commit).ok() != Some(current_root_cid) ··· 562 581 .iter() 563 582 .map(|c| c.to_string()) 564 583 .collect::<Vec<_>>(); 584 + let is_update = existing_cid.is_some(); 565 585 if let Err(e) = commit_and_log( 566 586 &state, 567 587 CommitParams { ··· 582 602 ) 583 603 .into_response(); 584 604 }; 605 + 606 + if let Some(ref controller) = controller_did { 607 + let _ = delegation::log_delegation_action( 608 + &state.db, 609 + &did, 610 + controller, 611 + Some(controller), 612 + DelegationActionType::RepoWrite, 613 + Some(json!({ 614 + "action": if is_update { "update" } else { "create" }, 615 + "collection": input.collection, 616 + "rkey": input.rkey 617 + })), 618 + None, 619 + None, 620 + ) 621 + .await; 622 + } 623 + 585 624 ( 586 625 StatusCode::OK, 587 626 Json(PutRecordOutput {
+60 -12
src/api/server/app_password.rs
··· 1 1 use crate::api::ApiError; 2 2 use crate::auth::BearerAuth; 3 + use crate::delegation::{self, DelegationActionType}; 3 4 use crate::state::{AppState, RateLimitKind}; 4 5 use crate::util::get_user_id_by_did; 5 6 use axum::{ ··· 20 21 pub privileged: bool, 21 22 #[serde(skip_serializing_if = "Option::is_none")] 22 23 pub scopes: Option<String>, 24 + #[serde(skip_serializing_if = "Option::is_none")] 25 + pub created_by_controller: Option<String>, 23 26 } 24 27 25 28 #[derive(Serialize)] ··· 36 39 Err(e) => return ApiError::from(e).into_response(), 37 40 }; 38 41 match sqlx::query!( 39 - "SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 42 + "SELECT name, created_at, privileged, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC", 40 43 user_id 41 44 ) 42 45 .fetch_all(&state.db) ··· 50 53 created_at: row.created_at.to_rfc3339(), 51 54 privileged: row.privileged, 52 55 scopes: row.scopes.clone(), 56 + created_by_controller: row.created_by_controller_did.clone(), 53 57 }) 54 58 .collect(); 55 59 Json(ListAppPasswordsOutput { passwords }).into_response() ··· 118 122 if let Ok(Some(_)) = existing { 119 123 return ApiError::DuplicateAppPassword.into_response(); 120 124 } 125 + 126 + let (final_scopes, controller_did) = if let Some(ref controller) = auth_user.controller_did { 127 + let grant = delegation::get_delegation(&state.db, &auth_user.did, controller) 128 + .await 129 + .ok() 130 + .flatten(); 131 + let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 132 + 133 + let requested = input.scopes.as_deref().unwrap_or("atproto"); 134 + let intersected = delegation::intersect_scopes(requested, &granted_scopes); 135 + 136 + if intersected.is_empty() && !granted_scopes.is_empty() { 137 + return ApiError::InsufficientScope.into_response(); 138 + } 139 + 140 + let scope_result = if intersected.is_empty() { 141 + None 142 + } else { 143 + Some(intersected) 144 + }; 145 + (scope_result, Some(controller.clone())) 146 + } else { 147 + (input.scopes.clone(), None) 148 + }; 149 + 121 150 let password: String = (0..4) 122 151 .map(|_| { 123 152 use rand::Rng; ··· 137 166 } 138 167 }; 139 168 let privileged = input.privileged.unwrap_or(false); 140 - let scopes = input.scopes.clone(); 141 169 let created_at = chrono::Utc::now(); 142 170 match sqlx::query!( 143 - "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)", 171 + "INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes, created_by_controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7)", 144 172 user_id, 145 173 name, 146 174 password_hash, 147 175 created_at, 148 176 privileged, 149 - scopes 177 + final_scopes, 178 + controller_did 150 179 ) 151 180 .execute(&state.db) 152 181 .await 153 182 { 154 - Ok(_) => Json(CreateAppPasswordOutput { 155 - name: name.to_string(), 156 - password, 157 - created_at: created_at.to_rfc3339(), 158 - privileged, 159 - scopes, 160 - }) 161 - .into_response(), 183 + Ok(_) => { 184 + if let Some(ref controller) = controller_did { 185 + let _ = delegation::log_delegation_action( 186 + &state.db, 187 + &auth_user.did, 188 + controller, 189 + Some(controller), 190 + DelegationActionType::AccountAction, 191 + Some(json!({ 192 + "action": "create_app_password", 193 + "name": name, 194 + "scopes": final_scopes 195 + })), 196 + None, 197 + None, 198 + ) 199 + .await; 200 + } 201 + Json(CreateAppPasswordOutput { 202 + name: name.to_string(), 203 + password, 204 + created_at: created_at.to_rfc3339(), 205 + privileged, 206 + scopes: final_scopes, 207 + }) 208 + .into_response() 209 + } 162 210 Err(e) => { 163 211 error!("DB error creating app password: {:?}", e); 164 212 ApiError::InternalError.into_response()
+1
src/api/server/service_auth.rs
··· 97 97 is_admin: false, 98 98 scope: result.scope, 99 99 key_bytes: None, 100 + controller_did: None, 100 101 }, 101 102 Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => { 102 103 return (
+21 -11
src/api/server/session.rs
··· 125 125 return ApiError::InternalError.into_response(); 126 126 } 127 127 }; 128 - let (password_valid, app_password_scopes) = if row 128 + let (password_valid, app_password_scopes, app_password_controller) = if row 129 129 .password_hash 130 130 .as_ref() 131 131 .map(|h| verify(&input.password, h).unwrap_or(false)) 132 132 .unwrap_or(false) 133 133 { 134 - (true, None) 134 + (true, None, None) 135 135 } else { 136 136 let app_passwords = sqlx::query!( 137 - "SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 137 + "SELECT password_hash, scopes, created_by_controller_did FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20", 138 138 row.id 139 139 ) 140 140 .fetch_all(&state.db) ··· 144 144 .iter() 145 145 .find(|app| verify(&input.password, &app.password_hash).unwrap_or(false)); 146 146 match matched { 147 - Some(app) => (true, app.scopes.clone()), 148 - None => (false, None), 147 + Some(app) => ( 148 + true, 149 + app.scopes.clone(), 150 + app.created_by_controller_did.clone(), 151 + ), 152 + None => (false, None, None), 149 153 } 150 154 }; 151 155 if !password_valid { ··· 155 159 } 156 160 let is_verified = 157 161 row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; 158 - if !is_verified { 162 + let is_delegated = crate::delegation::is_delegated_account(&state.db, &row.did) 163 + .await 164 + .unwrap_or(false); 165 + if !is_verified && !is_delegated { 159 166 warn!("Login attempt for unverified account: {}", row.did); 160 167 return ( 161 168 StatusCode::FORBIDDEN, ··· 181 188 ) 182 189 .into_response(); 183 190 } 184 - let access_meta = match crate::auth::create_access_token_with_scope_metadata( 191 + let access_meta = match crate::auth::create_access_token_with_delegation( 185 192 &row.did, 186 193 &key_bytes, 187 194 app_password_scopes.as_deref(), 195 + app_password_controller.as_deref(), 188 196 ) { 189 197 Ok(m) => m, 190 198 Err(e) => { ··· 200 208 } 201 209 }; 202 210 if let Err(e) = sqlx::query!( 203 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", 211 + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope, controller_did) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", 204 212 row.did, 205 213 access_meta.jti, 206 214 refresh_meta.jti, ··· 208 216 refresh_meta.expires_at, 209 217 is_legacy_login, 210 218 false, 211 - app_password_scopes 219 + app_password_scopes, 220 + app_password_controller 212 221 ) 213 222 .execute(&state.db) 214 223 .await ··· 397 406 .into_response(); 398 407 } 399 408 let session_row = match sqlx::query!( 400 - r#"SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version 409 + r#"SELECT st.id, st.did, st.scope, st.controller_did, k.key_bytes, k.encryption_version 401 410 FROM session_tokens st 402 411 JOIN users u ON st.did = u.did 403 412 JOIN user_keys k ON u.id = k.user_id ··· 429 438 if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { 430 439 return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); 431 440 } 432 - let new_access_meta = match crate::auth::create_access_token_with_scope_metadata( 441 + let new_access_meta = match crate::auth::create_access_token_with_delegation( 433 442 &session_row.did, 434 443 &key_bytes, 435 444 session_row.scope.as_deref(), 445 + session_row.controller_did.as_deref(), 436 446 ) { 437 447 Ok(m) => m, 438 448 Err(e) => {
+15 -2
src/auth/mod.rs
··· 24 24 pub use token::{ 25 25 SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS, 26 26 TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token, 27 - create_access_token_with_metadata, create_access_token_with_scope_metadata, 28 - create_refresh_token, create_refresh_token_with_metadata, create_service_token, 27 + create_access_token_with_delegation, create_access_token_with_metadata, 28 + create_access_token_with_scope_metadata, create_refresh_token, 29 + create_refresh_token_with_metadata, create_service_token, 29 30 }; 30 31 pub use verify::{ 31 32 TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token, ··· 62 63 pub is_oauth: bool, 63 64 pub is_admin: bool, 64 65 pub scope: Option<String>, 66 + pub controller_did: Option<String>, 65 67 } 66 68 67 69 impl AuthenticatedUser { ··· 249 251 } 250 252 251 253 if session_valid { 254 + let controller_did = token_data.claims.act.as_ref().map(|a| a.sub.clone()); 252 255 return Ok(AuthenticatedUser { 253 256 did: did.clone(), 254 257 key_bytes: Some(decrypted_key), 255 258 is_oauth: false, 256 259 is_admin, 257 260 scope: token_data.claims.scope.clone(), 261 + controller_did, 258 262 }); 259 263 } 260 264 } ··· 304 308 is_oauth: true, 305 309 is_admin: oauth_token.is_admin, 306 310 scope: oauth_info.scope, 311 + controller_did: oauth_info.controller_did, 307 312 }); 308 313 } else { 309 314 return Err(TokenValidationError::TokenExpired); ··· 378 383 is_oauth: true, 379 384 is_admin: user_info.is_admin, 380 385 scope: result.scope, 386 + controller_did: None, 381 387 }) 382 388 } 383 389 Err(_) => Err(TokenValidationError::AuthenticationFailed), 384 390 } 391 + } 392 + 393 + #[derive(Debug, Clone, Serialize, Deserialize)] 394 + pub struct ActClaim { 395 + pub sub: String, 385 396 } 386 397 387 398 #[derive(Debug, Serialize, Deserialize)] ··· 396 407 #[serde(skip_serializing_if = "Option::is_none")] 397 408 pub lxm: Option<String>, 398 409 pub jti: String, 410 + #[serde(skip_serializing_if = "Option::is_none")] 411 + pub act: Option<ActClaim>, 399 412 } 400 413 401 414 #[derive(Debug, Serialize, Deserialize)]
+34 -1
src/auth/token.rs
··· 1 - use super::{Claims, Header}; 1 + use super::{ActClaim, Claims, Header}; 2 2 use anyhow::Result; 3 3 use base64::Engine as _; 4 4 use base64::engine::general_purpose::URL_SAFE_NO_PAD; ··· 51 51 ) 52 52 } 53 53 54 + pub fn create_access_token_with_delegation( 55 + did: &str, 56 + key_bytes: &[u8], 57 + scopes: Option<&str>, 58 + controller_did: Option<&str>, 59 + ) -> Result<TokenWithMetadata> { 60 + let scope = scopes.unwrap_or(SCOPE_ACCESS); 61 + let act = controller_did.map(|c| ActClaim { sub: c.to_string() }); 62 + create_signed_token_with_act( 63 + did, 64 + scope, 65 + TOKEN_TYPE_ACCESS, 66 + key_bytes, 67 + Duration::minutes(15), 68 + act, 69 + ) 70 + } 71 + 54 72 pub fn create_refresh_token_with_metadata( 55 73 did: &str, 56 74 key_bytes: &[u8], ··· 81 99 scope: None, 82 100 lxm: Some(lxm.to_string()), 83 101 jti: uuid::Uuid::new_v4().to_string(), 102 + act: None, 84 103 }; 85 104 86 105 sign_claims(claims, &signing_key) ··· 93 112 key_bytes: &[u8], 94 113 duration: Duration, 95 114 ) -> Result<TokenWithMetadata> { 115 + create_signed_token_with_act(did, scope, typ, key_bytes, duration, None) 116 + } 117 + 118 + fn create_signed_token_with_act( 119 + did: &str, 120 + scope: &str, 121 + typ: &str, 122 + key_bytes: &[u8], 123 + duration: Duration, 124 + act: Option<ActClaim>, 125 + ) -> Result<TokenWithMetadata> { 96 126 let signing_key = SigningKey::from_slice(key_bytes)?; 97 127 98 128 let expires_at = Utc::now() ··· 114 144 scope: Some(scope.to_string()), 115 145 lxm: None, 116 146 jti: jti.clone(), 147 + act, 117 148 }; 118 149 119 150 let token = sign_claims_with_type(claims, &signing_key, typ)?; ··· 202 233 scope: None, 203 234 lxm: Some(lxm.to_string()), 204 235 jti: uuid::Uuid::new_v4().to_string(), 236 + act: None, 205 237 }; 206 238 207 239 sign_claims_hs256(claims, TOKEN_TYPE_SERVICE, secret) ··· 233 265 scope: Some(scope.to_string()), 234 266 lxm: None, 235 267 jti: jti.clone(), 268 + act: None, 236 269 }; 237 270 238 271 let token = sign_claims_hs256(claims, typ, secret)?;
+142
src/delegation/audit.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + use sqlx::PgPool; 4 + use uuid::Uuid; 5 + 6 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] 7 + #[sqlx(type_name = "delegation_action_type", rename_all = "snake_case")] 8 + pub enum DelegationActionType { 9 + GrantCreated, 10 + GrantRevoked, 11 + ScopesModified, 12 + TokenIssued, 13 + RepoWrite, 14 + BlobUpload, 15 + AccountAction, 16 + } 17 + 18 + #[derive(Debug, Clone, Serialize, Deserialize)] 19 + pub struct AuditLogEntry { 20 + pub id: Uuid, 21 + pub delegated_did: String, 22 + pub actor_did: String, 23 + pub controller_did: Option<String>, 24 + pub action_type: DelegationActionType, 25 + pub action_details: Option<serde_json::Value>, 26 + pub ip_address: Option<String>, 27 + pub user_agent: Option<String>, 28 + pub created_at: DateTime<Utc>, 29 + } 30 + 31 + pub async fn log_delegation_action( 32 + pool: &PgPool, 33 + delegated_did: &str, 34 + actor_did: &str, 35 + controller_did: Option<&str>, 36 + action_type: DelegationActionType, 37 + action_details: Option<serde_json::Value>, 38 + ip_address: Option<&str>, 39 + user_agent: Option<&str>, 40 + ) -> Result<Uuid, sqlx::Error> { 41 + let id = sqlx::query_scalar!( 42 + r#" 43 + INSERT INTO delegation_audit_log 44 + (delegated_did, actor_did, controller_did, action_type, action_details, ip_address, user_agent) 45 + VALUES ($1, $2, $3, $4, $5, $6, $7) 46 + RETURNING id 47 + "#, 48 + delegated_did, 49 + actor_did, 50 + controller_did, 51 + action_type as DelegationActionType, 52 + action_details, 53 + ip_address, 54 + user_agent 55 + ) 56 + .fetch_one(pool) 57 + .await?; 58 + 59 + Ok(id) 60 + } 61 + 62 + pub async fn get_audit_log_for_account( 63 + pool: &PgPool, 64 + delegated_did: &str, 65 + limit: i64, 66 + offset: i64, 67 + ) -> Result<Vec<AuditLogEntry>, sqlx::Error> { 68 + let entries = sqlx::query_as!( 69 + AuditLogEntry, 70 + r#" 71 + SELECT 72 + id, 73 + delegated_did, 74 + actor_did, 75 + controller_did, 76 + action_type as "action_type: DelegationActionType", 77 + action_details, 78 + ip_address, 79 + user_agent, 80 + created_at 81 + FROM delegation_audit_log 82 + WHERE delegated_did = $1 83 + ORDER BY created_at DESC 84 + LIMIT $2 OFFSET $3 85 + "#, 86 + delegated_did, 87 + limit, 88 + offset 89 + ) 90 + .fetch_all(pool) 91 + .await?; 92 + 93 + Ok(entries) 94 + } 95 + 96 + pub async fn get_audit_log_by_controller( 97 + pool: &PgPool, 98 + controller_did: &str, 99 + limit: i64, 100 + offset: i64, 101 + ) -> Result<Vec<AuditLogEntry>, sqlx::Error> { 102 + let entries = sqlx::query_as!( 103 + AuditLogEntry, 104 + r#" 105 + SELECT 106 + id, 107 + delegated_did, 108 + actor_did, 109 + controller_did, 110 + action_type as "action_type: DelegationActionType", 111 + action_details, 112 + ip_address, 113 + user_agent, 114 + created_at 115 + FROM delegation_audit_log 116 + WHERE controller_did = $1 117 + ORDER BY created_at DESC 118 + LIMIT $2 OFFSET $3 119 + "#, 120 + controller_did, 121 + limit, 122 + offset 123 + ) 124 + .fetch_all(pool) 125 + .await?; 126 + 127 + Ok(entries) 128 + } 129 + 130 + pub async fn count_audit_log_entries( 131 + pool: &PgPool, 132 + delegated_did: &str, 133 + ) -> Result<i64, sqlx::Error> { 134 + let count = sqlx::query_scalar!( 135 + r#"SELECT COUNT(*) as "count!" FROM delegation_audit_log WHERE delegated_did = $1"#, 136 + delegated_did 137 + ) 138 + .fetch_one(pool) 139 + .await?; 140 + 141 + Ok(count) 142 + }
+267
src/delegation/db.rs
··· 1 + use chrono::{DateTime, Utc}; 2 + use serde::{Deserialize, Serialize}; 3 + use sqlx::PgPool; 4 + use uuid::Uuid; 5 + 6 + #[derive(Debug, Clone, Serialize, Deserialize)] 7 + pub struct DelegationGrant { 8 + pub id: Uuid, 9 + pub delegated_did: String, 10 + pub controller_did: String, 11 + pub granted_scopes: String, 12 + pub granted_at: DateTime<Utc>, 13 + pub granted_by: String, 14 + pub revoked_at: Option<DateTime<Utc>>, 15 + pub revoked_by: Option<String>, 16 + } 17 + 18 + #[derive(Debug, Clone, Serialize, Deserialize)] 19 + pub struct DelegatedAccountInfo { 20 + pub did: String, 21 + pub handle: String, 22 + pub granted_scopes: String, 23 + pub granted_at: DateTime<Utc>, 24 + } 25 + 26 + #[derive(Debug, Clone, Serialize, Deserialize)] 27 + pub struct ControllerInfo { 28 + pub did: String, 29 + pub handle: String, 30 + pub granted_scopes: String, 31 + pub granted_at: DateTime<Utc>, 32 + pub is_active: bool, 33 + } 34 + 35 + pub async fn is_delegated_account(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> { 36 + let result = sqlx::query_scalar!( 37 + r#"SELECT account_type::text = 'delegated' as "is_delegated!" FROM users WHERE did = $1"#, 38 + did 39 + ) 40 + .fetch_optional(pool) 41 + .await?; 42 + 43 + Ok(result.unwrap_or(false)) 44 + } 45 + 46 + pub async fn create_delegation( 47 + pool: &PgPool, 48 + delegated_did: &str, 49 + controller_did: &str, 50 + granted_scopes: &str, 51 + granted_by: &str, 52 + ) -> Result<Uuid, sqlx::Error> { 53 + let id = sqlx::query_scalar!( 54 + r#" 55 + INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by) 56 + VALUES ($1, $2, $3, $4) 57 + RETURNING id 58 + "#, 59 + delegated_did, 60 + controller_did, 61 + granted_scopes, 62 + granted_by 63 + ) 64 + .fetch_one(pool) 65 + .await?; 66 + 67 + Ok(id) 68 + } 69 + 70 + pub async fn revoke_delegation( 71 + pool: &PgPool, 72 + delegated_did: &str, 73 + controller_did: &str, 74 + revoked_by: &str, 75 + ) -> Result<bool, sqlx::Error> { 76 + let result = sqlx::query!( 77 + r#" 78 + UPDATE account_delegations 79 + SET revoked_at = NOW(), revoked_by = $1 80 + WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL 81 + "#, 82 + revoked_by, 83 + delegated_did, 84 + controller_did 85 + ) 86 + .execute(pool) 87 + .await?; 88 + 89 + Ok(result.rows_affected() > 0) 90 + } 91 + 92 + pub async fn update_delegation_scopes( 93 + pool: &PgPool, 94 + delegated_did: &str, 95 + controller_did: &str, 96 + new_scopes: &str, 97 + ) -> Result<bool, sqlx::Error> { 98 + let result = sqlx::query!( 99 + r#" 100 + UPDATE account_delegations 101 + SET granted_scopes = $1 102 + WHERE delegated_did = $2 AND controller_did = $3 AND revoked_at IS NULL 103 + "#, 104 + new_scopes, 105 + delegated_did, 106 + controller_did 107 + ) 108 + .execute(pool) 109 + .await?; 110 + 111 + Ok(result.rows_affected() > 0) 112 + } 113 + 114 + pub async fn get_delegation( 115 + pool: &PgPool, 116 + delegated_did: &str, 117 + controller_did: &str, 118 + ) -> Result<Option<DelegationGrant>, sqlx::Error> { 119 + let grant = sqlx::query_as!( 120 + DelegationGrant, 121 + r#" 122 + SELECT id, delegated_did, controller_did, granted_scopes, 123 + granted_at, granted_by, revoked_at, revoked_by 124 + FROM account_delegations 125 + WHERE delegated_did = $1 AND controller_did = $2 AND revoked_at IS NULL 126 + "#, 127 + delegated_did, 128 + controller_did 129 + ) 130 + .fetch_optional(pool) 131 + .await?; 132 + 133 + Ok(grant) 134 + } 135 + 136 + pub async fn get_delegations_for_account( 137 + pool: &PgPool, 138 + delegated_did: &str, 139 + ) -> Result<Vec<ControllerInfo>, sqlx::Error> { 140 + let controllers = sqlx::query_as!( 141 + ControllerInfo, 142 + r#" 143 + SELECT 144 + u.did, 145 + u.handle, 146 + d.granted_scopes, 147 + d.granted_at, 148 + (u.deactivated_at IS NULL AND u.takedown_ref IS NULL) as "is_active!" 149 + FROM account_delegations d 150 + JOIN users u ON u.did = d.controller_did 151 + WHERE d.delegated_did = $1 AND d.revoked_at IS NULL 152 + ORDER BY d.granted_at DESC 153 + "#, 154 + delegated_did 155 + ) 156 + .fetch_all(pool) 157 + .await?; 158 + 159 + Ok(controllers) 160 + } 161 + 162 + pub async fn get_accounts_controlled_by( 163 + pool: &PgPool, 164 + controller_did: &str, 165 + ) -> Result<Vec<DelegatedAccountInfo>, sqlx::Error> { 166 + let accounts = sqlx::query_as!( 167 + DelegatedAccountInfo, 168 + r#" 169 + SELECT 170 + u.did, 171 + u.handle, 172 + d.granted_scopes, 173 + d.granted_at 174 + FROM account_delegations d 175 + JOIN users u ON u.did = d.delegated_did 176 + WHERE d.controller_did = $1 177 + AND d.revoked_at IS NULL 178 + AND u.deactivated_at IS NULL 179 + AND u.takedown_ref IS NULL 180 + ORDER BY d.granted_at DESC 181 + "#, 182 + controller_did 183 + ) 184 + .fetch_all(pool) 185 + .await?; 186 + 187 + Ok(accounts) 188 + } 189 + 190 + pub async fn get_active_controllers_for_account( 191 + pool: &PgPool, 192 + delegated_did: &str, 193 + ) -> Result<Vec<ControllerInfo>, sqlx::Error> { 194 + let controllers = sqlx::query_as!( 195 + ControllerInfo, 196 + r#" 197 + SELECT 198 + u.did, 199 + u.handle, 200 + d.granted_scopes, 201 + d.granted_at, 202 + true as "is_active!" 203 + FROM account_delegations d 204 + JOIN users u ON u.did = d.controller_did 205 + WHERE d.delegated_did = $1 206 + AND d.revoked_at IS NULL 207 + AND u.deactivated_at IS NULL 208 + AND u.takedown_ref IS NULL 209 + ORDER BY d.granted_at DESC 210 + "#, 211 + delegated_did 212 + ) 213 + .fetch_all(pool) 214 + .await?; 215 + 216 + Ok(controllers) 217 + } 218 + 219 + pub async fn count_active_controllers( 220 + pool: &PgPool, 221 + delegated_did: &str, 222 + ) -> Result<i64, sqlx::Error> { 223 + let count = sqlx::query_scalar!( 224 + r#" 225 + SELECT COUNT(*) as "count!" 226 + FROM account_delegations d 227 + JOIN users u ON u.did = d.controller_did 228 + WHERE d.delegated_did = $1 229 + AND d.revoked_at IS NULL 230 + AND u.deactivated_at IS NULL 231 + AND u.takedown_ref IS NULL 232 + "#, 233 + delegated_did 234 + ) 235 + .fetch_one(pool) 236 + .await?; 237 + 238 + Ok(count) 239 + } 240 + 241 + pub async fn has_any_controllers(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> { 242 + let exists = sqlx::query_scalar!( 243 + r#"SELECT EXISTS( 244 + SELECT 1 FROM account_delegations 245 + WHERE delegated_did = $1 AND revoked_at IS NULL 246 + ) as "exists!""#, 247 + did 248 + ) 249 + .fetch_one(pool) 250 + .await?; 251 + 252 + Ok(exists) 253 + } 254 + 255 + pub async fn controls_any_accounts(pool: &PgPool, did: &str) -> Result<bool, sqlx::Error> { 256 + let exists = sqlx::query_scalar!( 257 + r#"SELECT EXISTS( 258 + SELECT 1 FROM account_delegations 259 + WHERE controller_did = $1 AND revoked_at IS NULL 260 + ) as "exists!""#, 261 + did 262 + ) 263 + .fetch_one(pool) 264 + .await?; 265 + 266 + Ok(exists) 267 + }
+11
src/delegation/mod.rs
··· 1 + pub mod audit; 2 + pub mod db; 3 + pub mod scopes; 4 + 5 + pub use audit::{DelegationActionType, log_delegation_action}; 6 + pub use db::{ 7 + DelegationGrant, controls_any_accounts, create_delegation, get_accounts_controlled_by, 8 + get_delegation, get_delegations_for_account, has_any_controllers, is_delegated_account, 9 + revoke_delegation, update_delegation_scopes, 10 + }; 11 + pub use scopes::{SCOPE_PRESETS, ScopePreset, intersect_scopes};
+201
src/delegation/scopes.rs
··· 1 + use std::collections::HashSet; 2 + 3 + pub struct ScopePreset { 4 + pub name: &'static str, 5 + pub label: &'static str, 6 + pub description: &'static str, 7 + pub scopes: &'static str, 8 + } 9 + 10 + pub const SCOPE_PRESETS: &[ScopePreset] = &[ 11 + ScopePreset { 12 + name: "owner", 13 + label: "Owner", 14 + description: "Full control including delegation management", 15 + scopes: "atproto", 16 + }, 17 + ScopePreset { 18 + name: "admin", 19 + label: "Admin", 20 + description: "Manage account settings, post content, upload media", 21 + scopes: "atproto repo:* blob:*/* account:*?action=manage", 22 + }, 23 + ScopePreset { 24 + name: "editor", 25 + label: "Editor", 26 + description: "Post content and upload media", 27 + scopes: "repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*", 28 + }, 29 + ScopePreset { 30 + name: "viewer", 31 + label: "Viewer", 32 + description: "Read-only access", 33 + scopes: "", 34 + }, 35 + ]; 36 + 37 + pub fn intersect_scopes(requested: &str, granted: &str) -> String { 38 + if granted.is_empty() { 39 + return String::new(); 40 + } 41 + 42 + let requested_set: HashSet<&str> = requested.split_whitespace().collect(); 43 + let granted_set: HashSet<&str> = granted.split_whitespace().collect(); 44 + 45 + let granted_has_atproto = granted_set.contains("atproto"); 46 + let requested_has_atproto = requested_set.contains("atproto"); 47 + 48 + if granted_has_atproto && requested_has_atproto { 49 + return "atproto".to_string(); 50 + } 51 + 52 + if granted_has_atproto { 53 + return requested_set.into_iter().collect::<Vec<_>>().join(" "); 54 + } 55 + 56 + if requested_has_atproto { 57 + return granted_set.into_iter().collect::<Vec<_>>().join(" "); 58 + } 59 + 60 + let mut result: Vec<&str> = Vec::new(); 61 + 62 + for requested_scope in &requested_set { 63 + if granted_set.contains(requested_scope) { 64 + result.push(requested_scope); 65 + continue; 66 + } 67 + 68 + if let Some(match_result) = find_matching_scope(requested_scope, &granted_set) { 69 + result.push(match_result); 70 + } 71 + } 72 + 73 + result.sort(); 74 + result.join(" ") 75 + } 76 + 77 + fn find_matching_scope<'a>(requested: &str, granted: &HashSet<&'a str>) -> Option<&'a str> { 78 + for granted_scope in granted { 79 + if scopes_compatible(granted_scope, requested) { 80 + return Some(granted_scope); 81 + } 82 + } 83 + None 84 + } 85 + 86 + fn scopes_compatible(granted: &str, requested: &str) -> bool { 87 + if granted == requested { 88 + return true; 89 + } 90 + 91 + let (granted_base, _granted_params) = split_scope(granted); 92 + let (requested_base, _requested_params) = split_scope(requested); 93 + 94 + if granted_base.ends_with(":*") 95 + && requested_base.starts_with(&granted_base[..granted_base.len() - 1]) 96 + { 97 + return true; 98 + } 99 + 100 + if granted_base.ends_with(".*") { 101 + let prefix = &granted_base[..granted_base.len() - 2]; 102 + if requested_base.starts_with(prefix) && requested_base.len() > prefix.len() { 103 + return true; 104 + } 105 + } 106 + 107 + false 108 + } 109 + 110 + fn split_scope(scope: &str) -> (&str, Option<&str>) { 111 + if let Some(idx) = scope.find('?') { 112 + (&scope[..idx], Some(&scope[idx + 1..])) 113 + } else { 114 + (scope, None) 115 + } 116 + } 117 + 118 + pub fn validate_delegation_scopes(scopes: &str) -> Result<(), String> { 119 + if scopes.is_empty() { 120 + return Ok(()); 121 + } 122 + 123 + for scope in scopes.split_whitespace() { 124 + let (base, _) = split_scope(scope); 125 + 126 + if !is_valid_scope_prefix(base) { 127 + return Err(format!("Invalid scope: {}", scope)); 128 + } 129 + } 130 + 131 + Ok(()) 132 + } 133 + 134 + fn is_valid_scope_prefix(base: &str) -> bool { 135 + let valid_prefixes = [ 136 + "atproto", 137 + "repo:", 138 + "blob:", 139 + "rpc:", 140 + "account:", 141 + "identity:", 142 + "transition:", 143 + ]; 144 + 145 + for prefix in valid_prefixes { 146 + if base == prefix.trim_end_matches(':') || base.starts_with(prefix) { 147 + return true; 148 + } 149 + } 150 + 151 + false 152 + } 153 + 154 + #[cfg(test)] 155 + mod tests { 156 + use super::*; 157 + 158 + #[test] 159 + fn test_intersect_both_atproto() { 160 + assert_eq!(intersect_scopes("atproto", "atproto"), "atproto"); 161 + } 162 + 163 + #[test] 164 + fn test_intersect_granted_atproto() { 165 + let result = intersect_scopes("repo:* blob:*/*", "atproto"); 166 + assert!(result.contains("repo:*")); 167 + assert!(result.contains("blob:*/*")); 168 + } 169 + 170 + #[test] 171 + fn test_intersect_requested_atproto() { 172 + let result = intersect_scopes("atproto", "repo:* blob:*/*"); 173 + assert!(result.contains("repo:*")); 174 + assert!(result.contains("blob:*/*")); 175 + } 176 + 177 + #[test] 178 + fn test_intersect_exact_match() { 179 + assert_eq!( 180 + intersect_scopes("repo:*?action=create", "repo:*?action=create"), 181 + "repo:*?action=create" 182 + ); 183 + } 184 + 185 + #[test] 186 + fn test_intersect_empty_granted() { 187 + assert_eq!(intersect_scopes("atproto", ""), ""); 188 + } 189 + 190 + #[test] 191 + fn test_validate_scopes_valid() { 192 + assert!(validate_delegation_scopes("atproto").is_ok()); 193 + assert!(validate_delegation_scopes("repo:* blob:*/*").is_ok()); 194 + assert!(validate_delegation_scopes("").is_ok()); 195 + } 196 + 197 + #[test] 198 + fn test_validate_scopes_invalid() { 199 + assert!(validate_delegation_scopes("invalid:scope").is_err()); 200 + } 201 + }
+41
src/lib.rs
··· 6 6 pub mod comms; 7 7 pub mod config; 8 8 pub mod crawlers; 9 + pub mod delegation; 9 10 pub mod handle; 10 11 pub mod image; 11 12 pub mod metrics; ··· 528 529 "/oauth/authorize/consent", 529 530 post(oauth::endpoints::consent_post), 530 531 ) 532 + .route( 533 + "/oauth/delegation/auth", 534 + post(oauth::endpoints::delegation_auth), 535 + ) 536 + .route( 537 + "/oauth/delegation/totp", 538 + post(oauth::endpoints::delegation_totp_verify), 539 + ) 531 540 .route("/oauth/token", post(oauth::endpoints::token_endpoint)) 532 541 .route("/oauth/revoke", post(oauth::endpoints::revoke_token)) 533 542 .route( ··· 561 570 .route( 562 571 "/xrpc/com.tranquil.account.verifyToken", 563 572 post(api::server::verify_token), 573 + ) 574 + .route( 575 + "/xrpc/com.tranquil.delegation.listControllers", 576 + get(api::delegation::list_controllers), 577 + ) 578 + .route( 579 + "/xrpc/com.tranquil.delegation.addController", 580 + post(api::delegation::add_controller), 581 + ) 582 + .route( 583 + "/xrpc/com.tranquil.delegation.removeController", 584 + post(api::delegation::remove_controller), 585 + ) 586 + .route( 587 + "/xrpc/com.tranquil.delegation.updateControllerScopes", 588 + post(api::delegation::update_controller_scopes), 589 + ) 590 + .route( 591 + "/xrpc/com.tranquil.delegation.listControlledAccounts", 592 + get(api::delegation::list_controlled_accounts), 593 + ) 594 + .route( 595 + "/xrpc/com.tranquil.delegation.getAuditLog", 596 + get(api::delegation::get_audit_log), 597 + ) 598 + .route( 599 + "/xrpc/com.tranquil.delegation.getScopePresets", 600 + get(api::delegation::get_scope_presets), 601 + ) 602 + .route( 603 + "/xrpc/com.tranquil.delegation.createDelegatedAccount", 604 + post(api::delegation::create_delegated_account), 564 605 ) 565 606 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 566 607 .layer(middleware::from_fn(metrics::metrics_middleware))
+3 -3
src/oauth/db/mod.rs
··· 16 16 pub use request::{ 17 17 consume_authorization_request_by_code, create_authorization_request, 18 18 delete_authorization_request, delete_expired_authorization_requests, get_authorization_request, 19 - mark_request_authenticated, set_authorization_did, update_authorization_request, 20 - update_request_scope, 19 + mark_request_authenticated, set_authorization_did, set_controller_did, set_request_did, 20 + update_authorization_request, update_request_scope, 21 21 }; 22 22 pub use scope_preference::{ 23 23 ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent, ··· 27 27 check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user, 28 28 delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id, 29 29 get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user, 30 - revoke_tokens_for_client, rotate_token, 30 + revoke_tokens_for_client, revoke_tokens_for_controller, rotate_token, 31 31 }; 32 32 pub use two_factor::{ 33 33 TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+38 -2
src/oauth/db/request.rs
··· 38 38 ) -> Result<Option<RequestData>, OAuthError> { 39 39 let row = sqlx::query!( 40 40 r#" 41 - SELECT did, device_id, client_id, client_auth, parameters, expires_at, code 41 + SELECT did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did 42 42 FROM oauth_authorization_request 43 43 WHERE id = $1 44 44 "#, ··· 61 61 did: r.did, 62 62 device_id: r.device_id, 63 63 code: r.code, 64 + controller_did: r.controller_did, 64 65 })) 65 66 } 66 67 None => Ok(None), ··· 119 120 r#" 120 121 DELETE FROM oauth_authorization_request 121 122 WHERE code = $1 122 - RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code 123 + RETURNING did, device_id, client_id, client_auth, parameters, expires_at, code, controller_did 123 124 "#, 124 125 code 125 126 ) ··· 140 141 did: r.did, 141 142 device_id: r.device_id, 142 143 code: r.code, 144 + controller_did: r.controller_did, 143 145 })) 144 146 } 145 147 None => Ok(None), ··· 212 214 .await?; 213 215 Ok(()) 214 216 } 217 + 218 + pub async fn set_controller_did( 219 + pool: &PgPool, 220 + request_id: &str, 221 + controller_did: &str, 222 + ) -> Result<(), OAuthError> { 223 + sqlx::query!( 224 + r#" 225 + UPDATE oauth_authorization_request 226 + SET controller_did = $2 227 + WHERE id = $1 228 + "#, 229 + request_id, 230 + controller_did 231 + ) 232 + .execute(pool) 233 + .await?; 234 + Ok(()) 235 + } 236 + 237 + pub async fn set_request_did(pool: &PgPool, request_id: &str, did: &str) -> Result<(), OAuthError> { 238 + sqlx::query!( 239 + r#" 240 + UPDATE oauth_authorization_request 241 + SET did = $2 242 + WHERE id = $1 243 + "#, 244 + request_id, 245 + did 246 + ) 247 + .execute(pool) 248 + .await?; 249 + Ok(()) 250 + }
+26 -6
src/oauth/db/token.rs
··· 10 10 r#" 11 11 INSERT INTO oauth_token 12 12 (did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 13 - device_id, parameters, details, code, current_refresh_token, scope) 14 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) 13 + device_id, parameters, details, code, current_refresh_token, scope, controller_did) 14 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) 15 15 RETURNING id 16 16 "#, 17 17 data.did, ··· 27 27 data.code, 28 28 data.current_refresh_token, 29 29 data.scope, 30 + data.controller_did, 30 31 ) 31 32 .fetch_one(pool) 32 33 .await?; ··· 40 41 let row = sqlx::query!( 41 42 r#" 42 43 SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 43 - device_id, parameters, details, code, current_refresh_token, scope 44 + device_id, parameters, details, code, current_refresh_token, scope, controller_did 44 45 FROM oauth_token 45 46 WHERE token_id = $1 46 47 "#, ··· 63 64 code: r.code, 64 65 current_refresh_token: r.current_refresh_token, 65 66 scope: r.scope, 67 + controller_did: r.controller_did, 66 68 })), 67 69 None => Ok(None), 68 70 } ··· 75 77 let row = sqlx::query!( 76 78 r#" 77 79 SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 78 - device_id, parameters, details, code, current_refresh_token, scope 80 + device_id, parameters, details, code, current_refresh_token, scope, controller_did 79 81 FROM oauth_token 80 82 WHERE current_refresh_token = $1 81 83 "#, ··· 100 102 code: r.code, 101 103 current_refresh_token: r.current_refresh_token, 102 104 scope: r.scope, 105 + controller_did: r.controller_did, 103 106 }, 104 107 ))), 105 108 None => Ok(None), ··· 178 181 let row = sqlx::query!( 179 182 r#" 180 183 SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 181 - device_id, parameters, details, code, current_refresh_token, scope 184 + device_id, parameters, details, code, current_refresh_token, scope, controller_did 182 185 FROM oauth_token 183 186 WHERE previous_refresh_token = $1 AND rotated_at > $2 184 187 "#, ··· 204 207 code: r.code, 205 208 current_refresh_token: r.current_refresh_token, 206 209 scope: r.scope, 210 + controller_did: r.controller_did, 207 211 }, 208 212 ))), 209 213 None => Ok(None), ··· 238 242 let rows = sqlx::query!( 239 243 r#" 240 244 SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth, 241 - device_id, parameters, details, code, current_refresh_token, scope 245 + device_id, parameters, details, code, current_refresh_token, scope, controller_did 242 246 FROM oauth_token 243 247 WHERE did = $1 244 248 "#, ··· 262 266 code: r.code, 263 267 current_refresh_token: r.current_refresh_token, 264 268 scope: r.scope, 269 + controller_did: r.controller_did, 265 270 }); 266 271 } 267 272 Ok(tokens) ··· 327 332 .await?; 328 333 Ok(result.rows_affected()) 329 334 } 335 + 336 + pub async fn revoke_tokens_for_controller( 337 + pool: &PgPool, 338 + delegated_did: &str, 339 + controller_did: &str, 340 + ) -> Result<u64, OAuthError> { 341 + let result = sqlx::query!( 342 + "DELETE FROM oauth_token WHERE did = $1 AND controller_did = $2", 343 + delegated_did, 344 + controller_did 345 + ) 346 + .execute(pool) 347 + .await?; 348 + Ok(result.rows_affected()) 349 + }
+176 -8
src/oauth/endpoints/authorize.rs
··· 204 204 .into_response(); 205 205 } 206 206 let force_new_account = query.new_account.unwrap_or(false); 207 + 208 + if let Some(ref login_hint) = request_data.parameters.login_hint { 209 + tracing::info!(login_hint = %login_hint, "Checking login_hint for delegation"); 210 + let pds_hostname = 211 + std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 212 + let normalized = if login_hint.contains('@') || login_hint.starts_with("did:") { 213 + login_hint.clone() 214 + } else if !login_hint.contains('.') { 215 + format!("{}.{}", login_hint.to_lowercase(), pds_hostname) 216 + } else { 217 + login_hint.to_lowercase() 218 + }; 219 + tracing::info!(normalized = %normalized, "Normalized login_hint"); 220 + 221 + match sqlx::query!( 222 + "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", 223 + normalized 224 + ) 225 + .fetch_optional(&state.db) 226 + .await 227 + { 228 + Ok(Some(user)) => { 229 + tracing::info!(did = %user.did, has_password = user.password_hash.is_some(), "Found user for login_hint"); 230 + let is_delegated = crate::delegation::is_delegated_account(&state.db, &user.did) 231 + .await 232 + .unwrap_or(false); 233 + let has_password = user.password_hash.is_some(); 234 + tracing::info!(is_delegated = %is_delegated, has_password = %has_password, "Delegation check"); 235 + 236 + if is_delegated && !has_password { 237 + tracing::info!("Redirecting to delegation auth"); 238 + return redirect_see_other(&format!( 239 + "/#/oauth/delegation?request_uri={}&delegated_did={}", 240 + url_encode(&request_uri), 241 + url_encode(&user.did) 242 + )); 243 + } 244 + } 245 + Ok(None) => { 246 + tracing::info!(normalized = %normalized, "No user found for login_hint"); 247 + } 248 + Err(e) => { 249 + tracing::error!(error = %e, "Error looking up user for login_hint"); 250 + } 251 + } 252 + } else { 253 + tracing::info!("No login_hint in request"); 254 + } 255 + 207 256 if !force_new_account 208 257 && let Some(device_id) = extract_device_cookie(&headers) 209 258 && let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await ··· 445 494 SELECT id, did, email, password_hash, password_required, two_factor_enabled, 446 495 preferred_comms_channel as "preferred_comms_channel: CommsChannel", 447 496 deactivated_at, takedown_ref, 448 - email_verified, discord_verified, telegram_verified, signal_verified 497 + email_verified, discord_verified, telegram_verified, signal_verified, 498 + account_type::text as "account_type!" 449 499 FROM users 450 500 WHERE handle = $1 OR email = $1 451 501 "#, ··· 479 529 "Please verify your account before logging in.", 480 530 json_response, 481 531 ); 532 + } 533 + 534 + if user.account_type == "delegated" { 535 + if db::set_authorization_did(&state.db, &form.request_uri, &user.did, None) 536 + .await 537 + .is_err() 538 + { 539 + return show_login_error("An error occurred. Please try again.", json_response); 540 + } 541 + let redirect_url = format!( 542 + "/#/oauth/delegation?request_uri={}&delegated_did={}", 543 + url_encode(&form.request_uri), 544 + url_encode(&user.did) 545 + ); 546 + if json_response { 547 + return ( 548 + StatusCode::OK, 549 + Json(serde_json::json!({ 550 + "next": "delegation", 551 + "delegated_did": user.did, 552 + "redirect": redirect_url 553 + })), 554 + ) 555 + .into_response(); 556 + } 557 + return redirect_see_other(&redirect_url); 482 558 } 483 559 484 560 if !user.password_required { ··· 1053 1129 pub scopes: Vec<ScopeInfo>, 1054 1130 pub show_consent: bool, 1055 1131 pub did: String, 1132 + #[serde(skip_serializing_if = "Option::is_none")] 1133 + pub is_delegation: Option<bool>, 1134 + #[serde(skip_serializing_if = "Option::is_none")] 1135 + pub controller_did: Option<String>, 1136 + #[serde(skip_serializing_if = "Option::is_none")] 1137 + pub controller_handle: Option<String>, 1138 + #[serde(skip_serializing_if = "Option::is_none")] 1139 + pub delegation_level: Option<String>, 1056 1140 } 1057 1141 1058 1142 #[derive(Debug, Deserialize)] ··· 1127 1211 .parameters 1128 1212 .scope 1129 1213 .as_deref() 1214 + .filter(|s| !s.trim().is_empty()) 1130 1215 .unwrap_or("atproto"); 1131 - let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect(); 1216 + 1217 + let delegation_grant = if let Some(ref ctrl_did) = request_data.controller_did { 1218 + crate::delegation::get_delegation(&state.db, &did, ctrl_did) 1219 + .await 1220 + .ok() 1221 + .flatten() 1222 + } else { 1223 + None 1224 + }; 1225 + 1226 + let effective_scope_str = if let Some(ref grant) = delegation_grant { 1227 + crate::delegation::scopes::intersect_scopes(requested_scope_str, &grant.granted_scopes) 1228 + } else { 1229 + requested_scope_str.to_string() 1230 + }; 1231 + 1232 + let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); 1132 1233 let preferences = 1133 1234 db::get_scope_preferences(&state.db, &did, &request_data.parameters.client_id) 1134 1235 .await ··· 1182 1283 granted, 1183 1284 }); 1184 1285 } 1286 + let (is_delegation, controller_did, controller_handle, delegation_level) = 1287 + if let Some(ref ctrl_did) = request_data.controller_did { 1288 + let ctrl_handle = 1289 + sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", ctrl_did) 1290 + .fetch_optional(&state.db) 1291 + .await 1292 + .ok() 1293 + .flatten(); 1294 + 1295 + let level = if let Some(ref grant) = delegation_grant { 1296 + let preset = crate::delegation::SCOPE_PRESETS 1297 + .iter() 1298 + .find(|p| p.scopes == grant.granted_scopes); 1299 + preset 1300 + .map(|p| p.label.to_string()) 1301 + .unwrap_or_else(|| "Custom".to_string()) 1302 + } else { 1303 + "Unknown".to_string() 1304 + }; 1305 + 1306 + (Some(true), Some(ctrl_did.clone()), ctrl_handle, Some(level)) 1307 + } else { 1308 + (None, None, None, None) 1309 + }; 1310 + 1185 1311 Json(ConsentResponse { 1186 1312 request_uri: query.request_uri.clone(), 1187 1313 client_id: request_data.parameters.client_id.clone(), ··· 1191 1317 scopes, 1192 1318 show_consent, 1193 1319 did, 1320 + is_delegation, 1321 + controller_did, 1322 + controller_handle, 1323 + delegation_level, 1194 1324 }) 1195 1325 .into_response() 1196 1326 } ··· 1199 1329 State(state): State<AppState>, 1200 1330 Json(form): Json<ConsentSubmit>, 1201 1331 ) -> Response { 1332 + tracing::info!( 1333 + "consent_post: approved_scopes={:?}, remember={}", 1334 + form.approved_scopes, 1335 + form.remember 1336 + ); 1202 1337 let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { 1203 1338 Ok(Some(data)) => data, 1204 1339 Ok(None) => { ··· 1246 1381 .into_response(); 1247 1382 } 1248 1383 }; 1249 - let requested_scope_str = request_data 1384 + let original_scope_str = request_data 1250 1385 .parameters 1251 1386 .scope 1252 1387 .as_deref() 1253 1388 .unwrap_or("atproto"); 1254 - let requested_scopes: Vec<&str> = requested_scope_str.split_whitespace().collect(); 1389 + 1390 + let delegation_grant = if let Some(ref ctrl_did) = request_data.controller_did { 1391 + crate::delegation::get_delegation(&state.db, &did, ctrl_did) 1392 + .await 1393 + .ok() 1394 + .flatten() 1395 + } else { 1396 + None 1397 + }; 1398 + 1399 + let effective_scope_str = if let Some(ref grant) = delegation_grant { 1400 + crate::delegation::scopes::intersect_scopes(original_scope_str, &grant.granted_scopes) 1401 + } else { 1402 + original_scope_str.to_string() 1403 + }; 1404 + 1405 + let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect(); 1255 1406 let has_granular_scopes = requested_scopes.iter().any(|s| { 1256 1407 s.starts_with("repo:") 1257 1408 || s.starts_with("blob:") ··· 1640 1791 pub struct SecurityStatusResponse { 1641 1792 pub has_passkeys: bool, 1642 1793 pub has_totp: bool, 1794 + pub has_password: bool, 1795 + pub is_delegated: bool, 1796 + #[serde(skip_serializing_if = "Option::is_none")] 1797 + pub did: Option<String>, 1643 1798 } 1644 1799 1645 1800 pub async fn check_user_security_status( ··· 1658 1813 }; 1659 1814 1660 1815 let user = sqlx::query!( 1661 - "SELECT did FROM users WHERE handle = $1 OR email = $1", 1816 + "SELECT did, password_hash FROM users WHERE handle = $1 OR email = $1", 1662 1817 normalized_identifier 1663 1818 ) 1664 1819 .fetch_optional(&state.db) 1665 1820 .await; 1666 1821 1667 - let (has_passkeys, has_totp) = match user { 1822 + let (has_passkeys, has_totp, has_password, is_delegated, did): ( 1823 + bool, 1824 + bool, 1825 + bool, 1826 + bool, 1827 + Option<String>, 1828 + ) = match user { 1668 1829 Ok(Some(u)) => { 1669 1830 let passkeys = crate::api::server::has_passkeys_for_user(&state, &u.did).await; 1670 1831 let totp = crate::api::server::has_totp_enabled(&state, &u.did).await; 1671 - (passkeys, totp) 1832 + let has_pw = u.password_hash.is_some(); 1833 + let has_controllers = crate::delegation::is_delegated_account(&state.db, &u.did) 1834 + .await 1835 + .unwrap_or(false); 1836 + (passkeys, totp, has_pw, has_controllers, Some(u.did)) 1672 1837 } 1673 - _ => (false, false), 1838 + _ => (false, false, false, false, None), 1674 1839 }; 1675 1840 1676 1841 Json(SecurityStatusResponse { 1677 1842 has_passkeys, 1678 1843 has_totp, 1844 + has_password, 1845 + is_delegated, 1846 + did, 1679 1847 }) 1680 1848 .into_response() 1681 1849 }
+380
src/oauth/endpoints/delegation.rs
··· 1 + use crate::delegation; 2 + use crate::oauth::db; 3 + use crate::state::{AppState, RateLimitKind}; 4 + use crate::util::extract_client_ip; 5 + use axum::{ 6 + Json, 7 + extract::State, 8 + http::{HeaderMap, StatusCode}, 9 + response::{IntoResponse, Response}, 10 + }; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + #[derive(Debug, Deserialize)] 14 + pub struct DelegationAuthSubmit { 15 + pub request_uri: String, 16 + pub delegated_did: Option<String>, 17 + pub controller_did: String, 18 + pub password: String, 19 + #[serde(default)] 20 + pub remember_device: bool, 21 + } 22 + 23 + #[derive(Debug, Serialize)] 24 + pub struct DelegationAuthResponse { 25 + pub success: bool, 26 + #[serde(skip_serializing_if = "Option::is_none")] 27 + pub needs_totp: Option<bool>, 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub redirect_uri: Option<String>, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub error: Option<String>, 32 + } 33 + 34 + pub async fn delegation_auth( 35 + State(state): State<AppState>, 36 + headers: HeaderMap, 37 + Json(form): Json<DelegationAuthSubmit>, 38 + ) -> Response { 39 + let client_ip = extract_client_ip(&headers); 40 + if !state 41 + .check_rate_limit(RateLimitKind::Login, &client_ip) 42 + .await 43 + { 44 + return ( 45 + StatusCode::TOO_MANY_REQUESTS, 46 + Json(DelegationAuthResponse { 47 + success: false, 48 + needs_totp: None, 49 + redirect_uri: None, 50 + error: Some("Too many login attempts. Please try again later.".to_string()), 51 + }), 52 + ) 53 + .into_response(); 54 + } 55 + 56 + let request = match db::get_authorization_request(&state.db, &form.request_uri).await { 57 + Ok(Some(r)) => r, 58 + Ok(None) => { 59 + return Json(DelegationAuthResponse { 60 + success: false, 61 + needs_totp: None, 62 + redirect_uri: None, 63 + error: Some("Authorization request not found".to_string()), 64 + }) 65 + .into_response(); 66 + } 67 + Err(_) => { 68 + return Json(DelegationAuthResponse { 69 + success: false, 70 + needs_totp: None, 71 + redirect_uri: None, 72 + error: Some("Server error".to_string()), 73 + }) 74 + .into_response(); 75 + } 76 + }; 77 + 78 + let delegated_did = match form.delegated_did.as_ref().or(request.did.as_ref()) { 79 + Some(did) => did.clone(), 80 + None => { 81 + return Json(DelegationAuthResponse { 82 + success: false, 83 + needs_totp: None, 84 + redirect_uri: None, 85 + error: Some("No delegated account selected".to_string()), 86 + }) 87 + .into_response(); 88 + } 89 + }; 90 + 91 + if let Err(_) = db::set_request_did(&state.db, &form.request_uri, &delegated_did).await { 92 + tracing::warn!("Failed to set delegated DID on authorization request"); 93 + } 94 + 95 + let grant = 96 + match delegation::get_delegation(&state.db, &delegated_did, &form.controller_did).await { 97 + Ok(Some(g)) => g, 98 + Ok(None) => { 99 + return Json(DelegationAuthResponse { 100 + success: false, 101 + needs_totp: None, 102 + redirect_uri: None, 103 + error: Some("No delegation grant found for this controller".to_string()), 104 + }) 105 + .into_response(); 106 + } 107 + Err(_) => { 108 + return Json(DelegationAuthResponse { 109 + success: false, 110 + needs_totp: None, 111 + redirect_uri: None, 112 + error: Some("Server error".to_string()), 113 + }) 114 + .into_response(); 115 + } 116 + }; 117 + 118 + let controller = match sqlx::query!( 119 + r#" 120 + SELECT id, did, password_hash, deactivated_at, takedown_ref, 121 + email_verified, discord_verified, telegram_verified, signal_verified 122 + FROM users 123 + WHERE did = $1 124 + "#, 125 + form.controller_did 126 + ) 127 + .fetch_optional(&state.db) 128 + .await 129 + { 130 + Ok(Some(u)) => u, 131 + Ok(None) => { 132 + return Json(DelegationAuthResponse { 133 + success: false, 134 + needs_totp: None, 135 + redirect_uri: None, 136 + error: Some("Controller account not found".to_string()), 137 + }) 138 + .into_response(); 139 + } 140 + Err(_) => { 141 + return Json(DelegationAuthResponse { 142 + success: false, 143 + needs_totp: None, 144 + redirect_uri: None, 145 + error: Some("Server error".to_string()), 146 + }) 147 + .into_response(); 148 + } 149 + }; 150 + 151 + if controller.deactivated_at.is_some() { 152 + return Json(DelegationAuthResponse { 153 + success: false, 154 + needs_totp: None, 155 + redirect_uri: None, 156 + error: Some("Controller account is deactivated".to_string()), 157 + }) 158 + .into_response(); 159 + } 160 + 161 + if controller.takedown_ref.is_some() { 162 + return Json(DelegationAuthResponse { 163 + success: false, 164 + needs_totp: None, 165 + redirect_uri: None, 166 + error: Some("Controller account has been taken down".to_string()), 167 + }) 168 + .into_response(); 169 + } 170 + 171 + let password_valid = match &controller.password_hash { 172 + Some(hash) => match bcrypt::verify(&form.password, hash) { 173 + Ok(valid) => valid, 174 + Err(_) => false, 175 + }, 176 + None => false, 177 + }; 178 + 179 + if !password_valid { 180 + return Json(DelegationAuthResponse { 181 + success: false, 182 + needs_totp: None, 183 + redirect_uri: None, 184 + error: Some("Invalid password".to_string()), 185 + }) 186 + .into_response(); 187 + } 188 + 189 + if let Err(_) = db::set_controller_did(&state.db, &form.request_uri, &form.controller_did).await 190 + { 191 + return Json(DelegationAuthResponse { 192 + success: false, 193 + needs_totp: None, 194 + redirect_uri: None, 195 + error: Some("Failed to update authorization request".to_string()), 196 + }) 197 + .into_response(); 198 + } 199 + 200 + let has_totp = crate::api::server::has_totp_enabled(&state, &form.controller_did).await; 201 + if has_totp { 202 + return Json(DelegationAuthResponse { 203 + success: true, 204 + needs_totp: Some(true), 205 + redirect_uri: Some(format!( 206 + "/#/oauth/delegation-totp?request_uri={}", 207 + urlencoding::encode(&form.request_uri) 208 + )), 209 + error: None, 210 + }) 211 + .into_response(); 212 + } 213 + 214 + let ip = extract_client_ip(&headers); 215 + let user_agent = headers 216 + .get("user-agent") 217 + .and_then(|v| v.to_str().ok()) 218 + .map(|s| s.to_string()); 219 + 220 + let _ = delegation::log_delegation_action( 221 + &state.db, 222 + &delegated_did, 223 + &form.controller_did, 224 + Some(&form.controller_did), 225 + delegation::DelegationActionType::TokenIssued, 226 + Some(serde_json::json!({ 227 + "client_id": request.client_id, 228 + "granted_scopes": grant.granted_scopes 229 + })), 230 + Some(&ip), 231 + user_agent.as_deref(), 232 + ) 233 + .await; 234 + 235 + Json(DelegationAuthResponse { 236 + success: true, 237 + needs_totp: None, 238 + redirect_uri: Some(format!( 239 + "/#/oauth/consent?request_uri={}", 240 + urlencoding::encode(&form.request_uri) 241 + )), 242 + error: None, 243 + }) 244 + .into_response() 245 + } 246 + 247 + #[derive(Debug, Deserialize)] 248 + pub struct DelegationTotpSubmit { 249 + pub request_uri: String, 250 + pub code: String, 251 + } 252 + 253 + pub async fn delegation_totp_verify( 254 + State(state): State<AppState>, 255 + headers: HeaderMap, 256 + Json(form): Json<DelegationTotpSubmit>, 257 + ) -> Response { 258 + let client_ip = extract_client_ip(&headers); 259 + if !state 260 + .check_rate_limit(RateLimitKind::TotpVerify, &client_ip) 261 + .await 262 + { 263 + return ( 264 + StatusCode::TOO_MANY_REQUESTS, 265 + Json(DelegationAuthResponse { 266 + success: false, 267 + needs_totp: None, 268 + redirect_uri: None, 269 + error: Some("Too many verification attempts. Please try again later.".to_string()), 270 + }), 271 + ) 272 + .into_response(); 273 + } 274 + 275 + let request = match db::get_authorization_request(&state.db, &form.request_uri).await { 276 + Ok(Some(r)) => r, 277 + Ok(None) => { 278 + return Json(DelegationAuthResponse { 279 + success: false, 280 + needs_totp: None, 281 + redirect_uri: None, 282 + error: Some("Authorization request not found".to_string()), 283 + }) 284 + .into_response(); 285 + } 286 + Err(_) => { 287 + return Json(DelegationAuthResponse { 288 + success: false, 289 + needs_totp: None, 290 + redirect_uri: None, 291 + error: Some("Server error".to_string()), 292 + }) 293 + .into_response(); 294 + } 295 + }; 296 + 297 + let controller_did = match &request.controller_did { 298 + Some(did) => did.clone(), 299 + None => { 300 + return Json(DelegationAuthResponse { 301 + success: false, 302 + needs_totp: None, 303 + redirect_uri: None, 304 + error: Some("Controller not authenticated".to_string()), 305 + }) 306 + .into_response(); 307 + } 308 + }; 309 + 310 + let delegated_did = match &request.did { 311 + Some(did) => did.clone(), 312 + None => { 313 + return Json(DelegationAuthResponse { 314 + success: false, 315 + needs_totp: None, 316 + redirect_uri: None, 317 + error: Some("No delegated account".to_string()), 318 + }) 319 + .into_response(); 320 + } 321 + }; 322 + 323 + let grant = match delegation::get_delegation(&state.db, &delegated_did, &controller_did).await { 324 + Ok(Some(g)) => g, 325 + _ => { 326 + return Json(DelegationAuthResponse { 327 + success: false, 328 + needs_totp: None, 329 + redirect_uri: None, 330 + error: Some("Delegation grant not found".to_string()), 331 + }) 332 + .into_response(); 333 + } 334 + }; 335 + 336 + let totp_valid = 337 + crate::api::server::verify_totp_or_backup_for_user(&state, &controller_did, &form.code) 338 + .await; 339 + if !totp_valid { 340 + return Json(DelegationAuthResponse { 341 + success: false, 342 + needs_totp: Some(true), 343 + redirect_uri: None, 344 + error: Some("Invalid TOTP code".to_string()), 345 + }) 346 + .into_response(); 347 + } 348 + 349 + let ip = extract_client_ip(&headers); 350 + let user_agent = headers 351 + .get("user-agent") 352 + .and_then(|v| v.to_str().ok()) 353 + .map(|s| s.to_string()); 354 + 355 + let _ = delegation::log_delegation_action( 356 + &state.db, 357 + &delegated_did, 358 + &controller_did, 359 + Some(&controller_did), 360 + delegation::DelegationActionType::TokenIssued, 361 + Some(serde_json::json!({ 362 + "client_id": request.client_id, 363 + "granted_scopes": grant.granted_scopes 364 + })), 365 + Some(&ip), 366 + user_agent.as_deref(), 367 + ) 368 + .await; 369 + 370 + Json(DelegationAuthResponse { 371 + success: true, 372 + needs_totp: None, 373 + redirect_uri: Some(format!( 374 + "/#/oauth/consent?request_uri={}", 375 + urlencoding::encode(&form.request_uri) 376 + )), 377 + error: None, 378 + }) 379 + .into_response() 380 + }
+2
src/oauth/endpoints/mod.rs
··· 1 1 pub mod authorize; 2 + pub mod delegation; 2 3 pub mod metadata; 3 4 pub mod par; 4 5 pub mod token; 5 6 6 7 pub use authorize::*; 8 + pub use delegation::*; 7 9 pub use metadata::*; 8 10 pub use par::*; 9 11 pub use token::*;
+5 -2
src/oauth/endpoints/par.rs
··· 58 58 serde_json::from_slice(&body) 59 59 .map_err(|e| OAuthError::InvalidRequest(format!("Invalid JSON: {}", e)))? 60 60 } else if content_type.starts_with("application/x-www-form-urlencoded") { 61 - serde_urlencoded::from_bytes(&body) 62 - .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))? 61 + let parsed: ParRequest = serde_urlencoded::from_bytes(&body) 62 + .map_err(|e| OAuthError::InvalidRequest(format!("Invalid form data: {}", e)))?; 63 + tracing::info!(login_hint = ?parsed.login_hint, "PAR request received (form)"); 64 + parsed 63 65 } else { 64 66 return Err(OAuthError::InvalidRequest( 65 67 "Content-Type must be application/json or application/x-www-form-urlencoded" ··· 128 130 did: None, 129 131 device_id: None, 130 132 code: None, 133 + controller_did: None, 131 134 }; 132 135 db::create_authorization_request(&state.db, &request_id.0, &request_data).await?; 133 136 tokio::spawn({
+30 -7
src/oauth/endpoints/token/grants.rs
··· 1 - use super::helpers::{create_access_token, verify_pkce}; 1 + use super::helpers::{create_access_token_with_delegation, verify_pkce}; 2 2 use super::types::{TokenRequest, TokenResponse}; 3 3 use crate::config::AuthConfig; 4 + use crate::delegation; 4 5 use crate::oauth::{ 5 6 ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, 6 7 client::{ClientMetadataCache, verify_client_auth}, ··· 106 107 let token_id = TokenId::generate(); 107 108 let refresh_token = RefreshToken::generate(); 108 109 let now = Utc::now(); 109 - let access_token = create_access_token( 110 + 111 + let (final_scope, controller_did) = if let Some(ref controller) = auth_request.controller_did { 112 + let grant = delegation::get_delegation(&state.db, &did, controller) 113 + .await 114 + .ok() 115 + .flatten(); 116 + let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default(); 117 + let requested = auth_request 118 + .parameters 119 + .scope 120 + .as_deref() 121 + .unwrap_or("atproto"); 122 + let intersected = delegation::intersect_scopes(requested, &granted_scopes); 123 + (Some(intersected), Some(controller.clone())) 124 + } else { 125 + (auth_request.parameters.scope.clone(), None) 126 + }; 127 + 128 + let access_token = create_access_token_with_delegation( 110 129 &token_id.0, 111 130 &did, 112 131 dpop_jkt.as_deref(), 113 - auth_request.parameters.scope.as_deref(), 132 + final_scope.as_deref(), 133 + controller_did.as_deref(), 114 134 )?; 115 135 let stored_client_auth = auth_request.client_auth.unwrap_or(ClientAuth::None); 116 136 let refresh_expiry_days = if matches!(stored_client_auth, ClientAuth::None) { ··· 131 151 details: None, 132 152 code: None, 133 153 current_refresh_token: Some(refresh_token.0.clone()), 134 - scope: auth_request.parameters.scope.clone(), 154 + scope: final_scope.clone(), 155 + controller_did: controller_did.clone(), 135 156 }; 136 157 db::create_token(&state.db, &token_data).await?; 137 158 tokio::spawn({ ··· 154 175 token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(), 155 176 expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64, 156 177 refresh_token: Some(refresh_token.0), 157 - scope: auth_request.parameters.scope, 178 + scope: final_scope, 158 179 sub: Some(did), 159 180 }), 160 181 )) ··· 183 204 "Refresh token reuse within grace period, returning existing tokens" 184 205 ); 185 206 let dpop_jkt = token_data.parameters.dpop_jkt.as_deref(); 186 - let access_token = create_access_token( 207 + let access_token = create_access_token_with_delegation( 187 208 &token_data.token_id, 188 209 &token_data.did, 189 210 dpop_jkt, 190 211 token_data.scope.as_deref(), 212 + token_data.controller_did.as_deref(), 191 213 )?; 192 214 let mut response_headers = HeaderMap::new(); 193 215 let config = AuthConfig::get(); ··· 282 304 new_expires_at = %new_expires_at, 283 305 "Refresh token rotated successfully" 284 306 ); 285 - let access_token = create_access_token( 307 + let access_token = create_access_token_with_delegation( 286 308 &new_token_id.0, 287 309 &token_data.did, 288 310 dpop_jkt.as_deref(), 289 311 token_data.scope.as_deref(), 312 + token_data.controller_did.as_deref(), 290 313 )?; 291 314 let mut response_headers = HeaderMap::new(); 292 315 let config = AuthConfig::get();
+13
src/oauth/endpoints/token/helpers.rs
··· 38 38 dpop_jkt: Option<&str>, 39 39 scope: Option<&str>, 40 40 ) -> Result<String, OAuthError> { 41 + create_access_token_with_delegation(token_id, sub, dpop_jkt, scope, None) 42 + } 43 + 44 + pub fn create_access_token_with_delegation( 45 + token_id: &str, 46 + sub: &str, 47 + dpop_jkt: Option<&str>, 48 + scope: Option<&str>, 49 + controller_did: Option<&str>, 50 + ) -> Result<String, OAuthError> { 41 51 use serde_json::json; 42 52 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 43 53 let issuer = format!("https://{}", pds_hostname); ··· 55 65 }); 56 66 if let Some(jkt) = dpop_jkt { 57 67 payload["cnf"] = json!({ "jkt": jkt }); 68 + } 69 + if let Some(controller) = controller_did { 70 + payload["act"] = json!({ "sub": controller }); 58 71 } 59 72 let header = json!({ 60 73 "alg": "HS256",
+16 -2
src/oauth/scopes/definitions.rs
··· 40 40 scope: "atproto", 41 41 category: ScopeCategory::Core, 42 42 required: true, 43 - description: "Use AT Protocol OAuth (required for all sessions)", 44 - display_name: "AT Protocol", 43 + description: "Full access to read, write, and manage this account", 44 + display_name: "Full Account Access", 45 45 }, 46 46 ScopeDefinition { 47 47 scope: "transition:generic", ··· 91 91 required: false, 92 92 description: "Upload images, videos, and other media files", 93 93 display_name: "Upload Media", 94 + }, 95 + ScopeDefinition { 96 + scope: "repo:*", 97 + category: ScopeCategory::Repo, 98 + required: false, 99 + description: "Full read and write access to all repository records", 100 + display_name: "Full Repository Access", 101 + }, 102 + ScopeDefinition { 103 + scope: "account:*?action=manage", 104 + category: ScopeCategory::Account, 105 + required: false, 106 + description: "Manage account settings and preferences", 107 + display_name: "Manage Account", 94 108 }, 95 109 ]; 96 110
+2
src/oauth/types.rs
··· 107 107 pub did: Option<String>, 108 108 pub device_id: Option<String>, 109 109 pub code: Option<String>, 110 + pub controller_did: Option<String>, 110 111 } 111 112 112 113 #[derive(Debug, Clone)] ··· 132 133 pub code: Option<String>, 133 134 pub current_refresh_token: Option<String>, 134 135 pub scope: Option<String>, 136 + pub controller_did: Option<String>, 135 137 } 136 138 137 139 #[derive(Debug, Clone, Serialize, Deserialize)]
+7
src/oauth/verify.rs
··· 24 24 pub client_id: String, 25 25 pub scope: Option<String>, 26 26 pub dpop_jkt: Option<String>, 27 + pub controller_did: Option<String>, 27 28 } 28 29 29 30 pub struct VerifyResult { ··· 148 149 .and_then(|c| c.as_str()) 149 150 .map(|s| s.to_string()) 150 151 .unwrap_or_default(); 152 + let controller_did = payload 153 + .get("act") 154 + .and_then(|a| a.get("sub")) 155 + .and_then(|s| s.as_str()) 156 + .map(|s| s.to_string()); 151 157 Ok(OAuthTokenInfo { 152 158 did, 153 159 token_id, 154 160 client_id, 155 161 scope, 156 162 dpop_jkt, 163 + controller_did, 157 164 }) 158 165 } 159 166
+16
src/util.rs
··· 1 + use axum::http::HeaderMap; 1 2 use rand::Rng; 2 3 use sqlx::PgPool; 3 4 use uuid::Uuid; ··· 70 71 .fetch_optional(db) 71 72 .await? 72 73 .ok_or(DbLookupError::NotFound) 74 + } 75 + 76 + pub fn extract_client_ip(headers: &HeaderMap) -> String { 77 + if let Some(forwarded) = headers.get("x-forwarded-for") 78 + && let Ok(value) = forwarded.to_str() 79 + && let Some(first_ip) = value.split(',').next() 80 + { 81 + return first_ip.trim().to_string(); 82 + } 83 + if let Some(real_ip) = headers.get("x-real-ip") 84 + && let Ok(value) = real_ip.to_str() 85 + { 86 + return value.trim().to_string(); 87 + } 88 + "unknown".to_string() 73 89 } 74 90 75 91 #[cfg(test)]
+41
src/validation/mod.rs
··· 382 382 Ok(()) 383 383 } 384 384 385 + pub fn is_valid_did(did: &str) -> bool { 386 + if !did.starts_with("did:") { 387 + return false; 388 + } 389 + let parts: Vec<&str> = did.splitn(3, ':').collect(); 390 + if parts.len() < 3 { 391 + return false; 392 + } 393 + let method = parts[1]; 394 + if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) { 395 + return false; 396 + } 397 + let id = parts[2]; 398 + !id.is_empty() 399 + } 400 + 401 + pub fn validate_did(did: &str) -> Result<(), ValidationError> { 402 + if !is_valid_did(did) { 403 + return Err(ValidationError::InvalidField { 404 + path: "did".to_string(), 405 + message: "Invalid DID format".to_string(), 406 + }); 407 + } 408 + Ok(()) 409 + } 410 + 385 411 pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> { 386 412 if collection.is_empty() { 387 413 return Err(ValidationError::InvalidRecord( ··· 603 629 assert!(validate_collection_nsid("invalid").is_err()); 604 630 assert!(validate_collection_nsid("a.b").is_err()); 605 631 assert!(validate_collection_nsid("").is_err()); 632 + } 633 + 634 + #[test] 635 + fn test_is_valid_did() { 636 + assert!(is_valid_did("did:plc:1234567890abcdefghijk")); 637 + assert!(is_valid_did("did:web:example.com")); 638 + assert!(is_valid_did( 639 + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" 640 + )); 641 + assert!(!is_valid_did("")); 642 + assert!(!is_valid_did("plc:1234567890abcdefghijk")); 643 + assert!(!is_valid_did("did:")); 644 + assert!(!is_valid_did("did:plc:")); 645 + assert!(!is_valid_did("did::something")); 646 + assert!(!is_valid_did("DID:plc:test")); 606 647 } 607 648 }
+187 -6
tests/oauth_security.rs
··· 3 3 mod helpers; 4 4 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 5 5 use chrono::Utc; 6 - use common::{base_url, client}; 6 + use common::{base_url, client, create_account_and_login}; 7 7 use helpers::verify_new_account; 8 8 use reqwest::StatusCode; 9 9 use serde_json::{Value, json}; ··· 439 439 .unwrap(); 440 440 assert_eq!( 441 441 rt_replay.status(), 442 + StatusCode::OK, 443 + "Refresh token reuse within grace period should return existing tokens" 444 + ); 445 + let grace_body: Value = rt_replay.json().await.unwrap(); 446 + assert_eq!( 447 + grace_body["refresh_token"].as_str().unwrap(), 448 + new_rt, 449 + "Grace period response should return the current refresh token" 450 + ); 451 + let second_refresh: Value = http_client 452 + .post(format!("{}/oauth/token", url)) 453 + .form(&[ 454 + ("grant_type", "refresh_token"), 455 + ("refresh_token", new_rt), 456 + ("client_id", &client_id), 457 + ]) 458 + .send() 459 + .await 460 + .unwrap() 461 + .json() 462 + .await 463 + .unwrap(); 464 + assert!( 465 + second_refresh["access_token"].is_string(), 466 + "Second refresh with new token should succeed" 467 + ); 468 + let newest_rt = second_refresh["refresh_token"].as_str().unwrap(); 469 + let replay_after_rotation = http_client 470 + .post(format!("{}/oauth/token", url)) 471 + .form(&[ 472 + ("grant_type", "refresh_token"), 473 + ("refresh_token", &stolen_rt), 474 + ("client_id", &client_id), 475 + ]) 476 + .send() 477 + .await 478 + .unwrap(); 479 + assert_eq!( 480 + replay_after_rotation.status(), 442 481 StatusCode::BAD_REQUEST, 443 - "Refresh token replay should fail" 482 + "Replay of original token after another rotation should fail" 444 483 ); 445 - let body: Value = rt_replay.json().await.unwrap(); 484 + let body: Value = replay_after_rotation.json().await.unwrap(); 446 485 assert!( 447 486 body["error_description"] 448 487 .as_str() 449 488 .unwrap() 450 489 .to_lowercase() 451 - .contains("reuse") 490 + .contains("reuse"), 491 + "Error should indicate token reuse" 452 492 ); 453 493 let family_revoked = http_client 454 494 .post(format!("{}/oauth/token", url)) 455 495 .form(&[ 456 496 ("grant_type", "refresh_token"), 457 - ("refresh_token", new_rt), 497 + ("refresh_token", newest_rt), 458 498 ("client_id", &client_id), 459 499 ]) 460 500 .send() ··· 463 503 assert_eq!( 464 504 family_revoked.status(), 465 505 StatusCode::BAD_REQUEST, 466 - "Token family should be revoked" 506 + "Token family should be revoked after replay detection" 467 507 ); 468 508 } 469 509 ··· 1065 1105 "HTTP method should be case-insensitive" 1066 1106 ); 1067 1107 } 1108 + 1109 + #[tokio::test] 1110 + async fn test_delegation_viewer_scope_cannot_write() { 1111 + let url = base_url().await; 1112 + let http_client = client(); 1113 + let ts = Utc::now().timestamp_millis(); 1114 + 1115 + let (controller_jwt, controller_did) = create_account_and_login(&http_client).await; 1116 + 1117 + let delegated_handle = format!("deleg-{}", ts); 1118 + let delegated_res = http_client 1119 + .post(format!("{}/xrpc/com.tranquil.delegation.createDelegatedAccount", url)) 1120 + .bearer_auth(controller_jwt) 1121 + .json(&json!({ 1122 + "handle": delegated_handle, 1123 + "controllerScopes": "" 1124 + })) 1125 + .send() 1126 + .await 1127 + .unwrap(); 1128 + if delegated_res.status() != StatusCode::OK { 1129 + let error_body = delegated_res.text().await.unwrap(); 1130 + panic!("Failed to create delegated account: {}", error_body); 1131 + } 1132 + let delegated_account: Value = delegated_res.json().await.unwrap(); 1133 + let delegated_did = delegated_account["did"].as_str().unwrap(); 1134 + 1135 + let redirect_uri = "https://example.com/deleg-callback"; 1136 + let mock_client = setup_mock_client_metadata(redirect_uri).await; 1137 + let client_id = mock_client.uri(); 1138 + let (code_verifier, code_challenge) = generate_pkce(); 1139 + 1140 + let par_body: Value = http_client 1141 + .post(format!("{}/oauth/par", url)) 1142 + .form(&[ 1143 + ("response_type", "code"), 1144 + ("client_id", &client_id), 1145 + ("redirect_uri", redirect_uri), 1146 + ("code_challenge", &code_challenge), 1147 + ("code_challenge_method", "S256"), 1148 + ("scope", "atproto"), 1149 + ("login_hint", delegated_did), 1150 + ]) 1151 + .send() 1152 + .await 1153 + .unwrap() 1154 + .json() 1155 + .await 1156 + .unwrap(); 1157 + let request_uri = par_body["request_uri"].as_str().unwrap(); 1158 + 1159 + let auth_res = http_client 1160 + .post(format!("{}/oauth/delegation/auth", url)) 1161 + .header("Content-Type", "application/json") 1162 + .json(&json!({ 1163 + "request_uri": request_uri, 1164 + "delegated_did": delegated_did, 1165 + "controller_did": controller_did, 1166 + "password": "Testpass123!", 1167 + "remember_device": false 1168 + })) 1169 + .send() 1170 + .await 1171 + .unwrap(); 1172 + if auth_res.status() != StatusCode::OK { 1173 + let error_body = auth_res.text().await.unwrap(); 1174 + panic!("Delegation auth failed: {}", error_body); 1175 + } 1176 + let auth_body: Value = auth_res.json().await.unwrap(); 1177 + assert!(auth_body["success"].as_bool().unwrap_or(false), "Delegation auth should succeed: {:?}", auth_body); 1178 + 1179 + let consent_res = http_client 1180 + .post(format!("{}/oauth/authorize/consent", url)) 1181 + .header("Content-Type", "application/json") 1182 + .json(&json!({ 1183 + "request_uri": request_uri, 1184 + "approved_scopes": ["atproto"], 1185 + "remember": false 1186 + })) 1187 + .send() 1188 + .await 1189 + .unwrap(); 1190 + if consent_res.status() != StatusCode::OK { 1191 + let error_body = consent_res.text().await.unwrap(); 1192 + panic!("Consent failed: {}", error_body); 1193 + } 1194 + let consent_body: Value = consent_res.json().await.unwrap(); 1195 + let location = consent_body["redirect_uri"].as_str().unwrap(); 1196 + 1197 + let code = location 1198 + .split("code=") 1199 + .nth(1) 1200 + .unwrap() 1201 + .split('&') 1202 + .next() 1203 + .unwrap(); 1204 + 1205 + let token_res = http_client 1206 + .post(format!("{}/oauth/token", url)) 1207 + .form(&[ 1208 + ("grant_type", "authorization_code"), 1209 + ("code", code), 1210 + ("redirect_uri", redirect_uri), 1211 + ("code_verifier", &code_verifier), 1212 + ("client_id", &client_id), 1213 + ]) 1214 + .send() 1215 + .await 1216 + .unwrap(); 1217 + assert_eq!(token_res.status(), StatusCode::OK); 1218 + let tokens: Value = token_res.json().await.unwrap(); 1219 + let access_token = tokens["access_token"].as_str().unwrap(); 1220 + 1221 + let create_post_res = http_client 1222 + .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) 1223 + .bearer_auth(access_token) 1224 + .json(&json!({ 1225 + "repo": delegated_did, 1226 + "collection": "app.bsky.feed.post", 1227 + "record": { 1228 + "$type": "app.bsky.feed.post", 1229 + "text": "Test post from viewer", 1230 + "createdAt": Utc::now().to_rfc3339() 1231 + } 1232 + })) 1233 + .send() 1234 + .await 1235 + .unwrap(); 1236 + 1237 + assert_eq!( 1238 + create_post_res.status(), 1239 + StatusCode::FORBIDDEN, 1240 + "Viewer scope delegation should not be able to create posts" 1241 + ); 1242 + let error_body: Value = create_post_res.json().await.unwrap(); 1243 + assert_eq!( 1244 + error_body["error"].as_str().unwrap(), 1245 + "InsufficientScope", 1246 + "Error should be InsufficientScope" 1247 + ); 1248 + }