···1415This software isn't an afterthought by a company with limited resources.
1617-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.
1819The 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
···1415This software isn't an afterthought by a company with limited resources.
1617+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.
1819The 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
+2-17
TODO.md
···23## Active development
45-### 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### Migration tool
23Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
24···85Passkeys 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.
8687App 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.
00
···23## Active development
4000000000000000005### Migration tool
6Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
7···68Passkeys 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.
6970App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
71+72+Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
+12
frontend/src/App.svelte
···25 import OAuth2FA from './routes/OAuth2FA.svelte'
26 import OAuthTotp from './routes/OAuthTotp.svelte'
27 import OAuthPasskey from './routes/OAuthPasskey.svelte'
028 import OAuthError from './routes/OAuthError.svelte'
29 import Security from './routes/Security.svelte'
30 import TrustedDevices from './routes/TrustedDevices.svelte'
00031 import Home from './routes/Home.svelte'
3233 initI18n()
···95 return OAuthTotp
96 case '/oauth/passkey':
97 return OAuthPasskey
0098 case '/oauth/error':
99 return OAuthError
100 case '/security':
101 return Security
102 case '/trusted-devices':
103 return TrustedDevices
000000104 default:
105 return Home
106 }
···25 import OAuth2FA from './routes/OAuth2FA.svelte'
26 import OAuthTotp from './routes/OAuthTotp.svelte'
27 import OAuthPasskey from './routes/OAuthPasskey.svelte'
28+ import OAuthDelegation from './routes/OAuthDelegation.svelte'
29 import OAuthError from './routes/OAuthError.svelte'
30 import Security from './routes/Security.svelte'
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'
35 import Home from './routes/Home.svelte'
3637 initI18n()
···99 return OAuthTotp
100 case '/oauth/passkey':
101 return OAuthPasskey
102+ case '/oauth/delegation':
103+ return OAuthDelegation
104 case '/oauth/error':
105 return OAuthError
106 case '/security':
107 return Security
108 case '/trusted-devices':
109 return TrustedDevices
110+ case '/controllers':
111+ return Controllers
112+ case '/delegation-audit':
113+ return DelegationAudit
114+ case '/act-as':
115+ return ActAs
116 default:
117 return Home
118 }
···6 "cancel": "Avbryt",
7 "back": "Tillbaka",
8 "done": "Klar",
9+ "continue": "Fortsätt",
10 "refresh": "Uppdatera",
11 "create": "Skapa",
12 "delete": "Radera",
···272 "scopeFull": "Full åtkomst",
273 "scopeReadOnly": "Endast läsning",
274 "scopePostOnly": "Endast publicering",
275+ "scopeCustom": "Anpassad",
276+ "byController": "Av controller"
277 },
278 "sessions": {
279 "title": "Aktiva sessioner",
···890 "codeLabel": "Verifieringskod",
891 "codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck.",
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."
1030 }
1031}
···178 <h3>App passwords with guardrails</h3>
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 </div>
00000181 </div>
182183 <h2>Everything in one place</h2>
···178 <h3>App passwords with guardrails</h3>
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 </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>
186 </div>
187188 <h2>Everything in one place</h2>
···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;
···1pub mod authorize;
02pub mod metadata;
3pub mod par;
4pub mod token;
56pub use authorize::*;
07pub use metadata::*;
8pub use par::*;
9pub use token::*;
···1pub mod authorize;
2+pub mod delegation;
3pub mod metadata;
4pub mod par;
5pub mod token;
67pub use authorize::*;
8+pub use delegation::*;
9pub use metadata::*;
10pub use par::*;
11pub use token::*;