@gemmein/sdk 0.9.0 → 0.10.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.
package/dist/expo.d.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { Gemmein } from "./index.js";
2
+ import type { GemmeinOptions, TokenStore, UploadInput, VisibilityHook } from "./index.js";
3
+ export * from "./index.js";
4
+ export { Gemmein, GemmeinError, AuthClient, PurchasesClient, FilesClient, SubscriptionsClient, PaymentsClient, AccountClient, CreditsClient, AiClient, StorageClient, CollectionClient, GemmeinServer, } from "./index.js";
5
+ /** The slice of `expo-secure-store` this entry calls. */
6
+ export type SecureStoreModule = {
7
+ getItemAsync(key: string, options?: Record<string, unknown>): Promise<string | null>;
8
+ setItemAsync(key: string, value: string, options?: Record<string, unknown>): Promise<void>;
9
+ deleteItemAsync(key: string, options?: Record<string, unknown>): Promise<void>;
10
+ /** `WHEN_UNLOCKED_THIS_DEVICE_ONLY` — read off the module when present. */
11
+ WHEN_UNLOCKED_THIS_DEVICE_ONLY?: unknown;
12
+ };
13
+ /** The slice of React Native's `AppState` this entry subscribes to. */
14
+ export type AppStateModule = {
15
+ currentState: string | null;
16
+ addEventListener(type: string, handler: (state: string) => void): {
17
+ remove(): void;
18
+ } | (() => void);
19
+ };
20
+ /** The slice of React Native's `Platform` this entry reads. */
21
+ export type PlatformModule = {
22
+ OS: string;
23
+ };
24
+ /**
25
+ * The slice of `expo-file-system` this entry calls: its `File` class.
26
+ *
27
+ * `new File(uri)` — the constructor takes the `file:///` URI a picker or a
28
+ * camera handed back (it accepts more segments, and a `Directory`/`File`
29
+ * first argument, which this entry never needs). The instance implements
30
+ * `Blob` and carries `uri`, `name`, `type` and `size`, which is exactly
31
+ * what `upload()` reads.
32
+ */
33
+ export type FileSystemModule = {
34
+ File: new (uri: string) => Blob & {
35
+ uri?: string;
36
+ name?: string;
37
+ type?: string | null;
38
+ size?: number | null;
39
+ };
40
+ };
41
+ /** Everything an Expo app can hand in instead of letting this file resolve
42
+ * it. Tests pass fakes here; an app may pass its own static imports, which
43
+ * is the fastest path (no dynamic resolution at all). */
44
+ export type ExpoModules = {
45
+ SecureStore?: SecureStoreModule;
46
+ AppState?: AppStateModule;
47
+ Platform?: PlatformModule;
48
+ /** `expo-file-system`, for `upload()`'s picker shape — see `expoUploadPart`. */
49
+ FileSystem?: FileSystemModule;
50
+ fetch?: typeof fetch;
51
+ };
52
+ /**
53
+ * iOS refuses a Keychain value much over 2 KB through expo-secure-store,
54
+ * and the failure is a native throw the app cannot read. A Gemmein session
55
+ * token is a short opaque string — two orders of magnitude under this — so
56
+ * anything at this size is a caller storing the wrong thing. It is refused
57
+ * HERE, with a sentence, rather than on a device the author cannot see.
58
+ */
59
+ export declare const SECURE_STORE_VALUE_LIMIT = 2048;
60
+ /**
61
+ * The session, kept in the iOS Keychain / Android Keystore through
62
+ * `expo-secure-store`. Keyed per app key exactly as `BrowserTokenStore`
63
+ * keys localStorage, so two Gemmein apps in one binary never share a
64
+ * token.
65
+ *
66
+ * `keychainAccessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY` — the token is
67
+ * readable only while the device is unlocked AND never travels in an
68
+ * iCloud/iTunes backup or to a restored device. A session that survives a
69
+ * device restore is a session the person cannot revoke by losing the
70
+ * phone.
71
+ *
72
+ * Reads and clears are guarded: a locked keychain, a wiped entry, or a
73
+ * runtime with no secure store at all degrades to "signed out", never to
74
+ * a crash. A WRITE the device refuses throws `secure_store_unavailable` —
75
+ * see the note on `set`.
76
+ */
77
+ export declare class SecureStoreTokenStore implements TokenStore {
78
+ private readonly key;
79
+ private readonly injected?;
80
+ constructor(appKey: string, secureStore?: SecureStoreModule);
81
+ private store;
82
+ /** The accessibility class, read off the module so a version that renames
83
+ * the constant does not silently fall back to the iCloud-backed default. */
84
+ private options;
85
+ get(): Promise<string | undefined>;
86
+ set(token: string): Promise<void>;
87
+ clear(): Promise<void>;
88
+ }
89
+ /**
90
+ * The `VisibilityHook` `watch()` sleeps on, answered by React Native's
91
+ * `AppState` instead of the browser's `document.visibilityState`.
92
+ *
93
+ * `AppState.currentState` is `"active" | "background" | "inactive"` (and
94
+ * `null` before the first read). Only `"active"` is in front of the
95
+ * person: `"inactive"` is the app-switcher card, a phone call, the
96
+ * Control Center pulled down — a screen the person is not reading, so
97
+ * polling there burns battery for nothing.
98
+ *
99
+ * Before the module has resolved the app is treated as VISIBLE, which is
100
+ * the browser default and the safe one: a watcher that wrongly believes
101
+ * it is hidden goes quiet, and quiet is the failure nobody notices.
102
+ */
103
+ export declare function appStateVisibility(appState?: AppStateModule): VisibilityHook;
104
+ /**
105
+ * `expo/fetch` — the WinterCG fetch Expo ships. React Native's own fetch
106
+ * is XHR-backed and cannot give a streaming body, which is what
107
+ * `g.ai.chat({ stream: true })` and `g.ai.run(..., { stream: true })`
108
+ * return. Resolved on the first request; `globalThis.fetch` if Expo's is
109
+ * not there, so a bare React Native app still works for everything that
110
+ * is not a stream.
111
+ */
112
+ export declare function expoFetch(injected?: typeof fetch): typeof fetch;
113
+ /**
114
+ * The `x-client-info` platform tag: `expo-ios`, `expo-android`, `expo-web`.
115
+ *
116
+ * It is baked into the header when the client is CONSTRUCTED, so it cannot
117
+ * wait on a dynamic import. Pass `Platform` from `react-native` (a static
118
+ * import that costs an Expo app nothing) and the tag names the OS; without
119
+ * it the tag is the honest `expo`. Either way it is a report, never a
120
+ * proof — the ledger stores it as one.
121
+ */
122
+ export declare function expoPlatformTag(platform?: PlatformModule): string;
123
+ /**
124
+ * W10 row 9, found by driving the Expo app: `upload()` takes the picker's
125
+ * `{ uri, name, type, size }`, but `expo/fetch` — the fetch this entry
126
+ * hands the client, and the only one that can stream an AI answer — refuses
127
+ * that part when its `FormData` serialises it:
128
+ * `Unsupported FormDataPart implementation`. React Native's own fetch reads
129
+ * the bytes at `uri`; Expo's will not.
130
+ *
131
+ * So the shape is converted here, before it ever reaches core `upload()`:
132
+ * `new File(uri)` from `expo-file-system` is a real `Blob` that carries
133
+ * `uri`, `name`, `type` and `size` — the three fields the presign reads,
134
+ * plus bytes any FormData can serialise. The picker shape therefore works
135
+ * through `@gemmein/sdk/expo` exactly as the docs say it does.
136
+ *
137
+ * Anything that already carries its own bytes — a `Blob`, a browser `File`,
138
+ * an `expo-file-system` `File` — is passed through untouched.
139
+ */
140
+ export declare function expoUploadPart(file: UploadInput, modules?: ExpoModules): Promise<UploadInput>;
141
+ /**
142
+ * The Expo client. Identical to `gemmein()` in every method, because it IS
143
+ * `Gemmein` — the only difference is where the session is kept, how "is
144
+ * the app in front?" is answered, which fetch carries the bytes, and what
145
+ * the client tag says.
146
+ *
147
+ * const g = createExpoGemmein({ appKey: "pk_live_..." }, { Platform });
148
+ *
149
+ * Every default is overridable: pass your own `tokenStore`, `visibility`,
150
+ * `fetch` or `platform` in `options` and it wins.
151
+ */
152
+ export declare function createExpoGemmein(options: GemmeinOptions, modules?: ExpoModules): Gemmein;
package/dist/expo.js ADDED
@@ -0,0 +1,387 @@
1
+ // W10 §1 A — `@gemmein/sdk/expo`: the SAME package, a second entry point.
2
+ //
3
+ // The core entry (`@gemmein/sdk`) stays platform-neutral and keeps ZERO
4
+ // dependency on Expo or React Native: every platform seam it needs is an
5
+ // option it already takes — `tokenStore`, `visibility`, `fetch`,
6
+ // `platform`. This file is nothing but those four options filled in with
7
+ // a phone's answers, plus the whole core surface re-exported so an Expo
8
+ // app never has to import from two places.
9
+ //
10
+ // Three rules this file keeps:
11
+ //
12
+ // 1. NOTHING is imported from Expo or React Native at module load. The
13
+ // modules are resolved lazily, inside a try, the first time a method
14
+ // that needs one runs — so importing this entry in Node (the tests
15
+ // below, an SSR pass, a script) resolves and runs without any of them
16
+ // installed. The specifiers are static strings so Metro can still see
17
+ // and bundle them in a real app.
18
+ // 2. Every module is INJECTABLE. Tests pass fakes; nothing here reaches
19
+ // for a global to decide what platform it is on.
20
+ // 3. A failure to READ the secure store degrades to "signed out" — the
21
+ // same law `BrowserTokenStore` keeps for a browser with storage
22
+ // blocked. A crash on a locked keychain would lock the app out. A
23
+ // failure to WRITE throws `secure_store_unavailable` (W10 row 9c): a
24
+ // store that cannot keep the session has broken the promise it exists
25
+ // for, and silence there signs the person out on the next launch with
26
+ // nothing anywhere saying why.
27
+ //
28
+ // One thing beyond those four seams: `upload()`'s picker shape. Expo's
29
+ // fetch refuses a bare `{ uri, name, type, size }` part, so the collections
30
+ // this entry hands out convert it to an `expo-file-system` `File` first —
31
+ // see `expoUploadPart`.
32
+ //
33
+ // Install in the app, not here (they are optional peer dependencies):
34
+ // npx expo install expo-secure-store expo-file-system
35
+ //
36
+ // import { createExpoGemmein } from "@gemmein/sdk/expo";
37
+ // import { Platform } from "react-native";
38
+ // export const g = createExpoGemmein({ appKey: "pk_live_..." }, { Platform });
39
+ import { Gemmein, GemmeinError } from "./index.js";
40
+ // The full core surface, re-exported. `export *` carries the types and the
41
+ // helpers; the explicit list below names every client class the SDK
42
+ // registry knows about (ops/docs/facts.ts SDK_CLASSES), which is what
43
+ // tests/security/sdkParity.test.ts reads to prove this entry is not a
44
+ // smaller SDK wearing the same name.
45
+ export * from "./index.js";
46
+ export { Gemmein, GemmeinError, AuthClient, PurchasesClient, FilesClient, SubscriptionsClient, PaymentsClient, AccountClient, CreditsClient, AiClient, StorageClient, CollectionClient, GemmeinServer, } from "./index.js";
47
+ /**
48
+ * The lazy resolver. Static specifiers so Metro bundles them; a `catch`
49
+ * so Node — where none of them exist — gets `undefined` rather than an
50
+ * unhandled rejection. Resolved once per specifier and remembered.
51
+ */
52
+ const resolved = new Map();
53
+ function loadModule(name) {
54
+ const cached = resolved.get(name);
55
+ if (cached)
56
+ return cached;
57
+ const attempt = (async () => {
58
+ try {
59
+ switch (name) {
60
+ // @ts-ignore optional peer dependency — present only in an Expo app
61
+ case "expo-secure-store": return (await import("expo-secure-store"));
62
+ // @ts-ignore optional peer dependency — present only in a React Native app
63
+ case "react-native": return (await import("react-native"));
64
+ // @ts-ignore optional peer dependency — present only in an Expo app
65
+ case "expo/fetch": return (await import("expo/fetch"));
66
+ // @ts-ignore optional peer dependency — present only in an Expo app
67
+ case "expo-file-system": return (await import("expo-file-system"));
68
+ default: return undefined;
69
+ }
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ })();
75
+ resolved.set(name, attempt);
76
+ return attempt;
77
+ }
78
+ // ═══════════════════════════════════════════════════════════════════════
79
+ // SecureStoreTokenStore — the session in the Keychain / Keystore
80
+ // ═══════════════════════════════════════════════════════════════════════
81
+ /**
82
+ * iOS refuses a Keychain value much over 2 KB through expo-secure-store,
83
+ * and the failure is a native throw the app cannot read. A Gemmein session
84
+ * token is a short opaque string — two orders of magnitude under this — so
85
+ * anything at this size is a caller storing the wrong thing. It is refused
86
+ * HERE, with a sentence, rather than on a device the author cannot see.
87
+ */
88
+ export const SECURE_STORE_VALUE_LIMIT = 2048;
89
+ /**
90
+ * The one sentence a refused WRITE reads as (W10 row 9c). The device's own
91
+ * throw travels on `cause`, so an app that logs it keeps the native message
92
+ * — `errSecMissingEntitlement`, a locked keychain, a Keystore that is not
93
+ * there — instead of a Gemmein sentence standing in for it.
94
+ */
95
+ function secureStoreUnavailable(cause) {
96
+ const detail = cause instanceof Error ? cause.message : String(cause);
97
+ return new GemmeinError({
98
+ status: 0,
99
+ code: "secure_store_unavailable",
100
+ message: `The session could not be stored: expo-secure-store is not available or refused the write (${detail}) ` +
101
+ "— install expo-secure-store, or pass a tokenStore",
102
+ cause,
103
+ });
104
+ }
105
+ /**
106
+ * The session, kept in the iOS Keychain / Android Keystore through
107
+ * `expo-secure-store`. Keyed per app key exactly as `BrowserTokenStore`
108
+ * keys localStorage, so two Gemmein apps in one binary never share a
109
+ * token.
110
+ *
111
+ * `keychainAccessible: WHEN_UNLOCKED_THIS_DEVICE_ONLY` — the token is
112
+ * readable only while the device is unlocked AND never travels in an
113
+ * iCloud/iTunes backup or to a restored device. A session that survives a
114
+ * device restore is a session the person cannot revoke by losing the
115
+ * phone.
116
+ *
117
+ * Reads and clears are guarded: a locked keychain, a wiped entry, or a
118
+ * runtime with no secure store at all degrades to "signed out", never to
119
+ * a crash. A WRITE the device refuses throws `secure_store_unavailable` —
120
+ * see the note on `set`.
121
+ */
122
+ export class SecureStoreTokenStore {
123
+ constructor(appKey, secureStore) {
124
+ // The same derivation as BrowserTokenStore (packages/sdk/src/index.ts):
125
+ // one law, two platforms. Every character a pk_ key can carry is legal
126
+ // in a SecureStore key.
127
+ this.key = `gemmein_session_${appKey.slice(0, 20)}`;
128
+ this.injected = secureStore;
129
+ }
130
+ async store() {
131
+ if (this.injected)
132
+ return this.injected;
133
+ const mod = await loadModule("expo-secure-store");
134
+ if (!mod)
135
+ return undefined;
136
+ const candidate = (mod.default ?? mod);
137
+ return typeof candidate?.getItemAsync === "function" ? candidate : undefined;
138
+ }
139
+ /** The accessibility class, read off the module so a version that renames
140
+ * the constant does not silently fall back to the iCloud-backed default. */
141
+ options(mod) {
142
+ const accessible = mod.WHEN_UNLOCKED_THIS_DEVICE_ONLY;
143
+ return accessible === undefined ? {} : { keychainAccessible: accessible };
144
+ }
145
+ async get() {
146
+ try {
147
+ const mod = await this.store();
148
+ if (!mod)
149
+ return undefined;
150
+ return (await mod.getItemAsync(this.key, this.options(mod))) ?? undefined;
151
+ }
152
+ catch {
153
+ return undefined; // locked, missing, or unavailable — signed out
154
+ }
155
+ }
156
+ async set(token) {
157
+ // Measured in BYTES: the limit is the platform's, not JavaScript's.
158
+ const bytes = new TextEncoder().encode(token).length;
159
+ if (bytes > SECURE_STORE_VALUE_LIMIT) {
160
+ throw new GemmeinError({
161
+ status: 0,
162
+ code: "token_too_large",
163
+ message: `This value is ${bytes} bytes; the device secure store holds ${SECURE_STORE_VALUE_LIMIT}. ` +
164
+ "A Gemmein session token is far smaller — store the token, not the session payload.",
165
+ });
166
+ }
167
+ // W10 row 9c. This used to return silently on both arms below, so an app
168
+ // signed in, stored nothing, and reported itself signed out on the next
169
+ // launch with nothing anywhere saying why — the same silence the Swift
170
+ // `KeychainTokenStore` kept, found by driving the reference app on an
171
+ // unsigned simulator build. A store that cannot PERSIST the session has
172
+ // broken the one promise it exists for, so it says so. Reads and clears
173
+ // stay lenient: unreadable means signed out, which every app handles.
174
+ //
175
+ // The session is real either way — it was minted server-side and
176
+ // `verifyEmailCode` never gets to return it — so an app that would rather
177
+ // run than stop can catch this one code and rebuild its client with a
178
+ // `MemoryTokenStore`: sign-in works, and ends with the process.
179
+ let mod;
180
+ try {
181
+ mod = await this.store();
182
+ }
183
+ catch (cause) {
184
+ throw secureStoreUnavailable(cause);
185
+ }
186
+ if (!mod)
187
+ throw secureStoreUnavailable(new Error("expo-secure-store did not resolve"));
188
+ try {
189
+ await mod.setItemAsync(this.key, token, this.options(mod));
190
+ }
191
+ catch (cause) {
192
+ throw secureStoreUnavailable(cause);
193
+ }
194
+ }
195
+ async clear() {
196
+ try {
197
+ const mod = await this.store();
198
+ if (!mod)
199
+ return;
200
+ await mod.deleteItemAsync(this.key, this.options(mod));
201
+ }
202
+ catch {
203
+ /* nothing to clear */
204
+ }
205
+ }
206
+ }
207
+ // ═══════════════════════════════════════════════════════════════════════
208
+ // appStateVisibility — "is the app in front of the person?"
209
+ // ═══════════════════════════════════════════════════════════════════════
210
+ /**
211
+ * The `VisibilityHook` `watch()` sleeps on, answered by React Native's
212
+ * `AppState` instead of the browser's `document.visibilityState`.
213
+ *
214
+ * `AppState.currentState` is `"active" | "background" | "inactive"` (and
215
+ * `null` before the first read). Only `"active"` is in front of the
216
+ * person: `"inactive"` is the app-switcher card, a phone call, the
217
+ * Control Center pulled down — a screen the person is not reading, so
218
+ * polling there burns battery for nothing.
219
+ *
220
+ * Before the module has resolved the app is treated as VISIBLE, which is
221
+ * the browser default and the safe one: a watcher that wrongly believes
222
+ * it is hidden goes quiet, and quiet is the failure nobody notices.
223
+ */
224
+ export function appStateVisibility(appState) {
225
+ let mod = appState;
226
+ const waiting = [];
227
+ if (!mod) {
228
+ void loadModule("react-native").then((rn) => {
229
+ const candidate = rn?.AppState;
230
+ if (!candidate || typeof candidate.addEventListener !== "function")
231
+ return;
232
+ mod = candidate;
233
+ for (const attach of waiting.splice(0))
234
+ attach(candidate);
235
+ });
236
+ }
237
+ return {
238
+ isHidden() {
239
+ return mod ? mod.currentState !== "active" : false;
240
+ },
241
+ onChange(cb) {
242
+ let remove;
243
+ let cancelled = false;
244
+ const attach = (m) => {
245
+ if (cancelled)
246
+ return;
247
+ // RN ≥ 0.65 returns a subscription with `.remove()`; older builds
248
+ // returned nothing and were removed by `removeEventListener`. Both
249
+ // shapes collapse to one remover here.
250
+ const subscription = m.addEventListener("change", () => cb());
251
+ remove = typeof subscription === "function"
252
+ ? subscription
253
+ : subscription && typeof subscription.remove === "function"
254
+ ? () => subscription.remove()
255
+ : undefined;
256
+ };
257
+ if (mod)
258
+ attach(mod);
259
+ else
260
+ waiting.push(attach);
261
+ return () => {
262
+ cancelled = true;
263
+ const off = remove;
264
+ remove = undefined;
265
+ off?.();
266
+ };
267
+ },
268
+ };
269
+ }
270
+ // ═══════════════════════════════════════════════════════════════════════
271
+ // The fetch and the platform tag
272
+ // ═══════════════════════════════════════════════════════════════════════
273
+ /**
274
+ * `expo/fetch` — the WinterCG fetch Expo ships. React Native's own fetch
275
+ * is XHR-backed and cannot give a streaming body, which is what
276
+ * `g.ai.chat({ stream: true })` and `g.ai.run(..., { stream: true })`
277
+ * return. Resolved on the first request; `globalThis.fetch` if Expo's is
278
+ * not there, so a bare React Native app still works for everything that
279
+ * is not a stream.
280
+ */
281
+ export function expoFetch(injected) {
282
+ let impl = injected;
283
+ return (async (input, init) => {
284
+ if (!impl) {
285
+ const mod = await loadModule("expo/fetch");
286
+ const candidate = mod?.fetch;
287
+ impl = typeof candidate === "function" ? candidate : (i, n) => globalThis.fetch(i, n);
288
+ }
289
+ return impl(input, init);
290
+ });
291
+ }
292
+ /**
293
+ * The `x-client-info` platform tag: `expo-ios`, `expo-android`, `expo-web`.
294
+ *
295
+ * It is baked into the header when the client is CONSTRUCTED, so it cannot
296
+ * wait on a dynamic import. Pass `Platform` from `react-native` (a static
297
+ * import that costs an Expo app nothing) and the tag names the OS; without
298
+ * it the tag is the honest `expo`. Either way it is a report, never a
299
+ * proof — the ledger stores it as one.
300
+ */
301
+ export function expoPlatformTag(platform) {
302
+ const os = typeof platform?.OS === "string" ? platform.OS : "";
303
+ return os ? `expo-${os}` : "expo";
304
+ }
305
+ // ═══════════════════════════════════════════════════════════════════════
306
+ // The upload part — the picker's `{ uri }`, turned into bytes
307
+ // ═══════════════════════════════════════════════════════════════════════
308
+ /**
309
+ * W10 row 9, found by driving the Expo app: `upload()` takes the picker's
310
+ * `{ uri, name, type, size }`, but `expo/fetch` — the fetch this entry
311
+ * hands the client, and the only one that can stream an AI answer — refuses
312
+ * that part when its `FormData` serialises it:
313
+ * `Unsupported FormDataPart implementation`. React Native's own fetch reads
314
+ * the bytes at `uri`; Expo's will not.
315
+ *
316
+ * So the shape is converted here, before it ever reaches core `upload()`:
317
+ * `new File(uri)` from `expo-file-system` is a real `Blob` that carries
318
+ * `uri`, `name`, `type` and `size` — the three fields the presign reads,
319
+ * plus bytes any FormData can serialise. The picker shape therefore works
320
+ * through `@gemmein/sdk/expo` exactly as the docs say it does.
321
+ *
322
+ * Anything that already carries its own bytes — a `Blob`, a browser `File`,
323
+ * an `expo-file-system` `File` — is passed through untouched.
324
+ */
325
+ export async function expoUploadPart(file, modules = {}) {
326
+ const part = file;
327
+ // Already byte-bearing: a Blob, a File, an expo-file-system File. Duck-typed
328
+ // rather than `instanceof Blob`, because the class a phone's runtime has is
329
+ // not always the one this module can see.
330
+ const carriesBytes = typeof part.arrayBuffer === "function" || typeof part.stream === "function" ||
331
+ typeof part.slice === "function" || typeof part.bytes === "function";
332
+ if (carriesBytes || typeof part.uri !== "string")
333
+ return file;
334
+ const injected = modules.FileSystem;
335
+ const mod = injected ?? (await loadModule("expo-file-system"));
336
+ const FileClass = mod?.File;
337
+ if (typeof FileClass !== "function") {
338
+ throw new GemmeinError({
339
+ status: 0,
340
+ code: "upload_input_unsupported",
341
+ message: "This upload input cannot be read on Expo — pass a Blob or an expo-file-system File; " +
342
+ "the picker's { uri } needs expo-file-system installed (npx expo install expo-file-system)",
343
+ });
344
+ }
345
+ return new FileClass(part.uri);
346
+ }
347
+ /**
348
+ * Every collection this client hands out gets the conversion above in front
349
+ * of its `upload`. It is wrapped on the ONE factory both spellings go
350
+ * through — `g.collection(name)` delegates to `g.storage.collection(name)`
351
+ * — so there is one seam, not two, and every other method is the core
352
+ * client's own, untouched.
353
+ */
354
+ function wrapUploads(client, modules) {
355
+ const storage = client.storage;
356
+ const core = storage.collection.bind(client.storage);
357
+ storage.collection = (name, options) => {
358
+ const collection = core(name, options);
359
+ const upload = collection.upload.bind(collection);
360
+ collection.upload = async (file, uploadOptions) => upload(await expoUploadPart(file, modules), uploadOptions);
361
+ return collection;
362
+ };
363
+ return client;
364
+ }
365
+ // ═══════════════════════════════════════════════════════════════════════
366
+ // createExpoGemmein — the four options, filled in
367
+ // ═══════════════════════════════════════════════════════════════════════
368
+ /**
369
+ * The Expo client. Identical to `gemmein()` in every method, because it IS
370
+ * `Gemmein` — the only difference is where the session is kept, how "is
371
+ * the app in front?" is answered, which fetch carries the bytes, and what
372
+ * the client tag says.
373
+ *
374
+ * const g = createExpoGemmein({ appKey: "pk_live_..." }, { Platform });
375
+ *
376
+ * Every default is overridable: pass your own `tokenStore`, `visibility`,
377
+ * `fetch` or `platform` in `options` and it wins.
378
+ */
379
+ export function createExpoGemmein(options, modules = {}) {
380
+ return wrapUploads(new Gemmein({
381
+ tokenStore: new SecureStoreTokenStore(options.appKey, modules.SecureStore),
382
+ visibility: appStateVisibility(modules.AppState),
383
+ fetch: expoFetch(modules.fetch),
384
+ platform: expoPlatformTag(modules.Platform),
385
+ ...options,
386
+ }), modules);
387
+ }