feat(mobile): send X-Device-Id, one-time adopt-legacy

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 <noreply@anthropic.com>
This commit is contained in:
jtrzupek 2026-06-10 08:58:02 +02:00
parent c8baa11604
commit 200db33d78
3 changed files with 61 additions and 1 deletions

View file

@ -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

View file

@ -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<Record<string, string>> {
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<string, number> }> {
return this.request('/me/adopt-legacy', { method: 'POST' });
}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const auth = await this._authHeaders();
const res = await fetch(`${this.baseUrl}${path}`, {

View file

@ -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<string> {
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<boolean> {
return (await SecureStore.getItemAsync(LEGACY_ADOPTED_KEY)) === '1';
}
export async function markLegacyAdopted(): Promise<void> {
await SecureStore.setItemAsync(LEGACY_ADOPTED_KEY, '1');
}
export interface Credentials {
baseUrl: string;