diff --git a/.gitignore b/.gitignore
index 8562ef2..39ac4a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,7 @@
reference-pds-hailey/
reference-pds-bsky/
+
+# Frontend build artifacts
+frontend/node_modules/
+frontend/dist/
diff --git a/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json b/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
index fed143b..a7f927b 100644
--- a/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
+++ b/.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
@@ -21,7 +21,7 @@
},
"nullable": [
false,
- false
+ true
]
},
"hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a"
diff --git a/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json b/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json
index 18627c1..e9b7308 100644
--- a/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json
+++ b/.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json
@@ -32,7 +32,7 @@
"nullable": [
false,
false,
- false,
+ true,
false
]
},
diff --git a/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json b/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json
new file mode 100644
index 0000000..dbd6434
--- /dev/null
+++ b/.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json
@@ -0,0 +1,76 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "did",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "handle",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "password_hash",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "email_confirmed",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 5,
+ "name": "discord_verified",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 6,
+ "name": "telegram_verified",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 7,
+ "name": "signal_verified",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 8,
+ "name": "key_bytes",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 9,
+ "name": "encryption_version",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd"
+}
diff --git a/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json b/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
index 7601810..99dc9cf 100644
--- a/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
+++ b/.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
@@ -64,7 +64,7 @@
"nullable": [
false,
false,
- false,
+ true,
false,
false,
false,
diff --git a/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json b/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json
deleted file mode 100644
index ea16568..0000000
--- a/.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- },
- {
- "ordinal": 1,
- "name": "did",
- "type_info": "Text"
- },
- {
- "ordinal": 2,
- "name": "handle",
- "type_info": "Text"
- },
- {
- "ordinal": 3,
- "name": "password_hash",
- "type_info": "Text"
- },
- {
- "ordinal": 4,
- "name": "key_bytes",
- "type_info": "Bytea"
- },
- {
- "ordinal": 5,
- "name": "encryption_version",
- "type_info": "Int4"
- }
- ],
- "parameters": {
- "Left": [
- "Text"
- ]
- },
- "nullable": [
- false,
- false,
- false,
- false,
- false,
- true
- ]
- },
- "hash": "583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb"
-}
diff --git a/.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json b/.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json
deleted file mode 100644
index 6084872..0000000
--- a/.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "id",
- "type_info": "Uuid"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Text",
- "Text",
- "Text"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4"
-}
diff --git a/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json b/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json
index 04c9297..c421a3c 100644
--- a/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json
+++ b/.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json
@@ -32,7 +32,7 @@
"nullable": [
false,
false,
- false,
+ true,
false
]
},
diff --git a/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json b/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json
new file mode 100644
index 0000000..e176d54
--- /dev/null
+++ b/.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1"
+}
diff --git a/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json b/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
index ea0c2f1..31e7026 100644
--- a/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
+++ b/.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
@@ -36,7 +36,7 @@
},
"nullable": [
false,
- false,
+ true,
true,
true,
true
diff --git a/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json b/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json
new file mode 100644
index 0000000..c494da4
--- /dev/null
+++ b/.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json
@@ -0,0 +1,94 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n id, handle, email,\n preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n discord_id, telegram_username, signal_number,\n email_confirmed, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "handle",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "email",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "channel: crate::notifications::NotificationChannel",
+ "type_info": {
+ "Custom": {
+ "name": "notification_channel",
+ "kind": {
+ "Enum": [
+ "email",
+ "discord",
+ "telegram",
+ "signal"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 4,
+ "name": "discord_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "telegram_username",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 6,
+ "name": "signal_number",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 7,
+ "name": "email_confirmed",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 8,
+ "name": "discord_verified",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 9,
+ "name": "telegram_verified",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 10,
+ "name": "signal_verified",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ false,
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6"
+}
diff --git a/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json b/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
index d8c81be..b0a5d1b 100644
--- a/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
+++ b/.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
@@ -37,7 +37,7 @@
]
},
"nullable": [
- false,
+ true,
false,
false
]
diff --git a/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json b/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json
index e9631fc..813ef1a 100644
--- a/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json
+++ b/.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json
@@ -32,7 +32,7 @@
"nullable": [
false,
false,
- false,
+ true,
false
]
},
diff --git a/.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json b/.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json
new file mode 100644
index 0000000..bd79fb2
--- /dev/null
+++ b/.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json
@@ -0,0 +1,76 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n u.id, u.did, u.handle,\n u.email_confirmation_code,\n u.email_confirmation_code_expires_at,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "did",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "handle",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "email_confirmation_code",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "email_confirmation_code_expires_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "channel: crate::notifications::NotificationChannel",
+ "type_info": {
+ "Custom": {
+ "name": "notification_channel",
+ "kind": {
+ "Enum": [
+ "email",
+ "discord",
+ "telegram",
+ "signal"
+ ]
+ }
+ }
+ }
+ },
+ {
+ "ordinal": 6,
+ "name": "key_bytes",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 7,
+ "name": "encryption_version",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ true,
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190"
+}
diff --git a/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json b/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json
index 8dd467e..59be9a8 100644
--- a/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json
+++ b/.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json
@@ -26,7 +26,7 @@
},
"nullable": [
false,
- false,
+ true,
false
]
},
diff --git a/Cargo.lock b/Cargo.lock
index fe625cf..1ae7493 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -960,6 +960,7 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
+ "tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
@@ -2558,6 +2559,12 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
[[package]]
name = "httparse"
version = "1.10.1"
@@ -3580,6 +3587,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "mini-moka"
version = "0.10.3"
@@ -6009,11 +6026,20 @@ checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456"
dependencies = [
"bitflags",
"bytes",
+ "futures-core",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
"iri-string",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
"pin-project-lite",
+ "tokio",
+ "tokio-util",
"tower",
"tower-layer",
"tower-service",
diff --git a/Cargo.toml b/Cargo.toml
index 03d00b9..d4e21e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -50,6 +50,7 @@ uuid = { version = "1.19.0", features = ["v4", "fast-rng"] }
iroh-car = "0.5.1"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
+tower-http = { version = "0.6", features = ["fs"] }
[features]
external-infra = []
diff --git a/Dockerfile b/Dockerfile
index 3cbb9c0..d2c6487 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,10 @@
+# Stage 1: Build frontend with Deno
+FROM denoland/deno:alpine AS frontend-builder
+WORKDIR /frontend
+COPY frontend/ ./
+RUN deno task build
+
+# Stage 2: Build Rust backend
FROM rust:1.91.1-alpine AS builder
RUN apk add ca-certificates openssl openssl-dev pkgconfig
@@ -13,15 +20,18 @@ COPY migrations ./migrations
COPY .sqlx ./.sqlx
RUN touch src/main.rs && cargo build --release
+# Stage 3: Final image
FROM alpine:3.23
COPY --from=builder /app/target/release/bspds /usr/local/bin/bspds
COPY --from=builder /app/migrations /app/migrations
+COPY --from=frontend-builder /frontend/dist /app/frontend/dist
WORKDIR /app
ENV SERVER_HOST=0.0.0.0
ENV SERVER_PORT=3000
+ENV FRONTEND_DIR=/app/frontend/dist
EXPOSE 3000
diff --git a/README.md b/README.md
index 547bc26..cd6406a 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Uses PostgreSQL instead of SQLite, S3-compatible blob storage, and is designed t
- Crawler notifications via `requestCrawl`
- Multi-channel notifications: email, discord, telegram, signal
- Per-IP rate limiting on sensitive endpoints
+- Built-in web UI for account management
## Running Locally
@@ -77,6 +78,25 @@ just lint # Clippy + fmt check
just db-reset # Drop and recreate local database
```
+## Web UI
+
+BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
+
+- Sign in and register new accounts
+- Manage app passwords
+- View and create invite codes
+- Update email and handle
+- Configure notification preferences
+- Browse their repository data
+
+The frontend is built with svelte and deno, and is served directly by the PDS.
+
+```bash
+just frontend-dev # Run frontend dev server
+just frontend-build # Build for production
+just frontend-test # Run frontend tests
+```
+
## Project Structure
```
@@ -94,6 +114,7 @@ src/
plc/ PLC directory client
circuit_breaker/ Circuit breaker for external services
rate_limit/ Per-IP rate limiting
+frontend/ Svelte web UI (deno)
tests/ Integration tests
migrations/ SQLx migrations
```
diff --git a/TODO.md b/TODO.md
index 30139bd..de347d2 100644
--- a/TODO.md
+++ b/TODO.md
@@ -258,16 +258,16 @@ These are implemented at PDS level to enable local-first reads (read-after-write
A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers.
### Architecture
-- [ ] Static SPA served from PDS (or separate static host)
+- [x] Static SPA served from PDS (or separate static host)
- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
-- [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
-- [ ] No server-side sessions or CSRF - pure API client
+- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
+- [x] No server-side sessions or CSRF - pure API client
### PDS-Specific XRPC Endpoints (new)
Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
Anyway... endpoints for PDS settings not covered by standard ATProto:
-- [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
-- [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel
+- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
+- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
@@ -276,23 +276,32 @@ Anyway... endpoints for PDS settings not covered by standard ATProto:
### Frontend Views
Uses existing ATProto endpoints where possible:
+Authentication
+- [x] Login page (uses `com.atproto.server.createSession`)
+- [x] Registration page (uses `com.atproto.server.createAccount`)
+- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`)
+- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
+
User Dashboard
-- [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
+- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
- [ ] Active sessions view (needs new endpoint or extend existing)
-- [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
-- [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
+- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
+- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
Notification Preferences
-- [ ] Channel selector (uses `com.bspds.account.*` endpoints above)
+- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
- [ ] Verification flows for Discord/Telegram/Signal
- [ ] Notification history view
Account Settings
-- [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
-- [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
-- [ ] Handle change (uses `com.atproto.identity.updateHandle`)
-- [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
-- [ ] Data export (uses `com.atproto.sync.getRepo`)
+- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
+- [ ] Password change while logged in (needs new endpoint - change password with current password)
+- [x] Handle change (uses `com.atproto.identity.updateHandle`)
+- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
+
+Data Management
+- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`)
+- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`)
Admin Dashboard (privileged users only)
- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
diff --git a/frontend/deno.json b/frontend/deno.json
new file mode 100644
index 0000000..2d7690d
--- /dev/null
+++ b/frontend/deno.json
@@ -0,0 +1,13 @@
+{
+ "tasks": {
+ "dev": "deno run -A npm:vite",
+ "build": "deno run -A npm:vite build",
+ "preview": "deno run -A npm:vite preview",
+ "test": "deno run -A npm:vitest",
+ "test:run": "deno run -A npm:vitest run",
+ "test:watch": "deno run -A npm:vitest watch",
+ "test:ui": "deno run -A npm:vitest --ui",
+ "test:coverage": "deno run -A npm:vitest run --coverage"
+ },
+ "nodeModulesDir": "auto"
+}
diff --git a/frontend/deno.lock b/frontend/deno.lock
new file mode 100644
index 0000000..0736697
--- /dev/null
+++ b/frontend/deno.lock
@@ -0,0 +1,1309 @@
+{
+ "version": "5",
+ "specifiers": {
+ "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
+ "npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
+ "npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1",
+ "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
+ "npm:jsdom@^25.0.1": "25.0.1",
+ "npm:svelte@5": "5.45.10_acorn@8.15.0",
+ "npm:vite@*": "6.4.1_picomatch@4.0.3",
+ "npm:vite@6": "6.4.1_picomatch@4.0.3",
+ "npm:vitest@*": "2.1.9_jsdom@25.0.1_vite@5.4.21",
+ "npm:vitest@^2.1.8": "2.1.9_jsdom@25.0.1_vite@5.4.21"
+ },
+ "npm": {
+ "@adobe/css-tools@4.4.4": {
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="
+ },
+ "@asamuzakjp/css-color@3.2.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dependencies": [
+ "@csstools/css-calc",
+ "@csstools/css-color-parser",
+ "@csstools/css-parser-algorithms",
+ "@csstools/css-tokenizer",
+ "lru-cache"
+ ]
+ },
+ "@babel/code-frame@7.27.1": {
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dependencies": [
+ "@babel/helper-validator-identifier",
+ "js-tokens",
+ "picocolors"
+ ]
+ },
+ "@babel/helper-validator-identifier@7.28.5": {
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
+ },
+ "@babel/runtime@7.28.4": {
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="
+ },
+ "@csstools/color-helpers@5.1.0": {
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="
+ },
+ "@csstools/css-calc@2.1.4_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dependencies": [
+ "@csstools/css-parser-algorithms",
+ "@csstools/css-tokenizer"
+ ]
+ },
+ "@csstools/css-color-parser@3.1.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dependencies": [
+ "@csstools/color-helpers",
+ "@csstools/css-calc",
+ "@csstools/css-parser-algorithms",
+ "@csstools/css-tokenizer"
+ ]
+ },
+ "@csstools/css-parser-algorithms@3.0.5_@csstools+css-tokenizer@3.0.4": {
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dependencies": [
+ "@csstools/css-tokenizer"
+ ]
+ },
+ "@csstools/css-tokenizer@3.0.4": {
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="
+ },
+ "@esbuild/aix-ppc64@0.21.5": {
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "os": ["aix"],
+ "cpu": ["ppc64"]
+ },
+ "@esbuild/aix-ppc64@0.25.12": {
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "os": ["aix"],
+ "cpu": ["ppc64"]
+ },
+ "@esbuild/android-arm64@0.21.5": {
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "os": ["android"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/android-arm64@0.25.12": {
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "os": ["android"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/android-arm@0.21.5": {
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "os": ["android"],
+ "cpu": ["arm"]
+ },
+ "@esbuild/android-arm@0.25.12": {
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "os": ["android"],
+ "cpu": ["arm"]
+ },
+ "@esbuild/android-x64@0.21.5": {
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "os": ["android"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/android-x64@0.25.12": {
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "os": ["android"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/darwin-arm64@0.21.5": {
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/darwin-arm64@0.25.12": {
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/darwin-x64@0.21.5": {
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/darwin-x64@0.25.12": {
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/freebsd-arm64@0.21.5": {
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "os": ["freebsd"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/freebsd-arm64@0.25.12": {
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "os": ["freebsd"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/freebsd-x64@0.21.5": {
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "os": ["freebsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/freebsd-x64@0.25.12": {
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "os": ["freebsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/linux-arm64@0.21.5": {
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/linux-arm64@0.25.12": {
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/linux-arm@0.21.5": {
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@esbuild/linux-arm@0.25.12": {
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@esbuild/linux-ia32@0.21.5": {
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "os": ["linux"],
+ "cpu": ["ia32"]
+ },
+ "@esbuild/linux-ia32@0.25.12": {
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "os": ["linux"],
+ "cpu": ["ia32"]
+ },
+ "@esbuild/linux-loong64@0.21.5": {
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "os": ["linux"],
+ "cpu": ["loong64"]
+ },
+ "@esbuild/linux-loong64@0.25.12": {
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "os": ["linux"],
+ "cpu": ["loong64"]
+ },
+ "@esbuild/linux-mips64el@0.21.5": {
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "os": ["linux"],
+ "cpu": ["mips64el"]
+ },
+ "@esbuild/linux-mips64el@0.25.12": {
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "os": ["linux"],
+ "cpu": ["mips64el"]
+ },
+ "@esbuild/linux-ppc64@0.21.5": {
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "os": ["linux"],
+ "cpu": ["ppc64"]
+ },
+ "@esbuild/linux-ppc64@0.25.12": {
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "os": ["linux"],
+ "cpu": ["ppc64"]
+ },
+ "@esbuild/linux-riscv64@0.21.5": {
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@esbuild/linux-riscv64@0.25.12": {
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@esbuild/linux-s390x@0.21.5": {
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "os": ["linux"],
+ "cpu": ["s390x"]
+ },
+ "@esbuild/linux-s390x@0.25.12": {
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "os": ["linux"],
+ "cpu": ["s390x"]
+ },
+ "@esbuild/linux-x64@0.21.5": {
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/linux-x64@0.25.12": {
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/netbsd-arm64@0.25.12": {
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "os": ["netbsd"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/netbsd-x64@0.21.5": {
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "os": ["netbsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/netbsd-x64@0.25.12": {
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "os": ["netbsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/openbsd-arm64@0.25.12": {
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "os": ["openbsd"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/openbsd-x64@0.21.5": {
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "os": ["openbsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/openbsd-x64@0.25.12": {
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "os": ["openbsd"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/openharmony-arm64@0.25.12": {
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "os": ["openharmony"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/sunos-x64@0.21.5": {
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "os": ["sunos"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/sunos-x64@0.25.12": {
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "os": ["sunos"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/win32-arm64@0.21.5": {
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "os": ["win32"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/win32-arm64@0.25.12": {
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "os": ["win32"],
+ "cpu": ["arm64"]
+ },
+ "@esbuild/win32-ia32@0.21.5": {
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "os": ["win32"],
+ "cpu": ["ia32"]
+ },
+ "@esbuild/win32-ia32@0.25.12": {
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "os": ["win32"],
+ "cpu": ["ia32"]
+ },
+ "@esbuild/win32-x64@0.21.5": {
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@esbuild/win32-x64@0.25.12": {
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@jridgewell/gen-mapping@0.3.13": {
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dependencies": [
+ "@jridgewell/sourcemap-codec",
+ "@jridgewell/trace-mapping"
+ ]
+ },
+ "@jridgewell/remapping@2.3.5": {
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dependencies": [
+ "@jridgewell/gen-mapping",
+ "@jridgewell/trace-mapping"
+ ]
+ },
+ "@jridgewell/resolve-uri@3.1.2": {
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="
+ },
+ "@jridgewell/sourcemap-codec@1.5.5": {
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+ },
+ "@jridgewell/trace-mapping@0.3.31": {
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dependencies": [
+ "@jridgewell/resolve-uri",
+ "@jridgewell/sourcemap-codec"
+ ]
+ },
+ "@rollup/rollup-android-arm-eabi@4.53.3": {
+ "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
+ "os": ["android"],
+ "cpu": ["arm"]
+ },
+ "@rollup/rollup-android-arm64@4.53.3": {
+ "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
+ "os": ["android"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-darwin-arm64@4.53.3": {
+ "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
+ "os": ["darwin"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-darwin-x64@4.53.3": {
+ "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
+ "os": ["darwin"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-freebsd-arm64@4.53.3": {
+ "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
+ "os": ["freebsd"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-freebsd-x64@4.53.3": {
+ "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
+ "os": ["freebsd"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-linux-arm-gnueabihf@4.53.3": {
+ "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@rollup/rollup-linux-arm-musleabihf@4.53.3": {
+ "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
+ "os": ["linux"],
+ "cpu": ["arm"]
+ },
+ "@rollup/rollup-linux-arm64-gnu@4.53.3": {
+ "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-linux-arm64-musl@4.53.3": {
+ "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
+ "os": ["linux"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-linux-loong64-gnu@4.53.3": {
+ "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
+ "os": ["linux"],
+ "cpu": ["loong64"]
+ },
+ "@rollup/rollup-linux-ppc64-gnu@4.53.3": {
+ "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
+ "os": ["linux"],
+ "cpu": ["ppc64"]
+ },
+ "@rollup/rollup-linux-riscv64-gnu@4.53.3": {
+ "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@rollup/rollup-linux-riscv64-musl@4.53.3": {
+ "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
+ "os": ["linux"],
+ "cpu": ["riscv64"]
+ },
+ "@rollup/rollup-linux-s390x-gnu@4.53.3": {
+ "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
+ "os": ["linux"],
+ "cpu": ["s390x"]
+ },
+ "@rollup/rollup-linux-x64-gnu@4.53.3": {
+ "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-linux-x64-musl@4.53.3": {
+ "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
+ "os": ["linux"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-openharmony-arm64@4.53.3": {
+ "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
+ "os": ["openharmony"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-win32-arm64-msvc@4.53.3": {
+ "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
+ "os": ["win32"],
+ "cpu": ["arm64"]
+ },
+ "@rollup/rollup-win32-ia32-msvc@4.53.3": {
+ "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
+ "os": ["win32"],
+ "cpu": ["ia32"]
+ },
+ "@rollup/rollup-win32-x64-gnu@4.53.3": {
+ "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@rollup/rollup-win32-x64-msvc@4.53.3": {
+ "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
+ "os": ["win32"],
+ "cpu": ["x64"]
+ },
+ "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": {
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "dependencies": [
+ "acorn"
+ ]
+ },
+ "@sveltejs/vite-plugin-svelte-inspector@4.0.1_@sveltejs+vite-plugin-svelte@5.1.1__svelte@5.45.10___acorn@8.15.0__vite@6.4.1___picomatch@4.0.3_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": {
+ "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
+ "dependencies": [
+ "@sveltejs/vite-plugin-svelte",
+ "debug",
+ "svelte",
+ "vite@6.4.1_picomatch@4.0.3"
+ ]
+ },
+ "@sveltejs/vite-plugin-svelte@5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": {
+ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
+ "dependencies": [
+ "@sveltejs/vite-plugin-svelte-inspector",
+ "debug",
+ "deepmerge",
+ "kleur",
+ "magic-string",
+ "svelte",
+ "vite@6.4.1_picomatch@4.0.3",
+ "vitefu"
+ ]
+ },
+ "@testing-library/dom@10.4.1": {
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dependencies": [
+ "@babel/code-frame",
+ "@babel/runtime",
+ "@types/aria-query",
+ "aria-query@5.3.0",
+ "dom-accessibility-api@0.5.16",
+ "lz-string",
+ "picocolors",
+ "pretty-format"
+ ]
+ },
+ "@testing-library/jest-dom@6.9.1": {
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dependencies": [
+ "@adobe/css-tools",
+ "aria-query@5.3.2",
+ "css.escape",
+ "dom-accessibility-api@0.6.3",
+ "picocolors",
+ "redent"
+ ]
+ },
+ "@testing-library/svelte@5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1": {
+ "integrity": "sha512-p0Lg/vL1iEsEasXKSipvW9nBCtItQGhYvxL8OZ4w7/IDdC+LGoSJw4mMS5bndVFON/gWryitEhMr29AlO4FvBg==",
+ "dependencies": [
+ "@testing-library/dom",
+ "svelte",
+ "vite@6.4.1_picomatch@4.0.3",
+ "vitest"
+ ],
+ "optionalPeers": [
+ "vite@6.4.1_picomatch@4.0.3",
+ "vitest"
+ ]
+ },
+ "@testing-library/user-event@14.6.1_@testing-library+dom@10.4.1": {
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dependencies": [
+ "@testing-library/dom"
+ ]
+ },
+ "@types/aria-query@5.0.4": {
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
+ },
+ "@types/estree@1.0.8": {
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
+ },
+ "@vitest/expect@2.1.9": {
+ "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
+ "dependencies": [
+ "@vitest/spy",
+ "@vitest/utils",
+ "chai",
+ "tinyrainbow"
+ ]
+ },
+ "@vitest/mocker@2.1.9_vite@5.4.21": {
+ "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
+ "dependencies": [
+ "@vitest/spy",
+ "estree-walker",
+ "magic-string",
+ "vite@5.4.21"
+ ],
+ "optionalPeers": [
+ "vite@5.4.21"
+ ]
+ },
+ "@vitest/pretty-format@2.1.9": {
+ "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+ "dependencies": [
+ "tinyrainbow"
+ ]
+ },
+ "@vitest/runner@2.1.9": {
+ "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
+ "dependencies": [
+ "@vitest/utils",
+ "pathe"
+ ]
+ },
+ "@vitest/snapshot@2.1.9": {
+ "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
+ "dependencies": [
+ "@vitest/pretty-format",
+ "magic-string",
+ "pathe"
+ ]
+ },
+ "@vitest/spy@2.1.9": {
+ "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
+ "dependencies": [
+ "tinyspy"
+ ]
+ },
+ "@vitest/utils@2.1.9": {
+ "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
+ "dependencies": [
+ "@vitest/pretty-format",
+ "loupe",
+ "tinyrainbow"
+ ]
+ },
+ "acorn@8.15.0": {
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "bin": true
+ },
+ "agent-base@7.1.4": {
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
+ },
+ "ansi-regex@5.0.1": {
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
+ },
+ "ansi-styles@5.2.0": {
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
+ },
+ "aria-query@5.3.0": {
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dependencies": [
+ "dequal"
+ ]
+ },
+ "aria-query@5.3.2": {
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
+ },
+ "assertion-error@2.0.1": {
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
+ },
+ "asynckit@0.4.0": {
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+ },
+ "axobject-query@4.1.0": {
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
+ },
+ "cac@6.7.14": {
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
+ },
+ "call-bind-apply-helpers@1.0.2": {
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dependencies": [
+ "es-errors",
+ "function-bind"
+ ]
+ },
+ "chai@5.3.3": {
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dependencies": [
+ "assertion-error",
+ "check-error",
+ "deep-eql",
+ "loupe",
+ "pathval"
+ ]
+ },
+ "check-error@2.1.1": {
+ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
+ },
+ "clsx@2.1.1": {
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
+ },
+ "combined-stream@1.0.8": {
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dependencies": [
+ "delayed-stream"
+ ]
+ },
+ "css.escape@1.5.1": {
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="
+ },
+ "cssstyle@4.6.0": {
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dependencies": [
+ "@asamuzakjp/css-color",
+ "rrweb-cssom@0.8.0"
+ ]
+ },
+ "data-urls@5.0.0": {
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dependencies": [
+ "whatwg-mimetype",
+ "whatwg-url"
+ ]
+ },
+ "debug@4.4.3": {
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dependencies": [
+ "ms"
+ ]
+ },
+ "decimal.js@10.6.0": {
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
+ },
+ "deep-eql@5.0.2": {
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="
+ },
+ "deepmerge@4.3.1": {
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
+ },
+ "delayed-stream@1.0.0": {
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
+ },
+ "dequal@2.0.3": {
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="
+ },
+ "devalue@5.6.1": {
+ "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="
+ },
+ "dom-accessibility-api@0.5.16": {
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
+ },
+ "dom-accessibility-api@0.6.3": {
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="
+ },
+ "dunder-proto@1.0.1": {
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dependencies": [
+ "call-bind-apply-helpers",
+ "es-errors",
+ "gopd"
+ ]
+ },
+ "entities@6.0.1": {
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="
+ },
+ "es-define-property@1.0.1": {
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
+ },
+ "es-errors@1.3.0": {
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
+ },
+ "es-module-lexer@1.7.0": {
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="
+ },
+ "es-object-atoms@1.1.1": {
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dependencies": [
+ "es-errors"
+ ]
+ },
+ "es-set-tostringtag@2.1.0": {
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dependencies": [
+ "es-errors",
+ "get-intrinsic",
+ "has-tostringtag",
+ "hasown"
+ ]
+ },
+ "esbuild@0.21.5": {
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "optionalDependencies": [
+ "@esbuild/aix-ppc64@0.21.5",
+ "@esbuild/android-arm@0.21.5",
+ "@esbuild/android-arm64@0.21.5",
+ "@esbuild/android-x64@0.21.5",
+ "@esbuild/darwin-arm64@0.21.5",
+ "@esbuild/darwin-x64@0.21.5",
+ "@esbuild/freebsd-arm64@0.21.5",
+ "@esbuild/freebsd-x64@0.21.5",
+ "@esbuild/linux-arm@0.21.5",
+ "@esbuild/linux-arm64@0.21.5",
+ "@esbuild/linux-ia32@0.21.5",
+ "@esbuild/linux-loong64@0.21.5",
+ "@esbuild/linux-mips64el@0.21.5",
+ "@esbuild/linux-ppc64@0.21.5",
+ "@esbuild/linux-riscv64@0.21.5",
+ "@esbuild/linux-s390x@0.21.5",
+ "@esbuild/linux-x64@0.21.5",
+ "@esbuild/netbsd-x64@0.21.5",
+ "@esbuild/openbsd-x64@0.21.5",
+ "@esbuild/sunos-x64@0.21.5",
+ "@esbuild/win32-arm64@0.21.5",
+ "@esbuild/win32-ia32@0.21.5",
+ "@esbuild/win32-x64@0.21.5"
+ ],
+ "scripts": true,
+ "bin": true
+ },
+ "esbuild@0.25.12": {
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "optionalDependencies": [
+ "@esbuild/aix-ppc64@0.25.12",
+ "@esbuild/android-arm@0.25.12",
+ "@esbuild/android-arm64@0.25.12",
+ "@esbuild/android-x64@0.25.12",
+ "@esbuild/darwin-arm64@0.25.12",
+ "@esbuild/darwin-x64@0.25.12",
+ "@esbuild/freebsd-arm64@0.25.12",
+ "@esbuild/freebsd-x64@0.25.12",
+ "@esbuild/linux-arm@0.25.12",
+ "@esbuild/linux-arm64@0.25.12",
+ "@esbuild/linux-ia32@0.25.12",
+ "@esbuild/linux-loong64@0.25.12",
+ "@esbuild/linux-mips64el@0.25.12",
+ "@esbuild/linux-ppc64@0.25.12",
+ "@esbuild/linux-riscv64@0.25.12",
+ "@esbuild/linux-s390x@0.25.12",
+ "@esbuild/linux-x64@0.25.12",
+ "@esbuild/netbsd-arm64",
+ "@esbuild/netbsd-x64@0.25.12",
+ "@esbuild/openbsd-arm64",
+ "@esbuild/openbsd-x64@0.25.12",
+ "@esbuild/openharmony-arm64",
+ "@esbuild/sunos-x64@0.25.12",
+ "@esbuild/win32-arm64@0.25.12",
+ "@esbuild/win32-ia32@0.25.12",
+ "@esbuild/win32-x64@0.25.12"
+ ],
+ "scripts": true,
+ "bin": true
+ },
+ "esm-env@1.2.2": {
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
+ },
+ "esrap@2.2.1": {
+ "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
+ "dependencies": [
+ "@jridgewell/sourcemap-codec"
+ ]
+ },
+ "estree-walker@3.0.3": {
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dependencies": [
+ "@types/estree"
+ ]
+ },
+ "expect-type@1.3.0": {
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="
+ },
+ "fdir@6.5.0_picomatch@4.0.3": {
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dependencies": [
+ "picomatch"
+ ],
+ "optionalPeers": [
+ "picomatch"
+ ]
+ },
+ "form-data@4.0.5": {
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dependencies": [
+ "asynckit",
+ "combined-stream",
+ "es-set-tostringtag",
+ "hasown",
+ "mime-types"
+ ]
+ },
+ "fsevents@2.3.3": {
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "os": ["darwin"],
+ "scripts": true
+ },
+ "function-bind@1.1.2": {
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
+ },
+ "get-intrinsic@1.3.0": {
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dependencies": [
+ "call-bind-apply-helpers",
+ "es-define-property",
+ "es-errors",
+ "es-object-atoms",
+ "function-bind",
+ "get-proto",
+ "gopd",
+ "has-symbols",
+ "hasown",
+ "math-intrinsics"
+ ]
+ },
+ "get-proto@1.0.1": {
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dependencies": [
+ "dunder-proto",
+ "es-object-atoms"
+ ]
+ },
+ "gopd@1.2.0": {
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
+ },
+ "has-symbols@1.1.0": {
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
+ },
+ "has-tostringtag@1.0.2": {
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dependencies": [
+ "has-symbols"
+ ]
+ },
+ "hasown@2.0.2": {
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dependencies": [
+ "function-bind"
+ ]
+ },
+ "html-encoding-sniffer@4.0.0": {
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dependencies": [
+ "whatwg-encoding"
+ ]
+ },
+ "http-proxy-agent@7.0.2": {
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dependencies": [
+ "agent-base",
+ "debug"
+ ]
+ },
+ "https-proxy-agent@7.0.6": {
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dependencies": [
+ "agent-base",
+ "debug"
+ ]
+ },
+ "iconv-lite@0.6.3": {
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dependencies": [
+ "safer-buffer"
+ ]
+ },
+ "indent-string@4.0.0": {
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="
+ },
+ "is-potential-custom-element-name@1.0.1": {
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
+ },
+ "is-reference@3.0.3": {
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "dependencies": [
+ "@types/estree"
+ ]
+ },
+ "js-tokens@4.0.0": {
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+ },
+ "jsdom@25.0.1": {
+ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
+ "dependencies": [
+ "cssstyle",
+ "data-urls",
+ "decimal.js",
+ "form-data",
+ "html-encoding-sniffer",
+ "http-proxy-agent",
+ "https-proxy-agent",
+ "is-potential-custom-element-name",
+ "nwsapi",
+ "parse5",
+ "rrweb-cssom@0.7.1",
+ "saxes",
+ "symbol-tree",
+ "tough-cookie",
+ "w3c-xmlserializer",
+ "webidl-conversions",
+ "whatwg-encoding",
+ "whatwg-mimetype",
+ "whatwg-url",
+ "ws",
+ "xml-name-validator"
+ ]
+ },
+ "kleur@4.1.5": {
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
+ },
+ "locate-character@3.0.0": {
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
+ },
+ "loupe@3.2.1": {
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="
+ },
+ "lru-cache@10.4.3": {
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
+ },
+ "lz-string@1.5.0": {
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "bin": true
+ },
+ "magic-string@0.30.21": {
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dependencies": [
+ "@jridgewell/sourcemap-codec"
+ ]
+ },
+ "math-intrinsics@1.1.0": {
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
+ },
+ "mime-db@1.52.0": {
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
+ },
+ "mime-types@2.1.35": {
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dependencies": [
+ "mime-db"
+ ]
+ },
+ "min-indent@1.0.1": {
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
+ },
+ "ms@2.1.3": {
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
+ },
+ "nanoid@3.3.11": {
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "bin": true
+ },
+ "nwsapi@2.2.23": {
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="
+ },
+ "parse5@7.3.0": {
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dependencies": [
+ "entities"
+ ]
+ },
+ "pathe@1.1.2": {
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="
+ },
+ "pathval@2.0.1": {
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="
+ },
+ "picocolors@1.1.1": {
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+ },
+ "picomatch@4.0.3": {
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
+ },
+ "postcss@8.5.6": {
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "dependencies": [
+ "nanoid",
+ "picocolors",
+ "source-map-js"
+ ]
+ },
+ "pretty-format@27.5.1": {
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dependencies": [
+ "ansi-regex",
+ "ansi-styles",
+ "react-is"
+ ]
+ },
+ "punycode@2.3.1": {
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
+ },
+ "react-is@17.0.2": {
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
+ },
+ "redent@3.0.0": {
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dependencies": [
+ "indent-string",
+ "strip-indent"
+ ]
+ },
+ "rollup@4.53.3": {
+ "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
+ "dependencies": [
+ "@types/estree"
+ ],
+ "optionalDependencies": [
+ "@rollup/rollup-android-arm-eabi",
+ "@rollup/rollup-android-arm64",
+ "@rollup/rollup-darwin-arm64",
+ "@rollup/rollup-darwin-x64",
+ "@rollup/rollup-freebsd-arm64",
+ "@rollup/rollup-freebsd-x64",
+ "@rollup/rollup-linux-arm-gnueabihf",
+ "@rollup/rollup-linux-arm-musleabihf",
+ "@rollup/rollup-linux-arm64-gnu",
+ "@rollup/rollup-linux-arm64-musl",
+ "@rollup/rollup-linux-loong64-gnu",
+ "@rollup/rollup-linux-ppc64-gnu",
+ "@rollup/rollup-linux-riscv64-gnu",
+ "@rollup/rollup-linux-riscv64-musl",
+ "@rollup/rollup-linux-s390x-gnu",
+ "@rollup/rollup-linux-x64-gnu",
+ "@rollup/rollup-linux-x64-musl",
+ "@rollup/rollup-openharmony-arm64",
+ "@rollup/rollup-win32-arm64-msvc",
+ "@rollup/rollup-win32-ia32-msvc",
+ "@rollup/rollup-win32-x64-gnu",
+ "@rollup/rollup-win32-x64-msvc",
+ "fsevents"
+ ],
+ "bin": true
+ },
+ "rrweb-cssom@0.7.1": {
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="
+ },
+ "rrweb-cssom@0.8.0": {
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="
+ },
+ "safer-buffer@2.1.2": {
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+ },
+ "saxes@6.0.0": {
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dependencies": [
+ "xmlchars"
+ ]
+ },
+ "siginfo@2.0.0": {
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="
+ },
+ "source-map-js@1.2.1": {
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
+ },
+ "stackback@0.0.2": {
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
+ },
+ "std-env@3.10.0": {
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="
+ },
+ "strip-indent@3.0.0": {
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dependencies": [
+ "min-indent"
+ ]
+ },
+ "svelte@5.45.10_acorn@8.15.0": {
+ "integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==",
+ "dependencies": [
+ "@jridgewell/remapping",
+ "@jridgewell/sourcemap-codec",
+ "@sveltejs/acorn-typescript",
+ "@types/estree",
+ "acorn",
+ "aria-query@5.3.2",
+ "axobject-query",
+ "clsx",
+ "devalue",
+ "esm-env",
+ "esrap",
+ "is-reference",
+ "locate-character",
+ "magic-string",
+ "zimmerframe"
+ ]
+ },
+ "symbol-tree@3.2.4": {
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
+ },
+ "tinybench@2.9.0": {
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
+ },
+ "tinyexec@0.3.2": {
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
+ },
+ "tinyglobby@0.2.15_picomatch@4.0.3": {
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dependencies": [
+ "fdir",
+ "picomatch"
+ ]
+ },
+ "tinypool@1.1.1": {
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="
+ },
+ "tinyrainbow@1.2.0": {
+ "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="
+ },
+ "tinyspy@3.0.2": {
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="
+ },
+ "tldts-core@6.1.86": {
+ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
+ },
+ "tldts@6.1.86": {
+ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
+ "dependencies": [
+ "tldts-core"
+ ],
+ "bin": true
+ },
+ "tough-cookie@5.1.2": {
+ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
+ "dependencies": [
+ "tldts"
+ ]
+ },
+ "tr46@5.1.1": {
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dependencies": [
+ "punycode"
+ ]
+ },
+ "vite-node@2.1.9": {
+ "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
+ "dependencies": [
+ "cac",
+ "debug",
+ "es-module-lexer",
+ "pathe",
+ "vite@5.4.21"
+ ],
+ "bin": true
+ },
+ "vite@5.4.21": {
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dependencies": [
+ "esbuild@0.21.5",
+ "postcss",
+ "rollup"
+ ],
+ "optionalDependencies": [
+ "fsevents"
+ ],
+ "bin": true
+ },
+ "vite@6.4.1_picomatch@4.0.3": {
+ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
+ "dependencies": [
+ "esbuild@0.25.12",
+ "fdir",
+ "picomatch",
+ "postcss",
+ "rollup",
+ "tinyglobby"
+ ],
+ "optionalDependencies": [
+ "fsevents"
+ ],
+ "bin": true
+ },
+ "vitefu@1.1.1_vite@6.4.1__picomatch@4.0.3": {
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "dependencies": [
+ "vite@6.4.1_picomatch@4.0.3"
+ ],
+ "optionalPeers": [
+ "vite@6.4.1_picomatch@4.0.3"
+ ]
+ },
+ "vitest@2.1.9_jsdom@25.0.1_vite@5.4.21": {
+ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
+ "dependencies": [
+ "@vitest/expect",
+ "@vitest/mocker",
+ "@vitest/pretty-format",
+ "@vitest/runner",
+ "@vitest/snapshot",
+ "@vitest/spy",
+ "@vitest/utils",
+ "chai",
+ "debug",
+ "expect-type",
+ "jsdom",
+ "magic-string",
+ "pathe",
+ "std-env",
+ "tinybench",
+ "tinyexec",
+ "tinypool",
+ "tinyrainbow",
+ "vite@5.4.21",
+ "vite-node",
+ "why-is-node-running"
+ ],
+ "optionalPeers": [
+ "jsdom"
+ ],
+ "bin": true
+ },
+ "w3c-xmlserializer@5.0.0": {
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dependencies": [
+ "xml-name-validator"
+ ]
+ },
+ "webidl-conversions@7.0.0": {
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
+ },
+ "whatwg-encoding@3.1.1": {
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "dependencies": [
+ "iconv-lite"
+ ]
+ },
+ "whatwg-mimetype@4.0.0": {
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="
+ },
+ "whatwg-url@14.2.0": {
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dependencies": [
+ "tr46",
+ "webidl-conversions"
+ ]
+ },
+ "why-is-node-running@2.3.0": {
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dependencies": [
+ "siginfo",
+ "stackback"
+ ],
+ "bin": true
+ },
+ "ws@8.18.3": {
+ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
+ },
+ "xml-name-validator@5.0.0": {
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="
+ },
+ "xmlchars@2.2.0": {
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
+ },
+ "zimmerframe@1.1.4": {
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
+ }
+ },
+ "workspace": {
+ "packageJson": {
+ "dependencies": [
+ "npm:@sveltejs/vite-plugin-svelte@5",
+ "npm:@testing-library/jest-dom@^6.6.3",
+ "npm:@testing-library/svelte@^5.2.6",
+ "npm:@testing-library/user-event@^14.5.2",
+ "npm:jsdom@^25.0.1",
+ "npm:svelte@5",
+ "npm:vite@6",
+ "npm:vitest@^2.1.8"
+ ]
+ }
+ }
+}
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..d5301b5
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+ BSPDS
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..26e6c21
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "bspds-frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "vitest",
+ "test:run": "vitest run",
+ "test:coverage": "vitest run --coverage"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^5.0.0",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@testing-library/svelte": "^5.2.6",
+ "@testing-library/user-event": "^14.5.2",
+ "jsdom": "^25.0.1",
+ "svelte": "^5.0.0",
+ "vite": "^6.0.0",
+ "vitest": "^2.1.8"
+ }
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
new file mode 100644
index 0000000..2691ebb
--- /dev/null
+++ b/frontend/src/App.svelte
@@ -0,0 +1,129 @@
+
+
+
+ {#if auth.loading}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..e038a0d
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,341 @@
+const API_BASE = '/xrpc'
+
+export class ApiError extends Error {
+ public did?: string
+
+ constructor(public status: number, public error: string, message: string, did?: string) {
+ super(message)
+ this.name = 'ApiError'
+ this.did = did
+ }
+}
+
+async function xrpc(method: string, options?: {
+ method?: 'GET' | 'POST'
+ params?: Record
+ body?: unknown
+ token?: string
+}): Promise {
+ const { method: httpMethod = 'GET', params, body, token } = options ?? {}
+
+ let url = `${API_BASE}/${method}`
+ if (params) {
+ const searchParams = new URLSearchParams(params)
+ url += `?${searchParams}`
+ }
+
+ const headers: Record = {}
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+ if (body) {
+ headers['Content-Type'] = 'application/json'
+ }
+
+ const res = await fetch(url, {
+ method: httpMethod,
+ headers,
+ body: body ? JSON.stringify(body) : undefined,
+ })
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
+ throw new ApiError(res.status, err.error, err.message, err.did)
+ }
+
+ return res.json()
+}
+
+export interface Session {
+ did: string
+ handle: string
+ email?: string
+ emailConfirmed?: boolean
+ accessJwt: string
+ refreshJwt: string
+}
+
+export interface AppPassword {
+ name: string
+ createdAt: string
+}
+
+export interface InviteCode {
+ code: string
+ available: number
+ disabled: boolean
+ forAccount: string
+ createdBy: string
+ createdAt: string
+ uses: { usedBy: string; usedAt: string }[]
+}
+
+export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
+
+export interface CreateAccountParams {
+ handle: string
+ email: string
+ password: string
+ inviteCode?: string
+ verificationChannel?: VerificationChannel
+ discordId?: string
+ telegramUsername?: string
+ signalNumber?: string
+}
+
+export interface CreateAccountResult {
+ handle: string
+ did: string
+ verificationRequired: boolean
+ verificationChannel: string
+}
+
+export interface ConfirmSignupResult {
+ accessJwt: string
+ refreshJwt: string
+ handle: string
+ did: string
+}
+
+export const api = {
+ async createAccount(params: CreateAccountParams): Promise {
+ return xrpc('com.atproto.server.createAccount', {
+ method: 'POST',
+ body: {
+ handle: params.handle,
+ email: params.email,
+ password: params.password,
+ inviteCode: params.inviteCode,
+ verificationChannel: params.verificationChannel,
+ discordId: params.discordId,
+ telegramUsername: params.telegramUsername,
+ signalNumber: params.signalNumber,
+ },
+ })
+ },
+
+ async confirmSignup(did: string, verificationCode: string): Promise {
+ return xrpc('com.atproto.server.confirmSignup', {
+ method: 'POST',
+ body: { did, verificationCode },
+ })
+ },
+
+ async resendVerification(did: string): Promise<{ success: boolean }> {
+ return xrpc('com.atproto.server.resendVerification', {
+ method: 'POST',
+ body: { did },
+ })
+ },
+
+ async createSession(identifier: string, password: string): Promise {
+ return xrpc('com.atproto.server.createSession', {
+ method: 'POST',
+ body: { identifier, password },
+ })
+ },
+
+ async getSession(token: string): Promise {
+ return xrpc('com.atproto.server.getSession', { token })
+ },
+
+ async refreshSession(refreshJwt: string): Promise {
+ return xrpc('com.atproto.server.refreshSession', {
+ method: 'POST',
+ token: refreshJwt,
+ })
+ },
+
+ async deleteSession(token: string): Promise {
+ await xrpc('com.atproto.server.deleteSession', {
+ method: 'POST',
+ token,
+ })
+ },
+
+ async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
+ return xrpc('com.atproto.server.listAppPasswords', { token })
+ },
+
+ async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> {
+ return xrpc('com.atproto.server.createAppPassword', {
+ method: 'POST',
+ token,
+ body: { name },
+ })
+ },
+
+ async revokeAppPassword(token: string, name: string): Promise {
+ await xrpc('com.atproto.server.revokeAppPassword', {
+ method: 'POST',
+ token,
+ body: { name },
+ })
+ },
+
+ async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
+ return xrpc('com.atproto.server.getAccountInviteCodes', { token })
+ },
+
+ async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> {
+ return xrpc('com.atproto.server.createInviteCode', {
+ method: 'POST',
+ token,
+ body: { useCount },
+ })
+ },
+
+ async requestPasswordReset(email: string): Promise {
+ await xrpc('com.atproto.server.requestPasswordReset', {
+ method: 'POST',
+ body: { email },
+ })
+ },
+
+ async resetPassword(token: string, password: string): Promise {
+ await xrpc('com.atproto.server.resetPassword', {
+ method: 'POST',
+ body: { token, password },
+ })
+ },
+
+ async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
+ return xrpc('com.atproto.server.requestEmailUpdate', {
+ method: 'POST',
+ token,
+ })
+ },
+
+ async updateEmail(token: string, email: string, emailToken?: string): Promise {
+ await xrpc('com.atproto.server.updateEmail', {
+ method: 'POST',
+ token,
+ body: { email, token: emailToken },
+ })
+ },
+
+ async updateHandle(token: string, handle: string): Promise {
+ await xrpc('com.atproto.identity.updateHandle', {
+ method: 'POST',
+ token,
+ body: { handle },
+ })
+ },
+
+ async requestAccountDelete(token: string): Promise {
+ await xrpc('com.atproto.server.requestAccountDelete', {
+ method: 'POST',
+ token,
+ })
+ },
+
+ async deleteAccount(did: string, password: string, deleteToken: string): Promise {
+ await xrpc('com.atproto.server.deleteAccount', {
+ method: 'POST',
+ body: { did, password, token: deleteToken },
+ })
+ },
+
+ async describeServer(): Promise<{
+ availableUserDomains: string[]
+ inviteCodeRequired: boolean
+ links?: { privacyPolicy?: string; termsOfService?: string }
+ }> {
+ return xrpc('com.atproto.server.describeServer')
+ },
+
+ async getNotificationPrefs(token: string): Promise<{
+ preferredChannel: string
+ email: string
+ discordId: string | null
+ discordVerified: boolean
+ telegramUsername: string | null
+ telegramVerified: boolean
+ signalNumber: string | null
+ signalVerified: boolean
+ }> {
+ return xrpc('com.bspds.account.getNotificationPrefs', { token })
+ },
+
+ async updateNotificationPrefs(token: string, prefs: {
+ preferredChannel?: string
+ discordId?: string
+ telegramUsername?: string
+ signalNumber?: string
+ }): Promise<{ success: boolean }> {
+ return xrpc('com.bspds.account.updateNotificationPrefs', {
+ method: 'POST',
+ token,
+ body: prefs,
+ })
+ },
+
+ async describeRepo(token: string, repo: string): Promise<{
+ handle: string
+ did: string
+ didDoc: unknown
+ collections: string[]
+ handleIsCorrect: boolean
+ }> {
+ return xrpc('com.atproto.repo.describeRepo', {
+ token,
+ params: { repo },
+ })
+ },
+
+ async listRecords(token: string, repo: string, collection: string, options?: {
+ limit?: number
+ cursor?: string
+ reverse?: boolean
+ }): Promise<{
+ records: Array<{ uri: string; cid: string; value: unknown }>
+ cursor?: string
+ }> {
+ const params: Record = { repo, collection }
+ if (options?.limit) params.limit = String(options.limit)
+ if (options?.cursor) params.cursor = options.cursor
+ if (options?.reverse) params.reverse = 'true'
+ return xrpc('com.atproto.repo.listRecords', { token, params })
+ },
+
+ async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{
+ uri: string
+ cid: string
+ value: unknown
+ }> {
+ return xrpc('com.atproto.repo.getRecord', {
+ token,
+ params: { repo, collection, rkey },
+ })
+ },
+
+ async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{
+ uri: string
+ cid: string
+ }> {
+ return xrpc('com.atproto.repo.createRecord', {
+ method: 'POST',
+ token,
+ body: { repo, collection, record, rkey },
+ })
+ },
+
+ async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{
+ uri: string
+ cid: string
+ }> {
+ return xrpc('com.atproto.repo.putRecord', {
+ method: 'POST',
+ token,
+ body: { repo, collection, rkey, record },
+ })
+ },
+
+ async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise {
+ await xrpc('com.atproto.repo.deleteRecord', {
+ method: 'POST',
+ token,
+ body: { repo, collection, rkey },
+ })
+ },
+}
diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts
new file mode 100644
index 0000000..5f8ed54
--- /dev/null
+++ b/frontend/src/lib/auth.svelte.ts
@@ -0,0 +1,172 @@
+import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
+
+const STORAGE_KEY = 'bspds_session'
+
+interface AuthState {
+ session: Session | null
+ loading: boolean
+ error: string | null
+}
+
+let state = $state({
+ session: null,
+ loading: true,
+ error: null,
+})
+
+function saveSession(session: Session | null) {
+ if (session) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
+ } else {
+ localStorage.removeItem(STORAGE_KEY)
+ }
+}
+
+function loadSession(): Session | null {
+ const stored = localStorage.getItem(STORAGE_KEY)
+ if (stored) {
+ try {
+ return JSON.parse(stored)
+ } catch {
+ return null
+ }
+ }
+ return null
+}
+
+export async function initAuth() {
+ state.loading = true
+ state.error = null
+
+ const stored = loadSession()
+ if (stored) {
+ try {
+ const session = await api.getSession(stored.accessJwt)
+ state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
+ } catch (e) {
+ if (e instanceof ApiError && e.status === 401) {
+ try {
+ const refreshed = await api.refreshSession(stored.refreshJwt)
+ state.session = refreshed
+ saveSession(refreshed)
+ } catch {
+ saveSession(null)
+ state.session = null
+ }
+ } else {
+ saveSession(null)
+ state.session = null
+ }
+ }
+ }
+
+ state.loading = false
+}
+
+export async function login(identifier: string, password: string): Promise {
+ state.loading = true
+ state.error = null
+
+ try {
+ const session = await api.createSession(identifier, password)
+ state.session = session
+ saveSession(session)
+ } catch (e) {
+ if (e instanceof ApiError) {
+ state.error = e.message
+ } else {
+ state.error = 'Login failed'
+ }
+ throw e
+ } finally {
+ state.loading = false
+ }
+}
+
+export async function register(params: CreateAccountParams): Promise {
+ try {
+ const result = await api.createAccount(params)
+ return result
+ } catch (e) {
+ if (e instanceof ApiError) {
+ state.error = e.message
+ } else {
+ state.error = 'Registration failed'
+ }
+ throw e
+ }
+}
+
+export async function confirmSignup(did: string, verificationCode: string): Promise {
+ state.loading = true
+ state.error = null
+
+ try {
+ const result = await api.confirmSignup(did, verificationCode)
+ const session: Session = {
+ did: result.did,
+ handle: result.handle,
+ accessJwt: result.accessJwt,
+ refreshJwt: result.refreshJwt,
+ }
+ state.session = session
+ saveSession(session)
+ } catch (e) {
+ if (e instanceof ApiError) {
+ state.error = e.message
+ } else {
+ state.error = 'Verification failed'
+ }
+ throw e
+ } finally {
+ state.loading = false
+ }
+}
+
+export async function resendVerification(did: string): Promise {
+ try {
+ await api.resendVerification(did)
+ } catch (e) {
+ if (e instanceof ApiError) {
+ throw e
+ }
+ throw new Error('Failed to resend verification code')
+ }
+}
+
+export async function logout(): Promise {
+ if (state.session) {
+ try {
+ await api.deleteSession(state.session.accessJwt)
+ } catch {
+ // Ignore errors on logout
+ }
+ }
+ state.session = null
+ saveSession(null)
+}
+
+export function getAuthState() {
+ return state
+}
+
+export function getToken(): string | null {
+ return state.session?.accessJwt ?? null
+}
+
+export function isAuthenticated(): boolean {
+ return state.session !== null
+}
+
+export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
+ state.session = newState.session
+ state.loading = newState.loading
+ state.error = newState.error
+}
+
+export function _testReset() {
+ state.session = null
+ state.loading = true
+ state.error = null
+ localStorage.removeItem(STORAGE_KEY)
+}
diff --git a/frontend/src/lib/router.svelte.ts b/frontend/src/lib/router.svelte.ts
new file mode 100644
index 0000000..1d113b0
--- /dev/null
+++ b/frontend/src/lib/router.svelte.ts
@@ -0,0 +1,13 @@
+let currentPath = $state(window.location.hash.slice(1) || '/')
+
+window.addEventListener('hashchange', () => {
+ currentPath = window.location.hash.slice(1) || '/'
+})
+
+export function navigate(path: string) {
+ window.location.hash = path
+}
+
+export function getCurrentPath() {
+ return currentPath
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
new file mode 100644
index 0000000..1a85b5f
--- /dev/null
+++ b/frontend/src/main.ts
@@ -0,0 +1,8 @@
+import App from './App.svelte'
+import { mount } from 'svelte'
+
+const app = mount(App, {
+ target: document.getElementById('app')!,
+})
+
+export default app
diff --git a/frontend/src/routes/AppPasswords.svelte b/frontend/src/routes/AppPasswords.svelte
new file mode 100644
index 0000000..0c2a2a3
--- /dev/null
+++ b/frontend/src/routes/AppPasswords.svelte
@@ -0,0 +1,333 @@
+
+
+
+
+
+
+ App passwords let you sign in to third-party apps without giving them your main password.
+ Each app password can be revoked individually.
+
+
+ {#if error}
+
{error}
+ {/if}
+
+ {#if createdPassword}
+
+
App Password Created
+
Copy this password now. You won't be able to see it again.
+
+ {createdPassword.password}
+
+
Name: {createdPassword.name}
+
+
+ {/if}
+
+
+ Create New App Password
+
+
+
+
+ Your App Passwords
+
+ {#if loading}
+ Loading...
+ {:else if passwords.length === 0}
+ No app passwords yet
+ {:else}
+
+ {/if}
+
+
+
+
diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte
new file mode 100644
index 0000000..5c848f5
--- /dev/null
+++ b/frontend/src/routes/Dashboard.svelte
@@ -0,0 +1,201 @@
+
+
+{#if auth.session}
+
+{:else if auth.loading}
+ Loading...
+{/if}
+
+
diff --git a/frontend/src/routes/InviteCodes.svelte b/frontend/src/routes/InviteCodes.svelte
new file mode 100644
index 0000000..6b94aa2
--- /dev/null
+++ b/frontend/src/routes/InviteCodes.svelte
@@ -0,0 +1,326 @@
+
+
+
+
+
+
+ Invite codes let you invite friends to join. Each code can be used once.
+
+
+ {#if error}
+
{error}
+ {/if}
+
+ {#if createdCode}
+
+
Invite Code Created
+
+ {createdCode}
+
+
+
+
+ {/if}
+
+
+
+
+
+
+ Your Invite Codes
+
+ {#if loading}
+ Loading...
+ {:else if codes.length === 0}
+ No invite codes yet
+ {:else}
+
+ {#each codes as code}
+ - 0 && code.available === 0}>
+
+ {code.code}
+
+
+
+ Created {new Date(code.createdAt).toLocaleDateString()}
+ {#if code.disabled}
+ Disabled
+ {:else if code.uses.length > 0}
+ Used by @{code.uses[0].usedBy.split(':').pop()}
+ {:else}
+ Available
+ {/if}
+
+
+ {/each}
+
+ {/if}
+
+
+
+
diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte
new file mode 100644
index 0000000..3af89ca
--- /dev/null
+++ b/frontend/src/routes/Login.svelte
@@ -0,0 +1,289 @@
+
+
+
+ {#if error}
+
{error}
+ {/if}
+
+ {#if pendingVerification}
+
Verify Your Account
+
+ Your account needs verification. Enter the code sent to your verification method.
+
+
+ {#if resendMessage}
+
{resendMessage}
+ {/if}
+
+
+ {:else}
+
Sign In
+
Sign in to manage your PDS account
+
+
+
+
+ Don't have an account? Create one
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/Notifications.svelte b/frontend/src/routes/Notifications.svelte
new file mode 100644
index 0000000..568eabb
--- /dev/null
+++ b/frontend/src/routes/Notifications.svelte
@@ -0,0 +1,453 @@
+
+
+
+
+
+
+ Choose how you want to receive important notifications like password resets,
+ security alerts, and account updates.
+
+
+ {#if loading}
+
Loading...
+ {:else}
+ {#if error}
+
{error}
+ {/if}
+
+ {#if success}
+
{success}
+ {/if}
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte
new file mode 100644
index 0000000..640b398
--- /dev/null
+++ b/frontend/src/routes/Register.svelte
@@ -0,0 +1,523 @@
+
+
+
+ {#if error}
+
{error}
+ {/if}
+
+ {#if pendingVerification}
+
Verify Your Account
+
+ We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
+ Enter it below to complete registration.
+
+
+ {#if resendMessage}
+
{resendMessage}
+ {/if}
+
+
+ {:else}
+
Create Account
+
Create a new account on this PDS
+
+ {#if loadingServerInfo}
+
Loading...
+ {:else}
+
+
+
+ Already have an account? Sign in
+
+ {/if}
+ {/if}
+
+
+
diff --git a/frontend/src/routes/RepoExplorer.svelte b/frontend/src/routes/RepoExplorer.svelte
new file mode 100644
index 0000000..69f9f5a
--- /dev/null
+++ b/frontend/src/routes/RepoExplorer.svelte
@@ -0,0 +1,940 @@
+
+
+
+
+
+ {#if error}
+
+ {#if error.code}
+ {error.code}
+ {/if}
+ {error.message}
+
+ {/if}
+
+ {#if success}
+
{success}
+ {/if}
+
+ {#if loading}
+
Loading...
+ {:else if view === 'collections'}
+
+
+
+
+
+ {#if collections.length === 0}
+
No collections yet. Create your first record to get started.
+ {:else}
+
+ {#each [...groupedCollections.entries()] as [authority, nsids]}
+
+
{authority}
+
+ {#each nsids as nsid}
+ -
+
+
+ {/each}
+
+
+ {/each}
+
+ {/if}
+
+ {:else if view === 'records'}
+
+
+
+
+
+ {#if records.length === 0}
+
No records in this collection.
+ {:else}
+
+ {#each filteredRecords as record}
+ -
+
+
+ {/each}
+
+
+ {#if recordsCursor}
+
+
+
+ {/if}
+ {/if}
+
+ {:else if view === 'record' && selectedRecord}
+
+
+ {:else if view === 'create'}
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/Settings.svelte b/frontend/src/routes/Settings.svelte
new file mode 100644
index 0000000..4103032
--- /dev/null
+++ b/frontend/src/routes/Settings.svelte
@@ -0,0 +1,409 @@
+
+
+
+
+
+ {#if message}
+
{message.text}
+ {/if}
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/tests/AppPasswords.test.ts b/frontend/src/tests/AppPasswords.test.ts
new file mode 100644
index 0000000..a4cc6ad
--- /dev/null
+++ b/frontend/src/tests/AppPasswords.test.ts
@@ -0,0 +1,453 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
+import AppPasswords from '../routes/AppPasswords.svelte'
+import {
+ setupFetchMock,
+ mockEndpoint,
+ jsonResponse,
+ errorResponse,
+ mockData,
+ clearMocks,
+ setupAuthenticatedUser,
+ setupUnauthenticatedUser,
+} from './mocks'
+
+describe('AppPasswords', () => {
+ beforeEach(() => {
+ clearMocks()
+ setupFetchMock()
+ window.confirm = vi.fn(() => true)
+ })
+
+ describe('authentication guard', () => {
+ it('redirects to login when not authenticated', async () => {
+ setupUnauthenticatedUser()
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(window.location.hash).toBe('#/login')
+ })
+ })
+ })
+
+ describe('page structure', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [] })
+ )
+ })
+
+ it('displays all page elements', async () => {
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
+ expect(screen.getByText(/third-party apps/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('shows loading text while fetching passwords', async () => {
+ mockEndpoint('com.atproto.server.listAppPasswords', async () => {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ return jsonResponse({ passwords: [] })
+ })
+
+ render(AppPasswords)
+
+ expect(screen.getByText(/loading/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('empty state', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [] })
+ )
+ })
+
+ it('shows empty message when no passwords exist', async () => {
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('password list', () => {
+ const testPasswords = [
+ mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }),
+ mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }),
+ ]
+
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: testPasswords })
+ )
+ })
+
+ it('displays all app passwords with dates and revoke buttons', async () => {
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText('Graysky')).toBeInTheDocument()
+ expect(screen.getByText('Skeets')).toBeInTheDocument()
+ expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument()
+ expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument()
+ expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2)
+ })
+ })
+ })
+
+ describe('create app password', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [] })
+ )
+ })
+
+ it('displays create form with input and button', async () => {
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument()
+ })
+ })
+
+ it('disables create button when input is empty', async () => {
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
+ })
+ })
+
+ it('enables create button when input has value', async () => {
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } })
+
+ expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
+ })
+
+ it('calls createAppPassword with correct name', async () => {
+ let capturedName: string | null = null
+
+ mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
+ const body = JSON.parse((options?.body as string) || '{}')
+ capturedName = body.name
+ return jsonResponse({
+ name: body.name,
+ password: 'xxxx-xxxx-xxxx-xxxx',
+ createdAt: new Date().toISOString(),
+ })
+ })
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } })
+ await fireEvent.click(screen.getByRole('button', { name: /create/i }))
+
+ await waitFor(() => {
+ expect(capturedName).toBe('Graysky')
+ })
+ })
+
+ it('shows loading state while creating', async () => {
+ mockEndpoint('com.atproto.server.createAppPassword', async () => {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ return jsonResponse({
+ name: 'Test',
+ password: 'xxxx-xxxx-xxxx-xxxx',
+ createdAt: new Date().toISOString(),
+ })
+ })
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
+ await fireEvent.click(screen.getByRole('button', { name: /create/i }))
+
+ expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
+ })
+
+ it('displays created password in success box and clears input', async () => {
+ mockEndpoint('com.atproto.server.createAppPassword', () =>
+ jsonResponse({
+ name: 'MyApp',
+ password: 'abcd-efgh-ijkl-mnop',
+ createdAt: new Date().toISOString(),
+ })
+ )
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
+ })
+
+ const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement
+ await fireEvent.input(input, { target: { value: 'MyApp' } })
+ await fireEvent.click(screen.getByRole('button', { name: /create/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/app password created/i)).toBeInTheDocument()
+ expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument()
+ expect(screen.getByText(/name: myapp/i)).toBeInTheDocument()
+ expect(input.value).toBe('')
+ })
+ })
+
+ it('dismisses created password box when clicking Done', async () => {
+ mockEndpoint('com.atproto.server.createAppPassword', () =>
+ jsonResponse({
+ name: 'Test',
+ password: 'xxxx-xxxx-xxxx-xxxx',
+ createdAt: new Date().toISOString(),
+ })
+ )
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
+ await fireEvent.click(screen.getByRole('button', { name: /create/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/app password created/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /done/i }))
+
+ await waitFor(() => {
+ expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('shows error when creation fails', async () => {
+ mockEndpoint('com.atproto.server.createAppPassword', () =>
+ errorResponse('InvalidRequest', 'Name already exists', 400)
+ )
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } })
+ await fireEvent.click(screen.getByRole('button', { name: /create/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/name already exists/i)).toBeInTheDocument()
+ expect(screen.getByText(/name already exists/i)).toHaveClass('error')
+ })
+ })
+ })
+
+ describe('revoke app password', () => {
+ const testPassword = mockData.appPassword({ name: 'TestApp' })
+
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('shows confirmation dialog before revoking', async () => {
+ const confirmSpy = vi.fn(() => false)
+ window.confirm = confirmSpy
+
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [testPassword] })
+ )
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText('TestApp')).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
+
+ expect(confirmSpy).toHaveBeenCalledWith(
+ expect.stringContaining('TestApp')
+ )
+ })
+
+ it('does not revoke when confirmation is cancelled', async () => {
+ window.confirm = vi.fn(() => false)
+ let revokeCalled = false
+
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [testPassword] })
+ )
+
+ mockEndpoint('com.atproto.server.revokeAppPassword', () => {
+ revokeCalled = true
+ return jsonResponse({})
+ })
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText('TestApp')).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
+
+ expect(revokeCalled).toBe(false)
+ })
+
+ it('calls revokeAppPassword with correct name', async () => {
+ window.confirm = vi.fn(() => true)
+ let capturedName: string | null = null
+
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [testPassword] })
+ )
+
+ mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => {
+ const body = JSON.parse((options?.body as string) || '{}')
+ capturedName = body.name
+ return jsonResponse({})
+ })
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText('TestApp')).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
+
+ await waitFor(() => {
+ expect(capturedName).toBe('TestApp')
+ })
+ })
+
+ it('shows loading state while revoking', async () => {
+ window.confirm = vi.fn(() => true)
+
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [testPassword] })
+ )
+
+ mockEndpoint('com.atproto.server.revokeAppPassword', async () => {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ return jsonResponse({})
+ })
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText('TestApp')).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
+
+ expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled()
+ })
+
+ it('reloads password list after successful revocation', async () => {
+ window.confirm = vi.fn(() => true)
+ let listCallCount = 0
+
+ mockEndpoint('com.atproto.server.listAppPasswords', () => {
+ listCallCount++
+ if (listCallCount === 1) {
+ return jsonResponse({ passwords: [testPassword] })
+ }
+ return jsonResponse({ passwords: [] })
+ })
+
+ mockEndpoint('com.atproto.server.revokeAppPassword', () =>
+ jsonResponse({})
+ )
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText('TestApp')).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
+
+ await waitFor(() => {
+ expect(screen.queryByText('TestApp')).not.toBeInTheDocument()
+ expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
+ })
+ })
+
+ it('shows error when revocation fails', async () => {
+ window.confirm = vi.fn(() => true)
+
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [testPassword] })
+ )
+
+ mockEndpoint('com.atproto.server.revokeAppPassword', () =>
+ errorResponse('InternalError', 'Server error', 500)
+ )
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText('TestApp')).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/server error/i)).toBeInTheDocument()
+ expect(screen.getByText(/server error/i)).toHaveClass('error')
+ })
+ })
+ })
+
+ describe('error handling', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('shows error when loading passwords fails', async () => {
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ errorResponse('InternalError', 'Database connection failed', 500)
+ )
+
+ render(AppPasswords)
+
+ await waitFor(() => {
+ expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
+ expect(screen.getByText(/database connection failed/i)).toHaveClass('error')
+ })
+ })
+ })
+})
diff --git a/frontend/src/tests/Dashboard.test.ts b/frontend/src/tests/Dashboard.test.ts
new file mode 100644
index 0000000..85b977a
--- /dev/null
+++ b/frontend/src/tests/Dashboard.test.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
+import Dashboard from '../routes/Dashboard.svelte'
+import {
+ setupFetchMock,
+ mockEndpoint,
+ jsonResponse,
+ mockData,
+ clearMocks,
+ setupAuthenticatedUser,
+ setupUnauthenticatedUser,
+} from './mocks'
+
+const STORAGE_KEY = 'bspds_session'
+
+describe('Dashboard', () => {
+ beforeEach(() => {
+ clearMocks()
+ setupFetchMock()
+ })
+
+ describe('authentication guard', () => {
+ it('redirects to login when not authenticated', async () => {
+ setupUnauthenticatedUser()
+ render(Dashboard)
+
+ await waitFor(() => {
+ expect(window.location.hash).toBe('#/login')
+ })
+ })
+
+ it('shows loading state while checking auth', () => {
+ render(Dashboard)
+
+ expect(screen.getByText(/loading/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('authenticated view', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('displays user account info and page structure', async () => {
+ render(Dashboard)
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument()
+ expect(screen.getByText(/@testuser\.test\.bspds\.dev/)).toBeInTheDocument()
+ expect(screen.getByText(/did:web:test\.bspds\.dev:u:testuser/)).toBeInTheDocument()
+ expect(screen.getByText('test@example.com')).toBeInTheDocument()
+ expect(screen.getByText('Verified')).toBeInTheDocument()
+ expect(screen.getByText('Verified')).toHaveClass('badge', 'success')
+ })
+ })
+
+ it('displays unverified badge when email not confirmed', async () => {
+ setupAuthenticatedUser({ emailConfirmed: false })
+ render(Dashboard)
+
+ await waitFor(() => {
+ expect(screen.getByText('Unverified')).toBeInTheDocument()
+ expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning')
+ })
+ })
+
+ it('displays all navigation cards', async () => {
+ render(Dashboard)
+
+ await waitFor(() => {
+ const navCards = [
+ { name: /app passwords/i, href: '#/app-passwords' },
+ { name: /invite codes/i, href: '#/invite-codes' },
+ { name: /account settings/i, href: '#/settings' },
+ { name: /notification preferences/i, href: '#/notifications' },
+ { name: /repository explorer/i, href: '#/repo' },
+ ]
+
+ for (const { name, href } of navCards) {
+ const card = screen.getByRole('link', { name })
+ expect(card).toBeInTheDocument()
+ expect(card).toHaveAttribute('href', href)
+ }
+ })
+ })
+ })
+
+ describe('logout functionality', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()))
+
+ mockEndpoint('com.atproto.server.deleteSession', () =>
+ jsonResponse({})
+ )
+ })
+
+ it('calls deleteSession and navigates to login on logout', async () => {
+ let deleteSessionCalled = false
+
+ mockEndpoint('com.atproto.server.deleteSession', () => {
+ deleteSessionCalled = true
+ return jsonResponse({})
+ })
+
+ render(Dashboard)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
+
+ await waitFor(() => {
+ expect(deleteSessionCalled).toBe(true)
+ expect(window.location.hash).toBe('#/login')
+ })
+ })
+
+ it('clears session from localStorage after logout', async () => {
+ const storedSession = localStorage.getItem(STORAGE_KEY)
+ expect(storedSession).not.toBeNull()
+
+ render(Dashboard)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
+
+ await waitFor(() => {
+ expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
+ })
+ })
+ })
+})
diff --git a/frontend/src/tests/Login.test.ts b/frontend/src/tests/Login.test.ts
new file mode 100644
index 0000000..41ea5f1
--- /dev/null
+++ b/frontend/src/tests/Login.test.ts
@@ -0,0 +1,167 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
+import Login from '../routes/Login.svelte'
+import {
+ setupFetchMock,
+ mockEndpoint,
+ jsonResponse,
+ errorResponse,
+ mockData,
+ clearMocks,
+} from './mocks'
+
+describe('Login', () => {
+ beforeEach(() => {
+ clearMocks()
+ setupFetchMock()
+ window.location.hash = ''
+ })
+
+ describe('initial render', () => {
+ it('renders login form with all elements and correct initial state', () => {
+ render(Login)
+
+ expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
+ expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
+ expect(screen.getByText(/don't have an account/i)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register')
+ })
+ })
+
+ describe('form validation', () => {
+ it('enables submit button only when both fields are filled', async () => {
+ render(Login)
+
+ const identifierInput = screen.getByLabelText(/handle or email/i)
+ const passwordInput = screen.getByLabelText(/password/i)
+ const submitButton = screen.getByRole('button', { name: /sign in/i })
+
+ await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
+ expect(submitButton).toBeDisabled()
+
+ await fireEvent.input(identifierInput, { target: { value: '' } })
+ await fireEvent.input(passwordInput, { target: { value: 'password123' } })
+ expect(submitButton).toBeDisabled()
+
+ await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
+ expect(submitButton).not.toBeDisabled()
+ })
+ })
+
+ describe('login submission', () => {
+ it('calls createSession with correct credentials', async () => {
+ let capturedBody: Record | null = null
+
+ mockEndpoint('com.atproto.server.createSession', (_url, options) => {
+ capturedBody = JSON.parse((options?.body as string) || '{}')
+ return jsonResponse(mockData.session())
+ })
+
+ render(Login)
+
+ await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } })
+ await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } })
+ await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
+
+ await waitFor(() => {
+ expect(capturedBody).toEqual({
+ identifier: 'testuser@example.com',
+ password: 'mypassword',
+ })
+ })
+ })
+
+ it('shows styled error message on invalid credentials', async () => {
+ mockEndpoint('com.atproto.server.createSession', () =>
+ errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
+ )
+
+ render(Login)
+
+ await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } })
+ await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } })
+ await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
+
+ await waitFor(() => {
+ const errorDiv = screen.getByText(/invalid identifier or password/i)
+ expect(errorDiv).toBeInTheDocument()
+ expect(errorDiv).toHaveClass('error')
+ })
+ })
+
+ it('navigates to dashboard on successful login', async () => {
+ mockEndpoint('com.atproto.server.createSession', () =>
+ jsonResponse(mockData.session())
+ )
+
+ render(Login)
+
+ await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
+ await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
+ await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
+
+ await waitFor(() => {
+ expect(window.location.hash).toBe('#/dashboard')
+ })
+ })
+ })
+
+ describe('account verification flow', () => {
+ it('shows verification form with all controls when account is not verified', async () => {
+ mockEndpoint('com.atproto.server.createSession', () => ({
+ ok: false,
+ status: 401,
+ json: async () => ({
+ error: 'AccountNotVerified',
+ message: 'Account not verified',
+ did: 'did:web:test.bspds.dev:u:testuser',
+ }),
+ }))
+
+ render(Login)
+
+ await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } })
+ await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
+ await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument()
+ expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /resend code/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
+ })
+ })
+
+ it('returns to login form when clicking back', async () => {
+ mockEndpoint('com.atproto.server.createSession', () => ({
+ ok: false,
+ status: 401,
+ json: async () => ({
+ error: 'AccountNotVerified',
+ message: 'Account not verified',
+ did: 'did:web:test.bspds.dev:u:testuser',
+ }),
+ }))
+
+ render(Login)
+
+ await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
+ await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
+ await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /back to login/i }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
+ expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/frontend/src/tests/Notifications.test.ts b/frontend/src/tests/Notifications.test.ts
new file mode 100644
index 0000000..46d4dbe
--- /dev/null
+++ b/frontend/src/tests/Notifications.test.ts
@@ -0,0 +1,443 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
+import Notifications from '../routes/Notifications.svelte'
+import {
+ setupFetchMock,
+ mockEndpoint,
+ jsonResponse,
+ errorResponse,
+ mockData,
+ clearMocks,
+ setupAuthenticatedUser,
+ setupUnauthenticatedUser,
+} from './mocks'
+
+describe('Notifications', () => {
+ beforeEach(() => {
+ clearMocks()
+ setupFetchMock()
+ })
+
+ describe('authentication guard', () => {
+ it('redirects to login when not authenticated', async () => {
+ setupUnauthenticatedUser()
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(window.location.hash).toBe('#/login')
+ })
+ })
+ })
+
+ describe('page structure', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+ })
+
+ it('displays all page elements and sections', async () => {
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
+ expect(screen.getByText(/password resets/i)).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('shows loading text while fetching preferences', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', async () => {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ return jsonResponse(mockData.notificationPrefs())
+ })
+
+ render(Notifications)
+
+ expect(screen.getByText(/loading/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('channel options', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('displays all four channel options', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument()
+ expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument()
+ expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument()
+ expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument()
+ })
+ })
+
+ it('email channel is always selectable', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ const emailRadio = screen.getByRole('radio', { name: /email/i })
+ expect(emailRadio).not.toBeDisabled()
+ })
+ })
+
+ it('discord channel is disabled when not configured', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs({ discordId: null }))
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ const discordRadio = screen.getByRole('radio', { name: /discord/i })
+ expect(discordRadio).toBeDisabled()
+ })
+ })
+
+ it('discord channel is enabled when configured', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs({ discordId: '123456789' }))
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ const discordRadio = screen.getByRole('radio', { name: /discord/i })
+ expect(discordRadio).not.toBeDisabled()
+ })
+ })
+
+ it('shows hint for disabled channels', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0)
+ })
+ })
+
+ it('selects current preferred channel', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' }))
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement
+ expect(emailRadio.checked).toBe(true)
+ })
+ })
+ })
+
+ describe('channel configuration', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('displays email as readonly with current value', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement
+ expect(emailInput).toBeDisabled()
+ expect(emailInput.value).toBe('test@example.com')
+ })
+ })
+
+ it('displays all channel inputs with current values', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs({
+ discordId: '123456789',
+ telegramUsername: 'testuser',
+ signalNumber: '+1234567890',
+ }))
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789')
+ expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser')
+ expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890')
+ })
+ })
+ })
+
+ describe('verification status badges', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('shows Primary badge for email', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByText('Primary')).toBeInTheDocument()
+ })
+ })
+
+ it('shows Verified badge for verified discord', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs({
+ discordId: '123456789',
+ discordVerified: true,
+ }))
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ const verifiedBadges = screen.getAllByText('Verified')
+ expect(verifiedBadges.length).toBeGreaterThan(0)
+ })
+ })
+
+ it('shows Not verified badge for unverified discord', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs({
+ discordId: '123456789',
+ discordVerified: false,
+ }))
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByText('Not verified')).toBeInTheDocument()
+ })
+ })
+
+ it('does not show badge when channel not configured', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByText('Primary')).toBeInTheDocument()
+ expect(screen.queryByText('Not verified')).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('save preferences', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('calls updateNotificationPrefs with correct data', async () => {
+ let capturedBody: Record | null = null
+
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ mockEndpoint('com.bspds.account.updateNotificationPrefs', (_url, options) => {
+ capturedBody = JSON.parse((options?.body as string) || '{}')
+ return jsonResponse({ success: true })
+ })
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } })
+ await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
+
+ await waitFor(() => {
+ expect(capturedBody).not.toBeNull()
+ expect(capturedBody?.discordId).toBe('999888777')
+ expect(capturedBody?.preferredChannel).toBe('email')
+ })
+ })
+
+ it('shows loading state while saving', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ mockEndpoint('com.bspds.account.updateNotificationPrefs', async () => {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ return jsonResponse({ success: true })
+ })
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
+
+ expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled()
+ })
+
+ it('shows success message after saving', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
+ jsonResponse({ success: true })
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument()
+ })
+ })
+
+ it('shows error when save fails', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
+ errorResponse('InvalidRequest', 'Invalid channel configuration', 400)
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument()
+ expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error')
+ })
+ })
+
+ it('reloads preferences after successful save', async () => {
+ let loadCount = 0
+
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () => {
+ loadCount++
+ return jsonResponse(mockData.notificationPrefs())
+ })
+
+ mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
+ jsonResponse({ success: true })
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
+ })
+
+ const initialLoadCount = loadCount
+ await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
+
+ await waitFor(() => {
+ expect(loadCount).toBeGreaterThan(initialLoadCount)
+ })
+ })
+ })
+
+ describe('channel selection interaction', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('enables discord channel after entering discord ID', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } })
+
+ await waitFor(() => {
+ expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
+ })
+ })
+
+ it('allows selecting a configured channel', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs({
+ discordId: '123456789',
+ discordVerified: true,
+ }))
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
+ })
+
+ await fireEvent.click(screen.getByRole('radio', { name: /discord/i }))
+
+ const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement
+ expect(discordRadio.checked).toBe(true)
+ })
+ })
+
+ describe('error handling', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('shows error when loading preferences fails', async () => {
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ errorResponse('InternalError', 'Database connection failed', 500)
+ )
+
+ render(Notifications)
+
+ await waitFor(() => {
+ expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/frontend/src/tests/Settings.test.ts b/frontend/src/tests/Settings.test.ts
new file mode 100644
index 0000000..d17fe8f
--- /dev/null
+++ b/frontend/src/tests/Settings.test.ts
@@ -0,0 +1,516 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
+import Settings from '../routes/Settings.svelte'
+import {
+ setupFetchMock,
+ mockEndpoint,
+ jsonResponse,
+ errorResponse,
+ clearMocks,
+ setupAuthenticatedUser,
+ setupUnauthenticatedUser,
+} from './mocks'
+
+describe('Settings', () => {
+ beforeEach(() => {
+ clearMocks()
+ setupFetchMock()
+ window.confirm = vi.fn(() => true)
+ })
+
+ describe('authentication guard', () => {
+ it('redirects to login when not authenticated', async () => {
+ setupUnauthenticatedUser()
+ render(Settings)
+
+ await waitFor(() => {
+ expect(window.location.hash).toBe('#/login')
+ })
+ })
+ })
+
+ describe('page structure', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('displays all page elements and sections', async () => {
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
+ expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('email change', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('displays current email and input field', async () => {
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ })
+ })
+
+ it('calls requestEmailUpdate when submitting', async () => {
+ let requestCalled = false
+
+ mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
+ requestCalled = true
+ return jsonResponse({ tokenRequired: true })
+ })
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
+
+ await waitFor(() => {
+ expect(requestCalled).toBe(true)
+ })
+ })
+
+ it('shows verification code input when token is required', async () => {
+ mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
+ jsonResponse({ tokenRequired: true })
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
+ })
+ })
+
+ it('calls updateEmail with token when confirming', async () => {
+ let updateCalled = false
+ let capturedBody: Record | null = null
+
+ mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
+ jsonResponse({ tokenRequired: true })
+ )
+
+ mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
+ updateCalled = true
+ capturedBody = JSON.parse((options?.body as string) || '{}')
+ return jsonResponse({})
+ })
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
+ await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
+
+ await waitFor(() => {
+ expect(updateCalled).toBe(true)
+ expect(capturedBody?.email).toBe('newemail@example.com')
+ expect(capturedBody?.token).toBe('123456')
+ })
+ })
+
+ it('shows success message after email update', async () => {
+ mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
+ jsonResponse({ tokenRequired: true })
+ )
+
+ mockEndpoint('com.atproto.server.updateEmail', () =>
+ jsonResponse({})
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
+ await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
+ })
+ })
+
+ it('shows cancel button to return to email form', async () => {
+ mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
+ jsonResponse({ tokenRequired: true })
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
+ })
+ })
+
+ it('shows error when email update fails', async () => {
+ mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
+ errorResponse('InvalidEmail', 'Invalid email format', 400)
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('handle change', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ })
+
+ it('displays current handle', async () => {
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByText(/current: @testuser\.test\.bspds\.dev/i)).toBeInTheDocument()
+ })
+ })
+
+ it('calls updateHandle with new handle', async () => {
+ let capturedHandle: string | null = null
+
+ mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
+ const body = JSON.parse((options?.body as string) || '{}')
+ capturedHandle = body.handle
+ return jsonResponse({})
+ })
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
+
+ await waitFor(() => {
+ expect(capturedHandle).toBe('newhandle.bsky.social')
+ })
+ })
+
+ it('shows success message after handle change', async () => {
+ mockEndpoint('com.atproto.identity.updateHandle', () =>
+ jsonResponse({})
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
+ })
+ })
+
+ it('shows error when handle change fails', async () => {
+ mockEndpoint('com.atproto.identity.updateHandle', () =>
+ errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
+ await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
+ })
+ })
+ })
+
+ describe('account deletion', () => {
+ beforeEach(() => {
+ setupAuthenticatedUser()
+ mockEndpoint('com.atproto.server.deleteSession', () =>
+ jsonResponse({})
+ )
+ })
+
+ it('displays delete section with warning and request button', async () => {
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+ })
+
+ it('calls requestAccountDelete when clicking request', async () => {
+ let requestCalled = false
+
+ mockEndpoint('com.atproto.server.requestAccountDelete', () => {
+ requestCalled = true
+ return jsonResponse({})
+ })
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
+
+ await waitFor(() => {
+ expect(requestCalled).toBe(true)
+ })
+ })
+
+ it('shows confirmation form after requesting deletion', async () => {
+ mockEndpoint('com.atproto.server.requestAccountDelete', () =>
+ jsonResponse({})
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
+ expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
+ })
+ })
+
+ it('shows confirmation dialog before final deletion', async () => {
+ const confirmSpy = vi.fn(() => false)
+ window.confirm = confirmSpy
+
+ mockEndpoint('com.atproto.server.requestAccountDelete', () =>
+ jsonResponse({})
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
+ await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
+ await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
+
+ expect(confirmSpy).toHaveBeenCalledWith(
+ expect.stringContaining('absolutely sure')
+ )
+ })
+
+ it('calls deleteAccount with correct parameters', async () => {
+ window.confirm = vi.fn(() => true)
+ let capturedBody: Record | null = null
+
+ mockEndpoint('com.atproto.server.requestAccountDelete', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
+ capturedBody = JSON.parse((options?.body as string) || '{}')
+ return jsonResponse({})
+ })
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
+ await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
+ await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
+
+ await waitFor(() => {
+ expect(capturedBody?.token).toBe('DEL123')
+ expect(capturedBody?.password).toBe('mypassword')
+ expect(capturedBody?.did).toBe('did:web:test.bspds.dev:u:testuser')
+ })
+ })
+
+ it('navigates to login after successful deletion', async () => {
+ window.confirm = vi.fn(() => true)
+
+ mockEndpoint('com.atproto.server.requestAccountDelete', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.deleteAccount', () =>
+ jsonResponse({})
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
+ await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
+ await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
+
+ await waitFor(() => {
+ expect(window.location.hash).toBe('#/login')
+ })
+ })
+
+ it('shows cancel button to return to request state', async () => {
+ mockEndpoint('com.atproto.server.requestAccountDelete', () =>
+ jsonResponse({})
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
+
+ await waitFor(() => {
+ const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
+ expect(cancelButtons.length).toBeGreaterThan(0)
+ })
+
+ const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
+ const deleteSection = deleteHeading.closest('section')
+ const cancelButton = deleteSection?.querySelector('button.secondary')
+ if (cancelButton) {
+ await fireEvent.click(cancelButton)
+ }
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+ })
+
+ it('shows error when deletion fails', async () => {
+ window.confirm = vi.fn(() => true)
+
+ mockEndpoint('com.atproto.server.requestAccountDelete', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.deleteAccount', () =>
+ errorResponse('InvalidToken', 'Invalid confirmation code', 400)
+ )
+
+ render(Settings)
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
+ })
+
+ await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
+ })
+
+ await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
+ await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
+ await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
+
+ await waitFor(() => {
+ expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
+ })
+ })
+ })
+})
diff --git a/frontend/src/tests/mocks.ts b/frontend/src/tests/mocks.ts
new file mode 100644
index 0000000..2377302
--- /dev/null
+++ b/frontend/src/tests/mocks.ts
@@ -0,0 +1,264 @@
+import { vi } from 'vitest'
+import type { Session, AppPassword, InviteCode } from '../lib/api'
+import { _testSetState } from '../lib/auth.svelte'
+
+export interface MockResponse {
+ ok: boolean
+ status: number
+ json: () => Promise
+}
+
+export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise
+
+const mockHandlers: Map = new Map()
+
+export function mockEndpoint(endpoint: string, handler: MockHandler): void {
+ mockHandlers.set(endpoint, handler)
+}
+
+export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
+ const originalHandler = mockHandlers.get(endpoint)
+ mockHandlers.set(endpoint, (url, options) => {
+ mockHandlers.set(endpoint, originalHandler!)
+ return handler(url, options)
+ })
+}
+
+export function clearMocks(): void {
+ mockHandlers.clear()
+}
+
+function extractEndpoint(url: string): string {
+ const match = url.match(/\/xrpc\/([^?]+)/)
+ return match ? match[1] : url
+}
+
+export function setupFetchMock(): void {
+ global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = typeof input === 'string' ? input : input.toString()
+ const endpoint = extractEndpoint(url)
+
+ const handler = mockHandlers.get(endpoint)
+ if (handler) {
+ const result = await handler(url, init)
+ return {
+ ok: result.ok,
+ status: result.status,
+ json: result.json,
+ text: async () => JSON.stringify(await result.json()),
+ headers: new Headers(),
+ redirected: false,
+ statusText: result.ok ? 'OK' : 'Error',
+ type: 'basic',
+ url,
+ clone: () => ({ ...result }) as Response,
+ body: null,
+ bodyUsed: false,
+ arrayBuffer: async () => new ArrayBuffer(0),
+ blob: async () => new Blob(),
+ formData: async () => new FormData(),
+ } as Response
+ }
+
+ return {
+ ok: false,
+ status: 404,
+ json: async () => ({ error: 'NotFound', message: `No mock for ${endpoint}` }),
+ text: async () => JSON.stringify({ error: 'NotFound', message: `No mock for ${endpoint}` }),
+ headers: new Headers(),
+ redirected: false,
+ statusText: 'Not Found',
+ type: 'basic',
+ url,
+ clone: function() { return this },
+ body: null,
+ bodyUsed: false,
+ arrayBuffer: async () => new ArrayBuffer(0),
+ blob: async () => new Blob(),
+ formData: async () => new FormData(),
+ } as Response
+ })
+}
+
+export function jsonResponse(data: T, status = 200): MockResponse {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ json: async () => data,
+ }
+}
+
+export function errorResponse(error: string, message: string, status = 400): MockResponse {
+ return {
+ ok: false,
+ status,
+ json: async () => ({ error, message }),
+ }
+}
+
+export const mockData = {
+ session: (overrides?: Partial): Session => ({
+ did: 'did:web:test.bspds.dev:u:testuser',
+ handle: 'testuser.test.bspds.dev',
+ email: 'test@example.com',
+ emailConfirmed: true,
+ accessJwt: 'mock-access-jwt-token',
+ refreshJwt: 'mock-refresh-jwt-token',
+ ...overrides,
+ }),
+
+ appPassword: (overrides?: Partial): AppPassword => ({
+ name: 'Test App',
+ createdAt: new Date().toISOString(),
+ ...overrides,
+ }),
+
+ inviteCode: (overrides?: Partial): InviteCode => ({
+ code: 'test-invite-123',
+ available: 1,
+ disabled: false,
+ forAccount: 'did:web:test.bspds.dev:u:testuser',
+ createdBy: 'did:web:test.bspds.dev:u:testuser',
+ createdAt: new Date().toISOString(),
+ uses: [],
+ ...overrides,
+ }),
+
+ notificationPrefs: (overrides?: Record) => ({
+ preferredChannel: 'email',
+ email: 'test@example.com',
+ discordId: null,
+ discordVerified: false,
+ telegramUsername: null,
+ telegramVerified: false,
+ signalNumber: null,
+ signalVerified: false,
+ ...overrides,
+ }),
+
+ describeServer: () => ({
+ availableUserDomains: ['test.bspds.dev'],
+ inviteCodeRequired: false,
+ links: {
+ privacyPolicy: 'https://example.com/privacy',
+ termsOfService: 'https://example.com/tos',
+ },
+ }),
+
+ describeRepo: (did: string) => ({
+ handle: 'testuser.test.bspds.dev',
+ did,
+ didDoc: {},
+ collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'],
+ handleIsCorrect: true,
+ }),
+}
+
+export function setupDefaultMocks(): void {
+ setupFetchMock()
+
+ mockEndpoint('com.atproto.server.getSession', () =>
+ jsonResponse(mockData.session())
+ )
+
+ mockEndpoint('com.atproto.server.createSession', (_url, options) => {
+ const body = JSON.parse((options?.body as string) || '{}')
+ if (body.identifier && body.password === 'correctpassword') {
+ return jsonResponse(mockData.session({ handle: body.identifier.replace('@', '') }))
+ }
+ return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
+ })
+
+ mockEndpoint('com.atproto.server.refreshSession', () =>
+ jsonResponse(mockData.session())
+ )
+
+ mockEndpoint('com.atproto.server.deleteSession', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.listAppPasswords', () =>
+ jsonResponse({ passwords: [mockData.appPassword()] })
+ )
+
+ mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
+ const body = JSON.parse((options?.body as string) || '{}')
+ return jsonResponse({
+ name: body.name,
+ password: 'xxxx-xxxx-xxxx-xxxx',
+ createdAt: new Date().toISOString(),
+ })
+ })
+
+ mockEndpoint('com.atproto.server.revokeAppPassword', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.getAccountInviteCodes', () =>
+ jsonResponse({ codes: [mockData.inviteCode()] })
+ )
+
+ mockEndpoint('com.atproto.server.createInviteCode', () =>
+ jsonResponse({ code: 'new-invite-' + Date.now() })
+ )
+
+ mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
+ jsonResponse(mockData.notificationPrefs())
+ )
+
+ mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
+ jsonResponse({ success: true })
+ )
+
+ mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
+ jsonResponse({ tokenRequired: true })
+ )
+
+ mockEndpoint('com.atproto.server.updateEmail', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.identity.updateHandle', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.requestAccountDelete', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.deleteAccount', () =>
+ jsonResponse({})
+ )
+
+ mockEndpoint('com.atproto.server.describeServer', () =>
+ jsonResponse(mockData.describeServer())
+ )
+
+ mockEndpoint('com.atproto.repo.describeRepo', (url) => {
+ const params = new URLSearchParams(url.split('?')[1])
+ const repo = params.get('repo') || 'did:web:test'
+ return jsonResponse(mockData.describeRepo(repo))
+ })
+
+ mockEndpoint('com.atproto.repo.listRecords', () =>
+ jsonResponse({ records: [] })
+ )
+}
+
+export function setupAuthenticatedUser(sessionOverrides?: Partial): Session {
+ const session = mockData.session(sessionOverrides)
+ _testSetState({
+ session,
+ loading: false,
+ error: null,
+ })
+ return session
+}
+
+export function setupUnauthenticatedUser(): void {
+ _testSetState({
+ session: null,
+ loading: false,
+ error: null,
+ })
+}
diff --git a/frontend/src/tests/setup.ts b/frontend/src/tests/setup.ts
new file mode 100644
index 0000000..b419348
--- /dev/null
+++ b/frontend/src/tests/setup.ts
@@ -0,0 +1,35 @@
+import '@testing-library/jest-dom/vitest'
+import { vi, beforeEach, afterEach } from 'vitest'
+import { _testReset } from '../lib/auth.svelte'
+
+let locationHash = ''
+
+Object.defineProperty(window, 'location', {
+ value: {
+ get hash() { return locationHash },
+ set hash(value: string) {
+ locationHash = value.startsWith('#') ? value : `#${value}`
+ },
+ href: 'http://localhost:3000/',
+ origin: 'http://localhost:3000',
+ pathname: '/',
+ search: '',
+ assign: vi.fn(),
+ replace: vi.fn(),
+ reload: vi.fn(),
+ },
+ writable: true,
+ configurable: true,
+})
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ sessionStorage.clear()
+ locationHash = ''
+ _testReset()
+})
+
+afterEach(() => {
+ vi.restoreAllMocks()
+})
diff --git a/frontend/src/tests/utils.ts b/frontend/src/tests/utils.ts
new file mode 100644
index 0000000..0a80b2b
--- /dev/null
+++ b/frontend/src/tests/utils.ts
@@ -0,0 +1,86 @@
+import { render, type RenderResult } from '@testing-library/svelte'
+import { tick } from 'svelte'
+import type { ComponentType } from 'svelte'
+
+export async function renderAndWait(
+ component: T,
+ options?: Parameters[1]
+): Promise> {
+ const result = render(component, options)
+ await tick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+ return result
+}
+
+export async function waitForElement(
+ queryFn: () => HTMLElement | null,
+ timeout = 1000
+): Promise {
+ const start = Date.now()
+ while (Date.now() - start < timeout) {
+ const element = queryFn()
+ if (element) return element
+ await new Promise(resolve => setTimeout(resolve, 10))
+ }
+ throw new Error('Element not found within timeout')
+}
+
+export async function waitForElementToDisappear(
+ queryFn: () => HTMLElement | null,
+ timeout = 1000
+): Promise {
+ const start = Date.now()
+ while (Date.now() - start < timeout) {
+ const element = queryFn()
+ if (!element) return
+ await new Promise(resolve => setTimeout(resolve, 10))
+ }
+ throw new Error('Element still present after timeout')
+}
+
+export async function waitForText(
+ container: HTMLElement,
+ text: string | RegExp,
+ timeout = 1000
+): Promise {
+ const start = Date.now()
+ while (Date.now() - start < timeout) {
+ const content = container.textContent || ''
+ if (typeof text === 'string' ? content.includes(text) : text.test(content)) {
+ return
+ }
+ await new Promise(resolve => setTimeout(resolve, 10))
+ }
+ throw new Error(`Text "${text}" not found within timeout`)
+}
+
+export function mockLocalStorage(initialData: Record = {}): void {
+ const store: Record = { ...initialData }
+
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: (key: string) => store[key] || null,
+ setItem: (key: string, value: string) => { store[key] = value },
+ removeItem: (key: string) => { delete store[key] },
+ clear: () => { Object.keys(store).forEach(key => delete store[key]) },
+ key: (index: number) => Object.keys(store)[index] || null,
+ get length() { return Object.keys(store).length },
+ },
+ writable: true,
+ })
+}
+
+export function setAuthState(session: {
+ did: string
+ handle: string
+ email?: string
+ emailConfirmed?: boolean
+ accessJwt: string
+ refreshJwt: string
+}): void {
+ localStorage.setItem('session', JSON.stringify(session))
+}
+
+export function clearAuthState(): void {
+ localStorage.removeItem('session')
+}
diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js
new file mode 100644
index 0000000..0c05c28
--- /dev/null
+++ b/frontend/svelte.config.js
@@ -0,0 +1,7 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+
+const isTest = process.env.VITEST === 'true' || process.env.VITEST === true
+
+export default {
+ preprocess: isTest ? [] : vitePreprocess(),
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..0c3ed1d
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+
+export default defineConfig({
+ plugins: [svelte()],
+ build: {
+ outDir: 'dist',
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/xrpc': 'http://localhost:3000',
+ '/oauth': 'http://localhost:3000',
+ '/.well-known': 'http://localhost:3000',
+ '/health': 'http://localhost:3000',
+ '/u': 'http://localhost:3000',
+ }
+ }
+})
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
new file mode 100644
index 0000000..562481a
--- /dev/null
+++ b/frontend/vitest.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vitest/config'
+import { svelte } from '@sveltejs/vite-plugin-svelte'
+
+export default defineConfig({
+ plugins: [
+ svelte({
+ hot: false,
+ }),
+ ],
+ resolve: {
+ conditions: ['browser', 'development'],
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./src/tests/setup.ts'],
+ include: ['src/**/*.{test,spec}.{js,ts}'],
+ alias: {
+ 'svelte': 'svelte',
+ },
+ },
+})
diff --git a/justfile b/justfile
index 05e253f..16c9f0b 100644
--- a/justfile
+++ b/justfile
@@ -77,3 +77,32 @@ docker-logs:
docker-build:
docker compose build
+
+# Frontend commands (Deno)
+frontend-dev:
+ . ~/.deno/env && cd frontend && deno task dev
+
+frontend-build:
+ . ~/.deno/env && cd frontend && deno task build
+
+frontend-clean:
+ rm -rf frontend/dist frontend/node_modules
+
+# Frontend tests
+frontend-test *args:
+ . ~/.deno/env && cd frontend && VITEST=true deno task test:run {{args}}
+
+frontend-test-watch:
+ . ~/.deno/env && cd frontend && VITEST=true deno task test:watch
+
+frontend-test-ui:
+ . ~/.deno/env && cd frontend && VITEST=true deno task test:ui
+
+frontend-test-coverage:
+ . ~/.deno/env && cd frontend && VITEST=true deno task test:run --coverage
+
+# Build all (frontend + backend)
+build-all: frontend-build build
+
+# Test all (backend + frontend)
+test-all: test frontend-test
diff --git a/migrations/202512211400_initial_schema.sql b/migrations/20251211_initial_schema.sql
similarity index 79%
rename from migrations/202512211400_initial_schema.sql
rename to migrations/20251211_initial_schema.sql
index 093e4a7..58f34d3 100644
--- a/migrations/202512211400_initial_schema.sql
+++ b/migrations/20251211_initial_schema.sql
@@ -6,13 +6,15 @@ CREATE TYPE notification_type AS ENUM (
'password_reset',
'email_update',
'account_deletion',
- 'admin_email'
+ 'admin_email',
+ 'plc_operation',
+ 'two_factor_code'
);
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
handle TEXT NOT NULL UNIQUE,
- email TEXT NOT NULL UNIQUE,
+ email TEXT UNIQUE,
did TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
@@ -29,11 +31,26 @@ CREATE TABLE IF NOT EXISTS users (
email_pending_verification TEXT,
email_confirmation_code TEXT,
- email_confirmation_code_expires_at TIMESTAMPTZ
+ email_confirmation_code_expires_at TIMESTAMPTZ,
+ email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
+
+ two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
+
+ discord_id TEXT,
+ discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
+
+ telegram_username TEXT,
+ telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
+
+ signal_number TEXT,
+ signal_verified BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
@@ -62,6 +79,7 @@ CREATE TABLE IF NOT EXISTS user_keys (
CREATE TABLE IF NOT EXISTS repos (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
repo_root_cid TEXT NOT NULL,
+ repo_rev TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -79,10 +97,13 @@ CREATE TABLE IF NOT EXISTS records (
rkey TEXT NOT NULL,
record_cid TEXT NOT NULL,
takedown_ref TEXT,
+ repo_rev TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(repo_id, collection, rkey)
);
+CREATE INDEX idx_records_repo_rev ON records(repo_rev);
+
CREATE TABLE IF NOT EXISTS blobs (
cid TEXT PRIMARY KEY,
mime_type TEXT NOT NULL,
@@ -265,3 +286,40 @@ CREATE TABLE oauth_dpop_jti (
);
CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
+
+CREATE TABLE plc_operation_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token TEXT NOT NULL UNIQUE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
+CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
+
+CREATE TABLE IF NOT EXISTS account_preferences (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ value_json JSONB NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(user_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
+CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
+
+CREATE TABLE oauth_2fa_challenge (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
+ request_uri TEXT NOT NULL,
+ code TEXT NOT NULL,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
+);
+
+CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
+CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
diff --git a/migrations/202512211406_plc_operation_tokens.sql b/migrations/202512211406_plc_operation_tokens.sql
deleted file mode 100644
index 730bae9..0000000
--- a/migrations/202512211406_plc_operation_tokens.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-CREATE TABLE plc_operation_tokens (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- token TEXT NOT NULL UNIQUE,
- expires_at TIMESTAMPTZ NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-);
-
-CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
-CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
diff --git a/migrations/202512211407_add_plc_operation_notification_type.sql b/migrations/202512211407_add_plc_operation_notification_type.sql
deleted file mode 100644
index d1cda80..0000000
--- a/migrations/202512211407_add_plc_operation_notification_type.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TYPE notification_type ADD VALUE 'plc_operation';
diff --git a/migrations/202512211500_account_preferences.sql b/migrations/202512211500_account_preferences.sql
deleted file mode 100644
index 56c1b4e..0000000
--- a/migrations/202512211500_account_preferences.sql
+++ /dev/null
@@ -1,12 +0,0 @@
-CREATE TABLE IF NOT EXISTS account_preferences (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
- name TEXT NOT NULL,
- value_json JSONB NOT NULL,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- UNIQUE(user_id, name)
-);
-
-CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
-CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
diff --git a/migrations/202512211600_add_repo_rev.sql b/migrations/202512211600_add_repo_rev.sql
deleted file mode 100644
index 6e81b79..0000000
--- a/migrations/202512211600_add_repo_rev.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-ALTER TABLE records ADD COLUMN repo_rev TEXT;
-CREATE INDEX idx_records_repo_rev ON records(repo_rev);
diff --git a/migrations/202512211700_add_2fa.sql b/migrations/202512211700_add_2fa.sql
deleted file mode 100644
index 8a6c332..0000000
--- a/migrations/202512211700_add_2fa.sql
+++ /dev/null
@@ -1,16 +0,0 @@
-ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE;
-
-ALTER TYPE notification_type ADD VALUE 'two_factor_code';
-
-CREATE TABLE oauth_2fa_challenge (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
- request_uri TEXT NOT NULL,
- code TEXT NOT NULL,
- attempts INTEGER NOT NULL DEFAULT 0,
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
- expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
-);
-
-CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
-CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
diff --git a/src/api/admin/account/email.rs b/src/api/admin/account/email.rs
index e38556e..7d1198c 100644
--- a/src/api/admin/account/email.rs
+++ b/src/api/admin/account/email.rs
@@ -65,7 +65,19 @@ pub async fn send_email(
.await;
let (user_id, email, handle) = match user {
- Ok(Some(row)) => (row.id, row.email, row.handle),
+ Ok(Some(row)) => {
+ let email = match row.email {
+ Some(e) => e,
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})),
+ )
+ .into_response();
+ }
+ };
+ (row.id, email, row.handle)
+ }
Ok(None) => {
return (
StatusCode::NOT_FOUND,
diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs
index 3b5e3ba..c129362 100644
--- a/src/api/admin/account/info.rs
+++ b/src/api/admin/account/info.rs
@@ -74,7 +74,7 @@ pub async fn get_account_info(
Json(AccountInfo {
did: row.did,
handle: row.handle,
- email: Some(row.email),
+ email: row.email,
indexed_at: row.created_at.to_rfc3339(),
invite_note: None,
invites_disabled: false,
@@ -150,7 +150,7 @@ pub async fn get_account_infos(
infos.push(AccountInfo {
did: row.did,
handle: row.handle,
- email: Some(row.email),
+ email: row.email,
indexed_at: row.created_at.to_rfc3339(),
invite_note: None,
invites_disabled: false,
diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs
index 0dbf59f..53c75e9 100644
--- a/src/api/identity/account.rs
+++ b/src/api/identity/account.rs
@@ -36,20 +36,24 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
#[serde(rename_all = "camelCase")]
pub struct CreateAccountInput {
pub handle: String,
- pub email: String,
+ pub email: Option,
pub password: String,
pub invite_code: Option,
pub did: Option,
pub signing_key: Option,
+ pub verification_channel: Option,
+ pub discord_id: Option,
+ pub telegram_username: Option,
+ pub signal_number: Option,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountOutput {
- pub access_jwt: String,
- pub refresh_jwt: String,
pub handle: String,
pub did: String,
+ pub verification_required: bool,
+ pub verification_channel: String,
}
pub async fn create_account(
@@ -82,12 +86,17 @@ pub async fn create_account(
.into_response();
}
- if !crate::api::validation::is_valid_email(&input.email) {
- return (
- StatusCode::BAD_REQUEST,
- Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
- )
- .into_response();
+ let email: Option = input.email.as_ref()
+ .map(|e| e.trim().to_string())
+ .filter(|e| !e.is_empty());
+ if let Some(ref email) = email {
+ if !crate::api::validation::is_valid_email(email) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
+ )
+ .into_response();
+ }
}
let did = if let Some(d) = &input.did {
@@ -202,18 +211,77 @@ pub async fn create_account(
}
};
- let user_insert = sqlx::query!(
- "INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id",
- input.handle,
- input.email,
- did,
- password_hash
+ let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
+ let valid_channels = ["email", "discord", "telegram", "signal"];
+ if !valid_channels.contains(&verification_channel) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})),
+ )
+ .into_response();
+ }
+
+ let verification_recipient = match verification_channel {
+ "email" => match &input.email {
+ Some(email) if !email.trim().is_empty() => email.trim().to_string(),
+ _ => return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
+ ).into_response(),
+ },
+ "discord" => match &input.discord_id {
+ Some(id) if !id.trim().is_empty() => id.trim().to_string(),
+ _ => return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
+ ).into_response(),
+ },
+ "telegram" => match &input.telegram_username {
+ Some(username) if !username.trim().is_empty() => username.trim().to_string(),
+ _ => return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
+ ).into_response(),
+ },
+ "signal" => match &input.signal_number {
+ Some(number) if !number.trim().is_empty() => number.trim().to_string(),
+ _ => return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
+ ).into_response(),
+ },
+ _ => return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
+ ).into_response(),
+ };
+
+ let verification_code = format!("{:06}", rand::random::() % 1_000_000);
+ let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
+
+ let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
+ r#"INSERT INTO users (
+ handle, email, did, password_hash,
+ email_confirmation_code, email_confirmation_code_expires_at,
+ preferred_notification_channel,
+ discord_id, telegram_username, signal_number
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#,
)
+ .bind(&input.handle)
+ .bind(&email)
+ .bind(&did)
+ .bind(&password_hash)
+ .bind(&verification_code)
+ .bind(&code_expires_at)
+ .bind(verification_channel)
+ .bind(input.discord_id.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
+ .bind(input.telegram_username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
+ .bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
.fetch_one(&mut *tx)
.await;
let user_id = match user_insert {
- Ok(row) => row.id,
+ Ok((id,)) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error() {
if db_err.code().as_deref() == Some("23505") {
@@ -453,53 +521,6 @@ pub async fn create_account(
}
}
- let access_meta = crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
- error!("Error creating access token: {:?}", e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({"error": "InternalError"})),
- )
- .into_response()
- });
- let access_meta = match access_meta {
- Ok(m) => m,
- Err(r) => return r,
- };
-
- let refresh_meta = crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
- error!("Error creating refresh token: {:?}", e);
- (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({"error": "InternalError"})),
- )
- .into_response()
- });
- let refresh_meta = match refresh_meta {
- Ok(m) => m,
- Err(r) => return r,
- };
-
- let session_insert =
- sqlx::query!(
- "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
- did,
- access_meta.jti,
- refresh_meta.jti,
- access_meta.expires_at,
- refresh_meta.expires_at
- )
- .execute(&mut *tx)
- .await;
-
- if let Err(e) = session_insert {
- error!("Error inserting session: {:?}", e);
- return (
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(json!({"error": "InternalError"})),
- )
- .into_response();
- }
-
if let Err(e) = tx.commit().await {
error!("Error committing transaction: {:?}", e);
return (
@@ -509,18 +530,23 @@ pub async fn create_account(
.into_response();
}
- let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
- if let Err(e) = crate::notifications::enqueue_welcome(&state.db, user_id, &hostname).await {
- warn!("Failed to enqueue welcome notification: {:?}", e);
+ if let Err(e) = crate::notifications::enqueue_signup_verification(
+ &state.db,
+ user_id,
+ verification_channel,
+ &verification_recipient,
+ &verification_code,
+ ).await {
+ warn!("Failed to enqueue signup verification notification: {:?}", e);
}
(
StatusCode::OK,
Json(CreateAccountOutput {
- access_jwt: access_meta.token,
- refresh_jwt: refresh_meta.token,
handle: input.handle,
did,
+ verification_required: true,
+ verification_channel: verification_channel.to_string(),
}),
)
.into_response()
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 018c563..7ed9b42 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -5,6 +5,7 @@ pub mod feed;
pub mod identity;
pub mod moderation;
pub mod notification;
+pub mod notification_prefs;
pub mod proxy;
pub mod proxy_client;
pub mod read_after_write;
diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs
new file mode 100644
index 0000000..232ac1e
--- /dev/null
+++ b/src/api/notification_prefs.rs
@@ -0,0 +1,248 @@
+use axum::{
+ Json,
+ extract::State,
+ http::{HeaderMap, StatusCode},
+ response::{IntoResponse, Response},
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use sqlx::Row;
+use tracing::info;
+
+use crate::auth::validate_bearer_token;
+use crate::state::AppState;
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct NotificationPrefsResponse {
+ pub preferred_channel: String,
+ pub email: String,
+ pub discord_id: Option,
+ pub discord_verified: bool,
+ pub telegram_username: Option,
+ pub telegram_verified: bool,
+ pub signal_number: Option,
+ pub signal_verified: bool,
+}
+
+pub async fn get_notification_prefs(
+ State(state): State,
+ headers: HeaderMap,
+) -> Response {
+ let token = match crate::auth::extract_bearer_token_from_header(
+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
+ ) {
+ Some(t) => t,
+ None => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
+ )
+ .into_response()
+ }
+ };
+
+ let user = match validate_bearer_token(&state.db, &token).await {
+ Ok(u) => u,
+ Err(_) => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
+ )
+ .into_response()
+ }
+ };
+
+ let row = match sqlx::query(
+ r#"
+ SELECT
+ email,
+ preferred_notification_channel::text as channel,
+ discord_id,
+ discord_verified,
+ telegram_username,
+ telegram_verified,
+ signal_number,
+ signal_verified
+ FROM users
+ WHERE did = $1
+ "#
+ )
+ .bind(&user.did)
+ .fetch_one(&state.db)
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
+ )
+ .into_response()
+ }
+ };
+
+ let email: String = row.get("email");
+ let channel: String = row.get("channel");
+ let discord_id: Option = row.get("discord_id");
+ let discord_verified: bool = row.get("discord_verified");
+ let telegram_username: Option = row.get("telegram_username");
+ let telegram_verified: bool = row.get("telegram_verified");
+ let signal_number: Option = row.get("signal_number");
+ let signal_verified: bool = row.get("signal_verified");
+
+ Json(NotificationPrefsResponse {
+ preferred_channel: channel,
+ email,
+ discord_id,
+ discord_verified,
+ telegram_username,
+ telegram_verified,
+ signal_number,
+ signal_verified,
+ })
+ .into_response()
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateNotificationPrefsInput {
+ pub preferred_channel: Option,
+ pub discord_id: Option,
+ pub telegram_username: Option,
+ pub signal_number: Option,
+}
+
+pub async fn update_notification_prefs(
+ State(state): State,
+ headers: HeaderMap,
+ Json(input): Json,
+) -> Response {
+ let token = match crate::auth::extract_bearer_token_from_header(
+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
+ ) {
+ Some(t) => t,
+ None => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
+ )
+ .into_response()
+ }
+ };
+
+ let user = match validate_bearer_token(&state.db, &token).await {
+ Ok(u) => u,
+ Err(_) => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
+ )
+ .into_response()
+ }
+ };
+
+ if let Some(ref channel) = input.preferred_channel {
+ let valid_channels = ["email", "discord", "telegram", "signal"];
+ if !valid_channels.contains(&channel.as_str()) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "Invalid channel. Must be one of: email, discord, telegram, signal"
+ })),
+ )
+ .into_response();
+ }
+
+ if let Err(e) = sqlx::query(
+ r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"#
+ )
+ .bind(channel)
+ .bind(&user.did)
+ .execute(&state.db)
+ .await
+ {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
+ )
+ .into_response();
+ }
+
+ info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
+ }
+
+ if let Some(ref discord_id) = input.discord_id {
+ let discord_id_clean: Option<&str> = if discord_id.is_empty() {
+ None
+ } else {
+ Some(discord_id.as_str())
+ };
+
+ if let Err(e) = sqlx::query(
+ r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"#
+ )
+ .bind(discord_id_clean)
+ .bind(&user.did)
+ .execute(&state.db)
+ .await
+ {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
+ )
+ .into_response();
+ }
+
+ info!(did = %user.did, "Updated Discord ID");
+ }
+
+ if let Some(ref telegram) = input.telegram_username {
+ let telegram_clean: Option<&str> = if telegram.is_empty() {
+ None
+ } else {
+ Some(telegram.trim_start_matches('@'))
+ };
+
+ if let Err(e) = sqlx::query(
+ r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"#
+ )
+ .bind(telegram_clean)
+ .bind(&user.did)
+ .execute(&state.db)
+ .await
+ {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
+ )
+ .into_response();
+ }
+
+ info!(did = %user.did, "Updated Telegram username");
+ }
+
+ if let Some(ref signal) = input.signal_number {
+ let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) };
+
+ if let Err(e) = sqlx::query(
+ r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"#
+ )
+ .bind(signal_clean)
+ .bind(&user.did)
+ .execute(&state.db)
+ .await
+ {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
+ )
+ .into_response();
+ }
+
+ info!(did = %user.did, "Updated Signal number");
+ }
+
+ Json(json!({"success": true})).into_response()
+}
diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs
index e0c242f..48891fb 100644
--- a/src/api/repo/record/batch.rs
+++ b/src/api/repo/record/batch.rs
@@ -1,4 +1,5 @@
use super::validation::validate_record;
+use super::write::has_verified_notification_channel;
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -110,6 +111,28 @@ pub async fn apply_writes(
.into_response();
}
+ match has_verified_notification_channel(&state.db, &did).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::FORBIDDEN,
+ Json(json!({
+ "error": "AccountNotVerified",
+ "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
+ })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ error!("DB error checking notification channels: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError"})),
+ )
+ .into_response();
+ }
+ }
+
if input.writes.is_empty() {
return (
StatusCode::BAD_REQUEST,
diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs
index ff827b2..539d1de 100644
--- a/src/api/repo/record/write.rs
+++ b/src/api/repo/record/write.rs
@@ -14,11 +14,40 @@ use jacquard::types::string::Nsid;
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
use serde_json::json;
+use sqlx::{PgPool, Row};
use std::str::FromStr;
use std::sync::Arc;
use tracing::error;
use uuid::Uuid;
+pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result {
+ let row = sqlx::query(
+ r#"
+ SELECT
+ email_confirmed,
+ discord_verified,
+ telegram_verified,
+ signal_verified
+ FROM users
+ WHERE did = $1
+ "#
+ )
+ .bind(did)
+ .fetch_optional(db)
+ .await?;
+
+ match row {
+ Some(r) => {
+ let email_confirmed: bool = r.get("email_confirmed");
+ let discord_verified: bool = r.get("discord_verified");
+ let telegram_verified: bool = r.get("telegram_verified");
+ let signal_verified: bool = r.get("signal_verified");
+ Ok(email_confirmed || discord_verified || telegram_verified || signal_verified)
+ }
+ None => Ok(false),
+ }
+}
+
pub async fn prepare_repo_write(
state: &AppState,
headers: &HeaderMap,
@@ -52,6 +81,28 @@ pub async fn prepare_repo_write(
.into_response());
}
+ match has_verified_notification_channel(&state.db, &auth_user.did).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return Err((
+ StatusCode::FORBIDDEN,
+ Json(json!({
+ "error": "AccountNotVerified",
+ "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
+ })),
+ )
+ .into_response());
+ }
+ Err(e) => {
+ error!("DB error checking notification channels: {}", e);
+ return Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError"})),
+ )
+ .into_response());
+ }
+ }
+
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
.fetch_optional(&state.db)
.await
diff --git a/src/api/server/email.rs b/src/api/server/email.rs
index 382124e..af47514 100644
--- a/src/api/server/email.rs
+++ b/src/api/server/email.rs
@@ -343,8 +343,10 @@ pub async fn update_email(
.into_response();
}
- if new_email == current_email.to_lowercase() {
- return (StatusCode::OK, Json(json!({}))).into_response();
+ if let Some(ref current) = current_email {
+ if new_email == current.to_lowercase() {
+ return (StatusCode::OK, Json(json!({}))).into_response();
+ }
}
let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
diff --git a/src/api/server/meta.rs b/src/api/server/meta.rs
index 6e52070..cf6eed2 100644
--- a/src/api/server/meta.rs
+++ b/src/api/server/meta.rs
@@ -13,12 +13,19 @@ pub async fn robots_txt() -> impl IntoResponse {
}
pub async fn describe_server() -> impl IntoResponse {
+ let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let domains_str =
- std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| "example.com".to_string());
+ std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone());
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
+ let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
+ .map(|v| v == "true" || v == "1")
+ .unwrap_or(false);
+
Json(json!({
- "availableUserDomains": domains
+ "availableUserDomains": domains,
+ "inviteCodeRequired": invite_code_required,
+ "did": format!("did:web:{}", pds_hostname)
}))
}
diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs
index 475010e..0b3acc7 100644
--- a/src/api/server/mod.rs
+++ b/src/api/server/mod.rs
@@ -18,5 +18,5 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod
pub use meta::{describe_server, health, robots_txt};
pub use password::{request_password_reset, reset_password};
pub use service_auth::get_service_auth;
-pub use session::{create_session, delete_session, get_session, refresh_session};
+pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification};
pub use signing_key::reserve_signing_key;
diff --git a/src/api/server/session.rs b/src/api/server/session.rs
index 4129e6f..39160a3 100644
--- a/src/api/server/session.rs
+++ b/src/api/server/session.rs
@@ -8,6 +8,7 @@ use axum::{
response::{IntoResponse, Response},
};
use bcrypt::verify;
+use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
@@ -64,7 +65,13 @@ pub async fn create_session(
}
let row = match sqlx::query!(
- "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
+ r#"SELECT
+ u.id, u.did, u.handle, u.password_hash,
+ u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
+ k.key_bytes, k.encryption_version
+ FROM users u
+ JOIN user_keys k ON u.id = k.user_id
+ WHERE u.handle = $1 OR u.email = $1"#,
input.identifier
)
.fetch_optional(&state.db)
@@ -103,6 +110,23 @@ pub async fn create_session(
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
}
+ let is_verified = row.email_confirmed
+ || row.discord_verified
+ || row.telegram_verified
+ || row.signal_verified;
+
+ if !is_verified {
+ warn!("Login attempt for unverified account: {}", row.did);
+ return (
+ StatusCode::FORBIDDEN,
+ Json(json!({
+ "error": "AccountNotVerified",
+ "message": "Please verify your account before logging in",
+ "did": row.did
+ })),
+ ).into_response();
+ }
+
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
Ok(m) => m,
Err(e) => {
@@ -361,3 +385,230 @@ pub async fn refresh_session(
}
}
}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConfirmSignupInput {
+ pub did: String,
+ pub verification_code: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ConfirmSignupOutput {
+ pub access_jwt: String,
+ pub refresh_jwt: String,
+ pub handle: String,
+ pub did: String,
+}
+
+pub async fn confirm_signup(
+ State(state): State,
+ Json(input): Json,
+) -> Response {
+ info!("confirm_signup called for DID: {}", input.did);
+
+ let row = match sqlx::query!(
+ r#"SELECT
+ u.id, u.did, u.handle,
+ u.email_confirmation_code,
+ u.email_confirmation_code_expires_at,
+ u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
+ k.key_bytes, k.encryption_version
+ FROM users u
+ JOIN user_keys k ON u.id = k.user_id
+ WHERE u.did = $1"#,
+ input.did
+ )
+ .fetch_optional(&state.db)
+ .await
+ {
+ Ok(Some(row)) => row,
+ Ok(None) => {
+ warn!("User not found for confirm_signup: {}", input.did);
+ return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response();
+ }
+ Err(e) => {
+ error!("Database error in confirm_signup: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ };
+
+ let stored_code = match &row.email_confirmation_code {
+ Some(code) => code,
+ None => {
+ warn!("No verification code found for user: {}", input.did);
+ return ApiError::InvalidRequest("No pending verification".into()).into_response();
+ }
+ };
+
+ if stored_code != &input.verification_code {
+ warn!("Invalid verification code for user: {}", input.did);
+ return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
+ }
+
+ if let Some(expires_at) = row.email_confirmation_code_expires_at {
+ if expires_at < Utc::now() {
+ warn!("Verification code expired for user: {}", input.did);
+ return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
+ }
+ }
+
+ let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
+ Ok(k) => k,
+ Err(e) => {
+ error!("Failed to decrypt user key: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ };
+
+ let verified_column = match row.channel {
+ crate::notifications::NotificationChannel::Email => "email_confirmed",
+ crate::notifications::NotificationChannel::Discord => "discord_verified",
+ crate::notifications::NotificationChannel::Telegram => "telegram_verified",
+ crate::notifications::NotificationChannel::Signal => "signal_verified",
+ };
+
+ let update_query = format!(
+ "UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1",
+ verified_column
+ );
+
+ if let Err(e) = sqlx::query(&update_query)
+ .bind(&input.did)
+ .execute(&state.db)
+ .await
+ {
+ error!("Failed to update verification status: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+
+ let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
+ Ok(m) => m,
+ Err(e) => {
+ error!("Failed to create access token: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ };
+
+ let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
+ Ok(m) => m,
+ Err(e) => {
+ error!("Failed to create refresh token: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ };
+
+ if let Err(e) = sqlx::query!(
+ "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
+ row.did,
+ access_meta.jti,
+ refresh_meta.jti,
+ access_meta.expires_at,
+ refresh_meta.expires_at
+ )
+ .execute(&state.db)
+ .await
+ {
+ error!("Failed to insert session: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+
+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
+ if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
+ warn!("Failed to enqueue welcome notification: {:?}", e);
+ }
+
+ Json(ConfirmSignupOutput {
+ access_jwt: access_meta.token,
+ refresh_jwt: refresh_meta.token,
+ handle: row.handle,
+ did: row.did,
+ }).into_response()
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ResendVerificationInput {
+ pub did: String,
+}
+
+pub async fn resend_verification(
+ State(state): State,
+ Json(input): Json,
+) -> Response {
+ info!("resend_verification called for DID: {}", input.did);
+
+ let row = match sqlx::query!(
+ r#"SELECT
+ id, handle, email,
+ preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
+ discord_id, telegram_username, signal_number,
+ email_confirmed, discord_verified, telegram_verified, signal_verified
+ FROM users
+ WHERE did = $1"#,
+ input.did
+ )
+ .fetch_optional(&state.db)
+ .await
+ {
+ Ok(Some(row)) => row,
+ Ok(None) => {
+ return ApiError::InvalidRequest("User not found".into()).into_response();
+ }
+ Err(e) => {
+ error!("Database error in resend_verification: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ };
+
+ let is_verified = row.email_confirmed
+ || row.discord_verified
+ || row.telegram_verified
+ || row.signal_verified;
+
+ if is_verified {
+ return ApiError::InvalidRequest("Account is already verified".into()).into_response();
+ }
+
+ let verification_code = format!("{:06}", rand::random::() % 1_000_000);
+ let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
+
+ if let Err(e) = sqlx::query!(
+ "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
+ verification_code,
+ code_expires_at,
+ input.did
+ )
+ .execute(&state.db)
+ .await
+ {
+ error!("Failed to update verification code: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+
+ let (channel_str, recipient) = match row.channel {
+ crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()),
+ crate::notifications::NotificationChannel::Discord => {
+ ("discord", row.discord_id.unwrap_or_default())
+ }
+ crate::notifications::NotificationChannel::Telegram => {
+ ("telegram", row.telegram_username.unwrap_or_default())
+ }
+ crate::notifications::NotificationChannel::Signal => {
+ ("signal", row.signal_number.unwrap_or_default())
+ }
+ };
+
+ if let Err(e) = crate::notifications::enqueue_signup_verification(
+ &state.db,
+ row.id,
+ channel_str,
+ &recipient,
+ &verification_code,
+ ).await {
+ warn!("Failed to enqueue verification notification: {:?}", e);
+ }
+
+ Json(json!({"success": true})).into_response()
+}
diff --git a/src/crawlers.rs b/src/crawlers.rs
index 46b4414..8ad8f4b 100644
--- a/src/crawlers.rs
+++ b/src/crawlers.rs
@@ -106,9 +106,13 @@ impl Crawlers {
cb.record_success().await;
}
} else {
+ let status = response.status();
+ let body = response.text().await.unwrap_or_default();
warn!(
crawler = %url,
- status = %response.status(),
+ status = %status,
+ body = %body,
+ hostname = %hostname,
"Crawler notification returned non-success status"
);
if let Some(cb) = cb {
diff --git a/src/lib.rs b/src/lib.rs
index 3ced835..9e8f192 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -21,9 +21,10 @@ use axum::{
routing::{any, get, post},
};
use state::AppState;
+use tower_http::services::{ServeDir, ServeFile};
pub fn app(state: AppState) -> Router {
- Router::new()
+ let router = Router::new()
.route("/health", get(api::server::health))
.route("/xrpc/_health", get(api::server::health))
.route("/robots.txt", get(api::server::robots_txt))
@@ -51,6 +52,14 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.server.refreshSession",
post(api::server::refresh_session),
)
+ .route(
+ "/xrpc/com.atproto.server.confirmSignup",
+ post(api::server::confirm_signup),
+ )
+ .route(
+ "/xrpc/com.atproto.server.resendVerification",
+ post(api::server::resend_verification),
+ )
.route(
"/xrpc/com.atproto.server.getServiceAuth",
get(api::server::get_service_auth),
@@ -364,6 +373,26 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.atproto.temp.checkSignupQueue",
get(api::temp::check_signup_queue),
)
+ .route(
+ "/xrpc/com.bspds.account.getNotificationPrefs",
+ get(api::notification_prefs::get_notification_prefs),
+ )
+ .route(
+ "/xrpc/com.bspds.account.updateNotificationPrefs",
+ post(api::notification_prefs::update_notification_prefs),
+ )
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
- .with_state(state)
+ .with_state(state);
+
+ let frontend_dir = std::env::var("FRONTEND_DIR")
+ .unwrap_or_else(|_| "./frontend/dist".to_string());
+
+ if std::path::Path::new(&frontend_dir).join("index.html").exists() {
+ let index_path = format!("{}/index.html", frontend_dir);
+ let serve_dir = ServeDir::new(&frontend_dir)
+ .not_found_service(ServeFile::new(index_path));
+ router.fallback_service(serve_dir)
+ } else {
+ router
+ }
}
diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs
index 7c54d1f..a775865 100644
--- a/src/notifications/mod.rs
+++ b/src/notifications/mod.rs
@@ -9,7 +9,7 @@ pub use sender::{
pub use service::{
channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update,
enqueue_email_verification, enqueue_notification, enqueue_password_reset,
- enqueue_plc_operation, enqueue_welcome, NotificationService,
+ enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService,
};
pub use types::{
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
diff --git a/src/notifications/service.rs b/src/notifications/service.rs
index 0180bb1..2f4d80b 100644
--- a/src/notifications/service.rs
+++ b/src/notifications/service.rs
@@ -256,7 +256,7 @@ pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) ->
pub struct UserNotificationPrefs {
pub channel: NotificationChannel,
- pub email: String,
+ pub email: Option,
pub handle: String,
}
@@ -303,7 +303,7 @@ pub async fn enqueue_welcome(
user_id,
prefs.channel,
super::types::NotificationType::Welcome,
- prefs.email.clone(),
+ prefs.email.clone().unwrap_or_default(),
Some(format!("Welcome to {}", hostname)),
body,
),
@@ -356,7 +356,7 @@ pub async fn enqueue_password_reset(
user_id,
prefs.channel,
super::types::NotificationType::PasswordReset,
- prefs.email.clone(),
+ prefs.email.clone().unwrap_or_default(),
Some(format!("Password Reset - {}", hostname)),
body,
),
@@ -409,7 +409,7 @@ pub async fn enqueue_account_deletion(
user_id,
prefs.channel,
super::types::NotificationType::AccountDeletion,
- prefs.email.clone(),
+ prefs.email.clone().unwrap_or_default(),
Some(format!("Account Deletion Request - {}", hostname)),
body,
),
@@ -436,7 +436,7 @@ pub async fn enqueue_plc_operation(
user_id,
prefs.channel,
super::types::NotificationType::PlcOperation,
- prefs.email.clone(),
+ prefs.email.clone().unwrap_or_default(),
Some(format!("{} - PLC Operation Token", hostname)),
body,
),
@@ -463,7 +463,7 @@ pub async fn enqueue_2fa_code(
user_id,
prefs.channel,
super::types::NotificationType::TwoFactorCode,
- prefs.email.clone(),
+ prefs.email.clone().unwrap_or_default(),
Some(format!("Sign-in Verification - {}", hostname)),
body,
),
@@ -479,3 +479,44 @@ pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
NotificationChannel::Signal => "Signal",
}
}
+
+pub async fn enqueue_signup_verification(
+ db: &PgPool,
+ user_id: Uuid,
+ channel: &str,
+ recipient: &str,
+ code: &str,
+) -> Result {
+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
+
+ let notification_channel = match channel {
+ "email" => NotificationChannel::Email,
+ "discord" => NotificationChannel::Discord,
+ "telegram" => NotificationChannel::Telegram,
+ "signal" => NotificationChannel::Signal,
+ _ => NotificationChannel::Email,
+ };
+
+ let body = format!(
+ "Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.",
+ code, hostname
+ );
+
+ let subject = match notification_channel {
+ NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)),
+ _ => None,
+ };
+
+ enqueue_notification(
+ db,
+ NewNotification::new(
+ user_id,
+ notification_channel,
+ super::types::NotificationType::EmailVerification,
+ recipient.to_string(),
+ subject,
+ body,
+ ),
+ )
+ .await
+}
diff --git a/src/oauth/db/device.rs b/src/oauth/db/device.rs
index c60c422..8551f3b 100644
--- a/src/oauth/db/device.rs
+++ b/src/oauth/db/device.rs
@@ -6,7 +6,7 @@ use super::super::{DeviceData, OAuthError};
pub struct DeviceAccountRow {
pub did: String,
pub handle: String,
- pub email: String,
+ pub email: Option,
pub last_used_at: DateTime,
}
diff --git a/src/oauth/templates.rs b/src/oauth/templates.rs
index 6f8c80b..c4ce311 100644
--- a/src/oauth/templates.rs
+++ b/src/oauth/templates.rs
@@ -477,7 +477,7 @@ pub fn login_page(
pub struct DeviceAccount {
pub did: String,
pub handle: String,
- pub email: String,
+ pub email: Option,
pub last_used_at: DateTime,
}
@@ -493,6 +493,7 @@ pub fn account_selector_page(
.iter()
.map(|account| {
let initials = get_initials(&account.handle);
+ let email_display = account.email.as_deref().unwrap_or("");
format!(
r#"