+46
.sqlx/query-03e943475fd0af07d3e1ed5c14276c7841af9fc59076bd4017742844a91d29a1.json
+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
+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
+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
+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
+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
+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
+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
-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
+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
+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-a886fcf853e54f3be88143b373f58a7fbf0881d19649c036660ef6cf52d14fa2.json
+8
-2
.sqlx/query-53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765.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 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 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": "53d124a7cbdf5e121a3469f82225fa9ec69fb74c3fbf335be6ca76ecf9c16765"
99
+
"hash": "a886fcf853e54f3be88143b373f58a7fbf0881d19649c036660ef6cf52d14fa2"
94
100
}
-46
.sqlx/query-6b0245cefaec65a48c51239ed099e45c5347224c81f7d01d7af5bd7664d16883.json
-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
+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
+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
+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
+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
+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
+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
+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
+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-b474591bf3bd9359bd0d8af186f090a32c79a940771168d67160f3190da2eea4.json
+8
-2
.sqlx/query-b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8.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 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 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": "b5d3a6a68443fbf3e6027f462ffaf5ac7e0d44344ce181e5a81932e7610265c8"
99
+
"hash": "b474591bf3bd9359bd0d8af186f090a32c79a940771168d67160f3190da2eea4"
94
100
}
+16
.sqlx/query-bc0d078c738c6ebdaa19446608e96727c0f2f227e9fbcb06172e5c444bea6347.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+1
frontend/src/lib/api.ts
+4
-4
frontend/src/lib/oauth.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
← {$_('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
+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
+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
+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
+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
+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
src/api/mod.rs
+24
-3
src/api/repo/blob.rs
+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
+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
+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
+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
+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
+1
src/api/server/service_auth.rs
+21
-11
src/api/server/session.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
+
}
+380
src/oauth/endpoints/delegation.rs
+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
+2
src/oauth/endpoints/mod.rs
+5
-2
src/oauth/endpoints/par.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+
}