Our Personal Data Server from scratch!
0
fork

Configure Feed

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

at main 691 lines 20 kB view raw
1import { api, ApiError, castSession, typedApi } from "./api.ts"; 2import type { 3 CreateAccountParams, 4 CreateAccountResult, 5 Session, 6} from "./types/api.ts"; 7import { 8 type AccessToken, 9 type Did, 10 type Handle, 11 type RefreshToken, 12 unsafeAsAccessToken, 13 unsafeAsDid, 14 unsafeAsHandle, 15 unsafeAsRefreshToken, 16} from "./types/branded.ts"; 17import { err, isErr, isOk, ok, type Result } from "./types/result.ts"; 18import { assertNever } from "./types/exhaustive.ts"; 19import { 20 checkForOAuthCallback, 21 clearAllOAuthState, 22 clearOAuthCallbackParams, 23 handleOAuthCallback, 24 refreshOAuthToken, 25 startOAuthLogin, 26} from "./oauth.ts"; 27import { setLocale, type SupportedLocale } from "./i18n.ts"; 28 29const STORAGE_KEY = "tranquil_pds_session"; 30const ACCOUNTS_KEY = "tranquil_pds_accounts"; 31 32export interface SavedAccount { 33 readonly did: Did; 34 readonly handle: Handle; 35 readonly accessJwt: AccessToken; 36 readonly refreshJwt: RefreshToken; 37} 38 39export type AuthError = 40 | { readonly type: "network"; readonly message: string } 41 | { readonly type: "unauthorized"; readonly message: string } 42 | { readonly type: "validation"; readonly message: string } 43 | { readonly type: "oauth"; readonly message: string } 44 | { readonly type: "unknown"; readonly message: string }; 45 46function toAuthError(e: unknown): AuthError { 47 if (e instanceof ApiError) { 48 if (e.status === 401) { 49 return { type: "unauthorized", message: e.message }; 50 } 51 return { type: "validation", message: e.message }; 52 } 53 if (e instanceof Error) { 54 if (e.message.includes("network") || e.message.includes("fetch")) { 55 return { type: "network", message: e.message }; 56 } 57 return { type: "unknown", message: e.message }; 58 } 59 return { type: "unknown", message: "An unknown error occurred" }; 60} 61 62type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error"; 63 64export type AuthState = 65 | { 66 readonly kind: "unauthenticated"; 67 readonly savedAccounts: readonly SavedAccount[]; 68 } 69 | { 70 readonly kind: "loading"; 71 readonly savedAccounts: readonly SavedAccount[]; 72 readonly previousSession: Session | null; 73 } 74 | { 75 readonly kind: "authenticated"; 76 readonly session: Session; 77 readonly savedAccounts: readonly SavedAccount[]; 78 } 79 | { 80 readonly kind: "error"; 81 readonly error: AuthError; 82 readonly savedAccounts: readonly SavedAccount[]; 83 }; 84 85function createUnauthenticated( 86 savedAccounts: readonly SavedAccount[], 87): AuthState { 88 return { kind: "unauthenticated", savedAccounts }; 89} 90 91function createLoading( 92 savedAccounts: readonly SavedAccount[], 93 previousSession: Session | null = null, 94): AuthState { 95 return { kind: "loading", savedAccounts, previousSession }; 96} 97 98function createAuthenticated( 99 session: Session, 100 savedAccounts: readonly SavedAccount[], 101): AuthState { 102 return { kind: "authenticated", session, savedAccounts }; 103} 104 105function createError( 106 error: AuthError, 107 savedAccounts: readonly SavedAccount[], 108): AuthState { 109 return { kind: "error", error, savedAccounts }; 110} 111 112const state = $state<{ current: AuthState }>({ 113 current: createLoading([]), 114}); 115 116function applyLocaleFromSession(sessionInfo: { 117 preferredLocale?: string | null; 118}): void { 119 if (sessionInfo.preferredLocale) { 120 setLocale(sessionInfo.preferredLocale as SupportedLocale); 121 } 122} 123 124function sessionToSavedAccount(session: Session): SavedAccount { 125 return { 126 did: unsafeAsDid(session.did), 127 handle: unsafeAsHandle(session.handle), 128 accessJwt: unsafeAsAccessToken(session.accessJwt), 129 refreshJwt: unsafeAsRefreshToken(session.refreshJwt), 130 }; 131} 132 133interface StoredSession { 134 readonly did: string; 135 readonly handle: string; 136 readonly accessJwt: string; 137 readonly refreshJwt: string; 138 readonly email?: string; 139 readonly emailConfirmed?: boolean; 140 readonly preferredChannel?: string; 141 readonly preferredChannelVerified?: boolean; 142 readonly preferredLocale?: string | null; 143} 144 145function parseStoredSession(json: string): Result<StoredSession, Error> { 146 try { 147 const parsed = JSON.parse(json); 148 if ( 149 typeof parsed === "object" && 150 parsed !== null && 151 typeof parsed.did === "string" && 152 typeof parsed.handle === "string" && 153 typeof parsed.accessJwt === "string" && 154 typeof parsed.refreshJwt === "string" 155 ) { 156 return ok(parsed as StoredSession); 157 } 158 return err(new Error("Invalid session format")); 159 } catch (e) { 160 return err(e instanceof Error ? e : new Error("Failed to parse session")); 161 } 162} 163 164function parseStoredAccounts(json: string): Result<SavedAccount[], Error> { 165 try { 166 const parsed = JSON.parse(json); 167 if (!Array.isArray(parsed)) { 168 return err(new Error("Invalid accounts format")); 169 } 170 const accounts: SavedAccount[] = parsed 171 .filter( 172 ( 173 a, 174 ): a is { 175 did: string; 176 handle: string; 177 accessJwt: string; 178 refreshJwt: string; 179 } => 180 typeof a === "object" && 181 a !== null && 182 typeof a.did === "string" && 183 typeof a.handle === "string" && 184 typeof a.accessJwt === "string" && 185 typeof a.refreshJwt === "string", 186 ) 187 .map((a) => ({ 188 did: unsafeAsDid(a.did), 189 handle: unsafeAsHandle(a.handle), 190 accessJwt: unsafeAsAccessToken(a.accessJwt), 191 refreshJwt: unsafeAsRefreshToken(a.refreshJwt), 192 })); 193 return ok(accounts); 194 } catch (e) { 195 return err(e instanceof Error ? e : new Error("Failed to parse accounts")); 196 } 197} 198 199function loadSessionFromStorage(): StoredSession | null { 200 const stored = localStorage.getItem(STORAGE_KEY); 201 if (!stored) return null; 202 const result = parseStoredSession(stored); 203 return isOk(result) ? result.value : null; 204} 205 206function loadSavedAccountsFromStorage(): readonly SavedAccount[] { 207 const stored = localStorage.getItem(ACCOUNTS_KEY); 208 if (!stored) return []; 209 const result = parseStoredAccounts(stored); 210 return isOk(result) ? result.value : []; 211} 212 213function persistSession(session: Session | null): void { 214 if (session) { 215 localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); 216 } else { 217 localStorage.removeItem(STORAGE_KEY); 218 } 219} 220 221function persistSavedAccounts(accounts: readonly SavedAccount[]): void { 222 localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); 223} 224 225function updateSavedAccounts( 226 accounts: readonly SavedAccount[], 227 session: Session, 228): readonly SavedAccount[] { 229 const newAccount = sessionToSavedAccount(session); 230 const filtered = accounts.filter((a) => a.did !== newAccount.did); 231 return [...filtered, newAccount]; 232} 233 234function removeSavedAccountByDid( 235 accounts: readonly SavedAccount[], 236 did: Did, 237): readonly SavedAccount[] { 238 return accounts.filter((a) => a.did !== did); 239} 240 241function findSavedAccount( 242 accounts: readonly SavedAccount[], 243 did: Did, 244): SavedAccount | undefined { 245 return accounts.find((a) => a.did === did); 246} 247 248function getSavedAccounts(): readonly SavedAccount[] { 249 return state.current.savedAccounts; 250} 251 252function setState(newState: AuthState): void { 253 state.current = newState; 254} 255 256function setAuthenticated(session: Session): void { 257 const accounts = updateSavedAccounts(getSavedAccounts(), session); 258 persistSession(session); 259 persistSavedAccounts(accounts); 260 setState(createAuthenticated(session, accounts)); 261} 262 263function setUnauthenticated(): void { 264 persistSession(null); 265 setState(createUnauthenticated(getSavedAccounts())); 266} 267 268function setError(error: AuthError): void { 269 setState(createError(error, getSavedAccounts())); 270} 271 272function setLoading(previousSession: Session | null = null): void { 273 setState(createLoading(getSavedAccounts(), previousSession)); 274} 275 276export function clearError(): void { 277 if (state.current.kind === "error") { 278 setState(createUnauthenticated(getSavedAccounts())); 279 } 280} 281 282async function tryRefreshToken(): Promise<AccessToken | null> { 283 if (state.current.kind !== "authenticated") return null; 284 const currentSession = state.current.session; 285 try { 286 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 287 const sessionInfo = await api.getSession( 288 unsafeAsAccessToken(tokens.access_token), 289 ); 290 const session: Session = { 291 ...sessionInfo, 292 accessJwt: unsafeAsAccessToken(tokens.access_token), 293 refreshJwt: tokens.refresh_token 294 ? unsafeAsRefreshToken(tokens.refresh_token) 295 : currentSession.refreshJwt, 296 }; 297 setAuthenticated(session); 298 return session.accessJwt; 299 } catch { 300 return null; 301 } 302} 303 304import { setTokenRefreshCallback } from "./api.ts"; 305 306export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 307 setTokenRefreshCallback(tryRefreshToken); 308 const savedAccounts = loadSavedAccountsFromStorage(); 309 setState(createLoading(savedAccounts)); 310 311 const oauthCallback = checkForOAuthCallback(); 312 if (oauthCallback) { 313 clearOAuthCallbackParams(); 314 try { 315 const tokens = await handleOAuthCallback( 316 oauthCallback.code, 317 oauthCallback.state, 318 ); 319 const sessionInfo = await api.getSession( 320 unsafeAsAccessToken(tokens.access_token), 321 ); 322 const session: Session = { 323 ...sessionInfo, 324 accessJwt: unsafeAsAccessToken(tokens.access_token), 325 refreshJwt: unsafeAsRefreshToken(tokens.refresh_token || ""), 326 }; 327 setAuthenticated(session); 328 applyLocaleFromSession(session); 329 return { oauthLoginCompleted: true }; 330 } catch (e) { 331 clearAllOAuthState(); 332 setError({ 333 type: "oauth", 334 message: e instanceof Error ? e.message : "OAuth login failed", 335 }); 336 return { oauthLoginCompleted: false }; 337 } 338 } 339 340 const stored = loadSessionFromStorage(); 341 if (stored) { 342 try { 343 const sessionInfo = await api.getSession( 344 unsafeAsAccessToken(stored.accessJwt), 345 ); 346 const session: Session = { 347 ...sessionInfo, 348 accessJwt: unsafeAsAccessToken(stored.accessJwt), 349 refreshJwt: unsafeAsRefreshToken(stored.refreshJwt), 350 }; 351 setAuthenticated(session); 352 applyLocaleFromSession(session); 353 } catch (e) { 354 if (e instanceof ApiError && e.status === 401) { 355 try { 356 const tokens = await refreshOAuthToken(stored.refreshJwt); 357 const sessionInfo = await api.getSession( 358 unsafeAsAccessToken(tokens.access_token), 359 ); 360 const session: Session = { 361 ...sessionInfo, 362 accessJwt: unsafeAsAccessToken(tokens.access_token), 363 refreshJwt: tokens.refresh_token 364 ? unsafeAsRefreshToken(tokens.refresh_token) 365 : unsafeAsRefreshToken(stored.refreshJwt), 366 }; 367 setAuthenticated(session); 368 applyLocaleFromSession(session); 369 } catch (refreshError) { 370 console.error("Token refresh failed during init:", refreshError); 371 setUnauthenticated(); 372 } 373 } else { 374 console.error("Non-401 error during getSession:", e); 375 setUnauthenticated(); 376 } 377 } 378 } else { 379 setState(createUnauthenticated(savedAccounts)); 380 } 381 382 return { oauthLoginCompleted: false }; 383} 384 385export async function login( 386 identifier: string, 387 password: string, 388): Promise<Result<Session, AuthError>> { 389 const currentState = state.current; 390 const previousSession = currentState.kind === "authenticated" 391 ? currentState.session 392 : null; 393 setLoading(previousSession); 394 395 const result = await typedApi.createSession(identifier, password); 396 if (isErr(result)) { 397 const error = toAuthError(result.error); 398 setError(error); 399 return err(error); 400 } 401 402 setAuthenticated(result.value); 403 return ok(result.value); 404} 405 406export async function loginWithOAuth(): Promise<Result<void, AuthError>> { 407 clearAllOAuthState(); 408 setLoading(); 409 try { 410 await startOAuthLogin(); 411 return ok(undefined); 412 } catch (e) { 413 const error = toAuthError(e); 414 setError(error); 415 return err(error); 416 } 417} 418 419export async function register( 420 params: CreateAccountParams, 421): Promise<Result<CreateAccountResult, AuthError>> { 422 try { 423 const result = await api.createAccount(params); 424 return ok(result); 425 } catch (e) { 426 return err(toAuthError(e)); 427 } 428} 429 430export async function confirmSignup( 431 did: Did, 432 verificationCode: string, 433): Promise<Result<Session, AuthError>> { 434 setLoading(); 435 try { 436 const result = await api.confirmSignup(did, verificationCode); 437 const session = castSession(result); 438 setAuthenticated(session); 439 return ok(session); 440 } catch (e) { 441 const error = toAuthError(e); 442 setError(error); 443 return err(error); 444 } 445} 446 447export async function resendVerification( 448 did: Did, 449): Promise<Result<void, AuthError>> { 450 try { 451 await api.resendVerification(did); 452 return ok(undefined); 453 } catch (e) { 454 return err(toAuthError(e)); 455 } 456} 457 458export function setSession(session: { 459 did: string; 460 handle: string; 461 accessJwt: string; 462 refreshJwt: string; 463}): void { 464 const newSession: Session = { 465 did: unsafeAsDid(session.did), 466 handle: unsafeAsHandle(session.handle), 467 accessJwt: unsafeAsAccessToken(session.accessJwt), 468 refreshJwt: unsafeAsRefreshToken(session.refreshJwt), 469 contactKind: "none", 470 accountKind: "active", 471 isAdmin: false, 472 }; 473 setAuthenticated(newSession); 474} 475 476export async function logout(): Promise<Result<void, AuthError>> { 477 if (state.current.kind === "authenticated") { 478 const { session } = state.current; 479 const did = unsafeAsDid(session.did); 480 try { 481 await fetch("/oauth/revoke", { 482 method: "POST", 483 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 484 body: new URLSearchParams({ token: session.refreshJwt }), 485 }); 486 } catch { 487 // Ignore revocation errors 488 } 489 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 490 persistSavedAccounts(accounts); 491 persistSession(null); 492 setState(createUnauthenticated(accounts)); 493 } else { 494 setUnauthenticated(); 495 } 496 return ok(undefined); 497} 498 499export async function switchAccount( 500 did: Did, 501): Promise<Result<Session, AuthError>> { 502 const account = findSavedAccount(getSavedAccounts(), did); 503 if (!account) { 504 return err({ type: "validation", message: "Account not found" }); 505 } 506 507 setLoading(); 508 509 try { 510 const sessionInfo = await api.getSession(account.accessJwt); 511 const session: Session = { 512 ...sessionInfo, 513 accessJwt: account.accessJwt, 514 refreshJwt: account.refreshJwt, 515 }; 516 setAuthenticated(session); 517 return ok(session); 518 } catch (e) { 519 if (e instanceof ApiError && e.status === 401) { 520 try { 521 const tokens = await refreshOAuthToken(account.refreshJwt); 522 const sessionInfo = await api.getSession( 523 unsafeAsAccessToken(tokens.access_token), 524 ); 525 const session: Session = { 526 ...sessionInfo, 527 accessJwt: unsafeAsAccessToken(tokens.access_token), 528 refreshJwt: tokens.refresh_token 529 ? unsafeAsRefreshToken(tokens.refresh_token) 530 : account.refreshJwt, 531 }; 532 setAuthenticated(session); 533 return ok(session); 534 } catch { 535 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 536 persistSavedAccounts(accounts); 537 const error: AuthError = { 538 type: "unauthorized", 539 message: "Session expired. Please log in again.", 540 }; 541 setState(createError(error, accounts)); 542 return err(error); 543 } 544 } 545 const error = toAuthError(e); 546 setError(error); 547 return err(error); 548 } 549} 550 551export function forgetAccount(did: Did): void { 552 const accounts = removeSavedAccountByDid(getSavedAccounts(), did); 553 persistSavedAccounts(accounts); 554 setState({ 555 ...state.current, 556 savedAccounts: accounts, 557 } as AuthState); 558} 559 560export function getAuthState(): AuthState { 561 return state.current; 562} 563 564export async function refreshSession(): Promise<Result<Session, AuthError>> { 565 if (state.current.kind !== "authenticated") { 566 return err({ type: "unauthorized", message: "Not authenticated" }); 567 } 568 const currentSession = state.current.session; 569 try { 570 const sessionInfo = await api.getSession(currentSession.accessJwt); 571 const session: Session = { 572 ...sessionInfo, 573 accessJwt: currentSession.accessJwt, 574 refreshJwt: currentSession.refreshJwt, 575 }; 576 setAuthenticated(session); 577 return ok(session); 578 } catch (e) { 579 console.error("Failed to refresh session:", e); 580 return err(toAuthError(e)); 581 } 582} 583 584export function getToken(): AccessToken | null { 585 if (state.current.kind === "authenticated") { 586 return state.current.session.accessJwt; 587 } 588 return null; 589} 590 591export async function getValidToken(): Promise<AccessToken | null> { 592 if (state.current.kind !== "authenticated") return null; 593 const currentSession = state.current.session; 594 try { 595 await api.getSession(currentSession.accessJwt); 596 return currentSession.accessJwt; 597 } catch (e) { 598 if (e instanceof ApiError && e.status === 401) { 599 try { 600 const tokens = await refreshOAuthToken(currentSession.refreshJwt); 601 const sessionInfo = await api.getSession( 602 unsafeAsAccessToken(tokens.access_token), 603 ); 604 const session: Session = { 605 ...sessionInfo, 606 accessJwt: unsafeAsAccessToken(tokens.access_token), 607 refreshJwt: tokens.refresh_token 608 ? unsafeAsRefreshToken(tokens.refresh_token) 609 : currentSession.refreshJwt, 610 }; 611 setAuthenticated(session); 612 return session.accessJwt; 613 } catch { 614 return null; 615 } 616 } 617 return null; 618 } 619} 620 621export function isAuthenticated(): boolean { 622 return state.current.kind === "authenticated"; 623} 624 625export function isLoading(): boolean { 626 return state.current.kind === "loading"; 627} 628 629export function getError(): AuthError | null { 630 return state.current.kind === "error" ? state.current.error : null; 631} 632 633export function getSession(): Session | null { 634 return state.current.kind === "authenticated" ? state.current.session : null; 635} 636 637export function matchAuthState<T>(handlers: { 638 unauthenticated: (accounts: readonly SavedAccount[]) => T; 639 loading: ( 640 accounts: readonly SavedAccount[], 641 previousSession: Session | null, 642 ) => T; 643 authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; 644 error: (error: AuthError, accounts: readonly SavedAccount[]) => T; 645}): T { 646 const current = state.current; 647 switch (current.kind) { 648 case "unauthenticated": 649 return handlers.unauthenticated(current.savedAccounts); 650 case "loading": 651 return handlers.loading(current.savedAccounts, current.previousSession); 652 case "authenticated": 653 return handlers.authenticated(current.session, current.savedAccounts); 654 case "error": 655 return handlers.error(current.error, current.savedAccounts); 656 default: 657 return assertNever(current); 658 } 659} 660 661export function _testSetState(newState: { 662 session: Session | null; 663 loading: boolean; 664 error: string | null; 665 savedAccounts?: SavedAccount[]; 666}): void { 667 const accounts = newState.savedAccounts ?? []; 668 if (newState.loading) { 669 setState(createLoading(accounts, newState.session)); 670 } else if (newState.error) { 671 setState( 672 createError({ type: "unknown", message: newState.error }, accounts), 673 ); 674 } else if (newState.session) { 675 setState(createAuthenticated(newState.session, accounts)); 676 } else { 677 setState(createUnauthenticated(accounts)); 678 } 679} 680 681export function _testResetState(): void { 682 setState(createLoading([])); 683} 684 685export function _testReset(): void { 686 _testResetState(); 687 localStorage.removeItem(STORAGE_KEY); 688 localStorage.removeItem(ACCOUNTS_KEY); 689} 690 691export { type Session };