From b7df96711dcef8536c12c3e800ed86fef1b2676f Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:00:37 -0400 Subject: [PATCH 1/2] Hide internal desktop bearer sessions from the device list Sessions the desktop process mints against its own backend (phone-link relay bridges and similar machine-to-machine logins) authenticate with the desktop bootstrap token over bearer auth and accumulated as meaningless desktop-bootstrap rows under Connected devices. Filter them out of the device list and the auth-access stream, keeping the caller's own session visible so every viewer can still see how it is connected. --- .../auth/Layers/BootstrapCredentialService.ts | 3 +- .../server/src/auth/Layers/ServerAuth.test.ts | 37 +++++++++++++++++++ apps/server/src/auth/Layers/ServerAuth.ts | 17 ++++++--- apps/server/src/auth/utils.ts | 23 +++++++++++- apps/server/src/ws.ts | 12 +++++- 5 files changed, 83 insertions(+), 9 deletions(-) diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index eabf028b7..923b87a33 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -9,6 +9,7 @@ import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; import { ServerConfig } from "../../config.ts"; +import { DESKTOP_BOOTSTRAP_SUBJECT } from "../utils.ts"; import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts"; import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts"; import { @@ -89,7 +90,7 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { yield* seedGrant(config.desktopBootstrapToken, { method: "desktop-bootstrap", role: "owner", - subject: "desktop-bootstrap", + subject: DESKTOP_BOOTSTRAP_SUBJECT, expiresAt: DESKTOP_BOOTSTRAP_EXPIRES_AT, remainingUses: "unbounded", }); diff --git a/apps/server/src/auth/Layers/ServerAuth.test.ts b/apps/server/src/auth/Layers/ServerAuth.test.ts index 71d6a2854..feb9c06cb 100644 --- a/apps/server/src/auth/Layers/ServerAuth.test.ts +++ b/apps/server/src/auth/Layers/ServerAuth.test.ts @@ -343,4 +343,41 @@ it.layer(NodeServices.layer)("ServerAuthLive", (it) => { ), ), ); + + it.effect("hides internal desktop bearer sessions from the device list", () => + Effect.gen(function* () { + const serverAuth = yield* ServerAuth; + + const rendererExchange = yield* serverAuth.exchangeBootstrapCredential( + "desktop-bootstrap-token", + requestMetadata, + ); + const rendererSession = yield* serverAuth.authenticateHttpRequest( + makeCookieRequest(rendererExchange.sessionToken), + ); + const bridgeExchange = yield* serverAuth.exchangeBootstrapCredentialForBearerSession( + "desktop-bootstrap-token", + { deviceType: "desktop", ipAddress: "127.0.0.1" }, + ); + const bridgeSession = yield* serverAuth.authenticateHttpRequest( + makeAuthRequest({ + headers: { authorization: `Bearer ${bridgeExchange.sessionToken}` }, + }), + ); + + const rendererView = yield* serverAuth.listClientSessions(rendererSession.sessionId); + const bridgeView = yield* serverAuth.listClientSessions(bridgeSession.sessionId); + + expect(rendererView.map((entry) => entry.sessionId)).toEqual([rendererSession.sessionId]); + expect(bridgeView.find((entry) => entry.sessionId === bridgeSession.sessionId)?.current).toBe( + true, + ); + }).pipe( + Effect.provide( + makeServerAuthLayer({ + desktopBootstrapToken: "desktop-bootstrap-token", + }), + ), + ), + ); }); diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 37a60d9d8..464651a72 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -14,6 +14,7 @@ import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { ServerConfig } from "../../config.ts"; import { isLoopbackHost } from "../../startupAccess.ts"; +import { isInternalClientSession } from "../utils.ts"; import { AuthControlPlane } from "../Services/AuthControlPlane.ts"; import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts"; import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts"; @@ -416,12 +417,16 @@ export const makeServerAuth = Effect.gen(function* () { }), ), Effect.map((clientSessions) => - clientSessions.map( - (clientSession): AuthClientSession => ({ - ...clientSession, - current: clientSession.sessionId === currentSessionId, - }), - ), + clientSessions + .map( + (clientSession): AuthClientSession => ({ + ...clientSession, + current: clientSession.sessionId === currentSessionId, + }), + ) + .filter( + (clientSession) => clientSession.current || !isInternalClientSession(clientSession), + ), ), ); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 97f6964c8..bea689120 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -1,9 +1,30 @@ -import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@threadlines/contracts"; +import type { + AuthClientMetadata, + AuthClientMetadataDeviceType, + ServerAuthSessionMethod, +} from "@threadlines/contracts"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as Crypto from "node:crypto"; const SESSION_COOKIE_NAME = "threadlines_session"; +export const DESKTOP_BOOTSTRAP_SUBJECT = "desktop-bootstrap"; + +/** + * The desktop shell signs into its own backend with the desktop bootstrap + * token: a cookie session for the renderer window, and bearer sessions for + * machine-to-machine bridges such as the phone-link relay. The bearer ones are + * plumbing rather than devices the user paired, so device-list surfaces hide + * them — except the caller's own session, which callers must keep visible so + * every viewer can see how they themselves are connected. + */ +export function isInternalClientSession(session: { + readonly subject: string; + readonly method: ServerAuthSessionMethod; +}): boolean { + return session.subject === DESKTOP_BOOTSTRAP_SUBJECT && session.method === "bearer-session-token"; +} + export function resolveSessionCookieName(input: { readonly mode: "web" | "desktop"; readonly port: number; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 53c7536c1..93ef1ac78 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -143,6 +143,7 @@ import { type SessionCredentialChange, } from "./auth/Services/SessionCredentialService.ts"; import { respondToAuthError } from "./auth/http.ts"; +import { isInternalClientSession } from "./auth/utils.ts"; const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -2068,7 +2069,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const revisionRef = yield* Ref.make(1); const accessChanges: Stream.Stream< BootstrapCredentialChange | SessionCredentialChange - > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges); + > = Stream.merge(bootstrapCredentials.streamChanges, sessions.streamChanges).pipe( + // Same visibility rule as ServerAuth.listClientSessions: internal + // machine-to-machine sessions never reach device-list subscribers. + Stream.filter( + (change) => + change.type !== "clientUpserted" || + change.clientSession.sessionId === currentSessionId || + !isInternalClientSession(change.clientSession), + ), + ); const liveEvents: Stream.Stream = accessChanges.pipe( Stream.mapEffect((change) => From 246d7b39b8fb2f8b07910129d4b64f1255dcdf8c Mon Sep 17 00:00:00 2001 From: Badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:11:12 -0400 Subject: [PATCH 2/2] Confirm before removing other devices and spare phone-link bridges Remove other devices now revokes exactly what the device list shows: internal desktop bearer sessions are skipped, so the action no longer severs a live phone-link connection. The bulk revoke-all-except chain through the control plane, credential service, and repository is now unused and removed. The web button opens a confirmation dialog stating how many devices will be signed out before anything is revoked. --- .../src/auth/Layers/AuthControlPlane.ts | 8 ----- .../server/src/auth/Layers/ServerAuth.test.ts | 12 +++++++ apps/server/src/auth/Layers/ServerAuth.ts | 16 +++++++++- .../Layers/SessionCredentialService.test.ts | 6 ++-- .../auth/Layers/SessionCredentialService.ts | 28 ---------------- .../src/auth/Services/AuthControlPlane.ts | 3 -- .../auth/Services/SessionCredentialService.ts | 3 -- .../src/persistence/Layers/AuthSessions.ts | 26 --------------- .../src/persistence/Services/AuthSessions.ts | 9 ------ .../settings/ConnectionsSettings.tsx | 32 ++++++++++++++++--- .../settings/SettingsPanels.browser.tsx | 6 ++++ 11 files changed, 64 insertions(+), 85 deletions(-) diff --git a/apps/server/src/auth/Layers/AuthControlPlane.ts b/apps/server/src/auth/Layers/AuthControlPlane.ts index 435301eaf..1cdc69a8b 100644 --- a/apps/server/src/auth/Layers/AuthControlPlane.ts +++ b/apps/server/src/auth/Layers/AuthControlPlane.ts @@ -146,13 +146,6 @@ export const makeAuthControlPlane = Effect.gen(function* () { .revoke(sessionId) .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session."))); - const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = ( - sessionId, - ) => - sessions - .revokeAllExcept(sessionId) - .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions."))); - return { createPairingLink, listPairingLinks, @@ -160,7 +153,6 @@ export const makeAuthControlPlane = Effect.gen(function* () { issueSession, listSessions, revokeSession, - revokeOtherSessionsExcept, } satisfies AuthControlPlaneShape; }); diff --git a/apps/server/src/auth/Layers/ServerAuth.test.ts b/apps/server/src/auth/Layers/ServerAuth.test.ts index feb9c06cb..15f19a8ff 100644 --- a/apps/server/src/auth/Layers/ServerAuth.test.ts +++ b/apps/server/src/auth/Layers/ServerAuth.test.ts @@ -372,6 +372,18 @@ it.layer(NodeServices.layer)("ServerAuthLive", (it) => { expect(bridgeView.find((entry) => entry.sessionId === bridgeSession.sessionId)?.current).toBe( true, ); + + const pairedDevice = yield* serverAuth.issuePairingCredential({ label: "Julius iPhone" }); + yield* serverAuth.exchangeBootstrapCredential(pairedDevice.credential, requestMetadata); + const revokedCount = yield* serverAuth.revokeOtherClientSessions(rendererSession.sessionId); + const bridgeAfterRevoke = yield* serverAuth.authenticateHttpRequest( + makeAuthRequest({ + headers: { authorization: `Bearer ${bridgeExchange.sessionToken}` }, + }), + ); + + expect(revokedCount).toBe(1); + expect(bridgeAfterRevoke.sessionId).toBe(bridgeSession.sessionId); }).pipe( Effect.provide( makeServerAuthLayer({ diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 464651a72..57800df79 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -455,7 +455,21 @@ export const makeServerAuth = Effect.gen(function* () { const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = ( currentSessionId, ) => - authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe( + authControlPlane.listSessions().pipe( + // Revoke exactly what the device list shows: internal machine-to-machine + // sessions are skipped so removing other devices does not drop live + // phone-link bridges. + Effect.map((clientSessions) => + clientSessions.filter( + (clientSession) => + clientSession.sessionId !== currentSessionId && !isInternalClientSession(clientSession), + ), + ), + Effect.flatMap((targets) => + Effect.forEach(targets, (target) => authControlPlane.revokeSession(target.sessionId), { + discard: true, + }).pipe(Effect.as(targets.length)), + ), Effect.mapError( (cause) => new AuthError({ diff --git a/apps/server/src/auth/Layers/SessionCredentialService.test.ts b/apps/server/src/auth/Layers/SessionCredentialService.test.ts index 5609c2fde..e42b74ca0 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.test.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.test.ts @@ -101,7 +101,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))), ); - it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () => + it.effect("lists active sessions, tracks connectivity, and revokes sessions", () => Effect.gen(function* () { const sessions = yield* SessionCredentialService; const owner = yield* sessions.issue({ @@ -128,7 +128,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { yield* sessions.markConnected(client.sessionId); const beforeRevoke = yield* sessions.listActive(); - const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId); + const revoked = yield* sessions.revoke(client.sessionId); const afterRevoke = yield* sessions.listActive(); const revokedClient = yield* Effect.flip(sessions.verify(client.token)); @@ -142,7 +142,7 @@ it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => { expect( beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType, ).toBe("desktop"); - expect(revokedCount).toBe(1); + expect(revoked).toBe(true); expect(afterRevoke).toHaveLength(1); expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId); expect(revokedClient.message).toContain("revoked"); diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index b679d3290..547b4b43f 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -482,33 +482,6 @@ export const makeSessionCredentialService = Effect.gen(function* () { return revoked; }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session."))); - const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) => - Effect.gen(function* () { - const revokedAt = yield* DateTime.now; - const revokedSessionIds = yield* authSessions.revokeAllExcept({ - currentSessionId: sessionId, - revokedAt, - }); - if (revokedSessionIds.length > 0) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - for (const revokedSessionId of revokedSessionIds) { - next.delete(revokedSessionId); - } - return next; - }); - yield* Effect.forEach( - revokedSessionIds, - (revokedSessionId) => emitRemoved(revokedSessionId), - { - concurrency: "unbounded", - discard: true, - }, - ); - } - return revokedSessionIds.length; - }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); - return { cookieName, issue, @@ -520,7 +493,6 @@ export const makeSessionCredentialService = Effect.gen(function* () { return Stream.fromPubSub(changesPubSub); }, revoke, - revokeAllExcept, markConnected, markDisconnected, } satisfies SessionCredentialServiceShape; diff --git a/apps/server/src/auth/Services/AuthControlPlane.ts b/apps/server/src/auth/Services/AuthControlPlane.ts index c10f885f5..0d2bf3215 100644 --- a/apps/server/src/auth/Services/AuthControlPlane.ts +++ b/apps/server/src/auth/Services/AuthControlPlane.ts @@ -63,9 +63,6 @@ export interface AuthControlPlaneShape { readonly revokeSession: ( sessionId: AuthSessionId, ) => Effect.Effect; - readonly revokeOtherSessionsExcept: ( - sessionId: AuthSessionId, - ) => Effect.Effect; } export class AuthControlPlane extends Context.Service()( diff --git a/apps/server/src/auth/Services/SessionCredentialService.ts b/apps/server/src/auth/Services/SessionCredentialService.ts index f32c7e0c6..860b96d01 100644 --- a/apps/server/src/auth/Services/SessionCredentialService.ts +++ b/apps/server/src/auth/Services/SessionCredentialService.ts @@ -78,9 +78,6 @@ export interface SessionCredentialServiceShape { >; readonly streamChanges: Stream.Stream; readonly revoke: (sessionId: AuthSessionId) => Effect.Effect; - readonly revokeAllExcept: ( - sessionId: AuthSessionId, - ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; } diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts index 31f48b148..0a61aa154 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -19,7 +19,6 @@ import { GetAuthSessionByIdInput, ListActiveAuthSessionsInput, RevokeAuthSessionInput, - RevokeOtherAuthSessionsInput, SetAuthSessionLastConnectedAtInput, } from "../Services/AuthSessions.ts"; @@ -184,19 +183,6 @@ const makeAuthSessionRepository = Effect.gen(function* () { `, }); - const revokeOtherSessionRows = SqlSchema.findAll({ - Request: RevokeOtherAuthSessionsInput, - Result: Schema.Struct({ sessionId: AuthSessionId }), - execute: ({ currentSessionId, revokedAt }) => - sql` - UPDATE auth_sessions - SET revoked_at = ${revokedAt} - WHERE session_id <> ${currentSessionId} - AND revoked_at IS NULL - RETURNING session_id AS "sessionId" - `, - }); - const create: AuthSessionRepositoryShape["create"] = (input) => createSessionRow(input).pipe( Effect.mapError( @@ -245,17 +231,6 @@ const makeAuthSessionRepository = Effect.gen(function* () { Effect.map((rows) => rows.length > 0), ); - const revokeAllExcept: AuthSessionRepositoryShape["revokeAllExcept"] = (input) => - revokeOtherSessionRows(input).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "AuthSessionRepository.revokeAllExcept:query", - "AuthSessionRepository.revokeAllExcept:decodeRows", - ), - ), - Effect.map((rows) => rows.map((row) => row.sessionId)), - ); - const setLastConnectedAt: AuthSessionRepositoryShape["setLastConnectedAt"] = (input) => setLastConnectedAtRow(input).pipe( Effect.mapError( @@ -271,7 +246,6 @@ const makeAuthSessionRepository = Effect.gen(function* () { getById, listActive, revoke, - revokeAllExcept, setLastConnectedAt, } satisfies AuthSessionRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/AuthSessions.ts b/apps/server/src/persistence/Services/AuthSessions.ts index 443f0bcd6..72f012b52 100644 --- a/apps/server/src/persistence/Services/AuthSessions.ts +++ b/apps/server/src/persistence/Services/AuthSessions.ts @@ -56,12 +56,6 @@ export const RevokeAuthSessionInput = Schema.Struct({ }); export type RevokeAuthSessionInput = typeof RevokeAuthSessionInput.Type; -export const RevokeOtherAuthSessionsInput = Schema.Struct({ - currentSessionId: AuthSessionId, - revokedAt: Schema.DateTimeUtcFromString, -}); -export type RevokeOtherAuthSessionsInput = typeof RevokeOtherAuthSessionsInput.Type; - export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ sessionId: AuthSessionId, lastConnectedAt: Schema.DateTimeUtcFromString, @@ -81,9 +75,6 @@ export interface AuthSessionRepositoryShape { readonly revoke: ( input: RevokeAuthSessionInput, ) => Effect.Effect; - readonly revokeAllExcept: ( - input: RevokeOtherAuthSessionsInput, - ) => Effect.Effect, AuthSessionRepositoryError>; readonly setLastConnectedAt: ( input: SetAuthSessionLastConnectedAtInput, ) => Effect.Effect; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 33d4936ee..95dde5f80 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1098,8 +1098,10 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio onRevokeOtherClients, }: AuthorizedClientsHeaderActionProps) { const [dialogOpen, setDialogOpen] = useState(false); + const [confirmRemoveOpen, setConfirmRemoveOpen] = useState(false); const [pairingLabel, setPairingLabel] = useState(""); const [isCreatingPairingLink, setIsCreatingPairingLink] = useState(false); + const otherDeviceCount = clientSessions.filter((clientSession) => !clientSession.current).length; const handleCreatePairingLink = useCallback(async () => { setIsCreatingPairingLink(true); @@ -1126,13 +1128,35 @@ const AuthorizedClientsHeaderAction = memo(function AuthorizedClientsHeaderActio + + + + Remove other devices? + + {otherDeviceCount === 1 + ? "1 other device will be signed out and will need a new link to reconnect." + : `${otherDeviceCount} other devices will be signed out and will need a new link to reconnect.`} + + + + }>Cancel + + + + { diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 775df9c3a..b9a7899cb 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1457,6 +1457,12 @@ describe("GeneralSettingsPanel observability", () => { await expect.element(page.getByText("Julius iPhone")).toBeInTheDocument(); await page.getByRole("button", { name: "Remove other devices", exact: true }).click(); + await expect + .element( + page.getByText("1 other device will be signed out and will need a new link to reconnect."), + ) + .toBeInTheDocument(); + await page.getByRole("button", { name: "Remove device", exact: true }).click(); await expect.element(page.getByText("This Mac")).toBeInTheDocument(); await expect.element(page.getByText("Julius iPhone")).not.toBeInTheDocument(); expect(fetchMock).toHaveBeenCalled();