From c7004c98789f84f6e16ede79e496c8bdfb95d1c3 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 10 Jul 2026 18:06:11 -0700 Subject: [PATCH] [aidan] feat/affiliate-ids: unify install identity across electron and python backend Electron resolves ONE install id before spawning the backend (install.json app_install_id -> python settings installation_id -> fresh uuid) and exports it as OPENSWARM_INSTALLATION_ID; the backend adopts it when settings has no installation_id yet. The affiliate app_install_id and the analytics install_id are now the same value, so affiliate refs join directly to telemetry with no sign-in required. Drops the frontend app_install_id forwarding through sign-in flows; the desktop auth router forwards settings.installation_id as install_id instead. --- backend/apps/auth/router.py | 20 +++-- backend/main.py | 11 ++- backend/tests/test_auth_router.py | 11 ++- electron/affiliateTracking.js | 79 ++++++++++++++++++- electron/affiliateTracking.test.js | 79 +++++++++++++++++++ electron/main.js | 16 ++++ .../app/components/overlays/SignInDialog.tsx | 8 +- .../sections/subscription/AccountCard.tsx | 5 +- frontend/src/shared/affiliateInstall.ts | 14 ---- frontend/src/shared/hooks/useDeepLink.ts | 16 ++-- frontend/src/shared/state/settingsSlice.ts | 1 - frontend/src/types/electron.d.ts | 1 - 12 files changed, 210 insertions(+), 51 deletions(-) delete mode 100644 frontend/src/shared/affiliateInstall.ts diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index ee9b6b5b..706995ff 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -90,7 +90,6 @@ class SigninActivateRequest(BaseModel): token: str signin_method: Literal["google", "email"] email: Optional[str] = None - app_install_id: Optional[str] = None @auth.router.post("/signin-activate") @@ -107,16 +106,23 @@ async def signin_activate(body: SigninActivateRequest): raise HTTPException(status_code=400, detail="Invalid token") proxy = p_proxy_url() + # settings.installation_id doubles as the affiliate app_install_id (one + # unified id, resolved by Electron before the backend spawns). Forwarding + # it lets the cloud record affiliate attribution the moment sign-in + # identifies the user, instead of waiting for a Stripe checkout. + payload = { + "token": body.token, + "signin_method": body.signin_method, + "email": body.email, + } + p_install_id = getattr(load_settings(), "installation_id", None) + if p_install_id: + payload["install_id"] = p_install_id try: async with httpx.AsyncClient(timeout=10.0) as client: r = await client.post( f"{proxy}/api/auth/signin-activate", - json={ - "token": body.token, - "signin_method": body.signin_method, - "email": body.email, - "app_install_id": body.app_install_id, - }, + json=payload, ) except httpx.HTTPError as e: raise HTTPException( diff --git a/backend/main.py b/backend/main.py index dfc1817e..cfac7651 100644 --- a/backend/main.py +++ b/backend/main.py @@ -65,11 +65,20 @@ install_token_scrubber() # Generate the per-install id (installation_id) at the same pre-bind moment as the auth token. It is otherwise created lazily on the first analytics submission, so on a clean install the sign-in window can render and build its Google/email OAuth URL (which embeds install_id) before that submission fires, producing an empty install_id that the cloud rejects. Generating here guarantees the very first GET /api/settings already carries it. Platform-agnostic; wrapped so a settings hiccup never blocks startup, and the lazy path stays as a fallback. try: + import re as p_re import uuid as p_uuid from backend.apps.settings.store import load_settings as p_load_boot_settings, save_settings as p_save_boot_settings p_boot_settings = p_load_boot_settings() if not getattr(p_boot_settings, "installation_id", None): - p_boot_settings.installation_id = p_uuid.uuid4().hex + # Prefer the unified install id Electron resolved before spawning us + # (shared with the affiliate install.json app_install_id), so the + # analytics install_id and the affiliate id are the same value and + # affiliate refs join directly to telemetry. Falls back to a fresh + # uuid for bare `uvicorn backend.main:app` runs. + p_env_iid = os.environ.get("OPENSWARM_INSTALLATION_ID", "") + p_boot_settings.installation_id = ( + p_env_iid if p_re.fullmatch(r"[A-Za-z0-9_-]{8,128}", p_env_iid) else p_uuid.uuid4().hex + ) p_save_boot_settings(p_boot_settings) except Exception: pass diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_auth_router.py index 74e7c6d0..ec351619 100644 --- a/backend/tests/test_auth_router.py +++ b/backend/tests/test_auth_router.py @@ -39,6 +39,14 @@ def reset_settings(): # --------------------------------------------------------------------------- /api/auth/signin-activate --------------------------------------------------------------------------- def test_signin_activate_persists_user_id(client, reset_settings): + # Give settings a known installation_id so we can assert the router + # forwards it as install_id (the unified id the cloud uses for both + # identity stitching and affiliate attribution). + from backend.apps.settings.settings import load_settings, save_settings + s = load_settings() + s.installation_id = "unified-install-id-123" + save_settings(s) + fake_response = AsyncMock() fake_response.status_code = 200 fake_response.json = lambda: { @@ -58,12 +66,11 @@ def test_signin_activate_persists_user_id(client, reset_settings): "token": "fake-bearer-1234567890abcdef", "signin_method": "google", "email": "smoke@example.com", - "app_install_id": "app-install-affiliate-123", }, ) assert r.status_code == 200 assert any( - call.kwargs.get("json", {}).get("app_install_id") == "app-install-affiliate-123" + call.kwargs.get("json", {}).get("install_id") == "unified-install-id-123" for call in instance.post.call_args_list ) body = r.json() diff --git a/electron/affiliateTracking.js b/electron/affiliateTracking.js index 4c8a846f..6f060dd0 100644 --- a/electron/affiliateTracking.js +++ b/electron/affiliateTracking.js @@ -9,7 +9,7 @@ // // State lives in `/install.json`. The shape: // { -// app_install_id: "uuid", // generated once per install +// app_install_id: "uuid", // unified install id, see resolveInstallId() // first_launch_at: 1700000000000, // unix ms; presence = "this isn't first launch" // ref: "haik" | null, // populated once lookup succeeds // ref_bound_at: 1700000000000 | null, @@ -71,6 +71,73 @@ function writeState(userDataDir, state) { } } +// -------------------------------------------------------------------------- +// Unified install identity. +// +// The desktop historically had TWO per-install ids that never met: this +// module's app_install_id (install.json, affiliate handshake) and the Python +// backend's settings.installation_id (analytics envelope). Affiliate data +// could therefore only join to analytics through a signed-in user — and +// sign-in is optional. resolveInstallId collapses them into one value: +// main.js calls it BEFORE spawning the backend and exports the result as +// OPENSWARM_INSTALLATION_ID, so install_tokens.app_install_id in the cloud +// and the analytics install_id carry the same id and affiliate refs join +// directly to telemetry with no sign-in required. +// +// Resolution order (first hit wins): +// 1. install.json app_install_id — continuity for installs that already +// ran the affiliate handshake +// 2. python settings.json installation_id — upgrades adopt the existing +// analytics identity instead of minting a second one +// 3. fresh crypto.randomUUID(), persisted to install.json immediately so +// every later reader (handshake, renderer, next boot) agrees on it + +const INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/; + +// Mirrors backend/config/paths.py: packaged data root is per-OS app support; +// dev is /backend/data. +function pythonSettingsFile({ isPackaged, projectRoot, platform, env, homeDir }) { + if (!isPackaged) { + return path.join(projectRoot, "backend", "data", "settings", "settings.json"); + } + let appSupport; + if (platform === "darwin") { + appSupport = path.join(homeDir, "Library", "Application Support", "OpenSwarm"); + } else if (platform === "win32") { + appSupport = path.join(env.APPDATA || homeDir, "OpenSwarm"); + } else { + appSupport = path.join(env.XDG_DATA_HOME || path.join(homeDir, ".local", "share"), "OpenSwarm"); + } + return path.join(appSupport, "data", "settings", "settings.json"); +} + +function resolveInstallId({ + userDataDir, + isPackaged, + projectRoot, + platform = process.platform, + env = process.env, + homeDir = os.homedir(), +}) { + const state = readState(userDataDir); + if (typeof state.app_install_id === "string" && INSTALL_ID_RE.test(state.app_install_id)) { + return state.app_install_id; + } + + try { + const settingsPath = pythonSettingsFile({ isPackaged, projectRoot, platform, env, homeDir }); + const iid = JSON.parse(fs.readFileSync(settingsPath, "utf8")).installation_id; + if (typeof iid === "string" && INSTALL_ID_RE.test(iid)) { + writeState(userDataDir, { ...state, app_install_id: iid }); + return iid; + } + } catch (_) {} + + const freshId = crypto.randomUUID(); + writeState(userDataDir, { ...state, app_install_id: freshId }); + return freshId; +} + function urlsFromEnv() { return { landingUrl: (process.env.OPENSWARM_AFFILIATE_LANDING_URL || DEFAULT_LANDING_URL).replace(/\/$/, ""), @@ -246,8 +313,13 @@ async function maybeRunFirstLaunchHandshake({ return; } - // First launch. - const appInstallId = crypto.randomUUID(); + // First launch. Reuse the id resolveInstallId persisted before the backend + // spawned (the unified install id); only generate here if main.js never + // resolved one (e.g. direct module use in tests). + const appInstallId = + typeof state.app_install_id === "string" && INSTALL_ID_RE.test(state.app_install_id) + ? state.app_install_id + : crypto.randomUUID(); const now = Date.now(); const fresh = { app_install_id: appInstallId, @@ -296,6 +368,7 @@ async function maybeRunFirstLaunchHandshake({ module.exports = { maybeRunFirstLaunchHandshake, + resolveInstallId, // Exported for tests + IPC handlers. _readState: readState, _writeState: writeState, diff --git a/electron/affiliateTracking.test.js b/electron/affiliateTracking.test.js index 83b19191..8ae98705 100644 --- a/electron/affiliateTracking.test.js +++ b/electron/affiliateTracking.test.js @@ -312,6 +312,85 @@ test("download scan refuses ambiguous stamped installers", () => { assert.equal(hash, null); }); +test("resolveInstallId: reuses install.json app_install_id", () => { + const userDataDir = makeTempUserDataDir(); + affiliateTracking._writeState(userDataDir, { app_install_id: "existing-id-12345" }); + const id = affiliateTracking.resolveInstallId({ + userDataDir, isPackaged: true, projectRoot: userDataDir, homeDir: userDataDir, + }); + assert.equal(id, "existing-id-12345"); +}); + +test("resolveInstallId: adopts python settings installation_id and persists it", () => { + const userDataDir = makeTempUserDataDir(); + const settingsDir = path.join(userDataDir, "backend", "data", "settings"); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync( + path.join(settingsDir, "settings.json"), + JSON.stringify({ installation_id: "python-analytics-id-1" }), + ); + + const id = affiliateTracking.resolveInstallId({ + userDataDir, + isPackaged: false, + projectRoot: userDataDir, + }); + assert.equal(id, "python-analytics-id-1"); + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.app_install_id, "python-analytics-id-1"); +}); + +test("resolveInstallId: generates once and returns the same id on repeat calls", () => { + const userDataDir = makeTempUserDataDir(); + const first = affiliateTracking.resolveInstallId({ + userDataDir, isPackaged: true, projectRoot: userDataDir, homeDir: userDataDir, + }); + const second = affiliateTracking.resolveInstallId({ + userDataDir, isPackaged: true, projectRoot: userDataDir, homeDir: userDataDir, + }); + assert.ok(first && first.length >= 8); + assert.equal(second, first); + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.app_install_id, first); +}); + +test("first launch handshake reuses the pre-resolved install id", async () => { + const cloud = await makeMockCloud(); + try { + const userDataDir = makeTempUserDataDir(); + const shell = makeFakeShell(); + const hash = "abcDEF1234567890_hash"; + cloud.filenameHashes.set(hash, "unified-affiliate"); + + process.env.OPENSWARM_AFFILIATE_LANDING_URL = "https://landing.test"; + process.env.OPENSWARM_AFFILIATE_CLOUD_URL = cloud.url; + + const resolved = affiliateTracking.resolveInstallId({ + userDataDir, + isPackaged: true, + projectRoot: userDataDir, + homeDir: userDataDir, + }); + + await affiliateTracking.maybeRunFirstLaunchHandshake({ + shell, + userDataDir, + isDev: false, + isPackaged: true, + platform: "linux", + env: { APPIMAGE: `/tmp/OpenSwarm-x64-${hash}.AppImage` }, + }); + + const state = readJson(path.join(userDataDir, "install.json")); + assert.equal(state.app_install_id, resolved, "handshake must keep the unified id"); + assert.equal(state.ref, "unified-affiliate"); + const bound = [...cloud.tokens.values()].find((t) => t.ref === "unified-affiliate"); + assert.equal(bound.app_install_id, resolved, "cloud bind must carry the unified id"); + } finally { + await cloud.close(); + } +}); + test("returning launch: no-op when ref already bound", async () => { const cloud = await makeMockCloud(); try { diff --git a/electron/main.js b/electron/main.js index ac4470a7..fa6c63bb 100644 --- a/electron/main.js +++ b/electron/main.js @@ -944,6 +944,22 @@ async function startBackend() { PYTHONUTF8: '1', }; + // Unified install identity: resolve the affiliate app_install_id (adopting + // the backend's existing settings.installation_id on upgrades) BEFORE the + // backend spawns, and hand it down so the analytics install_id and the + // affiliate id are the same value. One id means affiliate refs join + // directly to telemetry without requiring a sign-in. See + // affiliateTracking.resolveInstallId(). + try { + env.OPENSWARM_INSTALLATION_ID = affiliateTracking.resolveInstallId({ + userDataDir: app.getPath('userData'), + isPackaged, + projectRoot, + }); + } catch (err) { + console.warn('[affiliate] resolveInstallId failed:', err && err.message); + } + // Tell the backend where to find a real Node binary for 9Router and // bundled MCP servers. Preferring this over ELECTRON_RUN_AS_NODE avoids // (a) the second OpenSwarm-as-Node process briefly registering in the diff --git a/frontend/src/app/components/overlays/SignInDialog.tsx b/frontend/src/app/components/overlays/SignInDialog.tsx index b46bb628..d2cc88b1 100644 --- a/frontend/src/app/components/overlays/SignInDialog.tsx +++ b/frontend/src/app/components/overlays/SignInDialog.tsx @@ -19,7 +19,6 @@ import { activateSignin, fetchSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { report } from '@/shared/serviceClient'; -import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; type Stage = 'choose' | 'email_form' | 'code_form'; @@ -47,15 +46,13 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. const cloudBase = proxyUrl.replace(/\/$/, ''); - const onGoogle = async () => { + const onGoogle = () => { report('signin', 'google_clicked'); const localPort = (window as any).__OPENSWARM_PORT__ || 8324; const params = new URLSearchParams({ install_id: installId, local_port: String(localPort), }); - const appInstallId = await getAffiliateAppInstallId(); - if (appInstallId) params.set('app_install_id', appInstallId); const startUrl = `${cloudBase}/api/auth/google/start?${params.toString()}`; const api = (window as any).openswarm; if (api?.openExternal) { @@ -118,7 +115,6 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. try { report('signin', 'email_verify_submitted'); const localPort = (window as any).__OPENSWARM_PORT__ || 8324; - const appInstallId = await getAffiliateAppInstallId(); const res = await fetch(`${cloudBase}/api/auth/email/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -126,7 +122,6 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. email: email.trim(), code, install_id: installId, - ...(appInstallId ? { app_install_id: appInstallId } : {}), local_port: localPort, }), }); @@ -142,7 +137,6 @@ export default function SignInDialog({ onClose }: { onClose: () => void }): JSX. token: data.bearer, email: data.user_email, signin_method: 'email', - ...(appInstallId ? { app_install_id: appInstallId } : {}), }), ).unwrap(); } catch (err) { diff --git a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx index 870ae1e7..034d0b83 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx +++ b/frontend/src/app/pages/Settings/sections/subscription/AccountCard.tsx @@ -8,7 +8,6 @@ import { signOut } from '@/shared/state/settingsSlice'; import { OPENSWARM_DEFAULT_PROXY_URL } from '@/shared/config'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import SignInDialog from '@/app/components/overlays/SignInDialog'; -import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; /** Account card at top of General tab; three states: signed in, paid-but-unlinked, or not signed in. */ const AccountCard: React.FC = () => { @@ -44,15 +43,13 @@ const AccountCard: React.FC = () => { } }; - const onSignIn = async () => { + const onSignIn = () => { // Pass local_port so the bearer-handoff page POSTs to the right backend (Electron binds in 8324..8424). const localPort = (window as any).__OPENSWARM_PORT__ || 8324; const params = new URLSearchParams({ install_id: installId, local_port: String(localPort), }); - const appInstallId = await getAffiliateAppInstallId(); - if (appInstallId) params.set('app_install_id', appInstallId); const startUrl = proxyUrl.replace(/\/$/, '') + '/api/auth/google/start?' + params.toString(); const api = (window as any).openswarm; if (api?.openExternal) api.openExternal(startUrl); diff --git a/frontend/src/shared/affiliateInstall.ts b/frontend/src/shared/affiliateInstall.ts deleted file mode 100644 index 4b593695..00000000 --- a/frontend/src/shared/affiliateInstall.ts +++ /dev/null @@ -1,14 +0,0 @@ -const APP_INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/; - -export async function getAffiliateAppInstallId(): Promise { - try { - const api = (window as any).openswarm; - const state = await api?.getInstallState?.(); - const appInstallId = state && typeof state.app_install_id === 'string' - ? state.app_install_id - : ''; - return APP_INSTALL_ID_RE.test(appInstallId) ? appInstallId : null; - } catch { - return null; - } -} diff --git a/frontend/src/shared/hooks/useDeepLink.ts b/frontend/src/shared/hooks/useDeepLink.ts index e0771ba0..66814d76 100644 --- a/frontend/src/shared/hooks/useDeepLink.ts +++ b/frontend/src/shared/hooks/useDeepLink.ts @@ -5,7 +5,6 @@ import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchTools } from '@/shared/state/toolsSlice'; import { API_BASE } from '@/shared/config'; import { report } from '@/shared/serviceClient'; -import { getAffiliateAppInstallId } from '@/shared/affiliateInstall'; /** Subscribe to openswarm:// auth/oauth deep-links from Electron main; no-op in browser. */ export function useDeepLink(): void { @@ -15,7 +14,7 @@ export function useDeepLink(): void { const api = (window as any).openswarm as OpenSwarmAPI | undefined; if (!api) return; - const unsubscribe = api.onAuthUrl?.(async (rawUrl: string) => { + const unsubscribe = api.onAuthUrl?.((rawUrl: string) => { try { // openswarm://auth?token=...; signin=true => free sign-in, else Stripe activation. const url = new URL(rawUrl); @@ -35,16 +34,11 @@ export function useDeepLink(): void { const expires = url.searchParams.get('expires'); if (isSignin) { - const signinMethod = signinMethodRaw === 'email' ? 'email' : 'google'; - const appInstallId = url.searchParams.get('app_install_id') || (await getAffiliateAppInstallId()); - report('signin', 'deep_link_received', { method: signinMethod }); + // 1.0.29 only ships Google sign-in; read for forward compat. + void signinMethodRaw; + report('signin', 'deep_link_received', { method: 'google' }); - dispatch(activateSignin({ - token, - signin_method: signinMethod, - email, - ...(appInstallId ? { app_install_id: appInstallId } : {}), - })) + dispatch(activateSignin({ token, signin_method: 'google', email })) .unwrap() .then((res) => { report('signin', 'activated', { method: res.signin_method, plan: res.plan }); diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 0b786066..cc8254d6 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -87,7 +87,6 @@ export interface ActivateSigninPayload { token: string; signin_method: 'google' | 'email'; email?: string | null; - app_install_id?: string | null; } export interface BrowseResult { diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 61f473b6..2f416e7b 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -48,7 +48,6 @@ declare global { onUpdateError: (cb: (message: string) => void) => () => void; onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void; openExternal: (url: string) => Promise; - getInstallState?: () => Promise<{ app_install_id?: string; ref?: string | null; ref_bind_method?: string | null }>; hardReset?: () => Promise; onAuthUrl?: (cb: (url: string) => void) => () => void; onOauthClaim?: (cb: (url: string) => void) => () => void;