this repo has no description
1
fork

Configure Feed

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

Unstick OAuth callback when fresh device has no local identity

Opake.init throws IdentityMissing on a device that just completed OAuth
but has no cryptographic identity yet — expected, not a login failure.
The callback's catch block was flattening it to session: error, so the
redirect-on-active effect never fired and the page stuck on the raw
error text.

Classify Opake.init outcomes as a discriminated union (opake /
identity-missing / signed-out) and route identity-missing through a
PDS-direct public-key probe to pick between RecoverIdentityView and
FreshAccountView. Apply the same classification in boot() so a valid
session with no local identity no longer silently degrades to
signed-out.

Also: full-width sign-in input.

+164 -36
+1 -1
apps/web/src/routes/devices/login.lazy.tsx
··· 61 61 Use the handle from any AT Protocol app — Bluesky, your own PDS, or anywhere else in the 62 62 Atmosphere. Opake doesn't issue its own accounts. 63 63 </p> 64 - <label className="input input-bordered mb-3 flex items-center gap-2"> 64 + <label className="input input-bordered mb-3 flex w-full items-center gap-2"> 65 65 <input 66 66 type="text" 67 67 placeholder="you.bsky.social"
+163 -35
apps/web/src/stores/auth.ts
··· 10 10 import { create } from "zustand"; 11 11 import { immer } from "zustand/middleware/immer"; 12 12 import type { Opake, ResolvedIdentity } from "@opake/sdk"; 13 + import { OpakeError } from "@opake/sdk"; 13 14 import type { IndexedDbStorage } from "@opake/sdk/storage/indexeddb"; 14 15 import { base64ToUint8Array } from "@/lib/encoding"; 15 16 import { ensurePersistentStorage } from "@/lib/persistent-storage"; ··· 215 216 return keysMatch ? { status: "ready" } : { status: "conflict" }; 216 217 } 217 218 219 + /** 220 + * Resolve identity state when no Opake instance is available — i.e. when 221 + * `Opake.init` threw `IdentityMissing` because there's no local identity on 222 + * this device yet. We can't call `opake.resolveIdentity(...)` without an 223 + * instance, so probe the PDS directly for `app.opake.publicKey/self` to 224 + * distinguish `remote_only` (user has an Opake identity elsewhere, needs 225 + * recovery) from `none` (genuinely fresh account, needs identity creation). 226 + * 227 + * `com.atproto.repo.getRecord` is unauthenticated, so no session is needed. 228 + */ 229 + async function resolveIdentityStateWithoutOpake( 230 + storage: IndexedDbStorage, 231 + did: string, 232 + pdsUrl: string, 233 + ): Promise<IdentityState> { 234 + const hasRemoteKey = await probeRemotePublicKey(pdsUrl, did); 235 + // `resolveIdentityState` handles the local-identity lookup + match logic; 236 + // pass a minimal remote shape reflecting only whether a key exists. 237 + const remote: ResolvedIdentity | null = hasRemoteKey 238 + ? ({ publicKey: new Uint8Array(0) } as ResolvedIdentity) 239 + : null; 240 + const state = await resolveIdentityState(storage, did, remote); 241 + // With no local identity, `resolveIdentityState` returns `remote_only` for 242 + // any non-null remote — the empty-publicKey sentinel is never compared. 243 + // If somehow a local identity also exists (unusual but possible mid-flow), 244 + // fall back to `remote_only` so the user lands on the recovery UI rather 245 + // than a bogus `conflict` computed against an empty key. 246 + return state.status === "conflict" ? { status: "remote_only" } : state; 247 + } 248 + 249 + async function probeRemotePublicKey(pdsUrl: string, did: string): Promise<boolean> { 250 + const url = new URL("/xrpc/com.atproto.repo.getRecord", pdsUrl); 251 + url.searchParams.set("repo", did); 252 + url.searchParams.set("collection", "app.opake.publicKey"); 253 + url.searchParams.set("rkey", "self"); 254 + try { 255 + const res = await fetch(url); 256 + return res.ok; 257 + } catch { 258 + // Network failure during the probe. Defaulting to `false` lands the user 259 + // on the fresh-account flow; a real remote key will surface on the next 260 + // boot when the probe retries. 261 + return false; 262 + } 263 + } 264 + 265 + /** 266 + * Outcome of attempting to open an Opake instance for a stored account. 267 + * 268 + * Boot and OAuth-callback both share the same three-way fork: either we 269 + * successfully constructed an Opake (happy path), we have a valid session 270 + * but no local identity yet (the user needs to recover or pair), or the 271 + * session itself is unusable (missing, revoked, corrupt). Modelling the 272 + * outcome as a discriminated union keeps the call sites linear. 273 + */ 274 + type OpakeInitResult = 275 + | { readonly kind: "opake"; readonly opake: Opake } 276 + | { readonly kind: "identity-missing" } 277 + | { readonly kind: "signed-out" }; 278 + 279 + /** 280 + * Try to build an Opake for `did`, classifying the outcome. Also runs the 281 + * liveness probe (`checkSession`) — a dead session degrades to `signed-out` 282 + * so the caller doesn't have to repeat the dead-token cleanup dance. 283 + * 284 + * The `seedIndexerUrl` call runs on the happy path so the returned Opake is 285 + * ready to use immediately; callers just need to assign it to the module 286 + * singleton and carry on. 287 + */ 288 + async function classifyOpakeInit( 289 + OpakeCtor: typeof Opake, 290 + storage: IndexedDbStorage, 291 + did: string, 292 + ): Promise<OpakeInitResult> { 293 + try { 294 + const opake = await OpakeCtor.init({ storage }); 295 + await seedIndexerUrl(opake); 296 + return await classifySession(opake, storage, did); 297 + } catch (err) { 298 + if (err instanceof OpakeError && err.kind === "IdentityMissing") { 299 + return { kind: "identity-missing" }; 300 + } 301 + return { kind: "signed-out" }; 302 + } 303 + } 304 + 305 + /** 306 + * Liveness-check an already-constructed Opake. Opake.init loads tokens from 307 + * storage without validating them — stale or revoked tokens only fail on the 308 + * first real XRPC call. `checkSession` forces that round-trip early so a 309 + * dead session doesn't limp into the app and fail somewhere less obvious. 310 + */ 311 + async function classifySession( 312 + opake: Opake, 313 + storage: IndexedDbStorage, 314 + did: string, 315 + ): Promise<OpakeInitResult> { 316 + try { 317 + await opake.checkSession(); 318 + return { kind: "opake", opake }; 319 + } catch (err) { 320 + if (isDeadSessionError(err)) { 321 + opake.destroy(); 322 + await storage.clearSession(did).catch(() => { 323 + /* best effort — storage may already be gone */ 324 + }); 325 + return { kind: "signed-out" }; 326 + } 327 + // Non-auth error (network blip, PDS hiccup) — keep the instance, the 328 + // caller can retry when the user takes their next action. 329 + return { kind: "opake", opake }; 330 + } 331 + } 332 + 218 333 /** Derive identity from seed phrase, save, re-init Opake with the new identity. */ 219 334 async function deriveAndPersistIdentity(seedPhrase: string, did: string): Promise<void> { 220 335 const { Opake } = await loadSdk(); ··· 269 384 return; 270 385 } 271 386 272 - // Opake.init loads the session — may not exist yet on the 273 - // OAuth callback page (config saved pre-redirect, session 274 - // saved by completeLogin after redirect). 275 - const opake = await Opake.init({ storage: s }).catch(() => null); 276 - if (!opake) { 387 + // Opake.init can land in three distinct states: 388 + // 1. signed-out — session missing / dead / unreadable 389 + // 2. identity-missing — authenticated but no local keys yet 390 + // (user needs recovery or pairing) 391 + // 3. opake — happy path 392 + const result = await classifyOpakeInit(Opake, s, did); 393 + 394 + if (result.kind === "signed-out") { 395 + bootPromise = null; 277 396 set((draft) => { 278 397 draft.session = { status: "none" }; 279 398 }); 280 399 return; 281 400 } 282 - await seedIndexerUrl(opake); 283 - opakeInstance = opake; 284 401 285 - // Probe: verify the session is actually usable. Opake.init() 286 - // loads tokens from storage without validating them — stale 287 - // or revoked tokens only fail on the first real XRPC call. 288 - try { 289 - await opake.checkSession(); 290 - } catch (err) { 291 - if (isDeadSessionError(err)) { 292 - opakeInstance = null; 293 - opake.destroy(); 294 - await s.clearSession(did).catch(() => { 295 - /* best effort */ 296 - }); 297 - bootPromise = null; 298 - set((draft) => { 299 - draft.session = { status: "none" }; 300 - }); 301 - return; 302 - } 303 - // Non-auth error (network blip, etc.) — continue 402 + if (result.kind === "opake") { 403 + opakeInstance = result.opake; 304 404 } 305 405 306 406 set((draft) => { ··· 316 416 317 417 fetchProfileInBackground(did); 318 418 319 - const identityState = await fetchAndResolveIdentity(opake, did, s); 419 + const identityState = 420 + result.kind === "identity-missing" 421 + ? await resolveIdentityStateWithoutOpake(s, did, account.pds_url) 422 + : await fetchAndResolveIdentity(result.opake, did, s); 320 423 set((draft) => { 321 424 draft.identity = identityState; 322 425 }); ··· 388 491 redirectUri: redirectUri(), 389 492 }); 390 493 391 - const opake = await Opake.init({ storage: s }); 392 - await seedIndexerUrl(opake); 393 - opakeInstance = opake; 394 - 494 + // Mark session active as soon as OAuth completes — the session is 495 + // persisted, so the login itself has succeeded. Identity bootstrap 496 + // (which may fail with `IdentityMissing` on a fresh device) is a 497 + // separate concern handled below. 395 498 set((draft) => { 396 499 draft.session = { 397 500 status: "active", ··· 405 508 406 509 fetchProfileInBackground(pending.did); 407 510 408 - const identityState = await fetchAndResolveIdentity(opake, pending.did, s); 409 - set((draft) => { 410 - draft.identity = identityState; 411 - }); 511 + // Try to construct an Opake instance. On a device that has logged in 512 + // to this account before, this succeeds and the full identity-state 513 + // resolution runs. On a fresh device, `Opake.init` throws 514 + // `IdentityMissing` — expected, not a login failure. Fall back to a 515 + // PDS-direct probe so the `/devices` route picks the right view 516 + // (`RecoverIdentityView` vs `FreshAccountView`). 517 + try { 518 + const opake = await Opake.init({ storage: s }); 519 + await seedIndexerUrl(opake); 520 + opakeInstance = opake; 521 + 522 + const identityState = await fetchAndResolveIdentity(opake, pending.did, s); 523 + set((draft) => { 524 + draft.identity = identityState; 525 + }); 526 + } catch (err) { 527 + if (err instanceof OpakeError && err.kind === "IdentityMissing") { 528 + const identityState = await resolveIdentityStateWithoutOpake( 529 + s, 530 + pending.did, 531 + pending.pdsUrl, 532 + ); 533 + set((draft) => { 534 + draft.identity = identityState; 535 + }); 536 + } else { 537 + throw err; 538 + } 539 + } 412 540 413 541 // Reset boot promise so it doesn't return stale state 414 542 bootPromise = null;