Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Age assurance override env var

+353 -48
+9
.env.example
··· 139 139 # REPORT_SERVICE_URL=https://mod.bsky.app 140 140 # REPORT_SERVICE_DID=did:plc:ar7c4by46qjdydhdevvrndac 141 141 # ============================================================================= 142 + # Age Assurance Override 143 + # ============================================================================= 144 + # Enable this if you have separately assured the ages of your users 145 + # (e.g., through your own age verification process). When enabled, the PDS 146 + # will return "assured" status for age assurance checks instead of proxying 147 + # to the appview. This helps migrated users avoid the age assurance 148 + # catch-22 on bsky.app. 149 + # PDS_AGE_ASSURANCE_OVERRIDE=1 150 + # ============================================================================= 142 151 # Miscellaneous 143 152 # ============================================================================= 144 153 # Allow HTTP for proxy requests (development only)
+16
.sqlx/query-6efda9a01aff3277386c617e8500150271613b6779178816d9acfb244b48066c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)\n ON CONFLICT (user_id, name) DO NOTHING", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Jsonb" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "6efda9a01aff3277386c617e8500150271613b6779178816d9acfb244b48066c" 16 + }
+16
.sqlx/query-839b7593dd13cfc4cd303a626c7e17c93d702ff1a8be8018f3f21e8fd3d550a8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)\n ON CONFLICT (user_id, name) DO NOTHING", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Jsonb" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "839b7593dd13cfc4cd303a626c7e17c93d702ff1a8be8018f3f21e8fd3d550a8" 16 + }
+22
.sqlx/query-b2294557cfcc57a9fa2ed90602ea66ce90ae92d28b41886c5bb9b81e4b53eaa2.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT created_at FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "created_at", 9 + "type_info": "Timestamptz" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "b2294557cfcc57a9fa2ed90602ea66ce90ae92d28b41886c5bb9b81e4b53eaa2" 22 + }
+39 -20
frontend/src/lib/migration/atproto-client.ts
··· 131 131 error: "Unknown", 132 132 message: res.statusText, 133 133 })); 134 - const error = new Error(err.message || err.error || res.statusText) as Error & { 135 - status: number; 136 - error: string; 137 - }; 134 + const error = new Error(err.message || err.error || res.statusText) as 135 + & Error 136 + & { 137 + status: number; 138 + error: string; 139 + }; 138 140 error.status = res.status; 139 141 error.error = err.error; 140 142 throw error; ··· 272 274 error: "Unknown", 273 275 message: res.statusText, 274 276 })); 275 - const error = new Error(err.message || err.error || res.statusText) as Error & { 276 - status: number; 277 - error: string; 278 - }; 277 + const error = new Error(err.message || err.error || res.statusText) as 278 + & Error 279 + & { 280 + status: number; 281 + error: string; 282 + }; 279 283 error.status = res.status; 280 284 error.error = err.error; 281 285 throw error; ··· 369 373 } 370 374 371 375 async deactivateAccount(migratingTo?: string): Promise<void> { 372 - apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, { 373 - migratingTo, 374 - }); 376 + apiLog( 377 + "POST", 378 + `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 + { 380 + migratingTo, 381 + }, 382 + ); 375 383 const start = Date.now(); 376 384 try { 377 385 const body: { migratingTo?: string } = {}; ··· 503 511 error: "Unknown", 504 512 message: res.statusText, 505 513 })); 506 - const error = new Error(err.message || err.error || res.statusText) as Error & { 507 - status: number; 508 - error: string; 509 - }; 514 + const error = new Error(err.message || err.error || res.statusText) as 515 + & Error 516 + & { 517 + status: number; 518 + error: string; 519 + }; 510 520 error.status = res.status; 511 521 error.error = err.error; 512 522 throw error; ··· 549 559 return directRes.json(); 550 560 } 551 561 552 - const protectedResourceUrl = `${pdsUrl}/.well-known/oauth-protected-resource`; 562 + const protectedResourceUrl = 563 + `${pdsUrl}/.well-known/oauth-protected-resource`; 553 564 const protectedRes = await fetch(protectedResourceUrl); 554 565 if (!protectedRes.ok) { 555 566 return null; ··· 561 572 return null; 562 573 } 563 574 564 - const authServerUrl = `${authServers[0]}/.well-known/oauth-authorization-server`; 575 + const authServerUrl = `${ 576 + authServers[0] 577 + }/.well-known/oauth-authorization-server`; 565 578 const authServerRes = await fetch(authServerUrl); 566 579 if (!authServerRes.ok) { 567 580 return null; ··· 595 608 for (let i = 0; i < bytes.length; i++) { 596 609 binary += String.fromCharCode(bytes[i]); 597 610 } 598 - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 611 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 612 + /=+$/, 613 + "", 614 + ); 599 615 } 600 616 601 617 export function base64UrlDecode(base64url: string): Uint8Array { ··· 730 746 error_description: res.statusText, 731 747 })); 732 748 throw new Error( 733 - retryErr.error_description || retryErr.error || "Token exchange failed", 749 + retryErr.error_description || retryErr.error || 750 + "Token exchange failed", 734 751 ); 735 752 } 736 753 return res.json(); 737 754 } 738 755 } 739 756 740 - throw new Error(err.error_description || err.error || "Token exchange failed"); 757 + throw new Error( 758 + err.error_description || err.error || "Token exchange failed", 759 + ); 741 760 } 742 761 743 762 return res.json();
+19 -8
frontend/src/lib/migration/flow.svelte.ts
··· 2 2 InboundMigrationState, 3 3 InboundStep, 4 4 MigrationProgress, 5 - OAuthServerMetadata, 6 5 OutboundMigrationState, 7 6 OutboundStep, 8 7 PasskeyAccountSetup, ··· 86 85 let sourceClient: AtprotoClient | null = null; 87 86 let localClient: AtprotoClient | null = null; 88 87 let localServerInfo: ServerDescription | null = null; 89 - let sourceOAuthMetadata: OAuthServerMetadata | null = null; 90 88 91 89 function setStep(step: InboundStep) { 92 90 state.step = step; ··· 271 269 if (state.authMethod === "passkey" && state.passkeySetupToken) { 272 270 localClient = createLocalClient(); 273 271 setStep("passkey-setup"); 274 - migrationLog("handleOAuthCallback: Resuming passkey flow at passkey-setup"); 272 + migrationLog( 273 + "handleOAuthCallback: Resuming passkey flow at passkey-setup", 274 + ); 275 275 } else { 276 276 setStep("email-verify"); 277 - migrationLog("handleOAuthCallback: Resuming at email-verify for re-auth"); 277 + migrationLog( 278 + "handleOAuthCallback: Resuming at email-verify for re-auth", 279 + ); 278 280 } 279 281 } else { 280 282 setStep(targetStep); ··· 337 339 serverDid: serverInfo.did, 338 340 }); 339 341 340 - migrationLog("startMigration: Getting service auth token from source PDS"); 342 + migrationLog( 343 + "startMigration: Getting service auth token from source PDS", 344 + ); 341 345 const { token } = await sourceClient.getServiceAuth( 342 346 serverInfo.did, 343 347 "com.atproto.server.createAccount", ··· 361 365 inviteCode: passkeyParams.inviteCode, 362 366 stateInviteCode: state.inviteCode, 363 367 }); 364 - passkeySetup = await localClient.createPasskeyAccount(passkeyParams, token); 368 + passkeySetup = await localClient.createPasskeyAccount( 369 + passkeyParams, 370 + token, 371 + ); 365 372 migrationLog("startMigration: Passkey account created on NEW PDS", { 366 373 did: passkeySetup.did, 367 374 hasAccessJwt: !!passkeySetup.accessJwt, ··· 743 750 migrationLog("Activating account on NEW PDS"); 744 751 const activateStart = Date.now(); 745 752 await localClient.activateAccount(); 746 - migrationLog("Account activated", { durationMs: Date.now() - activateStart }); 753 + migrationLog("Account activated", { 754 + durationMs: Date.now() - activateStart, 755 + }); 747 756 setProgress({ activated: true }); 748 757 749 758 setProgress({ currentOperation: "Deactivating old account..." }); ··· 757 766 setProgress({ deactivated: true }); 758 767 } catch (deactivateErr) { 759 768 const err = deactivateErr as Error & { error?: string }; 760 - migrationLog("Could not deactivate on source PDS", { error: err.message }); 769 + migrationLog("Could not deactivate on source PDS", { 770 + error: err.message, 771 + }); 761 772 } 762 773 763 774 migrationLog("completeDidWebMigration SUCCESS");
+3 -1
frontend/src/styles/migration.css
··· 352 352 border-radius: var(--radius-lg); 353 353 cursor: pointer; 354 354 margin-bottom: 0; 355 - transition: border-color var(--transition-normal), background-color var(--transition-normal); 355 + transition: 356 + border-color var(--transition-normal), 357 + background-color var(--transition-normal); 356 358 } 357 359 358 360 .auth-option:hover {
+2 -1
frontend/src/tests/Dashboard.test.ts
··· 77 77 setupAuthenticatedUser({ isAdmin: true }); 78 78 mockEndpoint( 79 79 "com.atproto.server.describeServer", 80 - () => jsonResponse(mockData.describeServer({ inviteCodeRequired: true })), 80 + () => 81 + jsonResponse(mockData.describeServer({ inviteCodeRequired: true })), 81 82 ); 82 83 render(Dashboard); 83 84 await waitFor(() => {
+9 -5
frontend/src/tests/Login.test.ts
··· 1 - import { beforeEach, describe, expect, it, vi } from "vitest"; 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 2 import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 3 import Login from "../routes/Login.svelte"; 4 4 import { ··· 15 15 clearMocks(); 16 16 setupFetchMock(); 17 17 globalThis.location.hash = ""; 18 - mockEndpoint("/oauth/par", () => 19 - jsonResponse({ request_uri: "urn:mock:request" }) 18 + mockEndpoint( 19 + "/oauth/par", 20 + () => jsonResponse({ request_uri: "urn:mock:request" }), 20 21 ); 21 22 }); 22 23 ··· 85 86 error: null, 86 87 savedAccounts, 87 88 }); 88 - mockEndpoint("com.atproto.server.getSession", () => 89 - jsonResponse(mockData.session({ handle: "alice.test.tranquil.dev" }))); 89 + mockEndpoint( 90 + "com.atproto.server.getSession", 91 + () => 92 + jsonResponse(mockData.session({ handle: "alice.test.tranquil.dev" })), 93 + ); 90 94 }); 91 95 92 96 it("displays saved accounts list", async () => {
+19 -9
frontend/src/tests/Settings.test.ts
··· 110 110 capturedBody = JSON.parse((options?.body as string) || "{}"); 111 111 return jsonResponse({}); 112 112 }); 113 - mockEndpoint("com.atproto.server.getSession", () => 114 - jsonResponse(mockData.session())); 113 + mockEndpoint( 114 + "com.atproto.server.getSession", 115 + () => jsonResponse(mockData.session()), 116 + ); 115 117 render(Settings); 116 118 await waitFor(() => { 117 119 expect(screen.getByRole("button", { name: /change email/i })) ··· 144 146 () => jsonResponse({ tokenRequired: true }), 145 147 ); 146 148 mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); 147 - mockEndpoint("com.atproto.server.getSession", () => 148 - jsonResponse(mockData.session())); 149 + mockEndpoint( 150 + "com.atproto.server.getSession", 151 + () => jsonResponse(mockData.session()), 152 + ); 149 153 render(Settings); 150 154 await waitFor(() => { 151 155 expect(screen.getByRole("button", { name: /change email/i })) ··· 188 192 expect(screen.getByRole("button", { name: /cancel/i })) 189 193 .toBeInTheDocument(); 190 194 }); 191 - const emailSection = screen.getByRole("heading", { name: /change email/i }) 195 + const emailSection = screen.getByRole("heading", { 196 + name: /change email/i, 197 + }) 192 198 .closest("section"); 193 199 const cancelButton = emailSection?.querySelector("button.secondary"); 194 200 if (cancelButton) { ··· 220 226 describe("handle change", () => { 221 227 beforeEach(() => { 222 228 setupAuthenticatedUser(); 223 - mockEndpoint("com.atproto.server.describeServer", () => 224 - jsonResponse(mockData.describeServer())); 229 + mockEndpoint( 230 + "com.atproto.server.describeServer", 231 + () => jsonResponse(mockData.describeServer()), 232 + ); 225 233 }); 226 234 it("displays current handle", async () => { 227 235 render(Settings); ··· 255 263 }); 256 264 it("shows success message after handle change", async () => { 257 265 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 258 - mockEndpoint("com.atproto.server.getSession", () => 259 - jsonResponse(mockData.session())); 266 + mockEndpoint( 267 + "com.atproto.server.getSession", 268 + () => jsonResponse(mockData.session()), 269 + ); 260 270 render(Settings); 261 271 await waitFor(() => { 262 272 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
+8 -2
frontend/src/tests/migration/atproto-client.test.ts
··· 1 - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 2 import { 3 3 base64UrlDecode, 4 4 base64UrlEncode, ··· 351 351 352 352 it("returns null and clears storage for expired key (> 24 hours)", async () => { 353 353 const stored = { 354 - privateJwk: { kty: "EC", crv: "P-256", x: "test", y: "test", d: "test" }, 354 + privateJwk: { 355 + kty: "EC", 356 + crv: "P-256", 357 + x: "test", 358 + y: "test", 359 + d: "test", 360 + }, 355 361 publicJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, 356 362 thumbprint: "test-thumb", 357 363 createdAt: Date.now() - 25 * 60 * 60 * 1000,
+1 -1
frontend/src/tests/migration/storage.test.ts
··· 1 - import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 2 import { 3 3 clearMigrationState, 4 4 getResumeInfo,
+3 -1
frontend/src/tests/migration/types.test.ts
··· 63 63 }); 64 64 65 65 it("can check if error is MigrationError", () => { 66 - const error = new MigrationError("Test", "ERR_TEST", true, { foo: "bar" }); 66 + const error = new MigrationError("Test", "ERR_TEST", true, { 67 + foo: "bar", 68 + }); 67 69 68 70 if (error instanceof MigrationError) { 69 71 expect(error.code).toBe("ERR_TEST");
+119
src/api/age_assurance.rs
··· 1 + use crate::auth::{extract_bearer_token_from_header, validate_bearer_token}; 2 + use crate::state::AppState; 3 + use axum::{ 4 + Json, 5 + body::Bytes, 6 + extract::{Path, RawQuery, State}, 7 + http::{HeaderMap, Method, StatusCode}, 8 + response::{IntoResponse, Response}, 9 + }; 10 + use serde_json::json; 11 + 12 + pub async fn get_state( 13 + State(state): State<AppState>, 14 + headers: HeaderMap, 15 + RawQuery(query): RawQuery, 16 + ) -> Response { 17 + if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_err() { 18 + return proxy_to_appview(state, headers, "app.bsky.ageassurance.getState", query).await; 19 + } 20 + 21 + let created_at = get_account_created_at(&state, &headers).await; 22 + let now = chrono::Utc::now().to_rfc3339(); 23 + 24 + ( 25 + StatusCode::OK, 26 + Json(json!({ 27 + "state": { 28 + "status": "assured", 29 + "access": "full", 30 + "lastInitiatedAt": now 31 + }, 32 + "metadata": { 33 + "accountCreatedAt": created_at 34 + } 35 + })), 36 + ) 37 + .into_response() 38 + } 39 + 40 + pub async fn get_age_assurance_state( 41 + State(state): State<AppState>, 42 + headers: HeaderMap, 43 + RawQuery(query): RawQuery, 44 + ) -> Response { 45 + if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_err() { 46 + return proxy_to_appview( 47 + state, 48 + headers, 49 + "app.bsky.unspecced.getAgeAssuranceState", 50 + query, 51 + ) 52 + .await; 53 + } 54 + 55 + (StatusCode::OK, Json(json!({"status": "assured"}))).into_response() 56 + } 57 + 58 + async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> { 59 + let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); 60 + tracing::debug!(?auth_header, "age assurance: extracting token"); 61 + 62 + let token = extract_bearer_token_from_header(auth_header)?; 63 + tracing::debug!("age assurance: got token, validating"); 64 + 65 + let auth_user = match validate_bearer_token(&state.db, &token).await { 66 + Ok(user) => { 67 + tracing::debug!(did = %user.did, "age assurance: validated user"); 68 + user 69 + } 70 + Err(e) => { 71 + tracing::warn!(?e, "age assurance: token validation failed"); 72 + return None; 73 + } 74 + }; 75 + 76 + let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", auth_user.did) 77 + .fetch_optional(&state.db) 78 + .await 79 + { 80 + Ok(r) => { 81 + tracing::debug!(?r, "age assurance: query result"); 82 + r 83 + } 84 + Err(e) => { 85 + tracing::warn!(?e, "age assurance: query failed"); 86 + return None; 87 + } 88 + }; 89 + 90 + row.map(|r| r.created_at.to_rfc3339()) 91 + } 92 + 93 + async fn proxy_to_appview( 94 + state: AppState, 95 + headers: HeaderMap, 96 + method: &str, 97 + query: Option<String>, 98 + ) -> Response { 99 + if headers.get("atproto-proxy").is_none() { 100 + return ( 101 + StatusCode::BAD_REQUEST, 102 + Json(json!({ 103 + "error": "InvalidRequest", 104 + "message": "Missing required atproto-proxy header" 105 + })), 106 + ) 107 + .into_response(); 108 + } 109 + 110 + crate::api::proxy::proxy_handler( 111 + State(state), 112 + Path(method.to_string()), 113 + Method::GET, 114 + headers, 115 + RawQuery(query), 116 + Bytes::new(), 117 + ) 118 + .await 119 + }
+18
src/api/identity/account.rs
··· 986 986 .into_response(); 987 987 } 988 988 } 989 + if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { 990 + let birthdate_pref = json!({ 991 + "$type": "app.bsky.actor.defs#personalDetailsPref", 992 + "birthDate": "1998-05-06T00:00:00.000Z" 993 + }); 994 + if let Err(e) = sqlx::query!( 995 + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) 996 + ON CONFLICT (user_id, name) DO NOTHING", 997 + user_id, 998 + "app.bsky.actor.defs#personalDetailsPref", 999 + birthdate_pref 1000 + ) 1001 + .execute(&mut *tx) 1002 + .await 1003 + { 1004 + warn!("Failed to set default birthdate preference: {:?}", e); 1005 + } 1006 + } 989 1007 if let Err(e) = tx.commit().await { 990 1008 error!("Error committing transaction: {:?}", e); 991 1009 return (
+1
src/api/mod.rs
··· 1 1 pub mod actor; 2 2 pub mod admin; 3 + pub mod age_assurance; 3 4 pub mod delegation; 4 5 pub mod error; 5 6 pub mod identity;
+21
src/api/repo/import.rs
··· 478 478 { 479 479 warn!("Failed to sequence import event: {:?}", e); 480 480 } 481 + if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { 482 + let birthdate_pref = json!({ 483 + "$type": "app.bsky.actor.defs#personalDetailsPref", 484 + "birthDate": "1998-05-06T00:00:00.000Z" 485 + }); 486 + if let Err(e) = sqlx::query!( 487 + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) 488 + ON CONFLICT (user_id, name) DO NOTHING", 489 + user_id, 490 + "app.bsky.actor.defs#personalDetailsPref", 491 + birthdate_pref 492 + ) 493 + .execute(&state.db) 494 + .await 495 + { 496 + warn!( 497 + "Failed to set default birthdate preference for migrated user: {:?}", 498 + e 499 + ); 500 + } 501 + } 481 502 (StatusCode::OK, Json(json!({}))).into_response() 482 503 } 483 504 Err(ImportError::SizeLimitExceeded) => (
+19
src/api/server/passkey_account.rs
··· 706 706 .await; 707 707 } 708 708 709 + if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { 710 + let birthdate_pref = json!({ 711 + "$type": "app.bsky.actor.defs#personalDetailsPref", 712 + "birthDate": "1998-05-06T00:00:00.000Z" 713 + }); 714 + if let Err(e) = sqlx::query!( 715 + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3) 716 + ON CONFLICT (user_id, name) DO NOTHING", 717 + user_id, 718 + "app.bsky.actor.defs#personalDetailsPref", 719 + birthdate_pref 720 + ) 721 + .execute(&mut *tx) 722 + .await 723 + { 724 + warn!("Failed to set default birthdate preference: {:?}", e); 725 + } 726 + } 727 + 709 728 if let Err(e) = tx.commit().await { 710 729 error!("Error committing transaction: {:?}", e); 711 730 return (
+1
src/delegation/audit.rs
··· 28 28 pub created_at: DateTime<Utc>, 29 29 } 30 30 31 + #[allow(clippy::too_many_arguments)] 31 32 pub async fn log_delegation_action( 32 33 pool: &PgPool, 33 34 delegated_did: &str,
+8
src/lib.rs
··· 626 626 "/xrpc/com.tranquil.delegation.createDelegatedAccount", 627 627 post(api::delegation::create_delegated_account), 628 628 ) 629 + .route( 630 + "/xrpc/app.bsky.ageassurance.getState", 631 + get(api::age_assurance::get_state), 632 + ) 633 + .route( 634 + "/xrpc/app.bsky.unspecced.getAgeAssuranceState", 635 + get(api::age_assurance::get_age_assurance_state), 636 + ) 629 637 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 630 638 .layer(DefaultBodyLimit::max(util::get_max_blob_size())) 631 639 .layer(middleware::from_fn(metrics::metrics_middleware))