PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.

Age assurance override env var

+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))