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}
-
+
+ {#snippet children({ session, client })}
+
+ {#if loading}
+
+
{$_('actAs.preparing')}
+
+ {:else}
+
- {#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 @@