From 200db33d783c3b842c57da82892eb83d2a79036c Mon Sep 17 00:00:00 2001 From: jtrzupek Date: Wed, 10 Jun 2026 08:58:02 +0200 Subject: [PATCH] feat(mobile): send X-Device-Id, one-time adopt-legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoonClient attaches a stable per-install device id (SecureStore, lazy UUID) on every request so server-side user state is scoped per device. On first launch after update, call /me/adopt-legacy once (SecureStore flag) to claim the previous shared state onto this device — the instance owner should relaunch first. Co-Authored-By: Claude Opus 4.8 --- mobile/App.tsx | 19 ++++++++++++++++++- mobile/src/api.ts | 8 ++++++++ mobile/src/storage.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/mobile/App.tsx b/mobile/App.tsx index 41a5c4e..f32ba12 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -33,7 +33,7 @@ import { AppNavigator } from './src/navigation'; import { AgeGateScreen } from './src/screens/AgeGateScreen'; import { AppLockScreen } from './src/screens/AppLockScreen'; import { LoginScreen } from './src/screens/LoginScreen'; -import { clearCredentials, loadCredentials } from './src/storage'; +import { clearCredentials, isLegacyAdopted, loadCredentials, markLegacyAdopted } from './src/storage'; import { theme } from './src/theme'; // Sentry: init przed registerRootComponent. Pusty DSN → SDK no-op (devel build bez @@ -141,6 +141,23 @@ export default function App() { })(); }, []); + // Jednorazowe przejęcie legacy stanu usera (favorites/progress/blacklisty sprzed + // device-scopingu, bug 2026-06-08). Po pierwszym launchu z nowym bundlem przepinamy + // legacy-shared rows na to urządzenie. Flaga w SecureStore → tylko raz. (Właściciel + // instancji powinien zrelaunchować apkę jako pierwszy, żeby przejąć swoją historię.) + useEffect(() => { + if (!client) return; + (async () => { + try { + if (await isLegacyAdopted()) return; + await client.adoptLegacy(); + await markLegacyAdopted(); + } catch { + // brak sieci / już przejęte — spróbuje przy następnym launchu (flaga nieustawiona) + } + })(); + }, [client]); + // FLAG_SECURE — blocks screenshots and hides app preview from app switcher. // TEMP 2026-06-02: wyłączone żeby umożliwić debug na emulatorze (screencap + // playback verification — FLAG_SECURE czyni screenshoty czarnymi). Jan jest na diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 0b41e14..2c2ffaa 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -1,5 +1,6 @@ // Cienki klient REST do backendu goon. Używa fetch + headerów X-API-Key. import { getAppSignatureHash } from './native/antiTamper'; +import { getDeviceId } from './storage'; import type { BlacklistKind, BlacklistOut, @@ -46,12 +47,19 @@ export class GoonClient { // backend zwraca 403 (anti-tamper sig check, patrz auth.py). private async _authHeaders(): Promise> { const sig = await getAppSignatureHash(); + const deviceId = await getDeviceId(); return { 'X-API-Key': this.apiKey, + 'X-Device-Id': deviceId, ...(sig ? { 'X-App-Signature': sig } : {}), }; } + // Jednorazowe przejęcie legacy stanu (sprzed device-scopingu) na to urządzenie. + async adoptLegacy(): Promise<{ device_id: string; moved: Record }> { + return this.request('/me/adopt-legacy', { method: 'POST' }); + } + private async request(path: string, init?: RequestInit): Promise { const auth = await this._authHeaders(); const res = await fetch(`${this.baseUrl}${path}`, { diff --git a/mobile/src/storage.ts b/mobile/src/storage.ts index 44b0928..686c0db 100644 --- a/mobile/src/storage.ts +++ b/mobile/src/storage.ts @@ -2,6 +2,41 @@ import * as SecureStore from 'expo-secure-store'; const URL_KEY = 'goon.backend_url'; const API_KEY = 'goon.api_key'; +const DEVICE_KEY = 'goon.device_id'; + +// Stabilne ID instalacji — scope'uje stan usera (favorites/progress/blacklisty) na +// serwerze per urządzenie (bug 2026-06-08: stan był globalny, nowi userzy nadpisywali +// ulubione). Generowane raz, trzymane w SecureStore (przeżywa restart, NIE reinstalację). +let _deviceIdCache: string | null = null; + +function _genDeviceId(): string { + // 32 hex chars — wystarczająco unikalne dla ID instalacji (nie security-critical). + let s = ''; + for (let i = 0; i < 32; i++) s += Math.floor(Math.random() * 16).toString(16); + return s; +} + +export async function getDeviceId(): Promise { + if (_deviceIdCache) return _deviceIdCache; + let id = await SecureStore.getItemAsync(DEVICE_KEY); + if (!id) { + id = _genDeviceId(); + await SecureStore.setItemAsync(DEVICE_KEY, id); + } + _deviceIdCache = id; + return id; +} + +// Jednorazowe przejęcie legacy stanu (sprzed device-scopingu) — flaga per instalacja. +const LEGACY_ADOPTED_KEY = 'goon.legacy_adopted'; + +export async function isLegacyAdopted(): Promise { + return (await SecureStore.getItemAsync(LEGACY_ADOPTED_KEY)) === '1'; +} + +export async function markLegacyAdopted(): Promise { + await SecureStore.setItemAsync(LEGACY_ADOPTED_KEY, '1'); +} export interface Credentials { baseUrl: string;