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:
parent
c8baa11604
commit
200db33d78
3 changed files with 61 additions and 1 deletions
|
|
@ -33,7 +33,7 @@ import { AppNavigator } from './src/navigation';
|
||||||
import { AgeGateScreen } from './src/screens/AgeGateScreen';
|
import { AgeGateScreen } from './src/screens/AgeGateScreen';
|
||||||
import { AppLockScreen } from './src/screens/AppLockScreen';
|
import { AppLockScreen } from './src/screens/AppLockScreen';
|
||||||
import { LoginScreen } from './src/screens/LoginScreen';
|
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';
|
import { theme } from './src/theme';
|
||||||
|
|
||||||
// Sentry: init przed registerRootComponent. Pusty DSN → SDK no-op (devel build bez
|
// 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.
|
// FLAG_SECURE — blocks screenshots and hides app preview from app switcher.
|
||||||
// TEMP 2026-06-02: wyłączone żeby umożliwić debug na emulatorze (screencap +
|
// TEMP 2026-06-02: wyłączone żeby umożliwić debug na emulatorze (screencap +
|
||||||
// playback verification — FLAG_SECURE czyni screenshoty czarnymi). Jan jest na
|
// playback verification — FLAG_SECURE czyni screenshoty czarnymi). Jan jest na
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// Cienki klient REST do backendu goon. Używa fetch + headerów X-API-Key.
|
// Cienki klient REST do backendu goon. Używa fetch + headerów X-API-Key.
|
||||||
import { getAppSignatureHash } from './native/antiTamper';
|
import { getAppSignatureHash } from './native/antiTamper';
|
||||||
|
import { getDeviceId } from './storage';
|
||||||
import type {
|
import type {
|
||||||
BlacklistKind,
|
BlacklistKind,
|
||||||
BlacklistOut,
|
BlacklistOut,
|
||||||
|
|
@ -46,12 +47,19 @@ export class GoonClient {
|
||||||
// backend zwraca 403 (anti-tamper sig check, patrz auth.py).
|
// backend zwraca 403 (anti-tamper sig check, patrz auth.py).
|
||||||
private async _authHeaders(): Promise<Record<string, string>> {
|
private async _authHeaders(): Promise<Record<string, string>> {
|
||||||
const sig = await getAppSignatureHash();
|
const sig = await getAppSignatureHash();
|
||||||
|
const deviceId = await getDeviceId();
|
||||||
return {
|
return {
|
||||||
'X-API-Key': this.apiKey,
|
'X-API-Key': this.apiKey,
|
||||||
|
'X-Device-Id': deviceId,
|
||||||
...(sig ? { 'X-App-Signature': sig } : {}),
|
...(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> {
|
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
const auth = await this._authHeaders();
|
const auth = await this._authHeaders();
|
||||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,41 @@ import * as SecureStore from 'expo-secure-store';
|
||||||
|
|
||||||
const URL_KEY = 'goon.backend_url';
|
const URL_KEY = 'goon.backend_url';
|
||||||
const API_KEY = 'goon.api_key';
|
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 {
|
export interface Credentials {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue