Automations and webhooks for the AT Protocol airglow.run

Easier sign-out and account switching #35

Closed tyb.there-hugo.dev
Closed opened by tyb.there-hugo.dev

Request #

Let a user be signed in to several accounts at once and switch between them without signing out and back in.

Clicking the avatar in the header opens a menu with:

  • Each signed-in account (avatar and @handle, with a check on the active one). Clicking one switches to it.
  • Add account: starts the normal OAuth sign-in and adds the new account to the list.
  • View profile: the avatar links to /u/<handle> today, so keep that as a menu item.
  • Sign out @handle: signs out only the active account and switches to the next one. With none left, it goes to /.

Remove the Sign out button from /settings/account. "Revoke sessions" stays there.

Switching accounts redirects to the new account's profile (/u/<handle>). This is simpler than working out whether the current page still makes sense for the new account.

Context #

From https://www.youtube.com/watch?v=5MnT-ZgbBIc, where the user built an automation that adds conference attendees to a Bluesky list:

Sign-out only exists on /settings/account. The user had to hunt for it to switch from their personal account to the AT Science account (about 1:28). People who run automations for an org account plus their own will switch often.

Details: docs/brainstorms/2026-10-17-graze-conference-automation-feedback.md (F7).

Most of what's needed is already in place:

  • OAuth sessions are stored server-side per DID, and web sign-out (app/routes/auth/signout.ts) only clears the cookie. Automations keep firing for an account that isn't the active one.
  • The airglow_session cookie holds one signed DID (lib/auth/middleware.ts, set in app/routes/auth/callback.tsx).

Suggested approach (open to change) #

  • Cookie: keep airglow_session as the active DID. Add a signed airglow_accounts cookie with the list of signed-in DIDs, capped at around 5. getSessionUser only accepts the active DID if it's in that list.
  • Callback: add the new DID to the list and make it active, instead of replacing the cookie.
  • Add account: /auth/login currently sends signed-in users away. Add a parameter like ?add=1 that skips that redirect and doesn't prefill the current handle.
  • Switch: POST /auth/switch with a did that must be in the list. Redirect to the new account's profile.
  • Sign out: POST /auth/signout removes only the active DID from the list and activates the next one. Keep the sign_out audit event, and add an account_switched event.
  • Menu: the header is server-rendered, so a native <details>/<summary> or popover menu works without a new island. It must be keyboard-accessible and close on Escape or an outside click.
  • Lost sessions: an account in the list whose OAuth session is gone (isOAuthSessionMissing) shows "Sign in again" instead of switching straight to it.

Edge cases #

  • Stale tabs: the cookie is shared across tabs. A form rendered for account A in one tab, submitted after switching to B in another, would run as B. This is the same "which account am I writing to" risk as #12. Mutating requests should send the DID they were rendered for, and the server should reject a mismatch with a clear "you switched accounts, reload" message.
  • Account cap: when the list is full, "Add account" says so instead of silently dropping an account.

Acceptance #

  • Sign in as A, add B, then switch back and forth from the header without re-authenticating.
  • Signing out of B leaves A signed in and active. Signing out of the last account leads to /.
  • /settings/account has no Sign out button.
  • Automations for the inactive account keep firing.
  • Tests cover the account list (add, switch, remove, an unlisted DID is rejected), /auth/login?add=1, and the stale-tab mismatch.

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreifsmm7pyhq3yczm6urclxq34jugezsgpdm5oreiubxwlvuwqobbke

Working on this, attempt 1.

loop:claim run=contribute-20261108-210747-35 branch=loop/35 attempt=1 issue=35

feat(auth): sign in to several accounts and switch between them from the header

A browser can now be signed in to up to 5 accounts at once. Clicking the header avatar opens an account menu where you can switch accounts, add one, view your profile, or sign out of only the active account. airglow_session still holds the active DID. A new signed airglow_accounts cookie holds the list, and getSessionUser only accepts the active DID while it is on that list. A page left open in another tab can no longer write as an account you switched to: each page records the DID it was rendered for, island writes send it back, and the server refuses a mismatch with a 409 "You switched accounts in another tab. Reload this page...".

What changed #

  • lib/auth/accounts.ts: reads and writes both cookies, and holds the list logic (add, switch, remove, cap of 5) plus the data the menu shows for each account.
  • lib/auth/account-guard.ts (safe to import in the browser): the X-Airglow-Account header, the account form field, the <meta name="airglow-account"> tag and accountHeaders().
  • Callback: adds the DID to the list and makes it active. If the list is full it shows an error instead of dropping an account.
  • /auth/login?add=1: signed-in users are no longer redirected away, the current handle is not prefilled, and the page reads "Add an account". When the list is full it shows a warning.
  • POST /auth/switch: accepts only a DID on the list, otherwise 403. It logs account_switched (with the previous account) and redirects to /u/<handle>. If that account's OAuth session is gone, it redirects to /auth/login?add=1&handle=....
  • POST /auth/signout: removes only the active DID, makes the next one active and redirects to its profile. When it was the last account, it redirects to /. It still logs sign_out, and also logs account_switched with reason: "sign_out" on the account that became active, so /settings/security shows "Switched to automatically after signing out of @x" and the event doesn't look like the user picked that account.
  • Stale tabs: a new global middleware returns 409 JSON when a write's X-Airglow-Account header names a DID that is no longer active. Every island write sends the header (AutomationForm, AutomationToolbar, SecretsManager, CocoreKeyPanel, NotificationSettings, SocialGraphSync, RevokeSessions, PartnerToggle, ShowcaseAdmin). These islands already show data.error, so the message appears where the save failed. RevokeSessions now shows the server's message too. The sign-out form sends the DID it was rendered for.
  • Revoke sessions: removes only that account from the list. Before, it cleared the cookie, which would have left the other accounts listed but none active.
  • /settings/account no longer has a Sign out button. "Revoke sessions" stays.
  • docs/oauth.md and CONCEPTS.md now describe the list, the switch, the guard and the new event.

What each state of the menu shows #

The avatar is now a <button popovertarget> named "Account menu for @handle". The menu is a native popover="auto", so Escape and a click outside close it with no new island and no inline script. Escape returns focus to the button, and the menu items come right after the button in tab order.

  • One account: "@alice" with a check (labelled "Active account"), "Add account", a divider, "View profile" (aria-current="page" on your own profile), "Sign out @alice".
  • Several accounts: each other account is a button "@bob" labelled "Switch to @bob".
  • An account whose OAuth session is gone: a link "@carol · Sign in again" labelled "Sign in again to @carol", going to /auth/login?add=1&handle=carol....
  • Five accounts: "Add account" is plain text, followed by "5 at most: sign out of one first".
  • Signed out: the "Sign in" button, as before.

The menu sits under the header row, aligned with the page gutter's right edge. It is built only from tokens (space, radii.lg, vars.shadow.md, vars.color.*, vars.focus.ring).

Verified #

  • pnpm run check and pnpm run test pass: 187 files, 2806 tests.
  • The new tests in app/routes/auth/accounts.test.tsx cover:
    • the list: add, cap, switch, remove, an unlisted DID being rejected, and a pre-list session being kept;
    • POST /auth/switch: switching to a listed account, the 403 for an unlisted one, and the redirect to sign in again;
    • POST /auth/signout: going to the next account, going to / after the last one, and refusing a stale tab;
    • the 409 for a write from a stale tab, and the meta tag;
    • /auth/login?add=1: no redirect, no prefill, the cap warning;
    • the menu markup.
  • SignOutForm.test.tsx checks that the hidden account field is present and that drafts are still cleared.
  • vp build --mode client succeeds, and the SignOutForm bundle pulls in no server code.
  • I could not run the real OAuth flow: there is no PDS or browser in this container. Owner check: sign in as alice, add bob, switch back and forth, sign out of bob (alice should stay active), then sign out of alice (should go to /). Also open the menu from the keyboard (Tab to the avatar, Enter, Tab through, Escape).

Acceptance #

Decisions you can reverse #

  • A browser with only the old airglow_session cookie (signed in before this change) counts as signed in to that one account, so nobody is signed out on deploy. Requiring the list cookie would sign everyone out once.
  • The stale-tab check refuses only a request that sends a DID that doesn't match. A request with no header still goes through (scripts, tests, a page rendered before deploy). Requiring the header would turn the check into a hard gate.
  • A sign-out from a stale tab gets a plain-text 409 response, not a styled page.
  • Signing out activates the account after the removed one in the list, or the one before it if the removed account was last.
  • When the list is full, signing in again to an account already on it still works. Only a new account is refused.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261108-210747-35 sha=3e7701ec0865476522e7be6c578fdb60ed9f963e

Verdict: changes-requested. The branch builds what the issue asks for: the account list cookie, add, switch, sign out of one account, the header popover menu, Sign out removed from /settings/account, and the stale-tab guard. pnpm run check and pnpm run test pass (187 files, 2806 tests). There are still gaps. A failed "Add account" quietly sends the user back to their profile. The add page's header says "Sign in" to someone who is signed in. The menu copies the page gutter values that utils.ts keeps in one place. The new revoke behaviour has no test and does not record its automatic switch. The owner-only AGENTS.md now gives the wrong way to sign out.

What I checked and how:

  • I read the issue against the code, not against pr.md. Each acceptance item has route tests in app/routes/auth/accounts.test.tsx: add, cap, switch, remove, an unlisted DID refused, ?add=1, the stale-tab 409, and the menu markup. Sign-out and switch never touch oauth_sessions or the workers, so automations keep firing.
  • I looked for every place that reads or writes the session cookie (rg COOKIE_NAME|airglow_session|*SignedCookie). Only getSessionUser reads the active DID now. lib/scripts/dev-jetstream-event.ts sets the session cookie alone, which still works through the path for sessions from before the list.
  • I looked for every client write (rg fetch( across app/, and every method="post" form). Every island write that mutates something sends accountHeaders(). The only native form posts are login, sign-out (which sends the account field) and switch.
  • I checked the menu against docs/design.md: tokens only, a 1px hairline gap, focus through vars.focus.ring, a native popover that Escape and an outside click close, and an accessible name on each row that tells it apart from the others.
  • Throwaway tests in /work, since removed:
    • I confirmed bug 1 below. The fixes for 1 and 2 work in the same harness as accounts.test.tsx.
    • The constant change in 3 passes vp check.
    • Steps for each are given with the change.

Changes:

  1. A failed "Add account" drops add=1. In app/routes/auth/login.tsx, the POST error redirects go to plain /auth/login?error=handle_required and /auth/login?error=authorize_failed. For a signed-in user, the GET then redirects to /u/<handle> and the error is never shown. A typo in the handle while adding an account just lands back on your own profile. I reproduced it: POST /auth/login?add=1 with handle typo.invalid gives Location: /auth/login?error=authorize_failed, and that page gives 302 /u/alice.test.

    • Fix: carry add=1 into both error redirects. For example, build them with new URLSearchParams({ error }) plus add=1 when c.req.query("add") === "1".
    • Tried: I ran the same flow with this fix. It went to /auth/login?error=authorize_failed&add=1, which returns 200 with "Add an account" and the error alert.
    • Test: add one in accounts.test.tsx that follows the redirect from the failed POST back to the add page.
  2. The add page's header says "Sign in" to a signed-in user. On /auth/login?add=1, the page reads "You stay signed in to @alice and can switch between them from the header". But it renders <Header /> with no user, so the header shows the "Sign in" button and no avatar menu.

    • Fix: render <Header user={adding ? user : null} /> in login.tsx.
    • Tried: with this change, the page shows "Account menu for @alice.test" and "Switch to @bob.test", and no "Sign in".
    • Test: add a check for this to the ?add=1 test.
  3. The menu copies the page gutter. menu.insetInlineEnd in app/components/Layout/Header/styles.css.ts writes 72rem, space[4] and space[6] (at mq.md) again. app/styles/utils.ts keeps those values in one place: "Written once here because three copies of it drifted apart before". If the gutter changes, the menu stops lining up with the avatar.

    • Fix: export the measure and the two paddings from utils.ts, for example pageMeasure = "72rem" and gutterPadding = { base: space[4], md: space[6] }. Use them in both pageGutter and the menu's insetInlineEnd.
    • Tried: this change passes vp check.
  4. Revoke sessions now switches accounts with no test and no record. app/routes/api/settings/revoke-sessions.ts used to clear the cookie. Now it takes only the revoked account off the list and makes the next one active. The island then goes to /auth/login, which redirects to the other account's profile. That switch is made by the system, but nothing records it, unlike sign-out, which logs account_switched with reason: "sign_out".

    • Fix: after removeAccount, when next.active is set, log account_switched on it with { from, fromHandle, reason: "revoke_sessions" }. Give it a label in formatDetails in app/routes/settings/security.tsx, for example "Switched to automatically after revoking @x's sessions".
    • Tests: add a route test for this endpoint, with getOAuthClient().restore mocked to give a session whose signOut resolves. With [A, B] and A active, the cookies should end as { active: B, dids: [B] } with the event written. With [A] alone, both cookies should be cleared.
  5. AGENTS.md is out of date (owner-only, so a follow-up). Lines 63–65 still say "go to /settings/account → click "Sign out"" and "Switch users: sign out, then sign in as the other." This branch removes that button. Add a followup.md that gives the exact edit. Replace those two paragraphs with:

    Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

    Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

The owner check in pr.md is a real OAuth run in a browser, with the menu used from the keyboard. Nobody can do it in this clone, so it stays with the owner after the merge.

loop:verdict run=review-20261108-213203-35 sha=3e7701ec0865476522e7be6c578fdb60ed9f963e verdict=changes-requested

Fix round 1: the review asked for changes.

loop:fix run=contribute-20261108-213747-35 round=1 issue=35

feat(auth): sign in to several accounts and switch between them from the header

A browser can now be signed in to up to 5 accounts at once. Clicking the header avatar opens an account menu where you can switch accounts, add one, view your profile, or sign out of only the active account. airglow_session still holds the active DID. A new signed airglow_accounts cookie holds the list, and getSessionUser only accepts the active DID while it is on that list. A page left open in another tab can no longer write as an account you switched to: each page records the DID it was rendered for, island writes send it back, and the server refuses a mismatch with a 409 "You switched accounts in another tab. Reload this page...".

Fix round 1 (from the review): a failed "Add account" now goes back to the add page with its error instead of to your profile, the add page's header shows your account menu instead of "Sign in", the menu's right edge reads the page gutter's measure and paddings from app/styles/utils.ts instead of copying them, and revoking sessions now records the automatic switch it makes (account_switched, reason: "revoke_sessions") with a test. The AGENTS.md sign-out steps are a follow-up, since that file is owner-only.

What changed #

  • lib/auth/accounts.ts: reads and writes both cookies, and holds the list logic (add, switch, remove, cap of 5) plus the data the menu shows for each account.
  • lib/auth/account-guard.ts (safe to import in the browser): the X-Airglow-Account header, the account form field, the <meta name="airglow-account"> tag and accountHeaders().
  • Callback: adds the DID to the list and makes it active. If the list is full it shows an error instead of dropping an account.
  • /auth/login?add=1: signed-in users are no longer redirected away, the current handle is not prefilled, and the page reads "Add an account". The header shows the signed-in account's menu, not "Sign in". When the list is full it shows a warning. A failed add (empty handle, or a handle that doesn't resolve) redirects to /auth/login?error=...&add=1, so the error shows on the add page instead of the GET sending the user to their profile.
  • POST /auth/switch: accepts only a DID on the list, otherwise 403. It logs account_switched (with the previous account) and redirects to /u/<handle>. If that account's OAuth session is gone, it redirects to /auth/login?add=1&handle=....
  • POST /auth/signout: removes only the active DID, makes the next one active and redirects to its profile. When it was the last account, it redirects to /. It still logs sign_out, and also logs account_switched with reason: "sign_out" on the account that became active, so /settings/security shows "Switched to automatically after signing out of @x" and the event doesn't look like the user picked that account. Sign-out and revoke share one helper for this, logAutomaticSwitch in lib/auth/accounts.ts.
  • Stale tabs: a new global middleware returns 409 JSON when a write's X-Airglow-Account header names a DID that is no longer active. Every island write sends the header (AutomationForm, AutomationToolbar, SecretsManager, CocoreKeyPanel, NotificationSettings, SocialGraphSync, RevokeSessions, PartnerToggle, ShowcaseAdmin). These islands already show data.error, so the message appears where the save failed. RevokeSessions now shows the server's message too. The sign-out form sends the DID it was rendered for.
  • Revoke sessions: removes only that account from the list. Before, it cleared the cookie, which would have left the other accounts listed but none active. When another account becomes active, it logs account_switched with reason: "revoke_sessions", shown on /settings/security as "Switched to automatically after revoking @x's sessions". With no account left, both cookies are cleared and nothing is logged.
  • /settings/account no longer has a Sign out button. "Revoke sessions" stays.
  • docs/oauth.md and CONCEPTS.md now describe the list, the switch, the guard and the new event.

What each state of the menu shows #

The avatar is now a <button popovertarget> named "Account menu for @handle". The menu is a native popover="auto", so Escape and a click outside close it with no new island and no inline script. Escape returns focus to the button, and the menu items come right after the button in tab order.

  • One account: "@alice" with a check (labelled "Active account"), "Add account", a divider, "View profile" (aria-current="page" on your own profile), "Sign out @alice".

  • Several accounts: each other account is a button "@bob" labelled "Switch to @bob".

  • An account whose OAuth session is gone: a link "@carol · Sign in again" labelled "Sign in again to @carol", going to /auth/login?add=1&handle=carol....

  • Five accounts: "Add account" is plain text, followed by "5 at most: sign out of one first".

  • Signed out: the "Sign in" button, as before.

  • On the add page (/auth/login?add=1): the header shows the account menu for the signed-in account, as on any other page. With an error, the page still reads "Add an account" and shows the error alert above the form.

The menu sits under the header row, aligned with the page gutter's right edge. Its offset uses pageMeasure and gutterPadding, now exported from app/styles/utils.ts and used by pageGutter too, so the menu follows the gutter if it changes. It is otherwise built only from tokens (space, radii.lg, vars.shadow.md, vars.color.*, vars.focus.ring).

Verified #

  • pnpm run check and pnpm run test pass: 2811 tests.
  • The new tests in app/routes/auth/accounts.test.tsx cover:
    • the list: add, cap, switch, remove, an unlisted DID being rejected, and a pre-list session being kept;
    • POST /auth/switch: switching to a listed account, the 403 for an unlisted one, and the redirect to sign in again;
    • POST /auth/signout: going to the next account, going to / after the last one, and refusing a stale tab;
    • the 409 for a write from a stale tab, and the meta tag;
    • /auth/login?add=1: no redirect, no prefill, the cap warning, the account menu in the header (no "Sign in"), and a failed add that follows its redirect back to the add page with the error (I checked the header assertion fails with the old <Header />);
    • POST /api/settings/revoke-sessions, with getOAuthClient().restore mocked: [A, B] with A active ends as { active: B, dids: [B] } with the revoke_sessions event on B; [A] alone clears both cookies and logs nothing;
    • the menu markup.
  • app/routes/settings/security.test.tsx checks the new "Switched to automatically after revoking @x's sessions" label.
  • SignOutForm.test.tsx checks that the hidden account field is present and that drafts are still cleared.
  • vp build --mode client succeeds, and the SignOutForm bundle pulls in no server code.
  • I could not run the real OAuth flow: there is no PDS or browser in this container. Owner check: sign in as alice, add bob, switch back and forth, sign out of bob (alice should stay active), then sign out of alice (should go to /). Also open the menu from the keyboard (Tab to the avatar, Enter, Tab through, Escape).

Acceptance #

Decisions you can reverse #

  • A browser with only the old airglow_session cookie (signed in before this change) counts as signed in to that one account, so nobody is signed out on deploy. Requiring the list cookie would sign everyone out once.
  • The stale-tab check refuses only a request that sends a DID that doesn't match. A request with no header still goes through (scripts, tests, a page rendered before deploy). Requiring the header would turn the check into a hard gate.
  • A sign-out from a stale tab gets a plain-text 409 response, not a styled page.
  • Signing out activates the account after the removed one in the list, or the one before it if the removed account was last.
  • When the list is full, signing in again to an account already on it still works. Only a new account is refused.

Follow-up, filed as a new issue on merge if the review agrees:

docs(agents): describe signing out and switching users from the header account menu

AGENTS.md (owner-only) still tells agents to sign out from /settings/account and to switch users by signing out and back in. The account-switching branch (#35) removed the Sign out button from /settings/account and moved sign-out and switching into the header's avatar menu, so an agent following lines 63–65 looks for a button that no longer exists. I checked with sed -n 60,66p AGENTS.md on the branch: the two paragraphs below are still there.

Replace these two paragraphs in AGENTS.md (under "Sign in / out"):

Sign out: go to http://127.0.0.1:5176/settings/account → click "Sign out" (POSTs to /auth/signout). Redirects to /.

Switch users: sign out, then sign in as the other.

with:

Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261108-213747-35 sha=8374897f5cd7fc139f4b4b9175da14a2c643f981

Verdict: changes-requested. Fix round 1 fixed all five points from the last review, and pnpm run check and pnpm run test pass (187 files, 2811 tests). One gap is left, and this branch causes it. "Sign out @handle" should sign out only the active account, but it still deletes every autosaved automation draft in the browser, including the drafts of accounts that stay signed in. Revoking sessions does the same. So if you start a draft as A, switch to B and sign out of B, A's draft is gone when you get back to A.

What I checked and how:

  • The five points from round 1, against the code at 8374897:
    • A failed add now keeps add=1 in both error redirects, and a test follows the redirect back to the add page.
    • The add page renders <Header user={adding ? user : null} />, and a test asserts the account menu is there and "Sign in" is not.
    • The menu's offset reads pageMeasure and gutterPadding, which utils.ts now exports and pageGutter uses too.
    • Revoking sessions now logs account_switched with reason: "revoke_sessions" through the shared logAutomaticSwitch. It has a route test for [A, B] and for [A], and /settings/security shows a label for it.
    • The AGENTS.md edit is in followup.md.
    • Sign-out still logs sign_out, then the automatic switch, and redirects to the next account's handle. Moving that into a helper did not change it.
  • The issue's acceptance list, against the code:
    • The account list: add, the cap of 5, switch, remove, and an unlisted DID refused with a 403.
    • Sign-out goes to the next account, or to / after the last one.
    • /settings/account has no Sign out button.
    • Sign-out and switch never touch oauth_sessions or the workers, so automations for inactive accounts keep firing.
    • ?add=1 neither redirects nor prefills the current handle, and shows the cap warning when the list is full.
    • The stale-tab 409. All of these have tests in app/routes/auth/accounts.test.tsx.
  • The stale-tab guard: I listed every client write myself (rg 'fetch\(' and every method="post"). Each mutating island fetch sends accountHeaders(). The only native posts are login, switch, and sign-out, and sign-out sends the account field. The islands already show data.error, so the 409 message appears where the save failed.
  • The menu, against docs/design.md:
    • It uses only tokens, apart from the 1px hairline.
    • Focus shows through vars.focus.ring.
    • It is a native popover: Escape and an outside click close it, and Escape returns focus to the avatar button.
    • Each row's accessible name tells it apart from the others.
    • At the cap, "Add account" shows the reason on the same line.
  • Docs: I searched for the deleted SignOutForm.css.ts and signOutButton, and for sign-out wording. Only the owner-only AGENTS.md is stale, and the follow-up covers it.
  • The draft bug: SignOutForm's onSubmit calls clearAllAutosavedDrafts(), which removes every airglow:automation-draft:* key. RevokeSessions calls it too. SignOutForm.test.tsx even seeds "bob's new" and asserts that signing out as alice clears it. Before this branch, signing out meant leaving the browser. Now another account can stay signed in, and its unsaved work is lost.

Changes:

  1. Clear only the signed-out account's drafts while another account stays signed in.
    • In app/islands/form-autosave.ts, give saveAutosavedDraft an optional viewer argument, stored as viewer: hashContext(viewer) on the entry next to savedAt/scope. In AutomationForm.tsx, pass viewerHandle at the saveAutosavedDraft(stored, autosaveContext, now, autosaveScope) call.
    • Add clearAutosavedDraftsFor(viewer). It removes the draft entries whose viewer matches hashContext(viewer), and entries with no viewer, since drafts saved before this change can't be attributed.
    • Give SignOutForm a last prop. On submit it calls clearAllAutosavedDrafts() when last is true, and clearAutosavedDraftsFor(handle) otherwise. In the header, pass last={accounts.length <= 1}.
    • Do the same for RevokeSessions. app/routes/settings/account.tsx passes the handle and whether it is the last account (from c.get("accounts")), so revoking A while B stays signed in keeps B's drafts.
    • Tests: update SignOutForm.test.tsx.
      • Signing out of bob with last={false} keeps alice's draft and removes bob's and the untagged one.
      • last clears every draft.
      • The same two cases for RevokeSessions.
    • How I tried it: I made the form-autosave, SignOutForm, AutomationForm and header changes above in this clone. A throwaway happy-dom test seeded drafts for alice (tagged), bob (tagged) and one untagged. Submitting <SignOutForm handle="bob.test" last={false} /> kept alice's draft and removed bob's and the untagged one. last removed them all. The island suite still passed (37 files). In the real flow, alice's draft is keyed by /automations/new and her context, and neither changes when she switches away and comes back through the "New" link. So the draft she gets back to is the one the fix kept. I could not run this in a browser: there is no PDS here.

The follow-up is accurate, so it is file. AGENTS.md lines 63–65 still say to sign out from /settings/account and to switch users by signing out, and this branch removes that button. The replacement text in followup.md matches the new menu. AGENTS.md is owner-only, so the contributor can't make the edit.

The owner check in pr.md stays with the owner after the merge: a real OAuth run in a browser, using the menu from the keyboard.

loop:verdict run=review-20261108-214636-35 sha=8374897f5cd7fc139f4b4b9175da14a2c643f981 verdict=changes-requested

Fix round 2: the review asked for changes.

loop:fix run=contribute-20261108-215237-35 round=2 issue=35

feat(auth): sign in to several accounts and switch between them from the header

A browser can now be signed in to up to 5 accounts at once. Clicking the header avatar opens an account menu where you can switch accounts, add one, view your profile, or sign out of only the active account. airglow_session still holds the active DID. A new signed airglow_accounts cookie holds the list, and getSessionUser only accepts the active DID while it is on that list. A page left open in another tab can no longer write as an account you switched to: each page records the DID it was rendered for, island writes send it back, and the server refuses a mismatch with a 409 "You switched accounts in another tab. Reload this page...".

Fix round 1 (from the review): a failed "Add account" now goes back to the add page with its error instead of to your profile, the add page's header shows your account menu instead of "Sign in", the menu's right edge reads the page gutter's measure and paddings from app/styles/utils.ts instead of copying them, and revoking sessions now records the automatic switch it makes (account_switched, reason: "revoke_sessions") with a test. The AGENTS.md sign-out steps are a follow-up, since that file is owner-only.

Fix round 2 (from the review): signing out of one account, or revoking its sessions, no longer deletes the autosaved automation drafts of accounts that stay signed in. Each draft now records a hash of the viewer's handle, and sign-out or revoke removes only that account's drafts, plus drafts saved before this change, which can't be attributed to anyone. Signing out of the last account still removes every draft in the browser.

What changed #

  • lib/auth/accounts.ts: reads and writes both cookies, and holds the list logic (add, switch, remove, cap of 5) plus the data the menu shows for each account.
  • lib/auth/account-guard.ts (safe to import in the browser): the X-Airglow-Account header, the account form field, the <meta name="airglow-account"> tag and accountHeaders().
  • Callback: adds the DID to the list and makes it active. If the list is full it shows an error instead of dropping an account.
  • /auth/login?add=1: signed-in users are no longer redirected away, the current handle is not prefilled, and the page reads "Add an account". The header shows the signed-in account's menu, not "Sign in". When the list is full it shows a warning. A failed add (empty handle, or a handle that doesn't resolve) redirects to /auth/login?error=...&add=1, so the error shows on the add page instead of the GET sending the user to their profile.
  • POST /auth/switch: accepts only a DID on the list, otherwise 403. It logs account_switched (with the previous account) and redirects to /u/<handle>. If that account's OAuth session is gone, it redirects to /auth/login?add=1&handle=....
  • POST /auth/signout: removes only the active DID, makes the next one active and redirects to its profile. When it was the last account, it redirects to /. It still logs sign_out, and also logs account_switched with reason: "sign_out" on the account that became active, so /settings/security shows "Switched to automatically after signing out of @x" and the event doesn't look like the user picked that account. Sign-out and revoke share one helper for this, logAutomaticSwitch in lib/auth/accounts.ts.
  • Stale tabs: a new global middleware returns 409 JSON when a write's X-Airglow-Account header names a DID that is no longer active. Every island write sends the header (AutomationForm, AutomationToolbar, SecretsManager, CocoreKeyPanel, NotificationSettings, SocialGraphSync, RevokeSessions, PartnerToggle, ShowcaseAdmin). These islands already show data.error, so the message appears where the save failed. RevokeSessions now shows the server's message too. The sign-out form sends the DID it was rendered for.
  • Revoke sessions: removes only that account from the list. Before, it cleared the cookie, which would have left the other accounts listed but none active. When another account becomes active, it logs account_switched with reason: "revoke_sessions", shown on /settings/security as "Switched to automatically after revoking @x's sessions". With no account left, both cookies are cleared and nothing is logged.
  • /settings/account no longer has a Sign out button. "Revoke sessions" stays.
  • Autosaved drafts: saveAutosavedDraft takes an optional viewer and stores viewer: hashContext(viewer) on the entry. AutomationForm passes viewerHandle. The new clearAutosavedDraftsFor(viewer) in app/islands/form-autosave.ts removes that viewer's drafts on every page, and drafts with no viewer. SignOutForm and RevokeSessions take handle and last. With last they call clearAllAutosavedDrafts() as before. Otherwise they call clearAutosavedDraftsFor(handle). The header passes last={accounts.length <= 1}, and /settings/account passes the handle and whether c.get("accounts") has one account or fewer. So if you start a draft as A, switch to B and sign out of B, A's draft is still there when you come back to A.
  • docs/oauth.md and CONCEPTS.md now describe the list, the switch, the guard and the new event.

What each state of the menu shows #

The avatar is now a <button popovertarget> named "Account menu for @handle". The menu is a native popover="auto", so Escape and a click outside close it with no new island and no inline script. Escape returns focus to the button, and the menu items come right after the button in tab order.

  • One account: "@alice" with a check (labelled "Active account"), "Add account", a divider, "View profile" (aria-current="page" on your own profile), "Sign out @alice".

  • Several accounts: each other account is a button "@bob" labelled "Switch to @bob".

  • An account whose OAuth session is gone: a link "@carol · Sign in again" labelled "Sign in again to @carol", going to /auth/login?add=1&handle=carol....

  • Five accounts: "Add account" is plain text, followed by "5 at most: sign out of one first".

  • Signed out: the "Sign in" button, as before.

  • On the add page (/auth/login?add=1): the header shows the account menu for the signed-in account, as on any other page. With an error, the page still reads "Add an account" and shows the error alert above the form.

The menu sits under the header row, aligned with the page gutter's right edge. Its offset uses pageMeasure and gutterPadding, now exported from app/styles/utils.ts and used by pageGutter too, so the menu follows the gutter if it changes. It is otherwise built only from tokens (space, radii.lg, vars.shadow.md, vars.color.*, vars.focus.ring).

Verified #

  • pnpm run check and pnpm run test pass: 2814 tests (round 2).
  • The new tests in app/routes/auth/accounts.test.tsx cover:
    • the list: add, cap, switch, remove, an unlisted DID being rejected, and a pre-list session being kept;
    • POST /auth/switch: switching to a listed account, the 403 for an unlisted one, and the redirect to sign in again;
    • POST /auth/signout: going to the next account, going to / after the last one, and refusing a stale tab;
    • the 409 for a write from a stale tab, and the meta tag;
    • /auth/login?add=1: no redirect, no prefill, the cap warning, the account menu in the header (no "Sign in"), and a failed add that follows its redirect back to the add page with the error (I checked the header assertion fails with the old <Header />);
    • POST /api/settings/revoke-sessions, with getOAuthClient().restore mocked: [A, B] with A active ends as { active: B, dids: [B] } with the revoke_sessions event on B; [A] alone clears both cookies and logs nothing;
    • the menu markup.
  • app/routes/settings/security.test.tsx checks the new "Switched to automatically after revoking @x's sessions" label.
  • SignOutForm.test.tsx seeds drafts for alice (edit and new pages), bob, and one with no viewer. It checks:
    • signing out of bob with last={false} keeps both of alice's drafts and removes bob's and the untagged one;
    • last removes every draft but leaves other keys alone;
    • revoking alice with last={false} keeps bob's draft;
    • revoking with last removes every draft;
    • the hidden account field is present.
  • form-autosave.test.ts covers clearAutosavedDraftsFor on its own.
  • vp build --mode client succeeds, and the SignOutForm bundle pulls in no server code.
  • I could not run the real OAuth flow: there is no PDS or browser in this container. Owner check: sign in as alice, add bob, switch back and forth, sign out of bob (alice should stay active), then sign out of alice (should go to /). Also open the menu from the keyboard (Tab to the avatar, Enter, Tab through, Escape).

Acceptance #

Decisions you can reverse #

  • A browser with only the old airglow_session cookie (signed in before this change) counts as signed in to that one account, so nobody is signed out on deploy. Requiring the list cookie would sign everyone out once.
  • The stale-tab check refuses only a request that sends a DID that doesn't match. A request with no header still goes through (scripts, tests, a page rendered before deploy). Requiring the header would turn the check into a hard gate.
  • A sign-out from a stale tab gets a plain-text 409 response, not a styled page.
  • Signing out activates the account after the removed one in the list, or the one before it if the removed account was last.
  • Drafts saved before this change have no viewer. Any sign-out removes them, because they can't be attributed. The other choice was to keep them until the last sign-out.
  • Drafts are tagged with the handle the page was rendered for, as the review asked, not the DID. A draft saved before a handle change is not removed by signing out under the new handle. It is removed by signing out of the last account, or when it expires after 7 days.
  • When the list is full, signing in again to an account already on it still works. Only a new account is refused.

Follow-up, filed as a new issue on merge if the review agrees:

docs(agents): describe signing out and switching users from the header account menu

AGENTS.md (owner-only) still tells agents to sign out from /settings/account and to switch users by signing out and back in. The account-switching branch (#35) removed the Sign out button from /settings/account and moved sign-out and switching into the header's avatar menu, so an agent following lines 63–65 looks for a button that no longer exists. I checked with sed -n 60,66p AGENTS.md on the branch: the two paragraphs below are still there.

Replace these two paragraphs in AGENTS.md (under "Sign in / out"):

Sign out: go to http://127.0.0.1:5176/settings/account → click "Sign out" (POSTs to /auth/signout). Redirects to /.

Switch users: sign out, then sign in as the other.

with:

Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261108-215237-35 sha=ef963db3ae899c76c2d3ad24500cd3e0ff53e9fb

Verdict: changes-requested. Fix round 2 does what the last review asked. Signing out of one account, or revoking its sessions, now removes only that account's autosaved drafts, plus the untagged ones. pnpm run check and pnpm run test pass (187 files, 2814 tests). One gap is left in the stale-tab case the issue names. SignOutForm deletes drafts in onSubmit, before the server has decided anything. So when the server refuses the sign-out with its 409 "You switched accounts in another tab", the drafts are already gone and nobody was signed out. When the tab was rendered with one account (last true), the drafts deleted are another signed-in account's.

What I checked and how:

  • Round 2's point, against the code at ef963db:
    • saveAutosavedDraft stores viewer: hashContext(viewer), and AutomationForm passes viewerHandle. That is the signed-in viewer on all three form routes: new, edit and duplicate.
    • clearAutosavedDraftsFor removes entries with that viewer, and entries with none.
    • The header passes last={accounts.length <= 1} and /settings/account passes (c.get("accounts") ?? []).length <= 1. If the account lookup failed, both fall back to clearing every draft, which is the safe side.
    • saveAutosavedDraft is the only writer of these keys, so every new draft carries its viewer.
  • Earlier rounds, still fixed: the failed add keeps add=1, the add page shows the account menu, the menu reads pageMeasure/gutterPadding, and revoke logs account_switched with reason: "revoke_sessions". Round 2 changed none of these files except to pass the new props.
  • Revoke from a stale tab: it clears drafts only after a successful response, and its fetch sends accountHeaders(), so the 409 leaves drafts alone. Only sign-out has this gap.
  • Docs: no doc still says that signing out clears every draft.
  • The follow-up: AGENTS.md lines 63–65 still say to sign out from /settings/account and to switch users by signing out, and this branch removes that button. The replacement text in followup.md matches the menu. AGENTS.md is owner-only, so the follow-up is file.

Changes:

  1. Clear drafts on sign-out only after the server accepts it.
    • The bug: in app/islands/SignOutForm.tsx, onSubmit={() => (last ? clearAllAutosavedDrafts() : clearAutosavedDraftsFor(handle))} runs before the native post. app/routes/auth/signout.ts can still refuse that post with a 409 when the form's account is no longer the active DID.
    • Example: tab 1 shows alice's menu with alice as the only account. In tab 2 the user adds bob and starts a draft as bob. Back in tab 1 they click "Sign out @alice". Bob's draft is deleted by clearAllAutosavedDrafts(), the server answers 409, and alice is still signed in.
    • Fix: in onSubmit, call ev.preventDefault(), then const res = await fetch(form.action, { method: "POST", body: new FormData(form) }). The fetch follows the redirect by default, and the cookies change as they do now.
      • If res.ok, clear the drafts as now (last ? clearAllAutosavedDrafts() : clearAutosavedDraftsFor(handle)) and set window.location.href = res.url. That is /u/<next> or /.
      • Otherwise, a 409 or a network error, leave the drafts alone and call form.submit(). That native post shows the server's message as it does today, and it does not fire onSubmit again. Nothing changed on the server, so it gets the same 409.
      • Keep method/action on the form so a sign-out before hydration still works.
    • Tests: update SignOutForm.test.tsx.
      • Stub fetch to resolve { ok: false, status: 409 } and spy on form.submit. With last and a seeded draft for bob, bob's draft is still there and form.submit was called.
      • Stub { ok: true, url: "http://localhost/u/alice.test" }. The posted FormData carries account, the drafts are cleared as the existing tests expect (after an await tick now), and location.href is set to res.url.
    • How I tried it: I made this change to SignOutForm.tsx in this clone and ran a throwaway happy-dom test with both stubs. The 409 kept bob's draft and fell back to form.submit(). Success cleared bob's draft, kept alice's and navigated to res.url. I also ran a throwaway route test in the accounts.test.tsx harness: POST /auth/signout with a multipart FormData body, which is what the fetch sends, gives 302 /u/alice.test for the active account and 409 for a stale one. I could not run it in a browser, since there is no PDS here. The return trip doesn't change: after a sign-out the user lands on the next account's profile, and its drafts are still keyed by the same page and context.

The owner check in pr.md stays with the owner after the merge: a real OAuth run in a browser, using the menu from the keyboard.

loop:verdict run=review-20261108-215903-35 sha=ef963db3ae899c76c2d3ad24500cd3e0ff53e9fb verdict=changes-requested

Fix round 3: the review asked for changes.

loop:fix run=contribute-20261108-220337-35 round=3 issue=35

feat(auth): sign in to several accounts and switch between them from the header

A browser can now be signed in to up to 5 accounts at once. Clicking the header avatar opens an account menu where you can switch accounts, add one, view your profile, or sign out of only the active account. airglow_session still holds the active DID. A new signed airglow_accounts cookie holds the list, and getSessionUser only accepts the active DID while it is on that list. A page left open in another tab can no longer write as an account you switched to: each page records the DID it was rendered for, island writes send it back, and the server refuses a mismatch with a 409 "You switched accounts in another tab. Reload this page...".

Fix round 1 (from the review): a failed "Add account" now goes back to the add page with its error instead of to your profile, the add page's header shows your account menu instead of "Sign in", the menu's right edge reads the page gutter's measure and paddings from app/styles/utils.ts instead of copying them, and revoking sessions now records the automatic switch it makes (account_switched, reason: "revoke_sessions") with a test. The AGENTS.md sign-out steps are a follow-up, since that file is owner-only.

Fix round 2 (from the review): signing out of one account, or revoking its sessions, no longer deletes the autosaved automation drafts of accounts that stay signed in. Each draft now records a hash of the viewer's handle, and sign-out or revoke removes only that account's drafts, plus drafts saved before this change, which can't be attributed to anyone. Signing out of the last account still removes every draft in the browser.

Fix round 3 (from the review): the account menu's sign-out now clears drafts only after the server has accepted the sign-out. SignOutForm posts with fetch; on success it clears the drafts and goes to the page the server redirected to (/u/<next> or /). On a 409 from a stale tab, or a failed request, it keeps every draft and falls back to the native post, which shows the server's message as before. Before, a stale tab rendered with one account deleted every draft, including another signed-in account's, and then got the 409 with nobody signed out.

What changed #

  • lib/auth/accounts.ts: reads and writes both cookies, and holds the list logic (add, switch, remove, cap of 5) plus the data the menu shows for each account.
  • lib/auth/account-guard.ts (safe to import in the browser): the X-Airglow-Account header, the account form field, the <meta name="airglow-account"> tag and accountHeaders().
  • Callback: adds the DID to the list and makes it active. If the list is full it shows an error instead of dropping an account.
  • /auth/login?add=1: signed-in users are no longer redirected away, the current handle is not prefilled, and the page reads "Add an account". The header shows the signed-in account's menu, not "Sign in". When the list is full it shows a warning. A failed add (empty handle, or a handle that doesn't resolve) redirects to /auth/login?error=...&add=1, so the error shows on the add page instead of the GET sending the user to their profile.
  • POST /auth/switch: accepts only a DID on the list, otherwise 403. It logs account_switched (with the previous account) and redirects to /u/<handle>. If that account's OAuth session is gone, it redirects to /auth/login?add=1&handle=....
  • POST /auth/signout: removes only the active DID, makes the next one active and redirects to its profile. When it was the last account, it redirects to /. It still logs sign_out, and also logs account_switched with reason: "sign_out" on the account that became active, so /settings/security shows "Switched to automatically after signing out of @x" and the event doesn't look like the user picked that account. Sign-out and revoke share one helper for this, logAutomaticSwitch in lib/auth/accounts.ts.
  • Stale tabs: a new global middleware returns 409 JSON when a write's X-Airglow-Account header names a DID that is no longer active. Every island write sends the header (AutomationForm, AutomationToolbar, SecretsManager, CocoreKeyPanel, NotificationSettings, SocialGraphSync, RevokeSessions, PartnerToggle, ShowcaseAdmin). These islands already show data.error, so the message appears where the save failed. RevokeSessions now shows the server's message too. The sign-out form sends the DID it was rendered for.
  • Revoke sessions: removes only that account from the list. Before, it cleared the cookie, which would have left the other accounts listed but none active. When another account becomes active, it logs account_switched with reason: "revoke_sessions", shown on /settings/security as "Switched to automatically after revoking @x's sessions". With no account left, both cookies are cleared and nothing is logged.
  • /settings/account no longer has a Sign out button. "Revoke sessions" stays.
  • Autosaved drafts: saveAutosavedDraft takes an optional viewer and stores viewer: hashContext(viewer) on the entry. AutomationForm passes viewerHandle. The new clearAutosavedDraftsFor(viewer) in app/islands/form-autosave.ts removes that viewer's drafts on every page, and drafts with no viewer. SignOutForm and RevokeSessions take handle and last. With last they call clearAllAutosavedDrafts() as before. Otherwise they call clearAutosavedDraftsFor(handle). The header passes last={accounts.length <= 1}, and /settings/account passes the handle and whether c.get("accounts") has one account or fewer. So if you start a draft as A, switch to B and sign out of B, A's draft is still there when you come back to A. SignOutForm clears only once its fetch post to /auth/signout returns ok, then sets location.href to the response's URL; otherwise it calls form.submit() and leaves the drafts alone. The form keeps method/action, so a sign-out before hydration still posts natively.
  • docs/oauth.md and CONCEPTS.md now describe the list, the switch, the guard and the new event.

What each state of the menu shows #

The avatar is now a <button popovertarget> named "Account menu for @handle". The menu is a native popover="auto", so Escape and a click outside close it with no new island and no inline script. Escape returns focus to the button, and the menu items come right after the button in tab order.

  • One account: "@alice" with a check (labelled "Active account"), "Add account", a divider, "View profile" (aria-current="page" on your own profile), "Sign out @alice".

  • Several accounts: each other account is a button "@bob" labelled "Switch to @bob".

  • An account whose OAuth session is gone: a link "@carol · Sign in again" labelled "Sign in again to @carol", going to /auth/login?add=1&handle=carol....

  • Five accounts: "Add account" is plain text, followed by "5 at most: sign out of one first".

  • Signed out: the "Sign in" button, as before.

  • On the add page (/auth/login?add=1): the header shows the account menu for the signed-in account, as on any other page. With an error, the page still reads "Add an account" and shows the error alert above the form.

The menu sits under the header row, aligned with the page gutter's right edge. Its offset uses pageMeasure and gutterPadding, now exported from app/styles/utils.ts and used by pageGutter too, so the menu follows the gutter if it changes. It is otherwise built only from tokens (space, radii.lg, vars.shadow.md, vars.color.*, vars.focus.ring).

Verified #

  • pnpm run check and pnpm run test pass: 2816 tests (round 3).
  • The new tests in app/routes/auth/accounts.test.tsx cover:
    • the list: add, cap, switch, remove, an unlisted DID being rejected, and a pre-list session being kept;
    • POST /auth/switch: switching to a listed account, the 403 for an unlisted one, and the redirect to sign in again;
    • POST /auth/signout: going to the next account, going to / after the last one, and refusing a stale tab;
    • the 409 for a write from a stale tab, and the meta tag;
    • /auth/login?add=1: no redirect, no prefill, the cap warning, the account menu in the header (no "Sign in"), and a failed add that follows its redirect back to the add page with the error (I checked the header assertion fails with the old <Header />);
    • POST /api/settings/revoke-sessions, with getOAuthClient().restore mocked: [A, B] with A active ends as { active: B, dids: [B] } with the revoke_sessions event on B; [A] alone clears both cookies and logs nothing;
    • the menu markup.
  • app/routes/settings/security.test.tsx checks the new "Switched to automatically after revoking @x's sessions" label.
  • SignOutForm.test.tsx seeds drafts for alice (edit and new pages), bob, and one with no viewer. It checks:
    • signing out of bob with last={false} keeps both of alice's drafts and removes bob's and the untagged one;
    • last removes every draft but leaves other keys alone;
    • revoking alice with last={false} keeps bob's draft;
    • revoking with last removes every draft;
    • the hidden account field is present;
    • nothing is cleared before the server answers, and the posted FormData carries account;
    • on success, location.href is set to the response's URL and the form is not submitted again;
    • a 409 (alice's tab, last, with a draft for bob) keeps every draft, bob's included, and calls form.submit();
    • a rejected fetch keeps every draft and calls form.submit().
  • form-autosave.test.ts covers clearAutosavedDraftsFor on its own.
  • vp build --mode client succeeds, and the SignOutForm bundle pulls in no server code.
  • I could not run the real OAuth flow: there is no PDS or browser in this container. Owner check: sign in as alice, add bob, switch back and forth, sign out of bob (alice should stay active), then sign out of alice (should go to /). Also open the menu from the keyboard (Tab to the avatar, Enter, Tab through, Escape).

Acceptance #

Decisions you can reverse #

  • A browser with only the old airglow_session cookie (signed in before this change) counts as signed in to that one account, so nobody is signed out on deploy. Requiring the list cookie would sign everyone out once.
  • The stale-tab check refuses only a request that sends a DID that doesn't match. A request with no header still goes through (scripts, tests, a page rendered before deploy). Requiring the header would turn the check into a hard gate.
  • A sign-out from a stale tab gets a plain-text 409 response, not a styled page.
  • While the sign-out fetch is in flight, the button shows no busy state; the page navigates as soon as the server answers.
  • Signing out activates the account after the removed one in the list, or the one before it if the removed account was last.
  • Drafts saved before this change have no viewer. Any sign-out removes them, because they can't be attributed. The other choice was to keep them until the last sign-out.
  • Drafts are tagged with the handle the page was rendered for, as the review asked, not the DID. A draft saved before a handle change is not removed by signing out under the new handle. It is removed by signing out of the last account, or when it expires after 7 days.
  • When the list is full, signing in again to an account already on it still works. Only a new account is refused.

Follow-up, filed as a new issue on merge if the review agrees:

docs(agents): describe signing out and switching users from the header account menu

AGENTS.md (owner-only) still tells agents to sign out from /settings/account and to switch users by signing out and back in. The account-switching branch (#35) removed the Sign out button from /settings/account and moved sign-out and switching into the header's avatar menu, so an agent following lines 63–65 looks for a button that no longer exists. I checked with sed -n 60,66p AGENTS.md on the branch: the two paragraphs below are still there.

Replace these two paragraphs in AGENTS.md (under "Sign in / out"):

Sign out: go to http://127.0.0.1:5176/settings/account → click "Sign out" (POSTs to /auth/signout). Redirects to /.

Switch users: sign out, then sign in as the other.

with:

Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261108-220337-35 sha=27f73ad729efbe5e3b05dd4725d325012d0beb87

Verdict: changes-requested. Fix round 3 did what the last review asked. SignOutForm now posts with fetch and clears drafts only once the server accepts. A 409 from a stale tab, or a failed request, keeps every draft and falls back to the native post. pnpm run check and pnpm run test pass (2816 tests). There is still a gap, and it comes from the fetch that the last review itself prescribed. The form decides whether the sign-out worked from the status of the page the redirect lands on, and nothing stops a second submit while the first is still running. Either case can show "You switched accounts in another tab" after a sign-out that worked, with the drafts left in place.

What I checked and how:

  • Round 3's point, at 27f73ad:
    • The diff since ef963db touches only app/islands/SignOutForm.tsx and its test.
    • onSubmit calls preventDefault, then fetches the form with its account field. On ok it clears the drafts and goes to res.url. Otherwise it calls form.submit().
    • The form keeps method/action, so a sign-out before hydration still works.
    • The tests cover all of this: nothing cleared before the server answers, success, the 409 and a rejected fetch.
  • Earlier rounds: the files those rounds fixed have not changed since ef963db, so these still hold: the failed add keeps add=1, the add page shows the account menu, the menu offset uses pageMeasure/gutterPadding, revoke logs account_switched with reason: "revoke_sessions", and drafts are cleared per viewer. /settings/account still has no Sign out button.
  • The two cases, in a throwaway happy-dom test (since removed):
    • Double submit: the first fetch resolves after 30 ms with a redirect to /u/alice.test, and the second resolves at once with a 409. Result: two posts, and form.submit() was called once.
    • Error on the landing page: the sign-out was accepted (redirected: true), but /u/<next> answered 500. Result: form.submit() was called and the drafts were kept. That native post now names an account the server has already signed out, so it gets the 409 "You switched accounts in another tab".

Changes:

  1. In app/islands/SignOutForm.tsx, judge success by the sign-out response, not by the page it redirects to.
    • /auth/signout always answers success with a redirect, to /u/<next> or /. Its refusal is a 409 that does not redirect.
    • So replace if (!res?.ok) with if (!res?.redirected). Keep the rest as it is: clear the drafts, then window.location.href = res.url || "/".
    • Then a transient error on the landing page still clears the drafts and goes there. It no longer re-posts into a 409 with the wrong message, and no longer leaves drafts behind on a shared computer.
  2. Ignore a second submit while one is in flight.
    • The popover stays open and the button gives no feedback while the fetch follows the redirect and renders the next profile, so a second click is likely.
    • That second post names the account that was just signed out. It gets the 409, its form.submit() can replace the page with "You switched accounts in another tab", and, when the user was signed in to one account (last), that can also stop the drafts being cleared.
    • Fix:
      • Add const pending = useRef(false) (from hono/jsx, as ShowcaseAdmin does).
      • In onSubmit, after ev.preventDefault(), add if (pending.current) return; pending.current = true;.
      • Reset it to false on the failure branch, before form.submit().
  3. Tests in SignOutForm.test.tsx:
    • Give the success stubs redirected: true. The two existing success tests fail without it once change 1 is in, as they should.
    • Add a test for a second submit while the first fetch is pending: only one post, and form.submit is not called.
    • Add a test for an accepted sign-out whose landing page fails ({ ok: false, status: 500, redirected: true, url: ".../u/alice.test" }): drafts cleared, location.href set, and form.submit not called.

How I tried it: I made changes 1 and 2 in this clone and ran the throwaway test above again. The double submit made one post, with no form.submit(), and went to /u/alice.test. The landing-page 500 cleared the drafts and went to /u/alice.test, with no form.submit(). In SignOutForm.test.tsx, the 409 and rejected-fetch tests still passed. Only the two success tests failed, because their stubs lack redirected. I could not run it in a browser, since there is no PDS here. A browser fetch follows the 302 and sets redirected to true, and the 409 is answered directly. After a sign-out the user lands on the next account's profile, as before, so the return trip doesn't change.

The follow-up is right, so it is file. AGENTS.md lines 63–65 still say to sign out from /settings/account and to switch users by signing out. This branch removes that button. The replacement in followup.md matches the menu. AGENTS.md is owner-only, so the contributor can't make the edit.

The owner check in pr.md is the owner's to do after the merge: a real OAuth run in a browser, using the menu from the keyboard.

loop:verdict run=review-20261108-220830-35 sha=27f73ad729efbe5e3b05dd4725d325012d0beb87 verdict=changes-requested

The branch used every fix round (3) and still needs one. Set the label back to ready, or reply on the issue, to have the loop pick it up again, past its caps.

loop:awaiting-owner run=none why=rounds after=2026-11-08T22:12:05.000Z

This is a big task, that must be done right, run ad many review rounds as needed

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreifsmm7pyhq3yczm6urclxq34jugezsgpdm5oreiubxwlvuwqobbke

Working on this, attempt 1.

loop:claim run=contribute-20261108-232723-35 branch=loop/35 attempt=1 issue=35

feat(auth): sign in to several accounts and switch from the header menu

A browser can now be signed in to up to five accounts at once. The avatar in the header opens an account menu where you can switch between them, add another one, view your profile or sign out of the active account, so you no longer have to sign out and back in. A new signed airglow_accounts cookie holds the list. airglow_session still holds the active DID, and it only counts while that DID is in the list. Pages carry the DID they were rendered for, and the server refuses a write from a tab left open across a switch.

What changed #

  • Cookies (lib/auth/accounts.ts): airglow_accounts is a signed JSON list of DIDs, capped at MAX_ACCOUNTS = 5. The add, switch and remove rules are small pure functions. A browser that only has the old airglow_session cookie counts that DID as its only account, so nobody is signed out when this deploys, and lib/scripts/dev-jetstream-event.ts keeps working.

  • Callback adds the DID to the list and makes it active. A sixth account is refused, not swapped in for an older one: the cookies stay as they were and the page says "You're already signed in to 5 accounts...". The stored OAuth session, the reactivation of automations and the sync resume still run.

  • /auth/login?add=1 does not redirect a signed-in user and does not prefill their handle. It is headed "Add an account". When the list is full it shows a status notice in place of the form.

  • POST /auth/switch takes a did, which must be in the list (otherwise it answers 400). It logs account_switched and redirects to /u/<handle>. If that account has lost its OAuth session, it goes to /auth/login?add=1&handle=... instead.

  • POST /auth/signout removes the account named in the form's did, falling back to the active account. When the removed account was active, the next one becomes active (logged as account_switched with reason sign_out) and the browser goes to its profile. When no account is left, both cookies are cleared and it goes to /. sign_out is still logged, and the OAuth grant is kept, so automations for inactive accounts keep firing.

  • Revoke sessions now removes only that account from the list, where it used to delete the cookie.

  • Header menu: a native popover (no new island), triggered by the avatar button "Account menu for @handle". It lists each account with its avatar and @handle:

    • the active account has a check and "(active account)" for screen readers;
    • each other account is a "Switch to @handle" button;
    • an account that has lost its session is a "Sign in again as @handle" link.

    Below the list come View profile, Add account and the SignOutForm island, now labelled "Sign out @handle". The island still clears autosaved drafts before it posts.

  • /settings/account no longer has the Sign out button. Revoke sessions stays.

  • Stale tabs: the renderer adds <meta name="airglow-account" content=did> to pages for signed-in users. Every mutating fetch in the islands sends that DID as X-Airglow-Account, through accountHeaders() in app/islands/account-guard.ts. requireApiAuth and requireApiAdmin (which guard every mutating /api route) answer 409 when it differs from the active DID, with code: "account_mismatch" and the message "You switched to @b in another tab. Reload this page to continue as @b." The islands show the server's error text. RevokeSessions used to show only "Request failed: N" and now shows it too.

  • Security log (/settings/security) labels account_switched and says why it happened. docs/oauth.md and CONCEPTS.md describe the new cookies and routes.

What each menu state shows #

  • One account: the avatar opens a menu with @you ✓, View profile, Add account and Sign out @you.
  • Several accounts: the active account has a check, and each of the others is a Switch button.
  • An account with a lost session: its row reads "Sign in again" and links to the login form with the handle prefilled.
  • Five accounts: "Add account" is shown as plain text with "Up to 5 accounts at once" beside it. /auth/login?add=1 shows the same limit as a notice instead of the form.
  • Signed out: the header shows the Sign in button, as before.

Verification #

  • pnpm run check and pnpm run test pass (190 files, 2825 tests).

  • New tests:

    • lib/auth/accounts.test.ts: the add, switch and remove rules and the cap.
    • app/routes/auth/accounts.test.ts: real signed cookies through /auth/switch, /auth/signout, getSessionUser and requireApiAuth, covering an unlisted DID, a lost session, the last account, a stale-tab sign-out, and the 409 mismatch alongside a request that passes.
    • app/routes/auth/callback.test.tsx: adding an account, and refusing a sixth.
    • app/routes/auth/login.test.tsx: ?add=1.
    • app/components/Layout/Header/index.test.tsx: the menu markup in each state.
  • Not checked in a browser. This sandbox has no browser or local PDS, so I could not sign in or try the menu with the keyboard. I checked the markup instead:

    • The trigger is a native button with popovertarget, so it opens with Enter or Space.
    • The popover follows the trigger in the tab order.
    • Escape or a click outside closes it and returns focus to the avatar.
    • Each action navigates to a new page, so focus is never left on a removed element.

    Please confirm the keyboard behaviour and the menu's position under the avatar. The menu uses CSS anchor positioning, and where that is unsupported it falls back to the top right under the header.

Acceptance #

Decisions you can reverse #

  • A mutating request without X-Airglow-Account is not checked. That covers scripts, curl and a bundle cached from before this change. Making the header required would only take a change to accountMismatch in lib/auth/middleware.ts, but several existing API tests would then need the header.
  • A stale tab's "Sign out @A" signs out A, not the active account. It names the account it was rendered for, so it is not refused like an API write.
  • Signing out of any one account still clears every autosaved draft in the browser, as before, for shared computers. Drafts are keyed by a hash, so they cannot be told apart by account.
  • When the active account is removed, the next account in the list becomes active, or the previous one when it was last. Accounts are listed in the order they were added.
  • The account_switched metadata stores the previous DID and a reason (switch, sign_out or sign_in). The security page shows only the reason.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261108-232723-35 sha=228de7df9e02a83c45ed4463024f3de484276252

Verdict: changes-requested. This head is a fresh implementation, not a fix round on 27f73ad. It covers the issue's main flows: the account list cookie, add, switch, sign out of one account, the header popover, no Sign out button on /settings/account, and the 409 stale-tab guard. pnpm run check and pnpm run test pass (190 files, 2825 tests). But the rewrite brings back four gaps that earlier rounds had already fixed: a failed "Add account" drops add=1, sign-out clears every account's drafts, revoke switches accounts without logging it, and the AGENTS.md follow-up is gone. It also adds two new small gaps.

What I checked and how:

  • The issue's acceptance list, against the code:
    • The list: add, the cap of 5 (refused, not dropped, at the callback and on /auth/login?add=1), switch, remove, and an unlisted DID refused with a 400.
    • Sign-out goes to the next account, or to / after the last one.
    • Neither sign-out nor switch touches oauth_sessions, so automations for inactive accounts keep firing.
    • A lost session shows "Sign in again".
    • Each of these has a test in app/routes/auth/accounts.test.ts, callback.test.tsx, login.test.tsx and the header test.
  • Stale-tab guard: I listed every mutating route myself. Each one under app/routes/api goes through requireApiAuth/requireApiAdmin, directly or through the _middleware.ts of automations/, secrets/ and admin/. Every island fetch that mutates sends accountHeaders() (rg 'fetch\(', rg 'method: "(POST|PUT|PATCH|DELETE)"'), and each one shows the server's error. The only native posts are login, switch and sign-out. Sign-out names its account, so a stale tab signs out the account its button named and never acts as the other one. I accept that.
  • Menu: it is a native popover with a popovertarget button, so Escape and an outside click close it. Each row has its own name: "Switch to @x", "Sign in again as @x", "Sign out @x". The global :focus-visible ring applies, and every value is a token. The cap note sits beside "Add account".
  • Earlier rounds, re-checked on this code:
    • The add page now renders the account menu (round 1, point 2).
    • The gutter copy (round 1, point 3) no longer applies: the menu uses anchor positioning.
    • Rounds 3 and 4 (the sign-out fetch) no longer apply: sign-out is a plain native post again, and the server never refuses it.
    • The other round-1 and round-2 points are back, listed below.
  • Throwaway tests in /work, since reverted: I tried each fix below. How is given under each one.

Changes:

  1. A failed "Add account" still lands back on your own profile (round 1, point 1).
    • The bug: formAction in app/routes/auth/login.tsx keeps scope and returnTo but not add. The POST error redirects are plain /auth/login?error=....
    • Reproduced: signed in as alice, I posted a typo through the add form. It went POST /auth/login → 302 /auth/login?error=authorize_failed → 302 /u/alice.test, and the error never showed.
    • Fix:
      • Add if (adding) formParams.set("add", "1");.
      • In POST, build both error redirects with a helper: new URLSearchParams({ error }), plus add=1 when c.req.query("add") === "1".
    • Tried: the same flow went to /auth/login?error=authorize_failed&add=1, which returns 200 with "Add an account" and "Could not start authentication".
    • Test: add one that follows the failed POST's redirect back to the add page.
  2. Signing out of one account deletes every account's drafts (round 2).
    • The bug: SignOutForm and RevokeSessions still call clearAllAutosavedDrafts(). Start a draft as A, switch to B, sign out of B, and A's draft is gone. pr.md lists this as reversible and says drafts can't be told apart. They can if each draft is tagged with its viewer.
    • Fix:
      • In app/islands/form-autosave.ts, give saveAutosavedDraft an optional viewer argument, stored as viewer: hashContext(viewer) on the entry.
      • Add clearAutosavedDraftsFor(viewer). It removes the entries whose viewer matches, and entries with no viewer, since older drafts can't be attributed.
      • In AutomationForm.tsx, pass viewerHandle as the fifth argument of saveAutosavedDraft(stored, autosaveContext, now, autosaveScope).
      • SignOutForm calls clearAutosavedDraftsFor(handle). RevokeSessions does the same after success, which needs the handle passed in from app/routes/settings/account.tsx.
      • No last prop is needed. Each account's drafts go when that account signs out, so once the last account is out nothing tagged is left, which covers the shared computer. It also avoids the stale-tab hazard round 3 found with a render-time "last" flag.
      • Update the header comment of form-autosave.ts ("signing out clears every draft").
    • Tried: with these changes, a happy-dom test seeded a tagged draft for alice, a tagged draft for bob and one untagged.
      • Submitting <SignOutForm handle="bob.test"> left only alice's draft.
      • Then signing out of alice left none.
      • The island suite still passes (37 files, 509 tests).
      • The return trip still works: alice's draft is keyed by /automations/new and [viewerHandle, ...], and neither changes when she switches back through the menu and opens "New".
    • Tests: update SignOutForm.test.tsx for both cases, and add the same for RevokeSessions.
  3. Revoke sessions switches accounts and records nothing (round 1, point 4).
    • The bug: app/routes/api/settings/revoke-sessions.ts now makes the next account active. That is a switch the system makes, and nothing logs it.
    • Fix:
      • After removeAccount, when after.active is set, log account_switched on after.active with { from: user.did, reason: "revoke_sessions" }.
      • Add a case to formatDetails in app/routes/settings/security.tsx, for example "after revoking another account's sessions". Without it, the default case says "from another account in the account menu", which is wrong.
    • Tried: with getOAuthClient().restore mocked to a session whose signOut resolves, and cookies [A, B] with A active: the response set airglow_session=B and airglow_accounts=["B"], and wrote the event.
    • Test: add a route test for [A, B] (as above) and for [A] alone (both cookies cleared, no account_switched). The route has no test today.
  4. docs/oauth.md "Audit events" still lists the events without account_switched. Add it, as CONCEPTS.md now does.
  5. The avatar trigger no longer shows hover, and its face styles are written twice.
    • The bug: avatarFace copies avatar's accent background, colour, size, radius and font. It fills the 32px button, so avatar's :hover accentHover background is hidden under it for every user without an avatar image.
    • Fix: move the hover onto the face with globalStyle(${avatar}:hover ${avatarFace}, { backgroundColor: vars.color.accentHover }). Then drop from avatar the background, colour, font and line-height that the face now paints. Keep size, radius and overflow so the focus ring keeps its shape.
    • Tried: the globalStyle passes vp check and builds to .<avatar>:hover .<face>{background-color:var(--color-accent-hover)}.
  6. Add a followup.md for the owner-only AGENTS.md.
    • What is stale: lines 63–65 still say to sign out from /settings/account and to switch users by signing out and back in. This branch removes that button. Earlier rounds carried this follow-up, and this head has none.

    • Replace the two paragraphs with:

      Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

      Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

The owner check in pr.md is the owner's to make after the merge: a real OAuth run with alice and bob in a browser, using the menu from the keyboard. Nobody can run it in this clone.

loop:verdict run=review-20261108-234302-35 sha=228de7df9e02a83c45ed4463024f3de484276252 verdict=changes-requested

Fix round 1: the review asked for changes.

loop:fix run=contribute-20261108-234940-35 round=1 issue=35

feat(auth): sign in to several accounts and switch from the header menu

A browser can now be signed in to up to five accounts at once. The avatar in the header opens an account menu where you can switch between them, add another one, view your profile or sign out of the active account, so you no longer have to sign out and back in. A new signed airglow_accounts cookie holds the list. airglow_session still holds the active DID, and it only counts while that DID is in the list. Pages carry the DID they were rendered for, and the server refuses a write from a tab left open across a switch.

What changed #

  • Cookies (lib/auth/accounts.ts): airglow_accounts is a signed JSON list of DIDs, capped at MAX_ACCOUNTS = 5. The add, switch and remove rules are small pure functions. A browser that only has the old airglow_session cookie counts that DID as its only account, so nobody is signed out when this deploys, and lib/scripts/dev-jetstream-event.ts keeps working.

  • Callback adds the DID to the list and makes it active. A sixth account is refused, not swapped in for an older one: the cookies stay as they were and the page says "You're already signed in to 5 accounts...". The stored OAuth session, the reactivation of automations and the sync resume still run.

  • /auth/login?add=1 does not redirect a signed-in user and does not prefill their handle. It is headed "Add an account". When the list is full it shows a status notice in place of the form. Its form posts to /auth/login?add=1, and a failed post (an empty handle, or a handle that cannot be authorized) comes back to the add page with its error, not to the signed-in redirect.

  • POST /auth/switch takes a did, which must be in the list (otherwise it answers 400). It logs account_switched and redirects to /u/<handle>. If that account has lost its OAuth session, it goes to /auth/login?add=1&handle=... instead.

  • POST /auth/signout removes the account named in the form's did, falling back to the active account. When the removed account was active, the next one becomes active (logged as account_switched with reason sign_out) and the browser goes to its profile. When no account is left, both cookies are cleared and it goes to /. sign_out is still logged, and the OAuth grant is kept, so automations for inactive accounts keep firing.

  • Revoke sessions now removes only that account from the list, where it used to delete the cookie. When another account takes over, it logs account_switched on that account with reason revoke_sessions.

  • Header menu: a native popover (no new island), triggered by the avatar button "Account menu for @handle". It lists each account with its avatar and @handle:

    • the active account has a check and "(active account)" for screen readers;
    • each other account is a "Switch to @handle" button;
    • an account that has lost its session is a "Sign in again as @handle" link.

    Below the list come View profile, Add account and the SignOutForm island, now labelled "Sign out @handle".

  • Autosaved drafts are now tagged with their viewer (a hash of the handle, like the rest of the key). Signing out of an account, or revoking its sessions, clears that account's drafts and any untagged older ones (clearAutosavedDraftsFor in app/islands/form-autosave.ts), so the other signed-in accounts keep theirs. Once the last account signs out, nothing tagged is left, which still covers a shared computer.

  • /settings/account no longer has the Sign out button. Revoke sessions stays.

  • Stale tabs: the renderer adds <meta name="airglow-account" content=did> to pages for signed-in users. Every mutating fetch in the islands sends that DID as X-Airglow-Account, through accountHeaders() in app/islands/account-guard.ts. requireApiAuth and requireApiAdmin (which guard every mutating /api route) answer 409 when it differs from the active DID, with code: "account_mismatch" and the message "You switched to @b in another tab. Reload this page to continue as @b." The islands show the server's error text. RevokeSessions used to show only "Request failed: N" and now shows it too.

  • Security log (/settings/security) labels account_switched and says why it happened, including "after revoking another account's sessions". docs/oauth.md and CONCEPTS.md describe the new cookies and routes, and the audit events in docs/oauth.md list account_switched and its reasons.

What each menu state shows #

  • The trigger: the avatar's face shows the accent hover colour when the pointer is over it, as on main.
  • One account: the avatar opens a menu with @you ✓, View profile, Add account and Sign out @you.
  • Several accounts: the active account has a check, and each of the others is a Switch button.
  • An account with a lost session: its row reads "Sign in again" and links to the login form with the handle prefilled.
  • Five accounts: "Add account" is shown as plain text with "Up to 5 accounts at once" beside it. /auth/login?add=1 shows the same limit as a notice instead of the form.
  • A failed add: /auth/login?add=1&error=... shows "Add an account", the error, and the empty form again.
  • Signed out: the header shows the Sign in button, as before.

Verification #

  • pnpm run check and pnpm run test pass (191 files, 2833 tests).

  • New tests:

    • lib/auth/accounts.test.ts: the add, switch and remove rules and the cap.
    • app/routes/auth/accounts.test.ts: real signed cookies through /auth/switch, /auth/signout, getSessionUser and requireApiAuth, covering an unlisted DID, a lost session, the last account, a stale-tab sign-out, and the 409 mismatch alongside a request that passes.
    • app/routes/auth/callback.test.tsx: adding an account, and refusing a sixth.
    • app/routes/auth/login.test.tsx: ?add=1, and a failed add post that comes back to the add page with its error.
    • app/routes/api/settings/revoke-sessions.test.ts: [A, B] with A active leaves B active and logs the switch; [A] clears both cookies and logs none.
    • app/islands/SignOutForm.test.tsx and form-autosave.test.ts: signing out of (or revoking) bob keeps alice's drafts and drops bob's and untagged ones; then alice's sign-out leaves none.
    • app/routes/settings/security.test.tsx: the revoke_sessions and sign_out switch labels.
    • app/components/Layout/Header/index.test.tsx: the menu markup in each state.
  • Not checked in a browser. This sandbox has no browser or local PDS, so I could not sign in or try the menu with the keyboard. I checked the markup instead:

    • The trigger is a native button with popovertarget, so it opens with Enter or Space.
    • The popover follows the trigger in the tab order.
    • Escape or a click outside closes it and returns focus to the avatar.
    • Each action navigates to a new page, so focus is never left on a removed element.

    Please confirm the keyboard behaviour and the menu's position under the avatar. The menu uses CSS anchor positioning, and where that is unsupported it falls back to the top right under the header.

Acceptance #

Decisions you can reverse #

  • A mutating request without X-Airglow-Account is not checked. That covers scripts, curl and a bundle cached from before this change. Making the header required would only take a change to accountMismatch in lib/auth/middleware.ts, but several existing API tests would then need the header.
  • A stale tab's "Sign out @A" signs out A, not the active account. It names the account it was rendered for, so it is not refused like an API write.
  • Signing out of one account clears only that account's autosaved drafts, plus untagged drafts from before this change, which cannot be attributed. Going back to clearing every draft would be a one-line change in SignOutForm and RevokeSessions.
  • When the active account is removed, the next account in the list becomes active, or the previous one when it was last. Accounts are listed in the order they were added.
  • The account_switched metadata stores the previous DID and a reason (switch, sign_out, sign_in or revoke_sessions). The security page shows only the reason.

This round #

The review asked for six changes, all made: a failed "Add account" keeps add=1 and its error; drafts are tagged by viewer, so a sign-out or revoke clears only that account's drafts; revoking sessions logs the switch it makes, and the security page labels it; docs/oauth.md lists account_switched; the avatar's hover shows on its face, and the face styles are no longer written twice; the AGENTS.md follow-up is back. The signed-cookie test helpers that two test files had copied now live in lib/test/account-cookies.ts, shared with the new revoke test.

Owner check #

After the merge, a real OAuth run with alice.pds.dev and bob.pds.dev in a browser, using the account menu from the keyboard. Nobody can run it in this clone.

Follow-up, filed as a new issue on merge if the review agrees:

docs(agents): describe sign-out and switching through the header's account menu

AGENTS.md (owner-only) still tells agents to sign out from /settings/account and to switch users by signing out and back in. The multi-account branch for #35 removes the Sign out button from /settings/account (app/routes/settings/account.tsx now renders only Identity and Revoke sessions), and sign-out and switching now live in the avatar menu in the header (app/components/Layout/Header/index.tsx). An agent following lines 63–65 looks for a button that is gone.

Where to start: in AGENTS.md, under "Sign in / out", replace the two paragraphs

Sign out: go to http://127.0.0.1:5176/settings/account → click "Sign out" (POSTs to /auth/signout). Redirects to /.

Switch users: sign out, then sign in as the other.

with

Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261108-234940-35 sha=f5e3badb4ed149270332643b204a3dafe2f34d6e

Verdict: changes-requested. This round fixes all six points from the last review, and pnpm run check and pnpm run test pass (191 files, 2833 tests). Two small gaps are left. First, when the account list is full, the callback page tells the user to "Sign out of one from the account menu", but it renders a header with no user. So that page has no account menu, only a "Sign in" button, under the heading "Authentication Failed", even though the sign-in at the PDS worked. Second, a doc in docs/solutions/ still says that sign-out and revoke clear the cookie.

What I checked and how:

  • Round 5's six points, against the code at f5e3bad:
    1. A failed add keeps add=1: formAction carries it, both POST error redirects go through errorRedirect, and tests follow the redirect back to the add page.
    2. Drafts are tagged with viewer: hashContext(viewerHandle). clearAutosavedDraftsFor removes the viewer's drafts and untagged ones, and both SignOutForm and RevokeSessions (after a success) call it. There is no last flag, as asked. saveAutosavedDraft is the only writer of these keys, and all five AutomationForm routes pass the signed-in viewer's handle.
    3. Revoke logs account_switched with reason: "revoke_sessions" on the account that takes over, /settings/security labels it, and a route test covers [A, B] and [A].
    4. docs/oauth.md lists account_switched and its reasons.
    5. The avatar's hover is a globalStyle on the face, and the trigger no longer paints the colour itself.
    6. followup.md is back.
  • Earlier rounds: the add page still renders the account menu. The gutter and sign-out fetch points no longer apply to this design.
  • The issue's acceptance list:
    • The account list: add, the cap of 5 (refused, not dropped), switch, remove, and an unlisted DID refused with a 400.
    • Sign-out of B leaves A active, and the last sign-out goes to / with both cookies cleared.
    • /settings/account has no Sign out button.
    • Switching and signing out never touch oauth_sessions.
    • A lost session shows "Sign in again".
    • The stale-tab 409.
  • Stale-tab guard:
    • I listed every mutating fetch in app/islands myself, and each one sends accountHeaders().
    • Every mutating route under app/routes/api goes through requireApiAuth/requireApiAdmin, either in its own file or through the _middleware.ts of automations/, secrets/ and admin/.
    • Signed-in pages are private, no-store, so the <meta name="airglow-account"> is never shared from a cache.
    • A stale tab's sign-out signs out the account its button names, which round 5 accepted.
  • The most common real flow: a throwaway test (since removed) ran the global middleware, the renderer, the header and the real routes with a cookie jar. It went: a pre-list airglow_session for alice only (the deploy-day case), /auth/login?add=1, the callback adding bob, the menu showing "Switch to @alice.test", the switch, signing out alice (which landed on /u/bob.test with bob's meta), then signing out bob (which landed on / with an empty jar). Every step behaved as the issue asks.
  • Menu against docs/design.md: it uses tokens only, a native popover (Escape and an outside click close it), a distinct accessible name on each row, and the cap reason on the same line as "Add account".

Changes:

  1. Give the full-list callback page the signed-in header and an accurate heading. In app/routes/auth/callback.tsx, the !added.ok branch renders ErrorPage, which always uses <Header /> and <h1>Authentication Failed</h1>. The cookies are unchanged at that point, so the user is still signed in to five accounts. The page says "Sign out of one from the account menu", but there is no menu, only a "Sign in" button. A user reaches this page by signing in with a handle other than the prefilled one from a "Sign in again" link or a scope re-auth, by picking another account at the PDS, or by filling the list in another tab.
    • Fix:
      • Give ErrorPage two optional props, heading = "Authentication Failed" and user: SessionUser | null = null. Render <Header user={user} /> and <h1>{heading}</h1>.
      • In the cap branch, pass heading="Account not added" and user={await getSessionUser(c)}, with getSessionUser and type SessionUser imported from @/auth/middleware.js.
      • The global middleware has already read the unchanged cookies into c.get("accounts"), so the menu lists all five accounts, and "Add account" carries its cap note.
    • Test: in the "refuses a sixth account" test in app/routes/auth/callback.test.tsx, also assert that html contains "Account menu for @alice.test" and "Account not added", and does not contain "Authentication Failed".
    • How I tried it: I made exactly this change in this clone. The new assertions fail on f5e3bad ("expected ... to contain 'Account menu for @alice.test'") and pass with the fix, and vp check passes. Then I reverted it. From that page, the menu's own links (the other accounts' Switch buttons, View profile, Sign out) work as they do on any page, because they are the same header.
  2. Update docs/solutions/security-issues/oauth-grant-revocation-error-contract.md. It describes the live code, and this branch changed that code.
    • In the "After" block, which names app/routes/api/settings/revoke-sessions.ts, replace deleteCookie(c, COOKIE_NAME, { path: "/" }); with the branch's const after = removeAccount(await readAccountCookies(c), user.did); ... await writeAccountCookies(c, after);. Keep the account_switched log, or add a one-line comment that stands in for it.
    • Under "Key shape changes", change "The handler falls through to clear the cookie and redirect" to "The handler falls through to drop the account from this browser's account list and redirect".
    • Under "Prevention", change "POST /auth/signout ... deliberately only clears the cookie and leaves the OAuth grant intact" to "... deliberately only drops that account from this browser's account list (airglow_accounts) and leaves the OAuth grant intact".

The follow-up is file. AGENTS.md lines 63–65 still say to sign out from /settings/account and to switch users by signing out and back in. This branch removes that button and moves both actions to the avatar menu. The replacement text in followup.md matches the menu's labels: "Sign out @handle", "Add account" and "Switch to @handle". AGENTS.md is owner-only, so the contributor cannot make the edit.

The owner check in pr.md is the owner's to make after the merge: a real OAuth run with alice and bob in a browser, using the menu from the keyboard. Nobody can run it in this clone.

loop:verdict run=review-20261108-235740-35 sha=f5e3badb4ed149270332643b204a3dafe2f34d6e verdict=changes-requested

Fix round 2: the review asked for changes.

loop:fix run=contribute-20261109-000348-35 round=2 issue=35

feat(auth): sign in to several accounts and switch from the header menu

A browser can now be signed in to up to five accounts at once. The avatar in the header opens an account menu where you can switch between them, add another one, view your profile or sign out of the active account, so you no longer have to sign out and back in. A new signed airglow_accounts cookie holds the list. airglow_session still holds the active DID, and it only counts while that DID is in the list. Pages carry the DID they were rendered for, and the server refuses a write from a tab left open across a switch.

What changed #

  • Cookies (lib/auth/accounts.ts): airglow_accounts is a signed JSON list of DIDs, capped at MAX_ACCOUNTS = 5. The add, switch and remove rules are small pure functions. A browser that only has the old airglow_session cookie counts that DID as its only account, so nobody is signed out when this deploys, and lib/scripts/dev-jetstream-event.ts keeps working.

  • Callback adds the DID to the list and makes it active. A sixth account is refused, not swapped in for an older one: the cookies stay as they were and the page, headed "Account not added", says "You're already signed in to 5 accounts...". That page keeps the signed-in header, so the account menu it points to is there. The stored OAuth session, the reactivation of automations and the sync resume still run.

  • /auth/login?add=1 does not redirect a signed-in user and does not prefill their handle. It is headed "Add an account". When the list is full it shows a status notice in place of the form. Its form posts to /auth/login?add=1, and a failed post (an empty handle, or a handle that cannot be authorized) comes back to the add page with its error, not to the signed-in redirect.

  • POST /auth/switch takes a did, which must be in the list (otherwise it answers 400). It logs account_switched and redirects to /u/<handle>. If that account has lost its OAuth session, it goes to /auth/login?add=1&handle=... instead.

  • POST /auth/signout removes the account named in the form's did, falling back to the active account. When the removed account was active, the next one becomes active (logged as account_switched with reason sign_out) and the browser goes to its profile. When no account is left, both cookies are cleared and it goes to /. sign_out is still logged, and the OAuth grant is kept, so automations for inactive accounts keep firing.

  • Revoke sessions now removes only that account from the list, where it used to delete the cookie. When another account takes over, it logs account_switched on that account with reason revoke_sessions.

  • Header menu: a native popover (no new island), triggered by the avatar button "Account menu for @handle". It lists each account with its avatar and @handle:

    • the active account has a check and "(active account)" for screen readers;
    • each other account is a "Switch to @handle" button;
    • an account that has lost its session is a "Sign in again as @handle" link.

    Below the list come View profile, Add account and the SignOutForm island, now labelled "Sign out @handle".

  • Autosaved drafts are now tagged with their viewer (a hash of the handle, like the rest of the key). Signing out of an account, or revoking its sessions, clears that account's drafts and any untagged older ones (clearAutosavedDraftsFor in app/islands/form-autosave.ts), so the other signed-in accounts keep theirs. Once the last account signs out, nothing tagged is left, which still covers a shared computer.

  • /settings/account no longer has the Sign out button. Revoke sessions stays.

  • Stale tabs: the renderer adds <meta name="airglow-account" content=did> to pages for signed-in users. Every mutating fetch in the islands sends that DID as X-Airglow-Account, through accountHeaders() in app/islands/account-guard.ts. requireApiAuth and requireApiAdmin (which guard every mutating /api route) answer 409 when it differs from the active DID, with code: "account_mismatch" and the message "You switched to @b in another tab. Reload this page to continue as @b." The islands show the server's error text. RevokeSessions used to show only "Request failed: N" and now shows it too.

  • Security log (/settings/security) labels account_switched and says why it happened, including "after revoking another account's sessions". docs/oauth.md and CONCEPTS.md describe the new cookies and routes, and the audit events in docs/oauth.md list account_switched and its reasons.

What each menu state shows #

  • The trigger: the avatar's face shows the accent hover colour when the pointer is over it, as on main.
  • One account: the avatar opens a menu with @you ✓, View profile, Add account and Sign out @you.
  • Several accounts: the active account has a check, and each of the others is a Switch button.
  • An account with a lost session: its row reads "Sign in again" and links to the login form with the handle prefilled.
  • Five accounts: "Add account" is shown as plain text with "Up to 5 accounts at once" beside it. /auth/login?add=1 shows the same limit as a notice instead of the form.
  • A sixth account at the callback: the signed-in header with the account menu (all five accounts, "Add account" with its cap note), the heading "Account not added" and the error alert, with no "Try Again" button.
  • A failed add: /auth/login?add=1&error=... shows "Add an account", the error, and the empty form again.
  • Signed out: the header shows the Sign in button, as before.

Verification #

  • pnpm run check and pnpm run test pass (191 files, 2833 tests).

  • New tests:

    • lib/auth/accounts.test.ts: the add, switch and remove rules and the cap.
    • app/routes/auth/accounts.test.ts: real signed cookies through /auth/switch, /auth/signout, getSessionUser and requireApiAuth, covering an unlisted DID, a lost session, the last account, a stale-tab sign-out, and the 409 mismatch alongside a request that passes.
    • app/routes/auth/callback.test.tsx: adding an account, and refusing a sixth on a page that keeps the account menu and is headed "Account not added".
    • app/routes/auth/login.test.tsx: ?add=1, and a failed add post that comes back to the add page with its error.
    • app/routes/api/settings/revoke-sessions.test.ts: [A, B] with A active leaves B active and logs the switch; [A] clears both cookies and logs none.
    • app/islands/SignOutForm.test.tsx and form-autosave.test.ts: signing out of (or revoking) bob keeps alice's drafts and drops bob's and untagged ones; then alice's sign-out leaves none.
    • app/routes/settings/security.test.tsx: the revoke_sessions and sign_out switch labels.
    • app/components/Layout/Header/index.test.tsx: the menu markup in each state.
  • Not checked in a browser. This sandbox has no browser or local PDS, so I could not sign in or try the menu with the keyboard. I checked the markup instead:

    • The trigger is a native button with popovertarget, so it opens with Enter or Space.
    • The popover follows the trigger in the tab order.
    • Escape or a click outside closes it and returns focus to the avatar.
    • Each action navigates to a new page, so focus is never left on a removed element.

    Please confirm the keyboard behaviour and the menu's position under the avatar. The menu uses CSS anchor positioning, and where that is unsupported it falls back to the top right under the header.

Acceptance #

Decisions you can reverse #

  • A mutating request without X-Airglow-Account is not checked. That covers scripts, curl and a bundle cached from before this change. Making the header required would only take a change to accountMismatch in lib/auth/middleware.ts, but several existing API tests would then need the header.
  • A stale tab's "Sign out @A" signs out A, not the active account. It names the account it was rendered for, so it is not refused like an API write.
  • Signing out of one account clears only that account's autosaved drafts, plus untagged drafts from before this change, which cannot be attributed. Going back to clearing every draft would be a one-line change in SignOutForm and RevokeSessions.
  • When the active account is removed, the next account in the list becomes active, or the previous one when it was last. Accounts are listed in the order they were added.
  • The account_switched metadata stores the previous DID and a reason (switch, sign_out, sign_in or revoke_sessions). The security page shows only the reason.

This round #

Round 2 made the review's two changes: the full-list callback page now renders the signed-in header (so the account menu it mentions is there) under the heading "Account not added", through two optional ErrorPage props, heading and user; and docs/solutions/security-issues/oauth-grant-revocation-error-contract.md now says revoke and sign-out drop the account from this browser's account list, where it said they clear the cookie.

The round before, the review asked for six changes, all made: a failed "Add account" keeps add=1 and its error; drafts are tagged by viewer, so a sign-out or revoke clears only that account's drafts; revoking sessions logs the switch it makes, and the security page labels it; docs/oauth.md lists account_switched; the avatar's hover shows on its face, and the face styles are no longer written twice; the AGENTS.md follow-up is back. The signed-cookie test helpers that two test files had copied now live in lib/test/account-cookies.ts, shared with the new revoke test.

Owner check #

After the merge, a real OAuth run with alice.pds.dev and bob.pds.dev in a browser, using the account menu from the keyboard. Nobody can run it in this clone.

Follow-up, filed as a new issue on merge if the review agrees:

docs(agents): describe sign-out and switching through the header's account menu

AGENTS.md (owner-only) still tells agents to sign out from /settings/account and to switch users by signing out and back in. The multi-account branch for #35 removes the Sign out button from /settings/account (app/routes/settings/account.tsx now renders only Identity and Revoke sessions), and sign-out and switching now live in the avatar menu in the header (app/components/Layout/Header/index.tsx). An agent following lines 63–65 looks for a button that is gone.

Where to start: in AGENTS.md, under "Sign in / out", replace the two paragraphs

Sign out: go to http://127.0.0.1:5176/settings/account → click "Sign out" (POSTs to /auth/signout). Redirects to /.

Switch users: sign out, then sign in as the other.

with

Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261109-000348-35 sha=63006c7d0f5a446cf0ab8c03ca8deaf8777e46af

Verdict: changes-requested. Round 6 asked for two changes and this head makes both. pnpm run check and pnpm run test pass (191 files, 2833 tests), and the issue's acceptance list holds in the code. One small piece of stale text is left, and the branch caused it. The docblock of app/routes/api/settings/revoke-sessions.ts still says the restore()-fails path will "clear cookie". The branch rewrote the bullet just above it, and round 6 had the same sentence fixed in docs/solutions/. The code no longer does that: it takes the account off this browser's list, and another signed-in account becomes active.

What I checked and how:

  • Round 6's two points, at 63006c7. The diff since f5e3bad touches only these files: callback.tsx, its test, and the solutions doc.
    • ErrorPage now takes heading and user. The cap branch passes heading="Account not added" and user={await getSessionUser(c)}. That user comes from the global middleware's cached viewer, and the cookies are unchanged at that point. The test checks for "Account menu for @alice.test" and "Account not added", and that "Authentication Failed" is not there.
    • The solutions doc's "After" block now matches revoke-sessions.ts line for line. Its "Key shape changes" and "Prevention" bullets say the account is dropped from the list.
  • Earlier rounds still hold:
    • A failed add keeps add=1, and the add page shows the account menu.
    • Drafts are tagged per viewer and cleared per viewer, with no last flag.
    • Revoke logs account_switched with reason revoke_sessions, and /settings/security labels it.
    • The avatar's hover shows on its face.
    • docs/oauth.md and CONCEPTS.md list account_switched.
  • The issue, against the code:
    • lib/auth/accounts.ts: add, cap 5 (refused, not dropped), switch, and remove (the next account becomes active). An old single cookie counts as a one-account list.
    • /auth/switch gives a 400 for an unlisted DID, and sends a lost session to /auth/login?add=1&handle=....
    • The last sign-out clears both cookies and goes to /.
    • /settings/account has no Sign out button.
    • No sign-out or switch path touches oauth_sessions, so automations for inactive accounts keep firing.
    • The menu is a native popover="auto" with a popovertarget button, so it works from the keyboard and Escape or an outside click closes it. The popover follows the trigger in the tab order. Each row has its own name, and every value is a token apart from the menu's min width.
  • Stale-tab guard, listed myself:
    • Every export const POST|PUT|PATCH|DELETE under app/routes/api goes through requireApiAuth/requireApiAdmin, either in its own file or through the _middleware.ts of automations/, secrets/ and admin/.
    • The only other mutating routes are auth/login, auth/switch and auth/signout.
    • Every mutating island fetch (rg 'fetch\(' in app/islands) sends accountHeaders().
  • Every reader of the cookies (rg COOKIE_NAME|airglow_session|*SignedCookie|deleteCookie): only readAccountCookies/writeAccountCookies, plus lib/scripts/dev-jetstream-event.ts, which still works through the fallback for the old single cookie. No docs outside docs/plans/ (historical) and the owner-only AGENTS.md describe the old sign-out.

Changes:

  1. Update the stale bullet in the docblock of app/routes/api/settings/revoke-sessions.ts. In the restore() fails bullet, replace side). Log oauth_session_revoked, clear cookie, redirect. with:

     *     side). Log oauth_session_revoked, drop the account from this browser's
     *     account list, redirect.
    

    Leave the "Do NOT clear the cookie" lines on the 502 path as they are: on that path nothing is changed, so they are still right. How I tried it: I made this edit in this clone. vp check passed and revoke-sessions.test.ts passed (2 tests), and then I reverted it.

The follow-up is file. AGENTS.md lines 63–65 still say to sign out from /settings/account and to switch users by signing out and back in. This branch removes that button, and both actions now live in the avatar menu. The replacement text matches the menu's names: "Sign out @handle", "Add account" and "Switch to @handle". AGENTS.md is owner-only, so the contributor cannot make the edit.

The owner check in pr.md is the owner's to make after the merge: a real OAuth run with alice and bob in a browser, using the menu from the keyboard. Nobody can run it in this clone.

loop:verdict run=review-20261109-000814-35 sha=63006c7d0f5a446cf0ab8c03ca8deaf8777e46af verdict=changes-requested

Fix round 3: the review asked for changes.

loop:fix run=contribute-20261109-001237-35 round=3 issue=35

feat(auth): sign in to several accounts and switch from the header menu

A browser can now be signed in to up to five accounts at once. The avatar in the header opens an account menu where you can switch between them, add another one, view your profile or sign out of the active account, so you no longer have to sign out and back in. A new signed airglow_accounts cookie holds the list. airglow_session still holds the active DID, and it only counts while that DID is in the list. Pages carry the DID they were rendered for, and the server refuses a write from a tab left open across a switch.

What changed #

  • Cookies (lib/auth/accounts.ts): airglow_accounts is a signed JSON list of DIDs, capped at MAX_ACCOUNTS = 5. The add, switch and remove rules are small pure functions. A browser that only has the old airglow_session cookie counts that DID as its only account, so nobody is signed out when this deploys, and lib/scripts/dev-jetstream-event.ts keeps working.

  • Callback adds the DID to the list and makes it active. A sixth account is refused, not swapped in for an older one: the cookies stay as they were and the page, headed "Account not added", says "You're already signed in to 5 accounts...". That page keeps the signed-in header, so the account menu it points to is there. The stored OAuth session, the reactivation of automations and the sync resume still run.

  • /auth/login?add=1 does not redirect a signed-in user and does not prefill their handle. It is headed "Add an account". When the list is full it shows a status notice in place of the form. Its form posts to /auth/login?add=1, and a failed post (an empty handle, or a handle that cannot be authorized) comes back to the add page with its error, not to the signed-in redirect.

  • POST /auth/switch takes a did, which must be in the list (otherwise it answers 400). It logs account_switched and redirects to /u/<handle>. If that account has lost its OAuth session, it goes to /auth/login?add=1&handle=... instead.

  • POST /auth/signout removes the account named in the form's did, falling back to the active account. When the removed account was active, the next one becomes active (logged as account_switched with reason sign_out) and the browser goes to its profile. When no account is left, both cookies are cleared and it goes to /. sign_out is still logged, and the OAuth grant is kept, so automations for inactive accounts keep firing.

  • Revoke sessions now removes only that account from the list, where it used to delete the cookie. When another account takes over, it logs account_switched on that account with reason revoke_sessions.

  • Header menu: a native popover (no new island), triggered by the avatar button "Account menu for @handle". It lists each account with its avatar and @handle:

    • the active account has a check and "(active account)" for screen readers;
    • each other account is a "Switch to @handle" button;
    • an account that has lost its session is a "Sign in again as @handle" link.

    Below the list come View profile, Add account and the SignOutForm island, now labelled "Sign out @handle".

  • Autosaved drafts are now tagged with their viewer (a hash of the handle, like the rest of the key). Signing out of an account, or revoking its sessions, clears that account's drafts and any untagged older ones (clearAutosavedDraftsFor in app/islands/form-autosave.ts), so the other signed-in accounts keep theirs. Once the last account signs out, nothing tagged is left, which still covers a shared computer.

  • /settings/account no longer has the Sign out button. Revoke sessions stays.

  • Stale tabs: the renderer adds <meta name="airglow-account" content=did> to pages for signed-in users. Every mutating fetch in the islands sends that DID as X-Airglow-Account, through accountHeaders() in app/islands/account-guard.ts. requireApiAuth and requireApiAdmin (which guard every mutating /api route) answer 409 when it differs from the active DID, with code: "account_mismatch" and the message "You switched to @b in another tab. Reload this page to continue as @b." The islands show the server's error text. RevokeSessions used to show only "Request failed: N" and now shows it too.

  • Security log (/settings/security) labels account_switched and says why it happened, including "after revoking another account's sessions". docs/oauth.md and CONCEPTS.md describe the new cookies and routes, and the audit events in docs/oauth.md list account_switched and its reasons.

What each menu state shows #

  • The trigger: the avatar's face shows the accent hover colour when the pointer is over it, as on main.
  • One account: the avatar opens a menu with @you ✓, View profile, Add account and Sign out @you.
  • Several accounts: the active account has a check, and each of the others is a Switch button.
  • An account with a lost session: its row reads "Sign in again" and links to the login form with the handle prefilled.
  • Five accounts: "Add account" is shown as plain text with "Up to 5 accounts at once" beside it. /auth/login?add=1 shows the same limit as a notice instead of the form.
  • A sixth account at the callback: the signed-in header with the account menu (all five accounts, "Add account" with its cap note), the heading "Account not added" and the error alert, with no "Try Again" button.
  • A failed add: /auth/login?add=1&error=... shows "Add an account", the error, and the empty form again.
  • Signed out: the header shows the Sign in button, as before.

Verification #

  • pnpm run check and pnpm run test pass (191 files, 2833 tests).

  • New tests:

    • lib/auth/accounts.test.ts: the add, switch and remove rules and the cap.
    • app/routes/auth/accounts.test.ts: real signed cookies through /auth/switch, /auth/signout, getSessionUser and requireApiAuth, covering an unlisted DID, a lost session, the last account, a stale-tab sign-out, and the 409 mismatch alongside a request that passes.
    • app/routes/auth/callback.test.tsx: adding an account, and refusing a sixth on a page that keeps the account menu and is headed "Account not added".
    • app/routes/auth/login.test.tsx: ?add=1, and a failed add post that comes back to the add page with its error.
    • app/routes/api/settings/revoke-sessions.test.ts: [A, B] with A active leaves B active and logs the switch; [A] clears both cookies and logs none.
    • app/islands/SignOutForm.test.tsx and form-autosave.test.ts: signing out of (or revoking) bob keeps alice's drafts and drops bob's and untagged ones; then alice's sign-out leaves none.
    • app/routes/settings/security.test.tsx: the revoke_sessions and sign_out switch labels.
    • app/components/Layout/Header/index.test.tsx: the menu markup in each state.
  • Not checked in a browser. This sandbox has no browser or local PDS, so I could not sign in or try the menu with the keyboard. I checked the markup instead:

    • The trigger is a native button with popovertarget, so it opens with Enter or Space.
    • The popover follows the trigger in the tab order.
    • Escape or a click outside closes it and returns focus to the avatar.
    • Each action navigates to a new page, so focus is never left on a removed element.

    Please confirm the keyboard behaviour and the menu's position under the avatar. The menu uses CSS anchor positioning, and where that is unsupported it falls back to the top right under the header.

Acceptance #

Decisions you can reverse #

  • A mutating request without X-Airglow-Account is not checked. That covers scripts, curl and a bundle cached from before this change. Making the header required would only take a change to accountMismatch in lib/auth/middleware.ts, but several existing API tests would then need the header.
  • A stale tab's "Sign out @A" signs out A, not the active account. It names the account it was rendered for, so it is not refused like an API write.
  • Signing out of one account clears only that account's autosaved drafts, plus untagged drafts from before this change, which cannot be attributed. Going back to clearing every draft would be a one-line change in SignOutForm and RevokeSessions.
  • When the active account is removed, the next account in the list becomes active, or the previous one when it was last. Accounts are listed in the order they were added.
  • The account_switched metadata stores the previous DID and a reason (switch, sign_out, sign_in or revoke_sessions). The security page shows only the reason.

This round #

Round 3 fixes the last stale line the review found: the restore()-fails bullet in the docblock of app/routes/api/settings/revoke-sessions.ts now says the account is dropped from this browser's account list, where it said "clear cookie". The 502 path's "Do NOT clear the cookie" lines are unchanged, since nothing changes on that path.

Round 2 made the review's two changes: the full-list callback page now renders the signed-in header (so the account menu it mentions is there) under the heading "Account not added", through two optional ErrorPage props, heading and user; and docs/solutions/security-issues/oauth-grant-revocation-error-contract.md now says revoke and sign-out drop the account from this browser's account list, where it said they clear the cookie.

The round before, the review asked for six changes, all made: a failed "Add account" keeps add=1 and its error; drafts are tagged by viewer, so a sign-out or revoke clears only that account's drafts; revoking sessions logs the switch it makes, and the security page labels it; docs/oauth.md lists account_switched; the avatar's hover shows on its face, and the face styles are no longer written twice; the AGENTS.md follow-up is back. The signed-cookie test helpers that two test files had copied now live in lib/test/account-cookies.ts, shared with the new revoke test.

Owner check #

After the merge, a real OAuth run with alice.pds.dev and bob.pds.dev in a browser, using the account menu from the keyboard. Nobody can run it in this clone.

Follow-up, filed as a new issue on merge if the review agrees:

docs(agents): describe sign-out and switching through the header's account menu

AGENTS.md (owner-only) still tells agents to sign out from /settings/account and to switch users by signing out and back in. The multi-account branch for #35 removes the Sign out button from /settings/account (app/routes/settings/account.tsx now renders only Identity and Revoke sessions), and sign-out and switching now live in the avatar menu in the header (app/components/Layout/Header/index.tsx). An agent following lines 63–65 looks for a button that is gone.

Where to start: in AGENTS.md, under "Sign in / out", replace the two paragraphs

Sign out: go to http://127.0.0.1:5176/settings/account → click "Sign out" (POSTs to /auth/signout). Redirects to /.

Switch users: sign out, then sign in as the other.

with

Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261109-001237-35 sha=1a54193c1f41a2af4a97ce8235f990707cc89575

Verdict: changes-requested. Round 7 asked for one docblock fix, and this head makes it. pnpm run check and pnpm run test pass (191 files, 2833 tests), and the issue's acceptance list holds in the code. One small gap is left, and this branch brings it in. When five accounts are signed in and one of them has lost its session, its "Sign in again" link opens the form with that handle filled in. But if that sign-in fails to start, for example because the PDS can't be reached or the handle was edited, the page comes back with only the "You're signed in to 5 accounts..." notice and no form. The user is told they can't add an account when all they were doing was signing back in to one they already have.

What I checked and how:

  • Round 7's point, at 1a54193: the only change since 63006c7 is the restore() fails bullet in the docblock of app/routes/api/settings/revoke-sessions.ts. It now says the account is dropped from this browser's account list. The two "Do NOT clear the cookie" lines on the 502 path stay, and they are still right.
  • Earlier rounds still hold:
    • A failed add keeps add=1, and the add page and the full-list callback page both show the account menu.
    • Drafts are tagged per viewer and cleared per viewer, by both SignOutForm and RevokeSessions (the latter only after a success).
    • Revoke logs account_switched with reason revoke_sessions, and /settings/security labels it.
    • The avatar's hover shows on its face.
    • docs/oauth.md, CONCEPTS.md and the solutions doc describe the account list.
  • Read again in full: lib/auth/accounts.ts, lib/auth/middleware.ts, the global middleware and renderer, signout.ts, switch.ts, callback.tsx, login.tsx, revoke-sessions.ts, the header menu, SignOutForm, RevokeSessions and form-autosave.ts.
  • Stale docs: I searched outside docs/plans/ for signOutButton, clearAllAutosavedDrafts and "clear the cookie". Only the owner-only AGENTS.md is stale, and the follow-up covers it.
  • The gap, in a throwaway test in /work (since removed): the test used the same mocks as login.test.tsx, with five accounts signed in.
    • GET ?add=1&handle=bob.test shows the form, which is right.
    • POST /auth/login?add=1 with handle=bob.test fails to authorize and redirects to /auth/login?error=authorize_failed&add=1, which drops the handle.
    • That page has the cap notice and no name="handle" input.

Changes:

  1. In app/routes/auth/login.tsx, carry the typed handle into the POST error redirect.
    • Fix: in errorRedirect, after the add line, add: if (typeof handle === "string" && handle.trim()) params.set("handle", handle.trim());

    • What it does: the GET already treats a handle query as a re-sign-in, so the form stays. The handle is also prefilled, so a typo can be corrected rather than retyped.

    • The one trade-off: at the cap, a failed attempt with a brand-new handle now shows the form again instead of the notice. Submitting it then reaches the callback's "Account not added" page, which already handles that case. This matches what ?add=1&handle=<new> does today.

    • Tests:

      • In login.test.tsx, "brings a failed add back to the add form with its error" now expects /auth/login?error=authorize_failed&add=1&handle=typo.test, and should also check that the input is prefilled with typo.test.
      • Add a test with readAccountCookies mocked to five DIDs: a failed POST ?add=1 with handle=bob.test, followed to the page it redirects to. That page has the form, prefilled with bob.test, and no "signed in to 5 accounts" notice.
      • The empty-handle redirect stays /auth/login?error=handle_required&add=1, since a blank handle is not carried.
    • How I tried it: I made exactly this one-line change in this clone and ran the throwaway flow again. The redirect became /auth/login?error=authorize_failed&add=1&handle=bob.test, and the page showed the form prefilled with bob.test and no cap notice. The only test in app/routes/auth/ that failed was the exact-Location assertion named above (28 tests, 27 passed). The way back through the app is unchanged:

      • The page's form still posts to /auth/login?add=1, so a second failure comes back the same way.
      • The menu's "Add account" still goes to plain ?add=1, which shows the cap notice.
      • "Sign in again" still goes to ?add=1&handle=....

      I reverted the change afterwards.

The follow-up is file. AGENTS.md lines 63–65 still say to sign out from /settings/account and to switch users by signing out and back in. This branch removes that button and moves both actions to the avatar menu. The replacement text in followup.md matches the menu's names: "Sign out @handle", "Add account" and "Switch to @handle". AGENTS.md is owner-only, so the contributor cannot make the edit.

The owner check in pr.md is the owner's to make after the merge: a real OAuth run with alice and bob in a browser, using the menu from the keyboard. Nobody can run it in this clone.

loop:verdict run=review-20261109-001736-35 sha=1a54193c1f41a2af4a97ce8235f990707cc89575 verdict=changes-requested

The branch used every fix round (3) and still needs one. Set the label back to ready, or reply on the issue, to have the loop pick it up again, past its caps.

loop:awaiting-owner run=none why=rounds after=2026-11-09T00:21:11.000Z

Continue

Fix round 4: the owner commented.

loop:fix run=contribute-20261109-104723-35 round=4 issue=35

feat(auth): sign in to several accounts and switch from the header menu

A browser can now be signed in to up to five accounts at once. The avatar in the header opens an account menu where you can switch between them, add another one, view your profile or sign out of the active account, so you no longer have to sign out and back in. A new signed airglow_accounts cookie holds the list. airglow_session still holds the active DID, and it only counts while that DID is in the list. Pages carry the DID they were rendered for, and the server refuses a write from a tab left open across a switch.

What changed #

  • Cookies (lib/auth/accounts.ts): airglow_accounts is a signed JSON list of DIDs, capped at MAX_ACCOUNTS = 5. The add, switch and remove rules are small pure functions. A browser that only has the old airglow_session cookie counts that DID as its only account, so nobody is signed out when this deploys, and lib/scripts/dev-jetstream-event.ts keeps working.

  • Callback adds the DID to the list and makes it active. A sixth account is refused, not swapped in for an older one: the cookies stay as they were and the page, headed "Account not added", says "You're already signed in to 5 accounts...". That page keeps the signed-in header, so the account menu it points to is there. The stored OAuth session, the reactivation of automations and the sync resume still run.

  • /auth/login?add=1 does not redirect a signed-in user and does not prefill their handle. It is headed "Add an account". When the list is full it shows a status notice in place of the form. Its form posts to /auth/login?add=1, and a failed post (an empty handle, or a handle that cannot be authorized) comes back to the add page with its error, not to the signed-in redirect. A non-blank typed handle comes back with it (&handle=...), so the form is prefilled, and a failed "Sign in again" at five accounts keeps its form instead of the cap notice.

  • POST /auth/switch takes a did, which must be in the list (otherwise it answers 400). It logs account_switched and redirects to /u/<handle>. If that account has lost its OAuth session, it goes to /auth/login?add=1&handle=... instead.

  • POST /auth/signout removes the account named in the form's did, falling back to the active account. When the removed account was active, the next one becomes active (logged as account_switched with reason sign_out) and the browser goes to its profile. When no account is left, both cookies are cleared and it goes to /. sign_out is still logged, and the OAuth grant is kept, so automations for inactive accounts keep firing.

  • Revoke sessions now removes only that account from the list, where it used to delete the cookie. When another account takes over, it logs account_switched on that account with reason revoke_sessions.

  • Header menu: a native popover (no new island), triggered by the avatar button "Account menu for @handle". It lists each account with its avatar and @handle:

    • the active account has a check and "(active account)" for screen readers;
    • each other account is a "Switch to @handle" button;
    • an account that has lost its session is a "Sign in again as @handle" link.

    Below the list come View profile, Add account and the SignOutForm island, now labelled "Sign out @handle".

  • Autosaved drafts are now tagged with their viewer (a hash of the handle, like the rest of the key). Signing out of an account, or revoking its sessions, clears that account's drafts and any untagged older ones (clearAutosavedDraftsFor in app/islands/form-autosave.ts), so the other signed-in accounts keep theirs. Once the last account signs out, nothing tagged is left, which still covers a shared computer.

  • /settings/account no longer has the Sign out button. Revoke sessions stays.

  • Stale tabs: the renderer adds <meta name="airglow-account" content=did> to pages for signed-in users. Every mutating fetch in the islands sends that DID as X-Airglow-Account, through accountHeaders() in app/islands/account-guard.ts. requireApiAuth and requireApiAdmin (which guard every mutating /api route) answer 409 when it differs from the active DID, with code: "account_mismatch" and the message "You switched to @b in another tab. Reload this page to continue as @b." The islands show the server's error text. RevokeSessions used to show only "Request failed: N" and now shows it too.

  • Security log (/settings/security) labels account_switched and says why it happened, including "after revoking another account's sessions". docs/oauth.md and CONCEPTS.md describe the new cookies and routes, and the audit events in docs/oauth.md list account_switched and its reasons.

What each menu state shows #

  • The trigger: the avatar's face shows the accent hover colour when the pointer is over it, as on main.
  • One account: the avatar opens a menu with @you ✓, View profile, Add account and Sign out @you.
  • Several accounts: the active account has a check, and each of the others is a Switch button.
  • An account with a lost session: its row reads "Sign in again" and links to the login form with the handle prefilled.
  • Five accounts: "Add account" is shown as plain text with "Up to 5 accounts at once" beside it. /auth/login?add=1 shows the same limit as a notice instead of the form.
  • A sixth account at the callback: the signed-in header with the account menu (all five accounts, "Add account" with its cap note), the heading "Account not added" and the error alert, with no "Try Again" button.
  • A failed add: /auth/login?error=...&add=1&handle=... shows "Add an account", the error, and the form prefilled with the handle that was typed. A blank handle comes back as ?error=handle_required&add=1 with the empty form.
  • A failed "Sign in again" at five accounts: the error and the form prefilled with that handle, not the "You're signed in to 5 accounts" notice.
  • Signed out: the header shows the Sign in button, as before.

Verification #

  • pnpm run check and pnpm run test pass (191 files, 2834 tests).

  • New tests:

    • lib/auth/accounts.test.ts: the add, switch and remove rules and the cap.
    • app/routes/auth/accounts.test.ts: real signed cookies through /auth/switch, /auth/signout, getSessionUser and requireApiAuth, covering an unlisted DID, a lost session, the last account, a stale-tab sign-out, and the 409 mismatch alongside a request that passes.
    • app/routes/auth/callback.test.tsx: adding an account, and refusing a sixth on a page that keeps the account menu and is headed "Account not added".
    • app/routes/auth/login.test.tsx: ?add=1, a failed add post that comes back to the add page with its error and the handle prefilled, and a failed re-sign-in at five accounts that comes back to the form, not the cap notice.
    • app/routes/api/settings/revoke-sessions.test.ts: [A, B] with A active leaves B active and logs the switch; [A] clears both cookies and logs none.
    • app/islands/SignOutForm.test.tsx and form-autosave.test.ts: signing out of (or revoking) bob keeps alice's drafts and drops bob's and untagged ones; then alice's sign-out leaves none.
    • app/routes/settings/security.test.tsx: the revoke_sessions and sign_out switch labels.
    • app/components/Layout/Header/index.test.tsx: the menu markup in each state.
  • Not checked in a browser. This sandbox has no browser or local PDS, so I could not sign in or try the menu with the keyboard. I checked the markup instead:

    • The trigger is a native button with popovertarget, so it opens with Enter or Space.
    • The popover follows the trigger in the tab order.
    • Escape or a click outside closes it and returns focus to the avatar.
    • Each action navigates to a new page, so focus is never left on a removed element.

    Please confirm the keyboard behaviour and the menu's position under the avatar. The menu uses CSS anchor positioning, and where that is unsupported it falls back to the top right under the header.

Acceptance #

Decisions you can reverse #

  • A mutating request without X-Airglow-Account is not checked. That covers scripts, curl and a bundle cached from before this change. Making the header required would only take a change to accountMismatch in lib/auth/middleware.ts, but several existing API tests would then need the header.
  • A stale tab's "Sign out @A" signs out A, not the active account. It names the account it was rendered for, so it is not refused like an API write.
  • Signing out of one account clears only that account's autosaved drafts, plus untagged drafts from before this change, which cannot be attributed. Going back to clearing every draft would be a one-line change in SignOutForm and RevokeSessions.
  • A failed sign-in carries the typed handle in its redirect. At the cap, a failed attempt with a brand-new handle therefore shows the form again rather than the notice; submitting it reaches the callback's "Account not added" page, as ?add=1&handle=<new> already did. Dropping the one line in errorRedirect (app/routes/auth/login.tsx) reverses it.
  • When the active account is removed, the next account in the list becomes active, or the previous one when it was last. Accounts are listed in the order they were added.
  • The account_switched metadata stores the previous DID and a reason (switch, sign_out, sign_in or revoke_sessions). The security page shows only the reason.

This round #

Round 4 makes the review's one change: errorRedirect in app/routes/auth/login.tsx now carries the trimmed, non-blank handle into the redirect, so a "Sign in again" that fails to start at five accounts comes back to its prefilled form instead of the cap notice. The failed-add test now expects &handle=typo.test and the prefilled input, and a new test follows a failed POST ?add=1 for bob.test with five accounts to a page with the form, prefilled, and no cap notice.

Round 3 fixes the last stale line the review found: the restore()-fails bullet in the docblock of app/routes/api/settings/revoke-sessions.ts now says the account is dropped from this browser's account list, where it said "clear cookie". The 502 path's "Do NOT clear the cookie" lines are unchanged, since nothing changes on that path.

Round 2 made the review's two changes: the full-list callback page now renders the signed-in header (so the account menu it mentions is there) under the heading "Account not added", through two optional ErrorPage props, heading and user; and docs/solutions/security-issues/oauth-grant-revocation-error-contract.md now says revoke and sign-out drop the account from this browser's account list, where it said they clear the cookie.

The round before, the review asked for six changes, all made: a failed "Add account" keeps add=1 and its error; drafts are tagged by viewer, so a sign-out or revoke clears only that account's drafts; revoking sessions logs the switch it makes, and the security page labels it; docs/oauth.md lists account_switched; the avatar's hover shows on its face, and the face styles are no longer written twice; the AGENTS.md follow-up is back. The signed-cookie test helpers that two test files had copied now live in lib/test/account-cookies.ts, shared with the new revoke test.

Owner check #

After the merge, a real OAuth run with alice.pds.dev and bob.pds.dev in a browser, using the account menu from the keyboard. Nobody can run it in this clone.

Follow-up, filed as a new issue on merge if the review agrees:

docs(agents): describe sign-out and switching through the header's account menu

AGENTS.md (owner-only) still tells agents to sign out from /settings/account and to switch users by signing out and back in. The multi-account branch for #35 removes the Sign out button from /settings/account (app/routes/settings/account.tsx now renders only Identity and Revoke sessions), and sign-out and switching now live in the avatar menu in the header (app/components/Layout/Header/index.tsx). An agent following lines 63–65 looks for a button that is gone.

Where to start: in AGENTS.md, under "Sign in / out", replace the two paragraphs

Sign out: go to http://127.0.0.1:5176/settings/account → click "Sign out" (POSTs to /auth/signout). Redirects to /.

Switch users: sign out, then sign in as the other.

with

Sign out: click your avatar in the header → "Sign out @<handle>" (POSTs to /auth/signout). It signs out of the active account only: with another account signed in, that one becomes active and you land on its profile; signing out of the last account redirects to /.

Switch users: click your avatar → "Add account" and sign in as the other (same steps as Sign in above). Both stay signed in; switch between them from the avatar menu ("Switch to @<handle>").

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/35

loop:pushed run=contribute-20261109-104723-35 sha=0b3b8b1a95fa02f9e1603bbab322047d03baf6ac

Verdict: merge. Round 8 makes the one change the last review asked for, and nothing else. errorRedirect in app/routes/auth/login.tsx now carries the trimmed, non-blank typed handle. So a "Sign in again" that fails to start while five accounts are signed in comes back to its prefilled form, not to the cap notice. pnpm run check and pnpm run test pass (191 files, 2834 tests), and the issue's acceptance list holds in the code.

What I checked and how:

  • Round 7's point, at 0b3b8b1. The diff since 1a54193 touches only login.tsx and login.test.tsx.
    • The new line is exactly the one prescribed.
    • The failed-add test now expects &handle=typo.test and the prefilled input.
    • A new test mocks five accounts and follows a failed POST ?add=1 for bob.test. It lands on a page with the error and the prefilled form, and no cap notice. Without the fix, that test fails, because the GET shows the notice whenever no handle query is present.
    • A blank handle is still not carried (?error=handle_required&add=1).
    • The value is rendered through JSX, so it is escaped.
    • On a plain, signed-out sign-in, a failure now also prefills what was typed, which is harmless.
  • Earlier rounds still hold. No other file changed since 1a54193, and I re-read the core files:
    • A failed add keeps add=1.
    • The add page and the full-list callback page show the account menu.
    • Drafts are tagged per viewer, and SignOutForm/RevokeSessions clear them per viewer.
    • Revoke logs account_switched with reason revoke_sessions.
    • The docblock and the solutions doc describe dropping the account from the list.
  • The issue against the code (lib/auth/accounts.ts, signout.ts, switch.ts, lib/auth/middleware.ts, the header menu):
    • Add, a cap of 5 that refuses rather than drops, switch, and remove with the next account becoming active. An unlisted DID gets a 400.
    • The last sign-out clears both cookies and goes to /.
    • /settings/account has no Sign out button.
    • Sign-out and switch never touch oauth_sessions, so automations for inactive accounts keep firing.
    • A lost session shows "Sign in again".
  • Stale-tab guard, listed myself:
    • Every mutating route under app/routes/api goes through requireApiAuth/requireApiAdmin, either directly or through the _middleware.ts of automations/, secrets/ and admin/. Each answers 409 on a mismatch.
    • Every mutating island fetch sends accountHeaders().
    • The only native posts are login, switch and sign-out, and sign-out names its account.
  • Stale docs: a search for clearAllAutosavedDrafts, signOutButton and the old sign-out wording outside docs/plans/ finds only the owner-only AGENTS.md, which the follow-up covers.

The follow-up is file. AGENTS.md lines 63–65 still say to sign out from /settings/account and to switch users by signing out and back in. This branch removes that button and moves both actions to the avatar menu. The replacement text in followup.md matches the menu ("Sign out @handle", "Add account", "Switch to @handle") and signout.ts's behaviour. AGENTS.md is owner-only, so the contributor cannot make the edit.

The owner check in pr.md is the owner's to make after the merge: a real OAuth run with alice and bob in a browser, using the account menu from the keyboard. Nobody can run it in this clone, since there is no PDS here.

loop:verdict run=review-20261109-105256-35 sha=0b3b8b1a95fa02f9e1603bbab322047d03baf6ac verdict=merge followup=1

Merged onto main as 1f7897f1fa18c528e84bfdbc731f9887b2e36885.

loop:merged sha=1f7897f1fa18c528e84bfdbc731f9887b2e36885 head=0b3b8b1a95fa02f9e1603bbab322047d03baf6ac

Filed the follow-up as #107, a new issue, not marked ready, from branch loop/35 (1f7897f1fa18): docs(agents): describe sign-out and switching through the header's account menu

loop:filed issue=107

Sign up or Sign in to add to the discussion.