this repo has no description

Delegated accounts

lewis 79e64a8c ae6d13cc

Changed files
+6860 -253
.sqlx
frontend
migrations
src
tests
+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 + }