yaver-feedback-react-native 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ StyleSheet,
8
+ ActivityIndicator,
9
+ SafeAreaView,
10
+ ScrollView,
11
+ Linking,
12
+ Platform,
13
+ } from 'react-native';
14
+ import {
15
+ loginWithEmail,
16
+ signupWithEmail,
17
+ pollDeviceCode,
18
+ startDeviceCode,
19
+ validateToken,
20
+ saveToken,
21
+ saveUser,
22
+ OAuthProvider,
23
+ DeviceCodeStart,
24
+ } from './auth';
25
+
26
+ export interface YaverLoginScreenProps {
27
+ /** Invoked once a session token is issued and the user is loaded. */
28
+ onLoggedIn: (token: string) => void;
29
+ /** Optional cancel button shown in header. */
30
+ onCancel?: () => void;
31
+ }
32
+
33
+ type Mode = 'device' | 'email';
34
+
35
+ const PROVIDERS: { id: OAuthProvider; label: string; emoji: string }[] = [
36
+ { id: 'apple', label: 'Apple', emoji: '' },
37
+ { id: 'google', label: 'Google', emoji: 'G' },
38
+ { id: 'github', label: 'GitHub', emoji: '' },
39
+ { id: 'gitlab', label: 'GitLab', emoji: '' },
40
+ { id: 'microsoft', label: 'Microsoft', emoji: 'M' },
41
+ ];
42
+
43
+ /**
44
+ * Full-screen in-SDK login. Device-code is the default flow — users sign in
45
+ * with any OAuth provider (Apple/Google/GitHub/GitLab/Microsoft) or email on
46
+ * yaver.io and the SDK polls for the issued session token. Email/password is
47
+ * available as an inline fallback for headless environments.
48
+ */
49
+ export const YaverLoginScreen: React.FC<YaverLoginScreenProps> = ({
50
+ onLoggedIn,
51
+ onCancel,
52
+ }) => {
53
+ const [mode, setMode] = useState<Mode>('device');
54
+
55
+ const [code, setCode] = useState<DeviceCodeStart | null>(null);
56
+ const [codeError, setCodeError] = useState<string | null>(null);
57
+ const [starting, setStarting] = useState(false);
58
+ const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
59
+ const expiredTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
60
+
61
+ const [emailMode, setEmailMode] = useState<'login' | 'signup'>('login');
62
+ const [fullName, setFullName] = useState('');
63
+ const [email, setEmail] = useState('');
64
+ const [password, setPassword] = useState('');
65
+ const [emailBusy, setEmailBusy] = useState(false);
66
+ const [emailError, setEmailError] = useState<string | null>(null);
67
+
68
+ useEffect(() => {
69
+ if (mode === 'device' && !code && !starting) {
70
+ void beginDeviceCode(undefined);
71
+ }
72
+ return () => {
73
+ stopPolling();
74
+ };
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [mode]);
77
+
78
+ const stopPolling = () => {
79
+ if (pollRef.current) {
80
+ clearInterval(pollRef.current);
81
+ pollRef.current = null;
82
+ }
83
+ if (expiredTimerRef.current) {
84
+ clearTimeout(expiredTimerRef.current);
85
+ expiredTimerRef.current = null;
86
+ }
87
+ };
88
+
89
+ const beginDeviceCode = async (preferredProvider?: OAuthProvider) => {
90
+ setStarting(true);
91
+ setCodeError(null);
92
+ stopPolling();
93
+ try {
94
+ const result = await startDeviceCode({
95
+ platform: Platform.OS,
96
+ machineName: `feedback-sdk-${Platform.OS}`,
97
+ preferredProvider,
98
+ });
99
+ setCode(result);
100
+
101
+ pollRef.current = setInterval(async () => {
102
+ const poll = await pollDeviceCode(result.deviceCode);
103
+ if (poll.status === 'authorized') {
104
+ stopPolling();
105
+ const user = await validateToken(poll.token);
106
+ await saveToken(poll.token);
107
+ if (user) await saveUser(user);
108
+ onLoggedIn(poll.token);
109
+ } else if (poll.status === 'expired') {
110
+ stopPolling();
111
+ setCodeError('Kod doldu — tekrar başlat.');
112
+ setCode(null);
113
+ }
114
+ }, 3_000);
115
+
116
+ expiredTimerRef.current = setTimeout(() => {
117
+ stopPolling();
118
+ setCode(null);
119
+ setCodeError('Kod süresi doldu.');
120
+ }, Math.max(0, result.expiresAt - Date.now()));
121
+ } catch (err) {
122
+ setCodeError(err instanceof Error ? err.message : String(err));
123
+ } finally {
124
+ setStarting(false);
125
+ }
126
+ };
127
+
128
+ const openVerification = () => {
129
+ if (!code) return;
130
+ Linking.openURL(code.verificationUrl).catch(() => {
131
+ setCodeError('Tarayıcı açılamadı — URL’yi elle aç.');
132
+ });
133
+ };
134
+
135
+ const handleEmailSubmit = async () => {
136
+ setEmailError(null);
137
+ if (!email.trim() || !password) {
138
+ setEmailError('E-posta ve parola zorunlu.');
139
+ return;
140
+ }
141
+ setEmailBusy(true);
142
+ try {
143
+ const result =
144
+ emailMode === 'signup'
145
+ ? await signupWithEmail(fullName.trim() || email.trim(), email.trim(), password)
146
+ : await loginWithEmail(email.trim(), password);
147
+ const user = await validateToken(result.token);
148
+ await saveToken(result.token);
149
+ if (user) await saveUser(user);
150
+ onLoggedIn(result.token);
151
+ } catch (err) {
152
+ setEmailError(err instanceof Error ? err.message : String(err));
153
+ } finally {
154
+ setEmailBusy(false);
155
+ }
156
+ };
157
+
158
+ return (
159
+ <SafeAreaView style={styles.container}>
160
+ <ScrollView
161
+ contentContainerStyle={styles.content}
162
+ keyboardShouldPersistTaps="handled"
163
+ >
164
+ <View style={styles.header}>
165
+ <Text style={styles.title}>Yaver Girişi</Text>
166
+ {onCancel && (
167
+ <TouchableOpacity onPress={onCancel} style={styles.cancel}>
168
+ <Text style={styles.cancelText}>İptal</Text>
169
+ </TouchableOpacity>
170
+ )}
171
+ </View>
172
+
173
+ <View style={styles.tabRow}>
174
+ <TouchableOpacity
175
+ style={[styles.tab, mode === 'device' && styles.tabActive]}
176
+ onPress={() => setMode('device')}
177
+ >
178
+ <Text
179
+ style={[styles.tabText, mode === 'device' && styles.tabTextActive]}
180
+ >
181
+ Hızlı Giriş (OAuth)
182
+ </Text>
183
+ </TouchableOpacity>
184
+ <TouchableOpacity
185
+ style={[styles.tab, mode === 'email' && styles.tabActive]}
186
+ onPress={() => setMode('email')}
187
+ >
188
+ <Text
189
+ style={[styles.tabText, mode === 'email' && styles.tabTextActive]}
190
+ >
191
+ E-posta
192
+ </Text>
193
+ </TouchableOpacity>
194
+ </View>
195
+
196
+ {mode === 'device' && (
197
+ <View style={styles.section}>
198
+ {starting ? (
199
+ <ActivityIndicator color="#6366f1" style={{ marginVertical: 40 }} />
200
+ ) : code ? (
201
+ <>
202
+ <Text style={styles.hint}>
203
+ Tarayıcıda yaver.io/auth/device aç ve aşağıdaki kodu gir.
204
+ Sağlayıcıyla giriş yaptığında buraya otomatik dönecek.
205
+ </Text>
206
+ <View style={styles.codeBox}>
207
+ <Text style={styles.codeText}>{code.userCode}</Text>
208
+ </View>
209
+ <TouchableOpacity style={styles.primaryButton} onPress={openVerification}>
210
+ <Text style={styles.primaryButtonText}>Tarayıcıda Aç</Text>
211
+ </TouchableOpacity>
212
+
213
+ <Text style={[styles.hint, { marginTop: 20 }]}>
214
+ Sağlayıcıyı önceden seçmek istersen:
215
+ </Text>
216
+ <View style={styles.providerRow}>
217
+ {PROVIDERS.map((p) => (
218
+ <TouchableOpacity
219
+ key={p.id}
220
+ style={styles.providerButton}
221
+ onPress={() => beginDeviceCode(p.id)}
222
+ >
223
+ <Text style={styles.providerButtonText}>{p.label}</Text>
224
+ </TouchableOpacity>
225
+ ))}
226
+ </View>
227
+ </>
228
+ ) : (
229
+ <TouchableOpacity
230
+ style={styles.primaryButton}
231
+ onPress={() => beginDeviceCode(undefined)}
232
+ >
233
+ <Text style={styles.primaryButtonText}>Tekrar Başlat</Text>
234
+ </TouchableOpacity>
235
+ )}
236
+ {codeError && <Text style={styles.error}>{codeError}</Text>}
237
+ </View>
238
+ )}
239
+
240
+ {mode === 'email' && (
241
+ <View style={styles.section}>
242
+ <View style={styles.tabRow}>
243
+ <TouchableOpacity
244
+ style={[styles.subTab, emailMode === 'login' && styles.subTabActive]}
245
+ onPress={() => setEmailMode('login')}
246
+ >
247
+ <Text style={styles.subTabText}>Giriş</Text>
248
+ </TouchableOpacity>
249
+ <TouchableOpacity
250
+ style={[styles.subTab, emailMode === 'signup' && styles.subTabActive]}
251
+ onPress={() => setEmailMode('signup')}
252
+ >
253
+ <Text style={styles.subTabText}>Kayıt</Text>
254
+ </TouchableOpacity>
255
+ </View>
256
+
257
+ {emailMode === 'signup' && (
258
+ <>
259
+ <Text style={styles.label}>Ad Soyad</Text>
260
+ <TextInput
261
+ style={styles.input}
262
+ value={fullName}
263
+ onChangeText={setFullName}
264
+ placeholder="Adın Soyadın"
265
+ placeholderTextColor="#666"
266
+ autoCapitalize="words"
267
+ />
268
+ </>
269
+ )}
270
+
271
+ <Text style={styles.label}>E-posta</Text>
272
+ <TextInput
273
+ style={styles.input}
274
+ value={email}
275
+ onChangeText={setEmail}
276
+ placeholder="you@example.com"
277
+ placeholderTextColor="#666"
278
+ keyboardType="email-address"
279
+ autoCapitalize="none"
280
+ autoCorrect={false}
281
+ />
282
+
283
+ <Text style={styles.label}>Parola</Text>
284
+ <TextInput
285
+ style={styles.input}
286
+ value={password}
287
+ onChangeText={setPassword}
288
+ placeholder="••••••••"
289
+ placeholderTextColor="#666"
290
+ secureTextEntry
291
+ autoCapitalize="none"
292
+ />
293
+
294
+ <TouchableOpacity
295
+ style={styles.primaryButton}
296
+ onPress={handleEmailSubmit}
297
+ disabled={emailBusy}
298
+ >
299
+ {emailBusy ? (
300
+ <ActivityIndicator color="#fff" />
301
+ ) : (
302
+ <Text style={styles.primaryButtonText}>
303
+ {emailMode === 'signup' ? 'Kayıt Ol' : 'Giriş Yap'}
304
+ </Text>
305
+ )}
306
+ </TouchableOpacity>
307
+
308
+ {emailError && <Text style={styles.error}>{emailError}</Text>}
309
+ </View>
310
+ )}
311
+ </ScrollView>
312
+ </SafeAreaView>
313
+ );
314
+ };
315
+
316
+ const styles = StyleSheet.create({
317
+ container: { flex: 1, backgroundColor: '#1a1a2e' },
318
+ content: { padding: 24, paddingTop: 16 },
319
+ header: {
320
+ flexDirection: 'row',
321
+ alignItems: 'center',
322
+ justifyContent: 'space-between',
323
+ marginBottom: 20,
324
+ },
325
+ title: { fontSize: 22, fontWeight: '700', color: '#e0e0e0' },
326
+ cancel: { padding: 8 },
327
+ cancelText: { color: '#9ca3af', fontSize: 14 },
328
+ tabRow: { flexDirection: 'row', gap: 8, marginBottom: 16 },
329
+ tab: {
330
+ flex: 1,
331
+ paddingVertical: 12,
332
+ borderRadius: 10,
333
+ backgroundColor: 'rgba(255,255,255,0.05)',
334
+ alignItems: 'center',
335
+ },
336
+ tabActive: { backgroundColor: 'rgba(99,102,241,0.25)' },
337
+ tabText: { color: '#9ca3af', fontSize: 14, fontWeight: '600' },
338
+ tabTextActive: { color: '#e0e0e0' },
339
+ subTab: {
340
+ flex: 1,
341
+ paddingVertical: 8,
342
+ borderRadius: 8,
343
+ alignItems: 'center',
344
+ backgroundColor: 'rgba(255,255,255,0.05)',
345
+ },
346
+ subTabActive: { backgroundColor: 'rgba(99,102,241,0.2)' },
347
+ subTabText: { color: '#e0e0e0', fontSize: 13 },
348
+ section: { marginTop: 4 },
349
+ hint: { color: '#9ca3af', fontSize: 13, lineHeight: 18 },
350
+ codeBox: {
351
+ backgroundColor: 'rgba(99,102,241,0.15)',
352
+ borderWidth: 1,
353
+ borderColor: 'rgba(99,102,241,0.35)',
354
+ borderRadius: 14,
355
+ paddingVertical: 24,
356
+ alignItems: 'center',
357
+ marginTop: 16,
358
+ marginBottom: 16,
359
+ },
360
+ codeText: {
361
+ color: '#e0e7ff',
362
+ fontSize: 36,
363
+ fontWeight: '800',
364
+ letterSpacing: 6,
365
+ fontVariant: ['tabular-nums'],
366
+ },
367
+ primaryButton: {
368
+ backgroundColor: '#6366f1',
369
+ borderRadius: 12,
370
+ paddingVertical: 14,
371
+ alignItems: 'center',
372
+ marginTop: 8,
373
+ },
374
+ primaryButtonText: { color: '#fff', fontWeight: '700', fontSize: 15 },
375
+ providerRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
376
+ providerButton: {
377
+ backgroundColor: 'rgba(255,255,255,0.08)',
378
+ borderRadius: 10,
379
+ paddingVertical: 10,
380
+ paddingHorizontal: 14,
381
+ },
382
+ providerButtonText: { color: '#e0e0e0', fontSize: 13, fontWeight: '600' },
383
+ label: { color: '#9ca3af', fontSize: 12, marginTop: 14, marginBottom: 6 },
384
+ input: {
385
+ backgroundColor: 'rgba(255,255,255,0.08)',
386
+ borderWidth: 1,
387
+ borderColor: 'rgba(255,255,255,0.15)',
388
+ borderRadius: 10,
389
+ paddingHorizontal: 14,
390
+ paddingVertical: 12,
391
+ color: '#e0e0e0',
392
+ fontSize: 15,
393
+ },
394
+ error: { color: '#ef4444', fontSize: 13, marginTop: 12 },
395
+ });
@@ -0,0 +1,196 @@
1
+ import React, { useCallback, useEffect, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ActivityIndicator,
8
+ SafeAreaView,
9
+ ScrollView,
10
+ RefreshControl,
11
+ } from 'react-native';
12
+ import {
13
+ DeviceList,
14
+ RemoteDevice,
15
+ listReachableDevices,
16
+ saveSelectedDeviceId,
17
+ } from './auth';
18
+
19
+ export interface YaverMachinePickerProps {
20
+ token: string;
21
+ /** Currently-selected deviceId (from config / cache) — highlighted. */
22
+ currentDeviceId?: string;
23
+ onPick: (device: RemoteDevice) => void;
24
+ onCancel?: () => void;
25
+ }
26
+
27
+ /**
28
+ * List of remote dev machines the signed-in user can reach. Split into
29
+ * - Owned machines (user is the host)
30
+ * - Shared machines (host invited them as a guest)
31
+ *
32
+ * Tapping a device persists it to AsyncStorage and invokes `onPick`. The
33
+ * SDK then uses that device's deviceId for agent discovery (LAN probe +
34
+ * relay fallback through Convex).
35
+ */
36
+ export const YaverMachinePickerScreen: React.FC<YaverMachinePickerProps> = ({
37
+ token,
38
+ currentDeviceId,
39
+ onPick,
40
+ onCancel,
41
+ }) => {
42
+ const [loading, setLoading] = useState(true);
43
+ const [refreshing, setRefreshing] = useState(false);
44
+ const [error, setError] = useState<string | null>(null);
45
+ const [list, setList] = useState<DeviceList>({ owned: [], shared: [] });
46
+
47
+ const load = useCallback(async (silent = false) => {
48
+ if (!silent) setLoading(true);
49
+ setError(null);
50
+ try {
51
+ const result = await listReachableDevices(token);
52
+ setList(result);
53
+ if (result.owned.length === 0 && result.shared.length === 0) {
54
+ setError('Hiç makine bulunamadı — önce bir makinede `yaver auth` + `yaver serve` çalıştır.');
55
+ }
56
+ } catch (err) {
57
+ setError(err instanceof Error ? err.message : String(err));
58
+ } finally {
59
+ setLoading(false);
60
+ setRefreshing(false);
61
+ }
62
+ }, [token]);
63
+
64
+ useEffect(() => {
65
+ void load();
66
+ }, [load]);
67
+
68
+ const handlePick = async (device: RemoteDevice) => {
69
+ await saveSelectedDeviceId(device.deviceId);
70
+ onPick(device);
71
+ };
72
+
73
+ const renderDevice = (device: RemoteDevice) => {
74
+ const selected = device.deviceId === currentDeviceId;
75
+ const stale = Date.now() - device.lastHeartbeat > 60_000;
76
+ const healthColor = !device.isOnline
77
+ ? '#ef4444'
78
+ : device.needsAuth || device.runnerDown || stale
79
+ ? '#f59e0b'
80
+ : '#22c55e';
81
+ return (
82
+ <TouchableOpacity
83
+ key={device.deviceId}
84
+ style={[styles.deviceRow, selected && styles.deviceSelected]}
85
+ onPress={() => handlePick(device)}
86
+ >
87
+ <View style={[styles.health, { backgroundColor: healthColor }]} />
88
+ <View style={{ flex: 1 }}>
89
+ <Text style={styles.deviceName}>{device.name || device.deviceId}</Text>
90
+ <Text style={styles.deviceMeta}>
91
+ {device.platform}
92
+ {device.isGuest && device.hostEmail ? ` • ${device.hostEmail}` : ''}
93
+ {device.accessScope === 'shared-scoped' ? ' • paylaşılan' : ''}
94
+ </Text>
95
+ </View>
96
+ {selected && <Text style={styles.selectedBadge}>seçili</Text>}
97
+ </TouchableOpacity>
98
+ );
99
+ };
100
+
101
+ return (
102
+ <SafeAreaView style={styles.container}>
103
+ <View style={styles.header}>
104
+ <Text style={styles.title}>Makine Seç</Text>
105
+ {onCancel && (
106
+ <TouchableOpacity onPress={onCancel} style={styles.cancel}>
107
+ <Text style={styles.cancelText}>Kapat</Text>
108
+ </TouchableOpacity>
109
+ )}
110
+ </View>
111
+
112
+ <ScrollView
113
+ contentContainerStyle={styles.content}
114
+ refreshControl={
115
+ <RefreshControl
116
+ refreshing={refreshing}
117
+ onRefresh={() => {
118
+ setRefreshing(true);
119
+ void load(true);
120
+ }}
121
+ tintColor="#6366f1"
122
+ />
123
+ }
124
+ >
125
+ {loading ? (
126
+ <ActivityIndicator color="#6366f1" style={{ marginTop: 60 }} />
127
+ ) : (
128
+ <>
129
+ {list.owned.length > 0 && (
130
+ <View style={styles.section}>
131
+ <Text style={styles.sectionTitle}>Kendi makinelerim</Text>
132
+ {list.owned.map(renderDevice)}
133
+ </View>
134
+ )}
135
+ {list.shared.length > 0 && (
136
+ <View style={styles.section}>
137
+ <Text style={styles.sectionTitle}>Paylaşılan (guest)</Text>
138
+ {list.shared.map(renderDevice)}
139
+ </View>
140
+ )}
141
+ {error && <Text style={styles.error}>{error}</Text>}
142
+ </>
143
+ )}
144
+ </ScrollView>
145
+ </SafeAreaView>
146
+ );
147
+ };
148
+
149
+ const styles = StyleSheet.create({
150
+ container: { flex: 1, backgroundColor: '#1a1a2e' },
151
+ header: {
152
+ flexDirection: 'row',
153
+ alignItems: 'center',
154
+ justifyContent: 'space-between',
155
+ paddingHorizontal: 20,
156
+ paddingVertical: 16,
157
+ },
158
+ title: { color: '#e0e0e0', fontSize: 20, fontWeight: '700' },
159
+ cancel: { padding: 8 },
160
+ cancelText: { color: '#9ca3af', fontSize: 14 },
161
+ content: { padding: 20, paddingTop: 0 },
162
+ section: { marginBottom: 24 },
163
+ sectionTitle: {
164
+ color: '#9ca3af',
165
+ fontSize: 12,
166
+ fontWeight: '600',
167
+ textTransform: 'uppercase',
168
+ letterSpacing: 1,
169
+ marginBottom: 10,
170
+ },
171
+ deviceRow: {
172
+ flexDirection: 'row',
173
+ alignItems: 'center',
174
+ gap: 12,
175
+ padding: 14,
176
+ backgroundColor: 'rgba(255,255,255,0.05)',
177
+ borderRadius: 12,
178
+ marginBottom: 8,
179
+ borderWidth: 1,
180
+ borderColor: 'transparent',
181
+ },
182
+ deviceSelected: {
183
+ borderColor: 'rgba(99,102,241,0.5)',
184
+ backgroundColor: 'rgba(99,102,241,0.15)',
185
+ },
186
+ health: { width: 10, height: 10, borderRadius: 5 },
187
+ deviceName: { color: '#e0e0e0', fontSize: 15, fontWeight: '600' },
188
+ deviceMeta: { color: '#9ca3af', fontSize: 12, marginTop: 2 },
189
+ selectedBadge: {
190
+ color: '#a5b4fc',
191
+ fontSize: 11,
192
+ fontWeight: '700',
193
+ textTransform: 'uppercase',
194
+ },
195
+ error: { color: '#ef4444', fontSize: 13, marginTop: 16, textAlign: 'center' },
196
+ });
package/src/P2PClient.ts CHANGED
@@ -308,6 +308,116 @@ export class P2PClient {
308
308
  return { token: result.token, expiresAt: result.expiresAt };
309
309
  }
310
310
 
311
+ // ─── Feature flags (F1) ──────────────────────────────────────────
312
+
313
+ /**
314
+ * Evaluate every flag for a userId. Hits /flags/eval which uses
315
+ * SHA256 bucketing against rolloutPercent — stable per user per
316
+ * flag. Results are the dev's source of truth; the SDK caches
317
+ * for 30s in getFlagsCached().
318
+ */
319
+ async flagsEvaluate(userId: string = 'anonymous'): Promise<Record<string, unknown>> {
320
+ const res = await fetch(
321
+ `${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}`,
322
+ { headers: { Authorization: `Bearer ${this.authToken}` } },
323
+ );
324
+ if (!res.ok) return {};
325
+ const data = await res.json();
326
+ return data.flags ?? {};
327
+ }
328
+
329
+ /** Evaluate a single flag by key — shortcut when you only need one. */
330
+ async flagsEvaluateOne<T = unknown>(
331
+ key: string,
332
+ userId: string = 'anonymous',
333
+ ): Promise<T | undefined> {
334
+ const res = await fetch(
335
+ `${this.baseUrl}/flags/eval?userId=${encodeURIComponent(userId)}&flag=${encodeURIComponent(key)}`,
336
+ { headers: { Authorization: `Bearer ${this.authToken}` } },
337
+ );
338
+ if (!res.ok) return undefined;
339
+ const data = await res.json();
340
+ return data.value as T;
341
+ }
342
+
343
+ // ─── Releases (R1) ───────────────────────────────────────────────
344
+
345
+ /**
346
+ * Ask what bundle this device should run. Returns the latest
347
+ * release in the channel plus a rollout gate. The mobile app
348
+ * uses this on cold start to decide whether to download a new
349
+ * bundle from /releases/bundle.
350
+ */
351
+ async releasesLatest(
352
+ channel: string = 'production',
353
+ deviceId?: string,
354
+ ): Promise<{
355
+ ok: boolean;
356
+ channel: string;
357
+ semver?: string;
358
+ size?: number;
359
+ md5?: string;
360
+ hermesBcVersion?: number;
361
+ bundleUrl?: string;
362
+ rolloutPercent: number;
363
+ inRollout: boolean;
364
+ reason?: string;
365
+ } | null> {
366
+ const params = new URLSearchParams({ channel });
367
+ if (deviceId) params.set('device', deviceId);
368
+ const res = await fetch(`${this.baseUrl}/releases/latest?${params.toString()}`, {
369
+ headers: { Authorization: `Bearer ${this.authToken}` },
370
+ });
371
+ if (!res.ok) return null;
372
+ return res.json();
373
+ }
374
+
375
+ /** Download a specific bundle as raw bytes. */
376
+ async releasesDownload(
377
+ channel: string,
378
+ semver: string,
379
+ ): Promise<ArrayBuffer | null> {
380
+ const params = new URLSearchParams({ channel, semver });
381
+ const res = await fetch(`${this.baseUrl}/releases/bundle?${params.toString()}`, {
382
+ headers: { Authorization: `Bearer ${this.authToken}` },
383
+ });
384
+ if (!res.ok) return null;
385
+ return res.arrayBuffer();
386
+ }
387
+
388
+ // ─── Analytics ingest (A1 — direct POST path) ───────────────────
389
+
390
+ /**
391
+ * Fire-and-forget track event. Most callers should use
392
+ * `BlackBox.track()` which fans through the streaming channel;
393
+ * this method is the fallback for surfaces without a live SSE.
394
+ */
395
+ async analyticsIngest(
396
+ name: string,
397
+ props?: Record<string, string>,
398
+ opts?: { deviceId?: string; route?: string; timestamp?: number },
399
+ ): Promise<boolean> {
400
+ try {
401
+ const res = await fetch(`${this.baseUrl}/analytics/ingest`, {
402
+ method: 'POST',
403
+ headers: {
404
+ Authorization: `Bearer ${this.authToken}`,
405
+ 'Content-Type': 'application/json',
406
+ },
407
+ body: JSON.stringify({
408
+ name,
409
+ props,
410
+ deviceId: opts?.deviceId,
411
+ route: opts?.route,
412
+ timestamp: opts?.timestamp ?? Date.now(),
413
+ }),
414
+ });
415
+ return res.ok;
416
+ } catch {
417
+ return false;
418
+ }
419
+ }
420
+
311
421
  /** Internal helper for authenticated GET/POST requests. */
312
422
  private async request(method: string, path: string): Promise<Response> {
313
423
  const response = await fetch(`${this.baseUrl}${path}`, {