[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.
This commit is contained in:
abccodes
2026-07-10 18:09:26 -07:00
parent ac2f8e31b6
commit c7004c9878
12 changed files with 210 additions and 51 deletions
+13 -7
View File
@@ -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(
+10 -1
View File
@@ -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
+9 -2
View File
@@ -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()
+76 -3
View File
@@ -9,7 +9,7 @@
//
// State lives in `<userData>/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 <projectRoot>/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,
+79
View File
@@ -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 {
+16
View File
@@ -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
@@ -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) {
@@ -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);
-14
View File
@@ -1,14 +0,0 @@
const APP_INSTALL_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;
export async function getAffiliateAppInstallId(): Promise<string | null> {
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;
}
}
+5 -11
View File
@@ -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 });
@@ -87,7 +87,6 @@ export interface ActivateSigninPayload {
token: string;
signin_method: 'google' | 'email';
email?: string | null;
app_install_id?: string | null;
}
export interface BrowseResult {
-1
View File
@@ -48,7 +48,6 @@ declare global {
onUpdateError: (cb: (message: string) => void) => () => void;
onWebviewNewWindow: (cb: (url: string, webContentsId: number) => void) => () => void;
openExternal: (url: string) => Promise<void>;
getInstallState?: () => Promise<{ app_install_id?: string; ref?: string | null; ref_bind_method?: string | null }>;
hardReset?: () => Promise<void>;
onAuthUrl?: (cb: (url: string) => void) => () => void;
onOauthClaim?: (cb: (url: string) => void) => () => void;