From 8e5dc614fbcd2ec629ea09f5b6f49ecb1bc6571a Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 6 Nov 2026 09:57:20 -0500 Subject: [PATCH] Remember every signed-in account and switch between them headlessly Platform keeps the DIDs this browser signed in to, with each account's handle, display name, and avatar. switchAccount resumes a stored session in place and falls back to sign-in when it lapsed; signOut(did) leaves the other accounts signed in. The SDK gains actorProfile for the avatar. --- packages/app/src/pages/actor.ts | 6 +- packages/app/src/platform/commands.ts | 18 +++- packages/app/src/platform/state.ts | 108 ++++++++++++++++++++--- packages/app/tests/platform.test.ts | 114 +++++++++++++++++++++++++ packages/sdk/src/social/index.ts | 2 +- packages/sdk/src/social/page.ts | 24 ++++-- packages/sdk/tests/social-page.test.ts | 6 +- 7 files changed, 251 insertions(+), 27 deletions(-) diff --git a/packages/app/src/pages/actor.ts b/packages/app/src/pages/actor.ts index 42bb8fb..7c9253d 100644 --- a/packages/app/src/pages/actor.ts +++ b/packages/app/src/pages/actor.ts @@ -1,6 +1,5 @@ import { actorPage, - blobUrl, followers, myFollow, type Actor, @@ -8,6 +7,7 @@ import { type Session, type TangledRepoSummary, } from "@nibling/sdk"; +import { avatarUrlOf } from "../platform/state.ts"; import { cardFromProject, newestFirst, type ProjectCard } from "./cards.ts"; export interface ActorPageData { @@ -43,12 +43,12 @@ export async function loadActorPage(identifier: string, viewer?: Session): Promi const projects = page.projects.map((project) => cardFromProject(project, name)).sort(newestFirst); const byUri = new Map(projects.map((card) => [card.uri, card])); const pinned = profile.pinned.flatMap((ref) => byUri.get(ref.uri) ?? []); - const avatar = profile.avatar && "ref" in profile.avatar ? profile.avatar.ref.$link : undefined; + const avatarUrl = avatarUrlOf(actor, profile); return { actor, name, profile, - ...(avatar ? { avatarUrl: blobUrl(actor, avatar) } : {}), + ...(avatarUrl ? { avatarUrl } : {}), pinned, projects, tangledRepos: page.tangledRepos, diff --git a/packages/app/src/platform/commands.ts b/packages/app/src/platform/commands.ts index e2daba4..cd94a08 100644 --- a/packages/app/src/platform/commands.ts +++ b/packages/app/src/platform/commands.ts @@ -1,5 +1,5 @@ import type { ShellCommand } from "../shell/commands.ts"; -import type { Platform } from "./state.ts"; +import type { Did, Platform } from "./state.ts"; export interface PromptRequest { readonly title: string; @@ -12,6 +12,8 @@ export interface PromptRequest { readonly choices?: readonly { value: string; label: string }[]; } +const signInRequest: PromptRequest = { title: "Sign in with atproto", label: "Handle", submit: "Sign in", placeholder: "alice.bsky.social", autocomplete: "username" }; + /** Asks the person for one value in trusted chrome (D10). Resolves undefined when they cancel. */ export type Ask = (request: PromptRequest) => Promise; @@ -34,9 +36,19 @@ export function platformCommands(platform: Platform, ask: Ask): PlatformCommand[ { id: "account.signIn", title: "Sign In", + run: asking(signInRequest, (handle) => platform.signIn(handle)), + }, + { + id: "account.switch", + title: "Switch Account", run: asking( - { title: "Sign in with atproto", label: "Handle", submit: "Sign in", placeholder: "alice.bsky.social", autocomplete: "username" }, - (handle) => platform.signIn(handle), + () => { + const current = platform.state.account?.session.did; + const choices = platform.state.accounts.filter((a) => a.did !== current).map((a) => ({ value: a.did, label: a.handle ?? a.did })); + if (choices.length === 0) return Promise.resolve(signInRequest); + return Promise.resolve({ title: "Switch account", label: "Account", submit: "Switch", choices }); + }, + (value) => (value.startsWith("did:") ? platform.switchAccount(value as Did) : platform.signIn(value)), ), }, { id: "account.signOut", title: "Sign Out", run: () => void platform.signOut() }, diff --git a/packages/app/src/platform/state.ts b/packages/app/src/platform/state.ts index c50a9c3..2941d44 100644 --- a/packages/app/src/platform/state.ts +++ b/packages/app/src/platform/state.ts @@ -1,4 +1,6 @@ import { + actorProfile, + blobUrl, completeSignIn, createProject, diffAgainstBase, @@ -17,6 +19,8 @@ import { saveProject, signIn, signOut, + type Actor, + type ActorProfile, type FileTree, type Project, type ProjectRecord, @@ -26,7 +30,7 @@ import { import type { Unsubscribe } from "../shell/workbench.ts"; import { projectFiles, type ProjectFiles } from "./files.ts"; -type Did = Session["did"]; +export type Did = Session["did"]; type GitBase = NonNullable; export interface Account { @@ -34,6 +38,14 @@ export interface Account { handle?: string; } +/** An account this browser has signed in to, as the account menu shows it. Its session may have lapsed. */ +export interface KnownAccount { + readonly did: Did; + readonly handle?: string; + readonly displayName?: string; + readonly avatarUrl?: string; +} + export interface OpenProject { project: Project; /** The git commit the project's files overlay, with that commit's tree. */ @@ -47,7 +59,10 @@ export interface Notice { } export interface PlatformState { + /** The current account. */ readonly account?: Account; + /** Every account signed in on this browser and not signed out, in the order they first signed in. */ + readonly accounts: readonly KnownAccount[]; readonly project?: OpenProject; /** What is running now, such as "Saving". */ readonly busy?: string; @@ -77,6 +92,7 @@ export interface PlatformOptions { export const sdkAuth: Auth = { signIn, complete: completeSignIn, resume, signOut }; const DID_KEY = "nib.platform.did"; +const ACCOUNTS_KEY = "nib.platform.accounts"; const PROJECT_KEY = "nib.platform.project"; /** @@ -85,7 +101,7 @@ const PROJECT_KEY = "nib.platform.project"; * Each operation reports failure through `notice` rather than throwing, since commands fire and forget. */ export class Platform { - #state: PlatformState = {}; + #state: PlatformState = { accounts: [] }; readonly #listeners = new Set<(state: PlatformState) => void>(); readonly #auth: Auth; readonly #storage: KeyValue | undefined; @@ -118,7 +134,10 @@ export class Platform { boot(options: { project?: string | null } = {}): Promise { return this.#run("Signing in", async () => { const did = this.#recall(DID_KEY) as Did | undefined; - const session = did && (await this.#auth.resume(did)); + const dids = this.#recallAccounts(); + if (did && !dids.includes(did)) dids.unshift(did); + const [session, accounts] = await Promise.all([did && this.#auth.resume(did), Promise.all(dids.map(describe))]); + this.#set({ accounts }); if (session) await this.#signedIn(session); const uri = options.project === undefined ? session && this.#recall(PROJECT_KEY) : options.project; if (uri) await this.#load(uri); @@ -135,14 +154,40 @@ export class Platform { return this.#run("Signing in", async () => this.#signedIn(await this.#auth.complete(params))); } - signOut(): Promise { + /** + * Makes `did` the current account. A stored session that still resumes switches in place; one that + * lapsed sends the browser through sign-in for that account. The open project stays open. + */ + switchAccount(did: Did): Promise { + return this.#run("Switching account", async () => { + if (this.#state.account?.session.did === did) return; + const session = await this.#auth.resume(did); + if (session) return this.#signedIn(session); + const known = this.#state.accounts.find((account) => account.did === did); + await this.#auth.signIn(known?.handle ?? did); + }); + } + + /** + * Signs `did` out of this browser, the current account when it is undefined. Signing out the current + * account switches to the next one whose session resumes; with none left, it closes the project too. + */ + signOut(did: Did | undefined = this.#state.account?.session.did): Promise { return this.#run("Signing out", async () => { - const account = this.#state.account; - if (account) await this.#auth.signOut(account.session.did); + if (!did) return; + await this.#auth.signOut(did); + const accounts = this.#state.accounts.filter((account) => account.did !== did); + this.#rememberAccounts(accounts); + this.#set({ accounts }); + if (this.#state.account?.session.did !== did) return; this.#remember(DID_KEY, undefined); + for (const next of accounts) { + const session = await this.#auth.resume(next.did); + if (session) return this.#signedIn(session); + } + this.#set({ account: undefined }); this.#remember(PROJECT_KEY, undefined); this.#setProject(undefined); - this.#set({ account: undefined }); }); } @@ -267,8 +312,24 @@ export class Platform { async #signedIn(session: Session) { this.#remember(DID_KEY, session.did); - const handle = await resolveActor(session.did).then((actor) => actor.handle, () => undefined); - this.#set({ account: { session, ...(handle && { handle }) } }); + const known = await describe(session.did); + const listed = this.#state.accounts.some((account) => account.did === session.did); + const accounts = listed ? this.#state.accounts.map((account) => (account.did === session.did ? known : account)) : [...this.#state.accounts, known]; + this.#rememberAccounts(accounts); + this.#set({ account: { session, ...(known.handle && { handle: known.handle }) }, accounts }); + } + + #recallAccounts(): Did[] { + try { + const parsed: unknown = JSON.parse(this.#recall(ACCOUNTS_KEY) ?? "[]"); + return Array.isArray(parsed) ? parsed.filter((did): did is Did => typeof did === "string" && did.startsWith("did:")) : []; + } catch { + return []; + } + } + + #rememberAccounts(accounts: readonly KnownAccount[]) { + this.#remember(ACCOUNTS_KEY, accounts.length ? JSON.stringify(accounts.map((account) => account.did)) : undefined); } #show(open: OpenProject) { @@ -312,9 +373,9 @@ export class Platform { } #set(patch: Partial) { - const next: Record = { ...this.#state, ...patch }; - for (const key of Object.keys(next)) if (next[key] === undefined) delete next[key]; - this.#state = next as PlatformState; + const next = { ...this.#state, ...patch }; + for (const key of Object.keys(next) as (keyof PlatformState)[]) if (next[key] === undefined) delete next[key]; + this.#state = next; this.#emit(); } @@ -341,6 +402,29 @@ export class Platform { } } +/** Who `did` is: handle, display name, and avatar. Whatever fails to load is left out. */ +async function describe(did: Did): Promise { + try { + const { actor, profile } = await actorProfile(did); + const avatarUrl = avatarUrlOf(actor, profile); + return { + did, + ...(actor.handle && { handle: actor.handle }), + ...(profile.displayName && { displayName: profile.displayName }), + ...(avatarUrl && { avatarUrl }), + }; + } catch { + const handle = await resolveActor(did).then((actor) => actor.handle, () => undefined); + return { did, ...(handle && { handle }) }; + } +} + +/** A URL for a profile's avatar blob, on the gateway or the account's PDS. */ +export function avatarUrlOf(actor: Pick, profile: Pick): string | undefined { + const avatar = profile.avatar && "ref" in profile.avatar ? profile.avatar.ref.$link : undefined; + return avatar && blobUrl(actor, avatar); +} + /** An expected failure whose message is written for the person at the keyboard. */ export class PlatformError extends Error { override name = "PlatformError"; diff --git a/packages/app/tests/platform.test.ts b/packages/app/tests/platform.test.ts index 29bfee5..2933ca3 100644 --- a/packages/app/tests/platform.test.ts +++ b/packages/app/tests/platform.test.ts @@ -216,3 +216,117 @@ test("operations that need an account or a project say so", async () => { await platform.pushToTangled("m"); assert.deepEqual(platform.state.notice, { tone: "error", text: "Open a project first." }); }); + +/** OAuth as one browser keeps it: completed sign-ins store a session per DID until that DID signs out. */ +function browserAuth(stored = new Set()): Auth & { stored: Set } { + let pending: Session["did"] | undefined; + return { + stored, + signIn: async (handle) => void (pending = handle === "bob.test" ? bob : alice), + complete: async () => (stored.add(pending!), pds.session(pending!)), + resume: async (did) => (stored.has(did) ? pds.session(did) : undefined), + signOut: async (did) => void stored.delete(did), + }; +} + +async function signInAs(platform: Platform, handle: string) { + await platform.signIn(handle); + await platform.completeSignIn(new URLSearchParams()); +} + +const handles = (platform: Platform) => platform.state.accounts.map((a) => a.handle); + +test("a second sign-in adds an account, and switching resumes the stored session without signing in again", async () => { + const auth = browserAuth(); + const platform = new Platform({ auth, storage: memoryStorage() }); + await signInAs(platform, "alice.test"); + await signInAs(platform, "bob.test"); + assert.equal(platform.state.account?.handle, "bob.test"); + assert.deepEqual(handles(platform), ["alice.test", "bob.test"]); + + let redirected = false; + const signIn = auth.signIn; + auth.signIn = async (handle) => ((redirected = true), signIn(handle)); + await platform.switchAccount(alice); + assert.equal(platform.state.account?.session.did, alice); + assert.equal(platform.state.account?.handle, "alice.test"); + assert.equal(redirected, false); + assert.deepEqual(handles(platform), ["alice.test", "bob.test"]); +}); + +test("switching to an account whose session lapsed sends it through sign-in", async () => { + const auth = browserAuth(); + const platform = new Platform({ auth, storage: memoryStorage() }); + await signInAs(platform, "alice.test"); + await signInAs(platform, "bob.test"); + auth.stored.delete(alice); + const asked: string[] = []; + auth.signIn = async (handle) => void asked.push(handle); + await platform.switchAccount(alice); + assert.deepEqual(asked, ["alice.test"]); + assert.equal(platform.state.account?.session.did, bob); +}); + +test("signing out one account keeps the other signed in, and the project stays open", async () => { + const auth = browserAuth(); + const platform = new Platform({ auth, storage: memoryStorage(), starter: () => new Map([["a.txt", bytes("a")]]) }); + await signInAs(platform, "alice.test"); + await signInAs(platform, "bob.test"); + + await platform.signOut(alice); + assert.equal(platform.state.account?.session.did, bob); + assert.deepEqual(handles(platform), ["bob.test"]); + assert.deepEqual([...auth.stored], [bob]); + + await signInAs(platform, "alice.test"); + await platform.newProject("kept"); + await platform.signOut(); + assert.equal(platform.state.account?.session.did, bob); + assert.equal(platform.state.project?.project.record.name, "kept"); + + await platform.signOut(); + assert.equal(platform.state.account, undefined); + assert.deepEqual(platform.state.accounts, []); + assert.equal(platform.state.project, undefined); +}); + +test("the remembered accounts survive a fresh boot, with the current one resumed", async () => { + const storage = memoryStorage(); + const auth = browserAuth(); + const first = new Platform({ auth, storage }); + await signInAs(first, "alice.test"); + await signInAs(first, "bob.test"); + await first.switchAccount(alice); + + const rebooted = new Platform({ auth: browserAuth(auth.stored), storage }); + await rebooted.boot({ project: null }); + assert.equal(rebooted.state.account?.session.did, alice); + assert.deepEqual(handles(rebooted), ["alice.test", "bob.test"]); + await rebooted.switchAccount(bob); + assert.equal(rebooted.state.account?.session.did, bob); +}); + +test("unreadable or throwing storage only costs the remembered accounts", async () => { + const garbage = memoryStorage(); + garbage.setItem("nib.platform.accounts", "{not json"); + const booted = new Platform({ auth: browserAuth(), storage: garbage }); + await booted.boot({ project: null }); + assert.deepEqual(booted.state.accounts, []); + + const throwing: KeyValue = { + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + removeItem: () => { + throw new Error("denied"); + }, + }; + const platform = new Platform({ auth: browserAuth(), storage: throwing }); + await platform.boot({ project: null }); + await signInAs(platform, "alice.test"); + assert.deepEqual(handles(platform), ["alice.test"]); + assert.equal(platform.state.notice, undefined); +}); diff --git a/packages/sdk/src/social/index.ts b/packages/sdk/src/social/index.ts index d2f8dcc..14e2efa 100644 --- a/packages/sdk/src/social/index.ts +++ b/packages/sdk/src/social/index.ts @@ -2,4 +2,4 @@ export { like, unlike, likeCount, likers, myLike, type LikeSubject } from './lik export { comment, comments, type Comment, type CommentInput } from './comments.ts'; export { follow, unfollow, followers, following, myFollow, forks } from './follows.ts'; export { importFollows, type ImportFollowsOptions, type ImportFollowsResult } from './import.ts'; -export { actorPage, setProfile, type ActorPage, type ActorProfile, type TangledRepoSummary } from './page.ts'; +export { actorPage, actorProfile, setProfile, type ActorPage, type ActorProfile, type TangledRepoSummary } from './page.ts'; diff --git a/packages/sdk/src/social/page.ts b/packages/sdk/src/social/page.ts index b2a009b..bbc073b 100644 --- a/packages/sdk/src/social/page.ts +++ b/packages/sdk/src/social/page.ts @@ -39,22 +39,32 @@ export interface ActorPage { export async function actorPage(identifier: string): Promise { const actor = await resolveActor(identifier); const client = repoClient(actor.did, actor.pds); - - const [nibling, bsky, projects, tangledRepos] = await Promise.all([ - getSelfRecord(client, actor.did, PROFILE, RunNiblingActorProfile.mainSchema), - getSelfRecord(client, actor.did, BSKY_PROFILE, AppBskyActorProfile.mainSchema), + const [profile, projects, tangledRepos] = await Promise.all([ + profileOf(client, actor.did), listProjects(actor.did), listTangledRepos(client, actor.did), ]); + return { actor, profile, projects, tangledRepos }; +} - const profile: ActorProfile = { +/** Just an account's identity and profile, for showing who someone is without loading their page. */ +export async function actorProfile(identifier: string): Promise> { + const actor = await resolveActor(identifier); + return { actor, profile: await profileOf(repoClient(actor.did, actor.pds), actor.did) }; +} + +/** `run.nibling.actor.profile`, with each field falling back to `app.bsky.actor.profile`. */ +async function profileOf(client: Client, did: Did): Promise { + const [nibling, bsky] = await Promise.all([ + getSelfRecord(client, did, PROFILE, RunNiblingActorProfile.mainSchema), + getSelfRecord(client, did, BSKY_PROFILE, AppBskyActorProfile.mainSchema), + ]); + return { displayName: nibling?.displayName ?? bsky?.displayName, description: nibling?.description ?? bsky?.description, avatar: nibling?.avatar ?? bsky?.avatar, pinned: nibling?.pinned ?? [], }; - - return { actor, profile, projects, tangledRepos }; } /** Writes the session's `run.nibling.actor.profile`, merging `fields` into the existing record. */ diff --git a/packages/sdk/tests/social-page.test.ts b/packages/sdk/tests/social-page.test.ts index e3f3899..b50bade 100644 --- a/packages/sdk/tests/social-page.test.ts +++ b/packages/sdk/tests/social-page.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { actorPage, configure, createProject, setProfile } from '../src/index.ts'; +import { actorPage, actorProfile, configure, createProject, setProfile } from '../src/index.ts'; import { createFakePds, fakeNetwork } from './fake-pds.ts'; const pds = createFakePds({ url: 'https://pds.test' }); @@ -35,6 +35,10 @@ test('setProfile writes a nibling profile that then wins over the bsky fallback' const page = await actorPage(alice); assert.equal(page.profile.displayName, 'Alice Nibling'); assert.equal(page.profile.description, 'bsky bio'); + + const { actor, profile } = await actorProfile(alice); + assert.equal(actor.did, alice); + assert.deepEqual(profile, { displayName: 'Alice Nibling', description: 'bsky bio', avatar: undefined, pinned: [] }); }); test('actorPage works for a tangled org, an ordinary account with repo declarations', async () => { -- knotmirror 0.0.1