mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 18:27:45 +02:00
[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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
Vendored
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user