diff --git a/apps/code/src/main/di/bindings.ts b/apps/code/src/main/di/bindings.ts index 4eb6343372..cff8ae13d4 100644 --- a/apps/code/src/main/di/bindings.ts +++ b/apps/code/src/main/di/bindings.ts @@ -127,7 +127,10 @@ import type { URL_LAUNCHER_SERVICE } from "@posthog/platform/url-launcher"; import type { WORKSPACE_SETTINGS_SERVICE } from "@posthog/platform/workspace-settings"; import type { WorkspaceClient } from "@posthog/workspace-client/client"; import type { DatabaseService } from "@posthog/workspace-server/db/service"; -import type { GIT_SERVICE as WS_GIT_SERVICE } from "@posthog/workspace-server/di/tokens"; +import type { + BROWSER_TABS_SERVICE, + GIT_SERVICE as WS_GIT_SERVICE, +} from "@posthog/workspace-server/di/tokens"; import type { AgentService } from "@posthog/workspace-server/services/agent/agent"; import type { AGENT_AUTH, @@ -147,6 +150,7 @@ import type { } from "@posthog/workspace-server/services/archive/ports"; import type { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; import type { AuthProxyAuth } from "@posthog/workspace-server/services/auth-proxy/ports"; +import type { IBrowserTabsService } from "@posthog/workspace-server/services/browser-tabs/service"; import type { ENRICHMENT_AUTH, ENRICHMENT_FILE_READER, @@ -490,4 +494,5 @@ export interface MainBindings { [UI_SERVICE]: UIService; [MCP_APPS_SERVICE]: McpAppsService; [SUSPENSION_SERVICE]: SuspensionService; + [BROWSER_TABS_SERVICE]: IBrowserTabsService; } diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 71ec77ceaa..94b8b491b1 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -9,6 +9,8 @@ import log from "electron-log/main"; import "./utils/logger"; import "./services/index.js"; import type { AuthService } from "@posthog/core/auth/auth"; +import { getAccountScope } from "@posthog/core/auth/authIdentity"; +import { AuthServiceEvent } from "@posthog/core/auth/schemas"; import { focusHostModule } from "@posthog/core/focus/focus-host.module"; import { FOCUS_SESSION_STORE, @@ -38,6 +40,8 @@ import { ENVIRONMENT_CLIENT } from "@posthog/host-router/ports/environment-clien import { FILE_WATCHER_CONTROL } from "@posthog/host-router/ports/file-watcher-control"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { DatabaseService } from "@posthog/workspace-server/db/service"; +import { BROWSER_TABS_SERVICE } from "@posthog/workspace-server/di/tokens"; +import type { IBrowserTabsService } from "@posthog/workspace-server/services/browser-tabs/service"; import type { ExternalAppsService } from "@posthog/workspace-server/services/external-apps/external-apps"; import { FS_SERVICE, @@ -267,6 +271,19 @@ async function initializeServices(): Promise { // Eagerly start the Discord presence service so it connects when enabled. container.get(DISCORD_PRESENCE_SERVICE); + // Tie the Channels tab strips to the signed-in user: each auth change + // repoints the tabs service at that account's persisted tabs. An undefined + // scope means the identity isn't determined yet (session still restoring, + // or the user-context fetch hasn't resolved) — leave the current scope in + // place rather than treating it as signed out. + const browserTabsService = + container.get(BROWSER_TABS_SERVICE); + authService.on(AuthServiceEvent.StateChanged, (state) => { + const scope = getAccountScope(state); + if (scope === undefined) return; + browserTabsService.setAccountScope(scope); + }); + await authService.initialize(); // Initialize workspace branch watcher for live branch rename detection diff --git a/packages/core/src/auth/auth.test.ts b/packages/core/src/auth/auth.test.ts index 185cbd847f..91613091cc 100644 --- a/packages/core/src/auth/auth.test.ts +++ b/packages/core/src/auth/auth.test.ts @@ -236,6 +236,7 @@ describe("AuthService", () => { expect(service.getState()).toEqual({ status: "anonymous", bootstrapComplete: true, + accountKey: null, cloudRegion: null, orgProjectsMap: {}, currentOrgId: null, @@ -257,6 +258,7 @@ describe("AuthService", () => { expect(service.getState()).toEqual({ status: "anonymous", bootstrapComplete: true, + accountKey: null, cloudRegion: "us", orgProjectsMap: {}, currentOrgId: null, @@ -291,6 +293,7 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "authenticated", bootstrapComplete: true, + accountKey: "user-1", cloudRegion: "us", orgProjectsMap: { "org-1": { @@ -375,6 +378,81 @@ describe("AuthService", () => { } }); + it("discards a background restore that completes after an explicit logout", async () => { + vi.useFakeTimers(); + try { + seedStoredSession({ selectedProjectId: 42 }); + stubAuthFetch(); + let resolveRefresh!: (value: unknown) => void; + oauthFlow.refreshToken.mockReturnValue( + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + const initPromise = service.initialize(); + await vi.advanceTimersByTimeAsync(20_001); + await initPromise; + expect(service.getState().status).toBe("restoring"); + + await service.logout(); + expect(service.getState().status).toBe("anonymous"); + + // The old session's restore landing now must not resurrect it. + resolveRefresh(mockTokenResponse()); + await vi.advanceTimersByTimeAsync(0); + + expect(service.getState().status).toBe("anonymous"); + expect(sessionPort.getCurrent()).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not let a stale background restore clobber a newer login", async () => { + vi.useFakeTimers(); + try { + seedStoredSession({ selectedProjectId: 42 }); + stubAuthFetch(); + let resolveRefresh!: (value: unknown) => void; + oauthFlow.refreshToken.mockReturnValue( + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + const initPromise = service.initialize(); + await vi.advanceTimersByTimeAsync(20_001); + await initPromise; + expect(service.getState().status).toBe("restoring"); + + await service.logout(); + oauthFlow.startFlow.mockResolvedValue( + mockTokenResponse({ + accessToken: "login-access-token", + refreshToken: "login-refresh-token", + }), + ); + await service.login("us"); + const afterLogin = service.getState(); + expect(afterLogin.status).toBe("authenticated"); + + // The pre-logout restore resolving late must not overwrite the new + // session's state or stored refresh token. + resolveRefresh( + mockTokenResponse({ refreshToken: "stale-refresh-token" }), + ); + await vi.advanceTimersByTimeAsync(0); + + expect(service.getState()).toEqual(afterLogin); + expect(sessionPort.getCurrent()?.refreshTokenEncrypted).toBe( + "login-refresh-token", + ); + } finally { + vi.useRealTimers(); + } + }); + it("shares the in-flight bootstrap refresh with token callers after the deadline", async () => { vi.useFakeTimers(); try { diff --git a/packages/core/src/auth/auth.ts b/packages/core/src/auth/auth.ts index 872bb06e6f..ba93b79265 100644 --- a/packages/core/src/auth/auth.ts +++ b/packages/core/src/auth/auth.ts @@ -76,6 +76,7 @@ export class AuthService extends TypedEventEmitter { private state: AuthState = { status: "anonymous", bootstrapComplete: false, + accountKey: null, cloudRegion: null, orgProjectsMap: {}, currentOrgId: null, @@ -86,6 +87,13 @@ export class AuthService extends TypedEventEmitter { private session: InMemorySession | null = null; private initializePromise: Promise | null = null; private refreshPromise: Promise | null = null; + /** + * Bumped whenever the active session is discarded or replaced (logout, a + * new login flow). Async restore/refresh paths capture it before awaiting + * and abandon their result if it moved — a background restore finishing + * after a logout must not resurrect the old session or clobber a newer one. + */ + private sessionEpoch = 0; constructor( @inject(AUTH_PREFERENCE_STORE) private readonly authPreference: IAuthPreferenceStore, @@ -374,6 +382,7 @@ export class AuthService extends TypedEventEmitter { async logout(): Promise { const { cloudRegion, currentProjectId } = this.state; + this.sessionEpoch++; this.authSession.clearCurrent(); this.session = null; this.setAnonymousState({ cloudRegion, currentProjectId }); @@ -423,6 +432,10 @@ export class AuthService extends TypedEventEmitter { this.setRestoringState(storedSession, false); + // Guards every deferred handler below: after a logout or new login the + // restore's outcome (success or failure) is about a dead session and must + // not touch state. + const epoch = this.sessionEpoch; try { const restore = this.ensureValidSession().then(() => undefined); const outcome = await withTimeout(restore, AUTH_BOOTSTRAP_DEADLINE_MS); @@ -439,12 +452,16 @@ export class AuthService extends TypedEventEmitter { this.logger.warn("Background auth restore failed after deadline", { error, }); - this.handleStoredSessionRestoreFailure(storedSession); + if (epoch === this.sessionEpoch) { + this.handleStoredSessionRestoreFailure(storedSession); + } }); } } catch (error) { this.logger.warn("Failed to restore stored auth session", { error }); - this.handleStoredSessionRestoreFailure(storedSession); + if (epoch === this.sessionEpoch) { + this.handleStoredSessionRestoreFailure(storedSession); + } } } @@ -456,6 +473,7 @@ export class AuthService extends TypedEventEmitter { this.updateState({ status: "restoring", bootstrapComplete, + accountKey: null, cloudRegion: storedSession.cloudRegion, orgProjectsMap: {}, currentOrgId: null, @@ -502,7 +520,13 @@ export class AuthService extends TypedEventEmitter { const sessionInput = this.getSessionInputForRefresh(); const refreshAndSync = async (): Promise => { - const session = await this.refreshSession(sessionInput); + const epoch = this.sessionEpoch; + const session = await this.refreshSession(sessionInput, epoch); + if (epoch !== this.sessionEpoch) { + // The session this refresh belonged to was logged out or replaced + // while the request was in flight; publishing it would resurrect it. + throw new NotAuthenticatedError(); + } await this.syncAuthenticatedSession(session); return session; }; @@ -532,6 +556,7 @@ export class AuthService extends TypedEventEmitter { } private async refreshSession( input: StoredSessionInput, + epoch: number, ): Promise { if (!this.connectivity.getStatus().isOnline) { throw new Error("Offline"); @@ -557,12 +582,17 @@ export class AuthService extends TypedEventEmitter { if (result.errorCode === "auth_error") { this.logger.warn("Refresh token rejected by server, forcing logout"); - this.authSession.clearCurrent(); - this.session = null; - this.setAnonymousState({ - cloudRegion: input.cloudRegion, - currentProjectId: input.selectedProjectId, - }); + // Only force the logout if this refresh's session is still current — + // a stale rejection must not clear a session the user re-created in + // the meantime. + if (epoch === this.sessionEpoch) { + this.authSession.clearCurrent(); + this.session = null; + this.setAnonymousState({ + cloudRegion: input.cloudRegion, + currentProjectId: input.selectedProjectId, + }); + } throw new Error(lastError); } @@ -751,6 +781,10 @@ export class AuthService extends TypedEventEmitter { region: CloudRegion, fallbackError: string, ): Promise { + // A fresh login supersedes whatever session any in-flight restore or + // refresh belongs to; bumping the epoch makes those abandon their result. + this.sessionEpoch++; + const epoch = this.sessionEpoch; const result = await runFlow(); if (!result.success || !result.data) { throw new Error(result.error || fallbackError); @@ -760,6 +794,9 @@ export class AuthService extends TypedEventEmitter { cloudRegion: region, selectedProjectId: this.state.currentProjectId, }); + if (epoch !== this.sessionEpoch) { + throw new Error("Session was replaced while signing in"); + } await this.syncAuthenticatedSession(session); } private async syncAuthenticatedSession( @@ -776,6 +813,7 @@ export class AuthService extends TypedEventEmitter { this.updateState({ status: "authenticated", bootstrapComplete: true, + accountKey: session.accountKey, cloudRegion: session.cloudRegion, orgProjectsMap: session.orgProjectsMap, currentOrgId: session.currentOrgId, @@ -896,6 +934,7 @@ export class AuthService extends TypedEventEmitter { this.updateState({ status: "anonymous", bootstrapComplete: partial.bootstrapComplete ?? true, + accountKey: null, cloudRegion: partial.cloudRegion ?? null, orgProjectsMap: {}, currentOrgId: null, diff --git a/packages/core/src/auth/authIdentity.test.ts b/packages/core/src/auth/authIdentity.test.ts new file mode 100644 index 0000000000..968283a7f3 --- /dev/null +++ b/packages/core/src/auth/authIdentity.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { getAccountScope } from "./authIdentity"; +import type { AuthState } from "./schemas"; + +const state = (partial: Partial): AuthState => ({ + status: "anonymous", + bootstrapComplete: true, + accountKey: null, + cloudRegion: null, + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, + hasCodeAccess: null, + needsScopeReauth: false, + ...partial, +}); + +describe("getAccountScope", () => { + it("resolves the scope for a known authenticated identity", () => { + expect( + getAccountScope( + state({ + status: "authenticated", + accountKey: "user-1", + cloudRegion: "us", + }), + ), + ).toEqual({ accountKey: "user-1", cloudRegion: "us" }); + }); + + it("is null when signed out", () => { + expect(getAccountScope(state({ status: "anonymous" }))).toBeNull(); + }); + + // Undefined = "don't know who this is yet", distinct from "signed out": + // callers must leave per-user state untouched instead of clearing it. + it.each([ + ["restoring", state({ status: "restoring", cloudRegion: "us" })], + [ + "authenticated without accountKey", + state({ status: "authenticated", cloudRegion: "us" }), + ], + [ + "authenticated without cloudRegion", + state({ status: "authenticated", accountKey: "user-1" }), + ], + ])("is undefined while the identity is undetermined (%s)", (_name, s) => { + expect(getAccountScope(s)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/auth/authIdentity.ts b/packages/core/src/auth/authIdentity.ts index 554c7eb871..a6caa06359 100644 --- a/packages/core/src/auth/authIdentity.ts +++ b/packages/core/src/auth/authIdentity.ts @@ -1,3 +1,4 @@ +import type { AccountScope } from "@posthog/shared"; import type { AuthState } from "./schemas"; export function getAuthIdentity(authState: AuthState): string | null { @@ -6,3 +7,32 @@ export function getAuthIdentity(authState: AuthState): string | null { } return `${authState.cloudRegion}:${authState.currentProjectId ?? "none"}`; } + +/** + * Account owning per-user local state (e.g. browser tabs), stable across + * project/org switches. + * + * Three-way result: a scope when the signed-in identity is known, null when + * signed out, and undefined while the identity cannot be determined yet — + * a session still restoring, or an authenticated session whose user-context + * fetch has not resolved an accountKey. On undefined, callers must leave + * per-user state as it is rather than treat the user as signed out: the + * distinction is "we don't know who this is yet" vs "nobody is signed in". + */ +export function getAccountScope( + authState: AuthState, +): AccountScope | null | undefined { + if (authState.status === "restoring") { + return undefined; + } + if (authState.status === "authenticated") { + if (!authState.cloudRegion || !authState.accountKey) { + return undefined; + } + return { + accountKey: authState.accountKey, + cloudRegion: authState.cloudRegion, + }; + } + return null; +} diff --git a/packages/core/src/auth/schemas.ts b/packages/core/src/auth/schemas.ts index 3fa2d74ad9..4f786c430b 100644 --- a/packages/core/src/auth/schemas.ts +++ b/packages/core/src/auth/schemas.ts @@ -74,6 +74,9 @@ export function pickInitialProjectId(args: { export const authStateSchema = z.object({ status: authStatusSchema, bootstrapComplete: z.boolean(), + /** Stable id of the signed-in user (uuid > distinct_id > email); null when + * anonymous/restoring or when the user context could not be resolved. */ + accountKey: z.string().nullable(), cloudRegion: cloudRegion.nullable(), orgProjectsMap: orgProjectsMapSchema, currentOrgId: z.string().nullable(), diff --git a/packages/shared/src/browser-tabs-schemas.ts b/packages/shared/src/browser-tabs-schemas.ts index a307947c1b..e2a81096f8 100644 --- a/packages/shared/src/browser-tabs-schemas.ts +++ b/packages/shared/src/browser-tabs-schemas.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { CloudRegion } from "./regions"; /** * Persisted browser-tab domain shapes for the Channels canvas surface. @@ -63,3 +64,13 @@ export const tabsSnapshotSchema = z.object({ tabs: z.array(browserTabSchema), }); export type TabsSnapshot = z.infer; + +/** + * Owner of a persisted tab strip: one PostHog account on one cloud region. + * Mirrors the (accountKey, cloudRegion) pair the auth-preferences store uses + * to scope per-user local state. + */ +export interface AccountScope { + accountKey: string; + cloudRegion: CloudRegion; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9c71d7df07..9865f1e481 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -33,6 +33,7 @@ export { type TabTarget, } from "./browser-tabs"; export { + type AccountScope, type BrowserTab, type BrowserWindow, browserTabSchema, diff --git a/packages/ui/src/features/auth/store.ts b/packages/ui/src/features/auth/store.ts index 9dc5f2b508..218fe9ed9f 100644 --- a/packages/ui/src/features/auth/store.ts +++ b/packages/ui/src/features/auth/store.ts @@ -7,6 +7,7 @@ export { getAuthIdentity }; export const ANONYMOUS_AUTH_STATE: AuthState = { status: "anonymous", bootstrapComplete: false, + accountKey: null, cloudRegion: null, orgProjectsMap: {}, currentOrgId: null, diff --git a/packages/workspace-server/src/db/migrations/0017_material_nekra.sql b/packages/workspace-server/src/db/migrations/0017_material_nekra.sql new file mode 100644 index 0000000000..fa561665d1 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0017_material_nekra.sql @@ -0,0 +1,3 @@ +ALTER TABLE `browser_windows` ADD `account_key` text;--> statement-breakpoint +ALTER TABLE `browser_windows` ADD `cloud_region` text;--> statement-breakpoint +CREATE INDEX `browser_windows_account_region_idx` ON `browser_windows` (`account_key`,`cloud_region`); \ No newline at end of file diff --git a/packages/workspace-server/src/db/migrations/meta/0017_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0017_snapshot.json new file mode 100644 index 0000000000..67aa0b2863 --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0017_snapshot.json @@ -0,0 +1,968 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8d4bfe04-f191-44a4-8fd4-7a236b56bddd", + "prevId": "d684dd2a-3a32-4007-963f-8af1a5e72848", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_org_project_preferences": { + "name": "auth_org_project_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_tabs": { + "name": "browser_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "window_id": { + "name": "window_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dashboard_id": { + "name": "dashboard_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_section": { + "name": "channel_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scroll_state": { + "name": "scroll_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_tabs_window_idx": { + "name": "browser_tabs_window_idx", + "columns": ["window_id"], + "isUnique": false + } + }, + "foreignKeys": { + "browser_tabs_window_id_browser_windows_id_fk": { + "name": "browser_tabs_window_id_browser_windows_id_fk", + "tableFrom": "browser_tabs", + "tableTo": "browser_windows", + "columnsFrom": ["window_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_windows": { + "name": "browser_windows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_primary": { + "name": "is_primary", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bounds": { + "name": "bounds", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_tab_id": { + "name": "active_tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_windows_account_region_idx": { + "name": "browser_windows_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_session_imports": { + "name": "claude_session_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_session_id": { + "name": "source_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_session_id": { + "name": "imported_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_mtime_ms": { + "name": "source_mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_size_bytes": { + "name": "source_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_last_entry_uuid": { + "name": "source_last_entry_uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "claude_session_imports_importedSessionId_unique": { + "name": "claude_session_imports_importedSessionId_unique", + "columns": ["imported_session_id"], + "isUnique": true + }, + "claude_session_imports_source_idx": { + "name": "claude_session_imports_source_idx", + "columns": ["source_session_id"], + "isUnique": false + }, + "claude_session_imports_task_idx": { + "name": "claude_session_imports_task_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_metadata": { + "name": "task_metadata", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index 965f6e7ec0..7d7560097a 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1782781314961, "tag": "0016_add_channel_section", "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1783344159390, + "tag": "0017_material_nekra", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/db/repositories/browser-tabs-repository.mock.ts b/packages/workspace-server/src/db/repositories/browser-tabs-repository.mock.ts new file mode 100644 index 0000000000..9c611b1ea7 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/browser-tabs-repository.mock.ts @@ -0,0 +1,30 @@ +import type { AccountScope, TabsSnapshot } from "@posthog/shared"; +import type { IBrowserTabsRepository } from "./browser-tabs-repository"; + +const scopeKey = (scope: AccountScope) => + `${scope.cloudRegion}:${scope.accountKey}`; + +export interface MockBrowserTabsRepository extends IBrowserTabsRepository { + /** Persisted snapshots keyed by `:`. */ + _snapshots: Map; + /** Scopes claimUnscoped was called with, in order. */ + _claimed: AccountScope[]; +} + +export function createMockBrowserTabsRepository(): MockBrowserTabsRepository { + const snapshots = new Map(); + const claimed: AccountScope[] = []; + + return { + _snapshots: snapshots, + _claimed: claimed, + load: (scope) => + snapshots.get(scopeKey(scope)) ?? { windows: [], tabs: [] }, + save: (scope, snapshot) => { + snapshots.set(scopeKey(scope), snapshot); + }, + claimUnscoped: (scope) => { + claimed.push(scope); + }, + }; +} diff --git a/packages/workspace-server/src/db/repositories/browser-tabs-repository.test.ts b/packages/workspace-server/src/db/repositories/browser-tabs-repository.test.ts new file mode 100644 index 0000000000..a03ce5f553 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/browser-tabs-repository.test.ts @@ -0,0 +1,122 @@ +import type { AccountScope, TabsSnapshot } from "@posthog/shared"; +import { isNull } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { browserTabs, browserWindows } from "../schema"; +import type { DatabaseService } from "../service"; +import { createTestDb, type TestDatabase } from "../test-helpers"; +import { BrowserTabsRepository } from "./browser-tabs-repository"; + +let testDb: TestDatabase; +let repo: BrowserTabsRepository; + +beforeEach(() => { + testDb = createTestDb(); + const databaseService = { db: testDb.db } as unknown as DatabaseService; + repo = new BrowserTabsRepository(databaseService); +}); + +afterEach(() => { + testDb.close(); +}); + +const alice: AccountScope = { accountKey: "alice", cloudRegion: "us" }; +const bob: AccountScope = { accountKey: "bob", cloudRegion: "us" }; +const aliceEu: AccountScope = { accountKey: "alice", cloudRegion: "eu" }; + +const snapshot = ( + windowId: string, + tabIds: string[], + isPrimary = true, +): TabsSnapshot => ({ + windows: [{ id: windowId, isPrimary, bounds: null, activeTabId: null }], + tabs: tabIds.map((id, i) => ({ + id, + windowId, + dashboardId: `dash-${id}`, + taskId: null, + channelId: null, + channelSection: null, + position: (i + 1) * 1000, + scrollState: null, + createdAt: 1, + lastActiveAt: 1, + })), +}); + +describe("BrowserTabsRepository", () => { + it("round-trips a snapshot within one account scope", () => { + const saved = snapshot("win-a", ["tab-1", "tab-2"]); + repo.save(alice, saved); + + const loaded = repo.load(alice); + expect(loaded.windows.map((w) => w.id)).toEqual(["win-a"]); + expect(loaded.tabs.map((t) => t.id)).toEqual(["tab-1", "tab-2"]); + }); + + it("keeps accounts isolated: saving one scope never touches another", () => { + repo.save(alice, snapshot("win-a", ["tab-a"])); + repo.save(bob, snapshot("win-b", ["tab-b"])); + + repo.save(alice, snapshot("win-a2", [])); + + expect(repo.load(alice).windows.map((w) => w.id)).toEqual(["win-a2"]); + expect(repo.load(bob).tabs.map((t) => t.id)).toEqual(["tab-b"]); + }); + + it("keeps the same account isolated across regions", () => { + repo.save(alice, snapshot("win-a", ["tab-a"])); + expect(repo.load(aliceEu)).toEqual({ windows: [], tabs: [] }); + }); + + it("cascades tab deletion when a save replaces an account's windows", () => { + repo.save(alice, snapshot("win-a", ["tab-1", "tab-2"])); + repo.save(alice, snapshot("win-a2", [])); + + // save() relies on the window FK cascade to remove the old tabs; + // orphaned rows here would mean the cascade is not firing. + const allTabs = testDb.db.select().from(browserTabs).all(); + expect(allTabs).toHaveLength(0); + }); + + it("claimUnscoped adopts pre-account rows into the first account", () => { + // Simulate rows written before per-user scoping existed. + testDb.db + .insert(browserWindows) + .values({ + id: "legacy-win", + isPrimary: true, + position: 0, + createdAt: 1, + updatedAt: 1, + }) + .run(); + + repo.claimUnscoped(alice); + + expect(repo.load(alice).windows.map((w) => w.id)).toEqual(["legacy-win"]); + const unscoped = testDb.db + .select() + .from(browserWindows) + .where(isNull(browserWindows.accountKey)) + .all(); + expect(unscoped).toHaveLength(0); + }); + + it("claimUnscoped is a no-op when the account already has rows", () => { + repo.save(alice, snapshot("win-a", [])); + testDb.db + .insert(browserWindows) + .values({ + id: "legacy-win", + isPrimary: true, + position: 0, + createdAt: 1, + updatedAt: 1, + }) + .run(); + + repo.claimUnscoped(alice); + + expect(repo.load(alice).windows.map((w) => w.id)).toEqual(["win-a"]); + }); +}); diff --git a/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts b/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts index 5538a0dcd9..11c50f965e 100644 --- a/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts +++ b/packages/workspace-server/src/db/repositories/browser-tabs-repository.ts @@ -1,18 +1,27 @@ -import type { TabsSnapshot } from "@posthog/shared"; -import { asc } from "drizzle-orm"; +import type { AccountScope, TabsSnapshot } from "@posthog/shared"; +import { and, asc, eq, inArray, isNull } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { DATABASE_SERVICE } from "../identifiers"; import { browserTabs, browserWindows } from "../schema"; import type { DatabaseService } from "../service"; /** - * Durable storage for the Channels browser-tab strips. The whole snapshot is - * small (tens of rows), so each save is a transactional full replace — simple - * and free of delta-merge bugs. Window order is encoded by `position`. + * Durable storage for the Channels browser-tab strips, scoped per account + * (accountKey + cloudRegion, the same convention as auth_preferences) so each + * login restores its own tabs. The per-account snapshot is small (tens of + * rows), so each save is a transactional full replace of that account's rows — + * simple and free of delta-merge bugs. Window order is encoded by `position`; + * tabs inherit their account scope through the window FK. */ export interface IBrowserTabsRepository { - load(): TabsSnapshot; - save(snapshot: TabsSnapshot): void; + load(scope: AccountScope): TabsSnapshot; + save(scope: AccountScope, snapshot: TabsSnapshot): void; + /** + * Adopt rows persisted before tabs were per-user (null scope) into the + * given account, unless that account already has rows of its own. Keeps the + * upgrade path seamless for the machine's existing user. + */ + claimUnscoped(scope: AccountScope): void; } @injectable() @@ -26,13 +35,34 @@ export class BrowserTabsRepository implements IBrowserTabsRepository { return this.databaseService.db; } - load(): TabsSnapshot { + /** WHERE condition selecting the windows owned by an account. */ + private ownedBy(scope: AccountScope) { + return and( + eq(browserWindows.accountKey, scope.accountKey), + eq(browserWindows.cloudRegion, scope.cloudRegion), + ); + } + + load(scope: AccountScope): TabsSnapshot { const windowRows = this.db .select() .from(browserWindows) + .where(this.ownedBy(scope)) .orderBy(asc(browserWindows.position)) .all(); - const tabRows = this.db.select().from(browserTabs).all(); + const tabRows = this.db + .select() + .from(browserTabs) + .where( + inArray( + browserTabs.windowId, + this.db + .select({ id: browserWindows.id }) + .from(browserWindows) + .where(this.ownedBy(scope)), + ), + ) + .all(); return { windows: windowRows.map((w) => ({ @@ -56,18 +86,20 @@ export class BrowserTabsRepository implements IBrowserTabsRepository { }; } - save(snapshot: TabsSnapshot): void { + save(scope: AccountScope, snapshot: TabsSnapshot): void { const now = Date.now(); this.db.transaction((tx) => { - // Tabs first (FK), then windows. - tx.delete(browserTabs).run(); - tx.delete(browserWindows).run(); + // Deleting the windows cascades to their tabs (browser_tabs.window_id + // has ON DELETE CASCADE and foreign_keys is ON). + tx.delete(browserWindows).where(this.ownedBy(scope)).run(); if (snapshot.windows.length > 0) { tx.insert(browserWindows) .values( snapshot.windows.map((w, i) => ({ id: w.id, + accountKey: scope.accountKey, + cloudRegion: scope.cloudRegion, isPrimary: w.isPrimary, bounds: w.bounds ?? null, activeTabId: w.activeTabId ?? null, @@ -98,4 +130,24 @@ export class BrowserTabsRepository implements IBrowserTabsRepository { } }); } + + claimUnscoped(scope: AccountScope): void { + this.db.transaction((tx) => { + const owned = tx + .select({ id: browserWindows.id }) + .from(browserWindows) + .where(this.ownedBy(scope)) + .limit(1) + .all(); + if (owned.length > 0) return; + tx.update(browserWindows) + .set({ + accountKey: scope.accountKey, + cloudRegion: scope.cloudRegion, + updatedAt: Date.now(), + }) + .where(isNull(browserWindows.accountKey)) + .run(); + }); + } } diff --git a/packages/workspace-server/src/db/schema.ts b/packages/workspace-server/src/db/schema.ts index 5913b6c64e..2ac2d7135b 100644 --- a/packages/workspace-server/src/db/schema.ts +++ b/packages/workspace-server/src/db/schema.ts @@ -185,24 +185,36 @@ export const authOrgProjectPreferences = sqliteTable( * per OS window (or web window). The primary window is never torn down by * closing its last tab; secondaries are. */ -export const browserWindows = sqliteTable("browser_windows", { - id: id(), - isPrimary: integer({ mode: "boolean" }).notNull().default(false), - /** Saved geometry for session restore, JSON {x,y,width,height}. Null on web. */ - bounds: text({ mode: "json" }).$type<{ - x: number; - y: number; - width: number; - height: number; - } | null>(), - /** Focused tab in this window; null = channels landing. */ - activeTabId: text(), - /** Ordering across windows for deterministic restore. */ - position: integer().notNull().default(0), - /** Epoch ms. */ - createdAt: integer().notNull(), - updatedAt: integer().notNull(), -}); +export const browserWindows = sqliteTable( + "browser_windows", + { + id: id(), + /** Owning account, same (accountKey, cloudRegion) convention as + * auth_preferences. Both null = pre-account rows from before tabs were + * per-user; claimed by the first login. Tabs inherit scope through their + * window FK. */ + accountKey: text(), + cloudRegion: text({ enum: ["us", "eu", "dev"] }), + isPrimary: integer({ mode: "boolean" }).notNull().default(false), + /** Saved geometry for session restore, JSON {x,y,width,height}. Null on web. */ + bounds: text({ mode: "json" }).$type<{ + x: number; + y: number; + width: number; + height: number; + } | null>(), + /** Focused tab in this window; null = channels landing. */ + activeTabId: text(), + /** Ordering across windows for deterministic restore. */ + position: integer().notNull().default(0), + /** Epoch ms. */ + createdAt: integer().notNull(), + updatedAt: integer().notNull(), + }, + (t) => [ + index("browser_windows_account_region_idx").on(t.accountKey, t.cloudRegion), + ], +); /** * Open tabs in the Channels canvas surface. A tab references a canvas diff --git a/packages/workspace-server/src/services/browser-tabs/service.test.ts b/packages/workspace-server/src/services/browser-tabs/service.test.ts new file mode 100644 index 0000000000..f5117cd487 --- /dev/null +++ b/packages/workspace-server/src/services/browser-tabs/service.test.ts @@ -0,0 +1,180 @@ +import type { AccountScope, TabsSnapshot } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { createMockBrowserTabsRepository } from "../../db/repositories/browser-tabs-repository.mock"; +import { BrowserTabsEvent } from "./schemas"; +import { BrowserTabsService } from "./service"; + +const alice: AccountScope = { accountKey: "alice", cloudRegion: "us" }; +const bob: AccountScope = { accountKey: "bob", cloudRegion: "us" }; +const key = (scope: AccountScope) => `${scope.cloudRegion}:${scope.accountKey}`; + +const makeService = () => { + const repo = createMockBrowserTabsRepository(); + const service = new BrowserTabsService(repo); + return { repo, service }; +}; + +const openCanvasTab = (service: BrowserTabsService, dashboardId: string) => + service.openOrFocus({ + windowId: service.getPrimaryWindowId(), + dashboardId, + taskId: null, + channelId: null, + }); + +const savedSnapshot = ( + windowId: string, + tabs: { id: string; dashboardId: string }[], + activeTabId: string | null = null, +): TabsSnapshot => ({ + windows: [{ id: windowId, isPrimary: true, bounds: null, activeTabId }], + tabs: tabs.map((t, i) => ({ + id: t.id, + windowId, + dashboardId: t.dashboardId, + taskId: null, + channelId: null, + channelSection: null, + position: (i + 1) * 1000, + scrollState: null, + createdAt: 1, + lastActiveAt: 1, + })), +}); + +describe("BrowserTabsService account scoping", () => { + it("starts with an undetermined scope and a memory-only primary window", () => { + const { repo, service } = makeService(); + expect(service.getPrimaryWindowId()).toBeTruthy(); + service.newBlankTab({ windowId: service.getPrimaryWindowId() }); + expect(repo._snapshots.size).toBe(0); + }); + + it("persists mutations under the signed-in account's scope", () => { + const { repo, service } = makeService(); + service.setAccountScope(alice); + service.newBlankTab({ windowId: service.getPrimaryWindowId() }); + expect(repo._snapshots.get(key(alice))?.tabs).toHaveLength(1); + }); + + it("restores each account's tabs when switching users", () => { + const { service } = makeService(); + + service.setAccountScope(alice); + openCanvasTab(service, "dash-a"); + const aliceTabs = service.getSnapshot().tabs.map((t) => t.id); + expect(aliceTabs).toHaveLength(1); + + // Logout then login as bob: blank strip, none of alice's tabs. + service.setAccountScope(null); + expect(service.getSnapshot().tabs).toHaveLength(0); + service.setAccountScope(bob); + expect(service.getSnapshot().tabs).toHaveLength(0); + openCanvasTab(service, "dash-b1"); + openCanvasTab(service, "dash-b2"); + + // Back to alice: her single tab comes back. + service.setAccountScope(null); + service.setAccountScope(alice); + expect(service.getSnapshot().tabs.map((t) => t.id)).toEqual(aliceTabs); + }); + + it("adopts tabs opened before the first scope is known into the account's snapshot", () => { + const { repo, service } = makeService(); + repo._snapshots.set( + key(alice), + savedSnapshot( + "win-a", + [{ id: "tab-saved", dashboardId: "dash-saved" }], + "tab-saved", + ), + ); + + // Opened during app boot, before auth resolved. + openCanvasTab(service, "dash-boot"); + + service.setAccountScope(alice); + + const snapshot = service.getSnapshot(); + expect(snapshot.tabs.map((t) => t.dashboardId).sort()).toEqual([ + "dash-boot", + "dash-saved", + ]); + // The tab the user just opened stays focused, and the merge is persisted. + const adopted = snapshot.tabs.find((t) => t.dashboardId === "dash-boot"); + expect(snapshot.windows[0]?.activeTabId).toBe(adopted?.id); + expect(repo._snapshots.get(key(alice))).toEqual(snapshot); + }); + + it("dedupes adopted tabs against the account's saved tabs", () => { + const { repo, service } = makeService(); + repo._snapshots.set( + key(alice), + savedSnapshot("win-a", [{ id: "tab-saved", dashboardId: "dash-x" }]), + ); + + openCanvasTab(service, "dash-x"); + service.setAccountScope(alice); + + const snapshot = service.getSnapshot(); + expect(snapshot.tabs).toHaveLength(1); + expect(snapshot.tabs[0]?.id).toBe("tab-saved"); + expect(snapshot.windows[0]?.activeTabId).toBe("tab-saved"); + }); + + it("does not adopt tabs opened while signed out", () => { + const { service } = makeService(); + service.setAccountScope(null); + openCanvasTab(service, "dash-anon"); + + service.setAccountScope(alice); + expect(service.getSnapshot().tabs).toHaveLength(0); + }); + + it("claims pre-account rows when an account signs in", () => { + const { repo, service } = makeService(); + service.setAccountScope(alice); + expect(repo._claimed).toEqual([alice]); + }); + + it("emits a snapshot change when the scope changes, but not on an equal scope", () => { + const { service } = makeService(); + const emissions: TabsSnapshot[] = []; + service.on(BrowserTabsEvent.SnapshotChange, (s) => emissions.push(s)); + + service.setAccountScope({ ...alice }); + service.setAccountScope({ ...alice }); + expect(emissions).toHaveLength(1); + + service.setAccountScope(null); + expect(emissions).toHaveLength(2); + }); + + it("clears the live snapshot on logout without erasing persisted tabs", () => { + const { repo, service } = makeService(); + service.setAccountScope(alice); + service.newBlankTab({ windowId: service.getPrimaryWindowId() }); + + service.setAccountScope(null); + service.newBlankTab({ windowId: service.getPrimaryWindowId() }); + + expect(repo._snapshots.get(key(alice))?.tabs).toHaveLength(1); + }); + + it("ignores setActiveTab for a tab that is not in the window", () => { + const { service } = makeService(); + service.setAccountScope(alice); + const windowId = service.getPrimaryWindowId(); + openCanvasTab(service, "dash-a"); + const realTabId = service.getSnapshot().tabs[0]?.id; + expect(service.getSnapshot().windows[0]?.activeTabId).toBe(realTabId); + + // Stale renderer state (e.g. router history from a previous account's + // snapshot) must not leave the window pointing at a nonexistent tab. + service.setActiveTab({ windowId, tabId: "ghost-tab" }); + expect(service.getSnapshot().windows[0]?.activeTabId).toBe(realTabId); + + service.setActiveTab({ windowId, tabId: null }); + expect(service.getSnapshot().windows[0]?.activeTabId).toBeNull(); + }); +}); diff --git a/packages/workspace-server/src/services/browser-tabs/service.ts b/packages/workspace-server/src/services/browser-tabs/service.ts index 8ffe2ad689..a9d08c3b9d 100644 --- a/packages/workspace-server/src/services/browser-tabs/service.ts +++ b/packages/workspace-server/src/services/browser-tabs/service.ts @@ -1,4 +1,5 @@ import { + type AccountScope, type BrowserWindow, closeTab, closeTabs, @@ -18,9 +19,20 @@ import { BrowserTabsEvent, type BrowserTabsEvents } from "./schemas"; const makeId = () => crypto.randomUUID(); const now = () => Date.now(); +const sameScope = ( + a: AccountScope | null | undefined, + b: AccountScope | null | undefined, +): boolean => + a === b || + (a != null && + b != null && + a.accountKey === b.accountKey && + a.cloudRegion === b.cloudRegion); + export interface IBrowserTabsService { getSnapshot(): TabsSnapshot; getPrimaryWindowId(): string; + setAccountScope(scope: AccountScope | null): void; openOrFocus( input: TabTarget & { windowId: string; @@ -51,6 +63,19 @@ export interface IBrowserTabsService { * one source of truth; changes fan out to all windows via the snapshot-change * subscription. Durable state is persisted through the repository; the * back/forward action timeline is per-renderer and lives in the UI, not here. + * + * Tab strips are tied to the signed-in user: the host feeds auth changes in + * via `setAccountScope`, and each scope change swaps the live snapshot to + * that account's persisted tabs. + * + * The scope is three-state. `undefined` (initial) means the signed-in + * identity is not known yet — app boot before auth resolves, a session still + * restoring, or an identity fetch that hasn't succeeded. Tabs opened then are + * held in memory and adopted into the account's snapshot once the scope + * arrives, so nothing the user did while auth was catching up is lost. + * `null` means confirmed signed out: tabs are memory-only by design, no + * account's persisted tabs are read or overwritten, and nothing carries over + * into the next login. */ @injectable() export class BrowserTabsService @@ -58,6 +83,7 @@ export class BrowserTabsService implements IBrowserTabsService { private snapshot: TabsSnapshot; + private accountScope: AccountScope | null | undefined = undefined; constructor( @inject(BROWSER_TABS_REPOSITORY) @@ -65,10 +91,79 @@ export class BrowserTabsService ) { super(); this.setMaxListeners(0); - const loaded = this.repo.load(); - const seeded = this.ensurePrimaryWindow(loaded); - if (seeded !== loaded) this.repo.save(seeded); - this.snapshot = seeded; + this.snapshot = this.ensurePrimaryWindow({ windows: [], tabs: [] }); + } + + /** + * Point the service at an account's persisted tabs (null = signed out, + * memory-only). Loads that account's snapshot — adopting any pre-account + * rows on its first login, and re-homing tabs the user opened before the + * scope was known — then fans the change out to every window. + */ + setAccountScope(scope: AccountScope | null): void { + if (sameScope(scope, this.accountScope)) return; + const pending = this.accountScope === undefined ? this.snapshot : null; + this.accountScope = scope; + + if (scope === null) { + this.snapshot = this.ensurePrimaryWindow({ windows: [], tabs: [] }); + this.emit(BrowserTabsEvent.SnapshotChange, this.snapshot); + return; + } + + this.repo.claimUnscoped(scope); + let next = this.ensurePrimaryWindow(this.repo.load(scope)); + if (pending) { + const adopted = this.adoptPendingTabs(next, pending); + if (adopted !== next) { + next = adopted; + this.repo.save(scope, next); + } + } + this.snapshot = next; + this.emit(BrowserTabsEvent.SnapshotChange, this.snapshot); + } + + /** + * Re-home tabs opened while the account scope was still undetermined into + * the account's own snapshot, instead of discarding them with the swap. + * Goes through {@link openOrFocusTab} so they dedupe against the account's + * saved tabs; the pending strip's focused tab is opened last so it stays + * focused. Blank tabs carry no content and are not worth carrying over. + */ + private adoptPendingTabs( + loaded: TabsSnapshot, + pending: TabsSnapshot, + ): TabsSnapshot { + const targeted = pending.tabs + .filter((t) => t.dashboardId || t.taskId || t.channelId) + .sort((a, b) => a.position - b.position); + if (targeted.length === 0) return loaded; + + const windowId = loaded.windows.find((w) => w.isPrimary)?.id; + if (!windowId) return loaded; + + const activeTabId = pending.windows.find( + (w) => w.activeTabId !== null, + )?.activeTabId; + const ordered = [ + ...targeted.filter((t) => t.id !== activeTabId), + ...targeted.filter((t) => t.id === activeTabId), + ]; + + let next = loaded; + for (const tab of ordered) { + next = openOrFocusTab(next, { + windowId, + dashboardId: tab.dashboardId, + taskId: tab.taskId, + channelId: tab.channelId, + channelSection: tab.channelSection, + makeId, + now, + }).snapshot; + } + return next; } /** Guarantee a primary window exists so the first open has somewhere to land. */ @@ -147,6 +242,17 @@ export class BrowserTabsService windowId: string; tabId: string | null; }): TabsSnapshot { + // Stale renderer state (e.g. router history stamped under a previous + // account's snapshot) can reference a tab that no longer exists; writing + // that id would leave the window's activeTabId dangling. + if ( + input.tabId !== null && + !this.snapshot.tabs.some( + (t) => t.id === input.tabId && t.windowId === input.windowId, + ) + ) { + return this.snapshot; + } const next: TabsSnapshot = { ...this.snapshot, windows: this.snapshot.windows.map((w) => @@ -164,7 +270,7 @@ export class BrowserTabsService private commit(next: TabsSnapshot): TabsSnapshot { this.snapshot = next; - this.repo.save(next); + if (this.accountScope != null) this.repo.save(this.accountScope, next); this.emit(BrowserTabsEvent.SnapshotChange, next); return next; }