From c19904f6d6f60e29c1b8fb8c2fbef4f7de07d855 Mon Sep 17 00:00:00 2001 From: lewis Date: Mon, 26 Jan 2026 21:25:46 +0200 Subject: [PATCH] fix: make frontend more type-safe --- .../src/api/server/service_auth.rs | 4 + frontend/deno.lock | 333 +++++++++- .../src/components/AuthenticatedRoute.svelte | 59 ++ frontend/src/lib/api.ts | 252 ++++++- frontend/src/lib/auth.svelte.ts | 22 +- frontend/src/lib/authenticated-client.ts | 78 +++ frontend/src/lib/migration/atproto-client.ts | 113 ++++ frontend/src/lib/migration/flow.svelte.ts | 1 + frontend/src/lib/router.svelte.ts | 51 +- frontend/src/lib/types/api.ts | 125 +++- frontend/src/lib/types/branded.ts | 5 + frontend/src/lib/types/totp-state.ts | 67 ++ frontend/src/routes/ActAs.svelte | 215 +++--- frontend/src/routes/Controllers.svelte | 620 ++++++++---------- frontend/src/routes/Dashboard.svelte | 15 +- frontend/src/routes/DelegationAudit.svelte | 221 +++---- frontend/src/routes/Security.svelte | 199 +++--- frontend/src/routes/Settings.svelte | 25 +- frontend/src/styles/base.css | 8 +- frontend/src/tests/AppPasswords.test.ts | 2 + frontend/src/tests/Login.test.ts | 2 + frontend/src/tests/mocks.ts | 81 ++- frontend/src/tests/oauth-registration.test.ts | 57 +- 23 files changed, 1669 insertions(+), 886 deletions(-) create mode 100644 frontend/src/components/AuthenticatedRoute.svelte create mode 100644 frontend/src/lib/authenticated-client.ts create mode 100644 frontend/src/lib/types/totp-state.ts diff --git a/crates/tranquil-pds/src/api/server/service_auth.rs b/crates/tranquil-pds/src/api/server/service_auth.rs index 5f2ac9d..d83bc97 100644 --- a/crates/tranquil-pds/src/api/server/service_auth.rs +++ b/crates/tranquil-pds/src/api/server/service_auth.rs @@ -113,6 +113,10 @@ pub async fn get_service_auth( ) .into_response(); } + Err(crate::oauth::OAuthError::ExpiredToken(msg)) => { + warn!(error = %msg, "getServiceAuth DPoP token expired"); + return ApiError::OAuthExpiredToken(Some(msg)).into_response(); + } Err(e) => { warn!(error = ?e, "getServiceAuth DPoP auth validation failed"); return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response(); diff --git a/frontend/deno.lock b/frontend/deno.lock index 4535f15..55966df 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -11,6 +11,7 @@ "npm:@testing-library/svelte@^5.3.1": "5.3.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3_vitest@4.0.16__jsdom@25.0.1__vite@7.3.0___picomatch@4.0.3_jsdom@25.0.1", "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1", "npm:jsdom@^25.0.1": "25.0.1", + "npm:knip@*": "5.82.1_@types+node@25.0.3_typescript@5.9.3", "npm:multiformats@^13.4.2": "13.4.2", "npm:svelte-check@*": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", "npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", @@ -156,6 +157,25 @@ "@csstools/css-tokenizer@3.0.4": { "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==" }, + "@emnapi/core@1.8.1": { + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dependencies": [ + "@emnapi/wasi-threads", + "tslib" + ] + }, + "@emnapi/runtime@1.8.1": { + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dependencies": [ + "tslib" + ] + }, + "@emnapi/wasi-threads@1.1.0": { + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dependencies": [ + "tslib" + ] + }, "@esbuild/aix-ppc64@0.19.12": { "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", "os": ["aix"], @@ -464,9 +484,136 @@ "@jridgewell/sourcemap-codec" ] }, + "@napi-rs/wasm-runtime@1.1.1": { + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dependencies": [ + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util" + ] + }, "@noble/secp256k1@3.0.0": { "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" }, + "@nodelib/fs.scandir@2.1.5": { + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": [ + "@nodelib/fs.stat", + "run-parallel" + ] + }, + "@nodelib/fs.stat@2.0.5": { + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk@1.2.8": { + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": [ + "@nodelib/fs.scandir", + "fastq" + ] + }, + "@oxc-resolver/binding-android-arm-eabi@11.16.4": { + "integrity": "sha512-6XUHilmj8D6Ggus+sTBp64x/DUQ7LgC/dvTDdUOt4iMQnDdSep6N1mnvVLIiG+qM5tRnNHravNzBJnUlYwRQoA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@oxc-resolver/binding-android-arm64@11.16.4": { + "integrity": "sha512-5ODwd1F5mdkm6JIg1CNny9yxIrCzrkKpxmqas7Alw23vE0Ot8D4ykqNBW5Z/nIZkXVEo5VDmnm0sMBBIANcpeQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@oxc-resolver/binding-darwin-arm64@11.16.4": { + "integrity": "sha512-egwvDK9DMU4Q8F4BG74/n4E22pQ0lT5ukOVB6VXkTj0iG2fnyoStHoFaBnmDseLNRA4r61Mxxz8k940CIaJMDg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@oxc-resolver/binding-darwin-x64@11.16.4": { + "integrity": "sha512-HMkODYrAG4HaFNCpaYzSQFkxeiz2wzl+smXwxeORIQVEo1WAgUrWbvYT/0RNJg/A8z2aGMGK5KWTUr2nX5GiMw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@oxc-resolver/binding-freebsd-x64@11.16.4": { + "integrity": "sha512-mkcKhIdSlUqnndD928WAVVFMEr1D5EwHOBGHadypW0PkM0h4pn89ZacQvU7Qs/Z2qquzvbyw8m4Mq3jOYI+4Dw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@oxc-resolver/binding-linux-arm-gnueabihf@11.16.4": { + "integrity": "sha512-ZJvzbmXI/cILQVcJL9S2Fp7GLAIY4Yr6mpGb+k6LKLUSEq85yhG+rJ9eWCqgULVIf2BFps/NlmPTa7B7oj8jhQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@oxc-resolver/binding-linux-arm-musleabihf@11.16.4": { + "integrity": "sha512-iZUB0W52uB10gBUDAi79eTnzqp1ralikCAjfq7CdokItwZUVJXclNYANnzXmtc0Xr0ox+YsDsG2jGcj875SatA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@oxc-resolver/binding-linux-arm64-gnu@11.16.4": { + "integrity": "sha512-qNQk0H6q1CnwS9cnvyjk9a+JN8BTbxK7K15Bb5hYfJcKTG1hfloQf6egndKauYOO0wu9ldCMPBrEP1FNIQEhaA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@oxc-resolver/binding-linux-arm64-musl@11.16.4": { + "integrity": "sha512-wEXSaEaYxGGoVSbw0i2etjDDWcqErKr8xSkTdwATP798efsZmodUAcLYJhN0Nd4W35Oq6qAvFGHpKwFrrhpTrA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@oxc-resolver/binding-linux-ppc64-gnu@11.16.4": { + "integrity": "sha512-CUFOlpb07DVOFLoYiaTfbSBRPIhNgwc/MtlYeg3p6GJJw+kEm/vzc9lohPSjzF2MLPB5hzsJdk+L/GjrTT3UPw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@oxc-resolver/binding-linux-riscv64-gnu@11.16.4": { + "integrity": "sha512-d8It4AH8cN9ReK1hW6ZO4x3rMT0hB2LYH0RNidGogV9xtnjLRU+Y3MrCeClLyOSGCibmweJJAjnwB7AQ31GEhg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@oxc-resolver/binding-linux-riscv64-musl@11.16.4": { + "integrity": "sha512-d09dOww9iKyEHSxuOQ/Iu2aYswl0j7ExBcyy14D6lJ5ijQSP9FXcJYJsJ3yvzboO/PDEFjvRuF41f8O1skiPVg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@oxc-resolver/binding-linux-s390x-gnu@11.16.4": { + "integrity": "sha512-lhjyGmUzTWHduZF3MkdUSEPMRIdExnhsqv8u1upX3A15epVn6YVwv4msFQPJl1x1wszkACPeDHGOtzHsITXGdw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@oxc-resolver/binding-linux-x64-gnu@11.16.4": { + "integrity": "sha512-ZtqqiI5rzlrYBm/IMMDIg3zvvVj4WO/90Dg/zX+iA8lWaLN7K5nroXb17MQ4WhI5RqlEAgrnYDXW+hok1D9Kaw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@oxc-resolver/binding-linux-x64-musl@11.16.4": { + "integrity": "sha512-LM424h7aaKcMlqHnQWgTzO+GRNLyjcNnMpqm8SygEtFRVW693XS+XGXYvjORlmJtsyjo84ej1FMb3U2HE5eyjg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@oxc-resolver/binding-openharmony-arm64@11.16.4": { + "integrity": "sha512-8w8U6A5DDWTBv3OUxSD9fNk37liZuEC5jnAc9wQRv9DeYKAXvuUtBfT09aIZ58swaci0q1WS48/CoMVEO6jdCA==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@oxc-resolver/binding-wasm32-wasi@11.16.4": { + "integrity": "sha512-hnjb0mDVQOon6NdfNJ1EmNquonJUjoYkp7UyasjxVa4iiMcApziHP4czzzme6WZbp+vzakhVv2Yi5ACTon3Zlw==", + "dependencies": [ + "@napi-rs/wasm-runtime" + ], + "cpu": ["wasm32"] + }, + "@oxc-resolver/binding-win32-arm64-msvc@11.16.4": { + "integrity": "sha512-+i0XtNfSP7cfnh1T8FMrMm4HxTeh0jxKP/VQCLWbjdUxaAQ4damho4gN9lF5dl0tZahtdszXLUboBFNloSJNOQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@oxc-resolver/binding-win32-ia32-msvc@11.16.4": { + "integrity": "sha512-ePW1islJrv3lPnef/iWwrjrSpRH8kLlftdKf2auQNWvYLx6F0xvcnv9d+r/upnVuttoQY9amLnWJf+JnCRksTw==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@oxc-resolver/binding-win32-x64-msvc@11.16.4": { + "integrity": "sha512-qnjQhjHI4TDL3hkidZyEmQRK43w2NHl6TP5Rnt/0XxYuLdEgx/1yzShhYidyqWzdnhGhSPTM/WVP2mK66XLegA==", + "os": ["win32"], + "cpu": ["x64"] + }, "@rollup/rollup-android-arm-eabi@4.54.0": { "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "os": ["android"], @@ -657,6 +804,12 @@ "@testing-library/dom" ] }, + "@tybys/wasm-util@0.10.1": { + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dependencies": [ + "tslib" + ] + }, "@types/aria-query@5.0.4": { "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" }, @@ -673,6 +826,12 @@ "@types/estree@1.0.8": { "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, + "@types/node@25.0.3": { + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dependencies": [ + "undici-types" + ] + }, "@vitest/expect@4.0.16": { "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dependencies": [ @@ -740,6 +899,9 @@ "ansi-styles@5.2.0": { "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, "aria-query@5.3.0": { "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dependencies": [ @@ -758,6 +920,12 @@ "axobject-query@4.1.0": { "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, "call-bind-apply-helpers@1.0.2": { "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dependencies": [ @@ -1019,13 +1187,41 @@ "type" ] }, + "fast-glob@3.3.3": { + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": [ + "@nodelib/fs.stat", + "@nodelib/fs.walk", + "glob-parent", + "merge2", + "micromatch" + ] + }, + "fastq@1.20.1": { + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dependencies": [ + "reusify" + ] + }, + "fd-package-json@2.0.0": { + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dependencies": [ + "walk-up-path" + ] + }, "fdir@6.5.0_picomatch@4.0.3": { "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dependencies": [ - "picomatch" + "picomatch@4.0.3" ], "optionalPeers": [ - "picomatch" + "picomatch@4.0.3" + ] + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" ] }, "form-data@4.0.5": { @@ -1038,6 +1234,13 @@ "mime-types" ] }, + "formatly@0.3.0": { + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dependencies": [ + "fd-package-json" + ], + "bin": true + }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -1068,6 +1271,12 @@ "es-object-atoms" ] }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, "globalyzer@0.1.0": { "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==" }, @@ -1130,6 +1339,18 @@ "tslib" ] }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, "is-potential-custom-element-name@1.0.1": { "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" }, @@ -1142,9 +1363,20 @@ "@types/estree" ] }, + "jiti@2.6.1": { + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "bin": true + }, "js-tokens@4.0.0": { "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, + "js-yaml@4.1.1": { + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dependencies": [ + "argparse" + ], + "bin": true + }, "jsdom@25.0.1": { "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dependencies": [ @@ -1171,6 +1403,26 @@ "xml-name-validator" ] }, + "knip@5.82.1_@types+node@25.0.3_typescript@5.9.3": { + "integrity": "sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==", + "dependencies": [ + "@nodelib/fs.walk", + "@types/node", + "fast-glob", + "formatly", + "jiti", + "js-yaml", + "minimist", + "oxc-resolver", + "picocolors", + "picomatch@4.0.3", + "smol-toml", + "strip-json-comments", + "typescript", + "zod" + ], + "bin": true + }, "locate-character@3.0.0": { "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" }, @@ -1209,6 +1461,16 @@ "timers-ext" ] }, + "merge2@1.4.1": { + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch@2.3.1" + ] + }, "mime-db@1.52.0": { "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, @@ -1221,6 +1483,9 @@ "min-indent@1.0.1": { "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" }, + "minimist@1.2.8": { + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, "mri@1.2.0": { "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" }, @@ -1243,6 +1508,31 @@ "obug@2.1.1": { "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==" }, + "oxc-resolver@11.16.4": { + "integrity": "sha512-nvJr3orFz1wNaBA4neRw7CAn0SsjgVaEw1UHpgO/lzVW12w+nsFnvU/S6vVX3kYyFaZdxZheTExi/fa8R8PrZA==", + "optionalDependencies": [ + "@oxc-resolver/binding-android-arm-eabi", + "@oxc-resolver/binding-android-arm64", + "@oxc-resolver/binding-darwin-arm64", + "@oxc-resolver/binding-darwin-x64", + "@oxc-resolver/binding-freebsd-x64", + "@oxc-resolver/binding-linux-arm-gnueabihf", + "@oxc-resolver/binding-linux-arm-musleabihf", + "@oxc-resolver/binding-linux-arm64-gnu", + "@oxc-resolver/binding-linux-arm64-musl", + "@oxc-resolver/binding-linux-ppc64-gnu", + "@oxc-resolver/binding-linux-riscv64-gnu", + "@oxc-resolver/binding-linux-riscv64-musl", + "@oxc-resolver/binding-linux-s390x-gnu", + "@oxc-resolver/binding-linux-x64-gnu", + "@oxc-resolver/binding-linux-x64-musl", + "@oxc-resolver/binding-openharmony-arm64", + "@oxc-resolver/binding-wasm32-wasi", + "@oxc-resolver/binding-win32-arm64-msvc", + "@oxc-resolver/binding-win32-ia32-msvc", + "@oxc-resolver/binding-win32-x64-msvc" + ] + }, "parse5@7.3.0": { "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dependencies": [ @@ -1255,6 +1545,9 @@ "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, "picomatch@4.0.3": { "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" }, @@ -1277,6 +1570,9 @@ "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, + "queue-microtask@1.2.3": { + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, "react-is@17.0.2": { "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, @@ -1290,6 +1586,9 @@ "strip-indent" ] }, + "reusify@1.1.0": { + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" + }, "rollup@4.54.0": { "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dependencies": [ @@ -1328,6 +1627,12 @@ "rrweb-cssom@0.8.0": { "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" }, + "run-parallel@1.2.0": { + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dependencies": [ + "queue-microtask" + ] + }, "sade@1.8.1": { "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", "dependencies": [ @@ -1346,6 +1651,9 @@ "siginfo@2.0.0": { "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==" }, + "smol-toml@1.6.0": { + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==" + }, "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, @@ -1361,6 +1669,9 @@ "min-indent" ] }, + "strip-json-comments@5.0.3": { + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==" + }, "svelte-check@4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3": { "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", "dependencies": [ @@ -1435,7 +1746,7 @@ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dependencies": [ "fdir", - "picomatch" + "picomatch@4.0.3" ] }, "tinyrainbow@3.0.3": { @@ -1451,6 +1762,12 @@ ], "bin": true }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, "tough-cookie@5.1.2": { "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dependencies": [ @@ -1473,6 +1790,9 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "bin": true }, + "undici-types@7.16.0": { + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + }, "unicode-segmenter@0.14.5": { "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" }, @@ -1481,7 +1801,7 @@ "dependencies": [ "esbuild@0.27.2", "fdir", - "picomatch", + "picomatch@4.0.3", "postcss", "rollup", "tinyglobby" @@ -1516,7 +1836,7 @@ "magic-string", "obug", "pathe", - "picomatch", + "picomatch@4.0.3", "std-env", "tinybench", "tinyexec", @@ -1536,6 +1856,9 @@ "xml-name-validator" ] }, + "walk-up-path@4.0.0": { + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==" + }, "webidl-conversions@7.0.0": { "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" }, diff --git a/frontend/src/components/AuthenticatedRoute.svelte b/frontend/src/components/AuthenticatedRoute.svelte new file mode 100644 index 0000000..93cb53f --- /dev/null +++ b/frontend/src/components/AuthenticatedRoute.svelte @@ -0,0 +1,59 @@ + + +{#if auth.kind === 'authenticated'} + {@render children({ session: auth.session, client: createAuthenticatedClient(auth.session) })} +{:else} +
+{/if} + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 18975c4..d46dd92 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -7,6 +7,7 @@ import type { Nsid, RefreshToken, Rkey, + ScopeSet, } from "./types/branded.ts"; import { unsafeAsAccessToken, @@ -15,6 +16,7 @@ import { unsafeAsHandle, unsafeAsISODate, unsafeAsRefreshToken, + unsafeAsScopeSet, } from "./types/branded.ts"; import { createDPoPProofForRequest, @@ -23,15 +25,21 @@ import { } from "./oauth.ts"; import type { AccountInfo, + AccountState, ApiErrorCode, AppPassword, CompletePasskeySetupResponse, ConfirmSignupResult, + ContactState, CreateAccountParams, CreateAccountResult, CreateBackupResponse, CreatedAppPassword, CreateRecordResponse, + DelegationAuditEntry, + DelegationControlledAccount, + DelegationController, + DelegationScopePreset, DidDocument, DidType, EmailUpdateResponse, @@ -65,6 +73,7 @@ import type { ServerStats, Session, SetBackupEnabledResponse, + SsoLinkedAccount, StartPasskeyRegistrationResponse, SuccessResponse, TotpSecret, @@ -241,32 +250,119 @@ export interface VerificationMethod { } export type { AppPassword, DidDocument, InviteCodeInfo as InviteCode, Session }; -export type { - ConfirmSignupResult, - CreateAccountParams, - CreateAccountResult, - DidType, - VerificationChannel, -}; +export type { DidType, VerificationChannel }; -function castSession(raw: unknown): Session { +function buildContactState(s: Record): ContactState { + const preferredChannel = s.preferredChannel as VerificationChannel | undefined; + const email = s.email ? unsafeAsEmail(s.email as string) : undefined; + + if (preferredChannel) { + return { + contactKind: "channel", + preferredChannel, + preferredChannelVerified: Boolean(s.preferredChannelVerified), + email, + }; + } + + if (email) { + return { + contactKind: "email", + email, + emailConfirmed: Boolean(s.emailConfirmed), + }; + } + + return { contactKind: "none" }; +} + +function buildAccountState(s: Record): AccountState { + const status = s.status as string | undefined; + const isAdmin = Boolean(s.isAdmin); + const active = s.active as boolean | undefined; + + if (status === "migrated") { + return { + accountKind: "migrated", + migratedToPds: (s.migratedToPds as string) || "", + migratedAt: s.migratedAt + ? unsafeAsISODate(s.migratedAt as string) + : unsafeAsISODate(new Date().toISOString()), + isAdmin, + }; + } + + if (status === "deactivated" || active === false) { + return { accountKind: "deactivated", isAdmin }; + } + + if (status === "suspended") { + return { accountKind: "suspended", isAdmin }; + } + + return { accountKind: "active", isAdmin }; +} + +export function castSession(raw: unknown): Session { const s = raw as Record; + const contact = buildContactState(s); + const account = buildAccountState(s); + return { did: unsafeAsDid(s.did as string), handle: unsafeAsHandle(s.handle as string), - email: s.email ? unsafeAsEmail(s.email as string) : undefined, - emailConfirmed: s.emailConfirmed as boolean | undefined, - preferredChannel: s.preferredChannel as VerificationChannel | undefined, - preferredChannelVerified: s.preferredChannelVerified as boolean | undefined, - isAdmin: s.isAdmin as boolean | undefined, - active: s.active as boolean | undefined, - status: s.status as Session["status"], - migratedToPds: s.migratedToPds as string | undefined, - migratedAt: s.migratedAt - ? unsafeAsISODate(s.migratedAt as string) - : undefined, accessJwt: unsafeAsAccessToken(s.accessJwt as string), refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string), + preferredLocale: s.preferredLocale as string | null | undefined, + ...contact, + ...account, + }; +} + +function castDelegationController(raw: unknown): DelegationController { + const c = raw as Record; + return { + did: unsafeAsDid(c.did as string), + granted_scopes: unsafeAsScopeSet(c.granted_scopes as string), + added_at: unsafeAsISODate(c.added_at as string), + }; +} + +function castDelegationControlledAccount( + raw: unknown, +): DelegationControlledAccount { + const a = raw as Record; + return { + did: unsafeAsDid(a.did as string), + handle: unsafeAsHandle(a.handle as string), + granted_scopes: unsafeAsScopeSet(a.granted_scopes as string), + }; +} + +function castDelegationAuditEntry(raw: unknown): DelegationAuditEntry { + const e = raw as Record; + return { + id: e.id as string, + action: e.action as string, + actor_did: unsafeAsDid(e.actor_did as string), + target_did: e.target_did ? unsafeAsDid(e.target_did as string) : undefined, + details: e.details as string | undefined, + created_at: unsafeAsISODate(e.created_at as string), + }; +} + +function castSsoLinkedAccount(raw: unknown): SsoLinkedAccount { + const a = raw as Record; + return { + id: a.id as string, + provider: a.provider as string, + provider_name: a.provider_name as string, + provider_username: a.provider_username as string, + provider_email: a.provider_email as string | undefined, + created_at: unsafeAsISODate(a.created_at as string), + last_login_at: a.last_login_at + ? unsafeAsISODate(a.last_login_at as string) + : undefined, }; } @@ -1142,7 +1238,9 @@ export const api = { }, async getRepo(token: AccessToken, did: Did): Promise { - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`; + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ + encodeURIComponent(did) + }`; const res = await authenticatedFetch(url, { token }); if (!res.ok) { const errData = await res.json().catch(() => ({ @@ -1198,12 +1296,15 @@ export const api = { }, async importRepo(token: AccessToken, car: Uint8Array): Promise { - const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, { - method: "POST", - token, - headers: { "Content-Type": "application/vnd.ipld.car" }, - body: car as unknown as BodyInit, - }); + const res = await authenticatedFetch( + `${API_BASE}/com.atproto.repo.importRepo`, + { + method: "POST", + token, + headers: { "Content-Type": "application/vnd.ipld.car" }, + body: car as unknown as BodyInit, + }, + ); if (!res.ok) { const errData = await res.json().catch(() => ({ error: "Unknown", @@ -1213,7 +1314,9 @@ export const api = { } }, - async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> { + async establishOAuthSession( + token: AccessToken, + ): Promise<{ success: boolean; device_id: string }> { const res = await authenticatedFetch("/oauth/establish-session", { method: "POST", token, @@ -1228,6 +1331,101 @@ export const api = { } return res.json(); }, + + async getSsoLinkedAccounts( + token: AccessToken, + ): Promise<{ accounts: SsoLinkedAccount[] }> { + const res = await authenticatedFetch("/oauth/sso/linked", { token }); + if (!res.ok) { + const errData = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + throw new ApiError(res.status, errData.error, errData.message); + } + return res.json(); + }, + + listDelegationControllers( + token: AccessToken, + ): Promise> { + return xrpcResult("_delegation.listControllers", { token }); + }, + + listDelegationControlledAccounts( + token: AccessToken, + ): Promise> { + return xrpcResult("_delegation.listControlledAccounts", { token }); + }, + + getDelegationScopePresets(): Promise< + Result<{ presets: DelegationScopePreset[] }, ApiError> + > { + return xrpcResult("_delegation.getScopePresets"); + }, + + addDelegationController( + token: AccessToken, + controllerDid: Did, + grantedScopes: ScopeSet, + ): Promise> { + return xrpcResult("_delegation.addController", { + method: "POST", + token, + body: { controller_did: controllerDid, granted_scopes: grantedScopes }, + }); + }, + + removeDelegationController( + token: AccessToken, + controllerDid: Did, + ): Promise> { + return xrpcResult("_delegation.removeController", { + method: "POST", + token, + body: { controller_did: controllerDid }, + }); + }, + + createDelegatedAccount( + token: AccessToken, + handle: Handle, + email?: EmailAddress, + controllerScopes?: ScopeSet, + ): Promise> { + return xrpcResult("_delegation.createDelegatedAccount", { + method: "POST", + token, + body: { handle, email, controllerScopes }, + }); + }, + + getDelegationAuditLog( + token: AccessToken, + limit: number, + offset: number, + ): Promise< + Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError> + > { + return xrpcResult("_delegation.getAuditLog", { + token, + params: { limit: String(limit), offset: String(offset) }, + }); + }, + + async exportBlobs(token: AccessToken): Promise { + const res = await authenticatedFetch(`${API_BASE}/_backup.exportBlobs`, { + token, + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + throw new ApiError(res.status, errData.error, errData.message); + } + return res.blob(); + }, }; export const typedApi = { diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index 5beaa79..dd392ca 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -1,11 +1,9 @@ -import { - api, - ApiError, - type CreateAccountParams, - type CreateAccountResult, - typedApi, -} from "./api.ts"; -import type { Session } from "./types/api.ts"; +import { api, ApiError, typedApi, castSession } from "./api.ts"; +import type { + CreateAccountParams, + CreateAccountResult, + Session, +} from "./types/api.ts"; import { type AccessToken, type Did, @@ -436,8 +434,9 @@ export async function confirmSignup( setLoading(); try { const result = await api.confirmSignup(did, verificationCode); - setAuthenticated(result); - return ok(result); + const session = castSession(result); + setAuthenticated(session); + return ok(session); } catch (e) { const error = toAuthError(e); setError(error); @@ -467,6 +466,9 @@ export function setSession(session: { handle: unsafeAsHandle(session.handle), accessJwt: unsafeAsAccessToken(session.accessJwt), refreshJwt: unsafeAsRefreshToken(session.refreshJwt), + contactKind: "none", + accountKind: "active", + isAdmin: false, }; setAuthenticated(newSession); } diff --git a/frontend/src/lib/authenticated-client.ts b/frontend/src/lib/authenticated-client.ts new file mode 100644 index 0000000..2b75382 --- /dev/null +++ b/frontend/src/lib/authenticated-client.ts @@ -0,0 +1,78 @@ +import type { AccessToken, Did, EmailAddress, Handle, ScopeSet } from "./types/branded.ts"; +import type { Session } from "./types/api.ts"; +import type { + DelegationAuditEntry, + DelegationControlledAccount, + DelegationController, + DelegationScopePreset, + SsoLinkedAccount, +} from "./types/api.ts"; +import { api, ApiError } from "./api.ts"; +import type { Result } from "./types/result.ts"; + +export interface AuthenticatedClient { + readonly token: AccessToken; + readonly session: Session; + + getSsoLinkedAccounts(): Promise<{ accounts: SsoLinkedAccount[] }>; + + listDelegationControllers(): Promise< + Result<{ controllers: DelegationController[] }, ApiError> + >; + listDelegationControlledAccounts(): Promise< + Result<{ accounts: DelegationControlledAccount[] }, ApiError> + >; + getDelegationScopePresets(): Promise< + Result<{ presets: DelegationScopePreset[] }, ApiError> + >; + addDelegationController( + controllerDid: Did, + grantedScopes: ScopeSet, + ): Promise>; + removeDelegationController( + controllerDid: Did, + ): Promise>; + createDelegatedAccount( + handle: Handle, + email?: EmailAddress, + controllerScopes?: ScopeSet, + ): Promise>; + getDelegationAuditLog( + limit: number, + offset: number, + ): Promise< + Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError> + >; + + exportBlobs(): Promise; +} + +export function createAuthenticatedClient( + session: Session, +): AuthenticatedClient { + const token = session.accessJwt; + + return { + token, + session, + + getSsoLinkedAccounts: () => api.getSsoLinkedAccounts(token), + + listDelegationControllers: () => api.listDelegationControllers(token), + listDelegationControlledAccounts: () => + api.listDelegationControlledAccounts(token), + getDelegationScopePresets: () => api.getDelegationScopePresets(), + addDelegationController: (controllerDid, grantedScopes) => + api.addDelegationController(token, controllerDid, grantedScopes), + removeDelegationController: (controllerDid) => + api.removeDelegationController(token, controllerDid), + createDelegatedAccount: (handle, email, controllerScopes) => + api.createDelegatedAccount(token, handle, email, controllerScopes), + getDelegationAuditLog: (limit, offset) => + api.getDelegationAuditLog(token, limit, offset), + + exportBlobs: () => api.exportBlobs(token), + }; +} + +export { ApiError }; diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index 274d8ef..5138b46 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -33,8 +33,10 @@ function apiLog( export class AtprotoClient { private baseUrl: string; private accessToken: string | null = null; + private refreshToken: string | null = null; private dpopKeyPair: DPoPKeyPair | null = null; private dpopNonce: string | null = null; + private isRefreshing = false; constructor(pdsUrl: string) { this.baseUrl = pdsUrl.replace(/\/$/, ""); @@ -48,6 +50,14 @@ export class AtprotoClient { return this.accessToken; } + setRefreshToken(token: string | null) { + this.refreshToken = token; + } + + getRefreshToken(): string | null { + return this.refreshToken; + } + getBaseUrl(): string { return this.baseUrl; } @@ -56,6 +66,69 @@ export class AtprotoClient { this.dpopKeyPair = keyPair; } + private async tryRefreshToken(): Promise { + if (!this.refreshToken || this.isRefreshing) return false; + this.isRefreshing = true; + try { + const session = await this.refreshSessionInternal(this.refreshToken); + this.accessToken = session.accessJwt; + this.refreshToken = session.refreshJwt; + return true; + } catch { + return false; + } finally { + this.isRefreshing = false; + } + } + + private async refreshSessionInternal(refreshJwt: string): Promise { + const url = `${this.baseUrl}/xrpc/com.atproto.server.refreshSession`; + const headers: Record = {}; + + if (this.dpopKeyPair) { + headers["Authorization"] = `DPoP ${refreshJwt}`; + const tokenHash = await computeAccessTokenHash(refreshJwt); + const dpopProof = await createDPoPProof( + this.dpopKeyPair, + "POST", + url, + this.dpopNonce ?? undefined, + tokenHash, + ); + headers["DPoP"] = dpopProof; + } else { + headers["Authorization"] = `Bearer ${refreshJwt}`; + } + + let res = await fetch(url, { method: "POST", headers }); + + if (!res.ok && this.dpopKeyPair) { + const dpopNonce = res.headers.get("DPoP-Nonce"); + if (dpopNonce && dpopNonce !== this.dpopNonce) { + this.dpopNonce = dpopNonce; + headers["DPoP"] = await createDPoPProof( + this.dpopKeyPair, + "POST", + url, + dpopNonce, + await computeAccessTokenHash(refreshJwt), + ); + res = await fetch(url, { method: "POST", headers }); + } + } + + if (!res.ok) { + throw new Error("Token refresh failed"); + } + + const newNonce = res.headers.get("DPoP-Nonce"); + if (newNonce) { + this.dpopNonce = newNonce; + } + + return res.json(); + } + private async xrpc( method: string, options?: { @@ -135,6 +208,46 @@ export class AtprotoClient { error: "Unknown", message: res.statusText, })); + + const isTokenExpired = res.status === 401 && + (err.error === "ExpiredToken" || err.error === "invalid_token" || + (err.message && err.message.includes("expired"))); + + if (isTokenExpired && !authToken && await this.tryRefreshToken()) { + const retryNonce = res.headers.get("DPoP-Nonce") ?? this.dpopNonce; + if (retryNonce) this.dpopNonce = retryNonce; + res = await makeRequest(this.dpopNonce ?? undefined); + + if (!res.ok && this.dpopKeyPair) { + const dpopNonce = res.headers.get("DPoP-Nonce"); + if (dpopNonce && dpopNonce !== this.dpopNonce) { + this.dpopNonce = dpopNonce; + res = await makeRequest(dpopNonce); + } + } + + if (res.ok) { + const newNonce = res.headers.get("DPoP-Nonce"); + if (newNonce) this.dpopNonce = newNonce; + const responseContentType = res.headers.get("content-type") ?? ""; + if (responseContentType.includes("application/json")) { + return res.json(); + } + return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T; + } + + const retryErr = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + const retryError = new Error(retryErr.message || retryErr.error || res.statusText) as + & Error + & { status: number; error: string }; + retryError.status = res.status; + retryError.error = retryErr.error; + throw retryError; + } + const error = new Error(err.message || err.error || res.statusText) as & Error & { diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index 87ce05a..520f0e6 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -261,6 +261,7 @@ export function createInboundMigrationFlow() { state.sourceAccessToken = tokenResponse.access_token; state.sourceRefreshToken = tokenResponse.refresh_token ?? null; sourceClient.setAccessToken(tokenResponse.access_token); + sourceClient.setRefreshToken(tokenResponse.refresh_token ?? null); sourceClient.setDPoPKeyPair(dpopKeyPair); cleanupOAuthSessionData(); diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts index 04dcce3..df2bcd2 100644 --- a/frontend/src/lib/router.svelte.ts +++ b/frontend/src/lib/router.svelte.ts @@ -1,7 +1,5 @@ import { buildUrl, - isValidRoute, - parseRouteParams, type Route, type RouteParams, routes, @@ -71,19 +69,6 @@ export function navigate( updateState(); } -export function navigateTo(path: string, replace = false): void { - const normalizedPath = path.startsWith("/") ? path : "/" + path; - const fullPath = APP_BASE + normalizedPath; - - if (replace) { - globalThis.history.replaceState(null, "", fullPath); - } else { - globalThis.history.pushState(null, "", fullPath); - } - - updateState(); -} - export function getCurrentPath(): AppPath { return state.current.path; } @@ -100,43 +85,9 @@ export function getFullUrl(path: string): string { return APP_BASE + (path.startsWith("/") ? path : "/" + path); } -export function matchRoute(path: AppPath): Route | null { - const pathWithoutQuery = path.split("?")[0]; - if (isValidRoute(pathWithoutQuery)) { - return pathWithoutQuery; - } - return null; -} - export function isCurrentRoute(route: Route): boolean { const pathWithoutQuery = state.current.path.split("?")[0]; return pathWithoutQuery === route; } -export function getRouteParams( - _route: R, -): RouteParams[R] { - return parseRouteParams(_route); -} - -export type RouteMatch = - | { - readonly matched: true; - readonly route: Route; - readonly params: URLSearchParams; - } - | { readonly matched: false }; - -export function match(): RouteMatch { - const route = matchRoute(state.current.path); - if (route) { - return { - matched: true, - route, - params: state.current.searchParams, - }; - } - return { matched: false }; -} - -export { type Route, type RouteParams, routes, type RoutesWithParams }; +export { type Route, type RouteParams, routes }; diff --git a/frontend/src/lib/types/api.ts b/frontend/src/lib/types/api.ts index da55287..ebae420 100644 --- a/frontend/src/lib/types/api.ts +++ b/frontend/src/lib/types/api.ts @@ -10,6 +10,7 @@ import type { Nsid, PublicKeyMultibase, RefreshToken, + ScopeSet, } from "./branded.ts"; export type ApiErrorCode = @@ -52,21 +53,76 @@ export type DidType = "plc" | "web" | "web-external"; export type ReauthMethod = "password" | "totp" | "passkey"; -export interface Session { - did: Did; - handle: Handle; - email?: EmailAddress; - emailConfirmed?: boolean; - preferredChannel?: VerificationChannel; - preferredChannelVerified?: boolean; - preferredLocale?: string | null; - isAdmin?: boolean; - active?: boolean; - status?: AccountStatus; - migratedToPds?: string; - migratedAt?: ISODateString; - accessJwt: AccessToken; - refreshJwt: RefreshToken; +export type ContactState = + | { + readonly contactKind: "channel"; + readonly preferredChannel: VerificationChannel; + readonly preferredChannelVerified: boolean; + readonly email?: EmailAddress; + } + | { + readonly contactKind: "email"; + readonly email: EmailAddress; + readonly emailConfirmed: boolean; + } + | { readonly contactKind: "none" }; + +export type AccountState = + | { readonly accountKind: "active"; readonly isAdmin: boolean } + | { + readonly accountKind: "migrated"; + readonly migratedToPds: string; + readonly migratedAt: ISODateString; + readonly isAdmin: boolean; + } + | { readonly accountKind: "deactivated"; readonly isAdmin: boolean } + | { readonly accountKind: "suspended"; readonly isAdmin: boolean }; + +type SessionBase = { + readonly did: Did; + readonly handle: Handle; + readonly accessJwt: AccessToken; + readonly refreshJwt: RefreshToken; + readonly preferredLocale?: string | null; +}; + +export type Session = SessionBase & ContactState & AccountState; + +export function hasEmail( + session: Session, +): session is Session & { email: EmailAddress } { + return session.contactKind === "email" || + (session.contactKind === "channel" && session.email !== undefined); +} + +export function getSessionEmail(session: Session): EmailAddress | undefined { + return session.contactKind === "email" + ? session.email + : session.contactKind === "channel" + ? session.email + : undefined; +} + +export function isEmailVerified(session: Session): boolean { + return session.contactKind === "email" + ? session.emailConfirmed + : session.contactKind === "channel" + ? session.preferredChannelVerified + : false; +} + +export function isMigrated( + session: Session, +): session is Session & { accountKind: "migrated" } { + return session.accountKind === "migrated"; +} + +export function isDeactivated(session: Session): boolean { + return session.accountKind === "deactivated"; +} + +export function isActive(session: Session): boolean { + return session.accountKind === "active"; } export interface VerificationMethod { @@ -493,3 +549,42 @@ export interface VerifyMigrationEmailResponse { export interface ResendMigrationVerificationResponse { sent: boolean; } + +export interface SsoLinkedAccount { + id: string; + provider: string; + provider_name: string; + provider_username: string; + provider_email?: string; + created_at: ISODateString; + last_login_at?: ISODateString; +} + +export interface DelegationController { + did: Did; + grantedScopes: ScopeSet; + grantedAt: ISODateString; + isActive: boolean; +} + +export interface DelegationControlledAccount { + did: Did; + handle: Handle; + grantedScopes: ScopeSet; + grantedAt: ISODateString; +} + +export interface DelegationScopePreset { + name: string; + scopes: ScopeSet; + description: string; +} + +export interface DelegationAuditEntry { + id: string; + action: string; + actor_did: Did; + target_did?: Did; + details?: string; + created_at: ISODateString; +} diff --git a/frontend/src/lib/types/branded.ts b/frontend/src/lib/types/branded.ts index 08d1a43..7d00fee 100644 --- a/frontend/src/lib/types/branded.ts +++ b/frontend/src/lib/types/branded.ts @@ -23,6 +23,7 @@ export type InviteCode = Brand; export type PublicKeyMultibase = Brand; export type DidKeyString = Brand; +export type ScopeSet = Brand; const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/; const DID_WEB_REGEX = /^did:web:.+$/; @@ -179,6 +180,10 @@ export function unsafeAsDidKey(s: string): DidKeyString { return s as DidKeyString; } +export function unsafeAsScopeSet(s: string): ScopeSet { + return s as ScopeSet; +} + export function parseAtUri( uri: AtUri, ): { repo: Did; collection: Nsid; rkey: Rkey } { diff --git a/frontend/src/lib/types/totp-state.ts b/frontend/src/lib/types/totp-state.ts new file mode 100644 index 0000000..c150d7c --- /dev/null +++ b/frontend/src/lib/types/totp-state.ts @@ -0,0 +1,67 @@ +declare const __step: unique symbol; + +export type TotpIdle = { + readonly step: "idle"; + readonly [__step]: "idle"; +}; + +export type TotpQr = { + readonly step: "qr"; + readonly qrBase64: string; + readonly totpUri: string; + readonly [__step]: "qr"; +}; + +export type TotpVerify = { + readonly step: "verify"; + readonly qrBase64: string; + readonly totpUri: string; + readonly [__step]: "verify"; +}; + +export type TotpBackup = { + readonly step: "backup"; + readonly backupCodes: readonly string[]; + readonly [__step]: "backup"; +}; + +export type TotpSetupState = TotpIdle | TotpQr | TotpVerify | TotpBackup; + +export const idleState: TotpIdle = { step: "idle" } as TotpIdle; + +export function qrState(qrBase64: string, totpUri: string): TotpQr { + return { step: "qr", qrBase64, totpUri } as TotpQr; +} + +export function verifyState(state: TotpQr): TotpVerify { + return { step: "verify", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpVerify; +} + +export function backupState(state: TotpVerify, backupCodes: readonly string[]): TotpBackup { + void state; + return { step: "backup", backupCodes } as TotpBackup; +} + +export function goBackToQr(state: TotpVerify): TotpQr { + return { step: "qr", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpQr; +} + +export function finish(_state: TotpBackup): TotpIdle { + return idleState; +} + +export function isIdle(state: TotpSetupState): state is TotpIdle { + return state.step === "idle"; +} + +export function isQr(state: TotpSetupState): state is TotpQr { + return state.step === "qr"; +} + +export function isVerify(state: TotpSetupState): state is TotpVerify { + return state.step === "verify"; +} + +export function isBackup(state: TotpSetupState): state is TotpBackup { + return state.step === "backup"; +} diff --git a/frontend/src/routes/ActAs.svelte b/frontend/src/routes/ActAs.svelte index f205614..4aa3bcc 100644 --- a/frontend/src/routes/ActAs.svelte +++ b/frontend/src/routes/ActAs.svelte @@ -1,45 +1,24 @@ -
- {#if loading} -
-

{$_('actAs.preparing')}

-
- {:else} -
-

{$_('actAs.title')}

-
+ + {#snippet children({ session, client })} +
+ {#if loading} +
+

{$_('actAs.preparing')}

+
+ {:else} +
+

{$_('actAs.title')}

+
- {#if error} -
{error}
- {/if} + {#if error} +
{error}
+ {/if} -
- +
+ +
+ {/if}
- {/if} -
+ {/snippet} +
diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte index 412e149..5843085 100644 --- a/frontend/src/routes/Dashboard.svelte +++ b/frontend/src/routes/Dashboard.svelte @@ -11,6 +11,7 @@ import { isOk } from '../lib/types/result' import { unsafeAsDid, type Did } from '../lib/types/branded' import type { Session } from '../lib/types/api' + import { isMigrated, isDeactivated, getSessionEmail, isEmailVerified } from '../lib/types/api' import { onMount } from 'svelte' const auth = $derived(getAuthState()) @@ -123,12 +124,12 @@
- {#if session.status === 'migrated'} + {#if session.accountKind === 'migrated'}
{$_('dashboard.migratedTitle')}

{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}

- {:else if session.status === 'deactivated' || session.active === false} + {:else if session.accountKind === 'deactivated'}
{$_('dashboard.deactivatedTitle')}

{$_('dashboard.deactivatedMessage')}

@@ -144,15 +145,15 @@ {#if session.isAdmin} {$_('dashboard.admin')} {/if} - {#if session.status === 'migrated'} + {#if session.accountKind === 'migrated'} {$_('dashboard.migrated')} - {:else if session.status === 'deactivated' || session.active === false} + {:else if session.accountKind === 'deactivated'} {$_('dashboard.deactivated')} {/if}
{$_('dashboard.did')}
{session.did}
- {#if session.preferredChannel} + {#if session.contactKind === 'channel'}
{$_('dashboard.primaryContact')}
{#if session.preferredChannel === 'email'} @@ -172,7 +173,7 @@ {$_('dashboard.unverified')} {/if}
- {:else if session.email} + {:else if session.contactKind === 'email'}
{$_('register.email')}
{session.email} @@ -187,7 +188,7 @@