@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/index.js CHANGED
@@ -4,13 +4,83 @@
4
4
  * second module). `scripts/sync-version.mjs` rewrites the literal from
5
5
  * package.json before every build (`prebuild`), and a test pins the two
6
6
  * equal, so a bump can never ship with a stale header. */
7
- export const SDK_VERSION = "0.9.0"; // synced from package.json — do not edit by hand
7
+ export const SDK_VERSION = "0.10.0"; // synced from package.json — do not edit by hand
8
8
  /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
9
9
  * `x-client-info: gemmein-sdk/<version>`. The server records it on the
10
10
  * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
11
11
  * misbehaving integration can be attributed to an SDK version from day
12
12
  * one. It is a report, not a proof — any caller can set it. */
13
13
  export const CLIENT_INFO = `gemmein-sdk/${SDK_VERSION}`;
14
+ /** W10 §1 A: a mobile entry adds its platform — `gemmein-sdk/<version>
15
+ * expo-ios`. The ledger that records this header caps it at 64 characters
16
+ * and strips control characters (`MAX_CLIENT_LENGTH` / `capClient`,
17
+ * packages/db/src/keyUsageStore.ts), so the value is cleaned and capped
18
+ * HERE: a tag that arrives truncated attributes nothing. Anything outside
19
+ * `[A-Za-z0-9._/-]` collapses to a hyphen, so the label can never carry a
20
+ * newline — or a second space — into the ledger. */
21
+ export function clientInfoFor(platform) {
22
+ if (typeof platform !== "string")
23
+ return CLIENT_INFO;
24
+ const clean = platform.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "");
25
+ if (clean.length === 0)
26
+ return CLIENT_INFO;
27
+ return `${CLIENT_INFO} ${clean}`.slice(0, 64);
28
+ }
29
+ /** W10 §1 A: every request goes through ONE function, so a platform that
30
+ * needs its own fetch injects it once. The default reads `globalThis.fetch`
31
+ * at call time (a polyfill installed later still counts), and both forms
32
+ * are invoked as plain calls — which is what keeps a browser's
33
+ * `window.fetch` legal when it is handed over as a bare reference.
34
+ *
35
+ * W10 row 9, found by driving the Expo app with the engine stopped: a
36
+ * transport failure — connection refused, no DNS, no Wi-Fi, an `apiUrl`
37
+ * pointing at nothing — never reaches `handleResponse`, so whatever the
38
+ * fetch implementation threw came out unchanged (a `TypeError` in a
39
+ * browser and on Expo's fetch). An app branching on `err.code`, which is
40
+ * what every teaching tells it to do, got `undefined` there and nowhere
41
+ * else. EVERY request the client makes — the runtime clients, a
42
+ * collection, the S3 upload POST, both AI streams — goes through this one
43
+ * wrapper, so every one of them now answers a `GemmeinError`
44
+ * `network_unreachable` (status 0, the SDK's convention for "no HTTP
45
+ * status applies"), with the fetch's own error kept on `err.cause`.
46
+ *
47
+ * Two throws pass through untouched: an `AbortError` — the caller
48
+ * cancelled on purpose, and `{ signal }` is a documented option on the AI
49
+ * calls — and a `GemmeinError` an injected fetch raised itself. */
50
+ function resolveFetch(injected, apiUrl) {
51
+ const call = injected
52
+ ? (input, init) => injected(input, init)
53
+ : (input, init) => globalThis.fetch(input, init);
54
+ // The host is read ONCE, here, and only for the sentence: a client whose
55
+ // `apiUrl` is unparseable still gets a readable error, never a second throw.
56
+ const host = (() => {
57
+ try {
58
+ return new URL(apiUrl).host;
59
+ }
60
+ catch {
61
+ return apiUrl;
62
+ }
63
+ })();
64
+ return (async (input, init) => {
65
+ try {
66
+ return await call(input, init);
67
+ }
68
+ catch (cause) {
69
+ // The caller's own cancel, and an SDK refusal an injected fetch chose
70
+ // to raise, are both already typed — re-typing them would lie.
71
+ if (cause instanceof GemmeinError)
72
+ throw cause;
73
+ if (typeof cause === "object" && cause !== null && cause.name === "AbortError")
74
+ throw cause;
75
+ throw new GemmeinError({
76
+ status: 0,
77
+ code: "network_unreachable",
78
+ message: `Gemmein could not be reached — check the connection and the apiUrl (${host})`,
79
+ cause,
80
+ });
81
+ }
82
+ });
83
+ }
14
84
  export class GemmeinError extends Error {
15
85
  constructor(input) {
16
86
  super(input.message);
@@ -19,6 +89,7 @@ export class GemmeinError extends Error {
19
89
  this.code = input.code;
20
90
  this.resetAt = input.resetAt;
21
91
  this.requires = input.requires;
92
+ this.cause = input.cause;
22
93
  }
23
94
  }
24
95
  export class MemoryTokenStore {
@@ -35,8 +106,15 @@ export class MemoryTokenStore {
35
106
  // Persists the session across page reloads — the default in browsers.
36
107
  // Sessions are long-lived server-side; a memory-only default would log
37
108
  // users out on every refresh. Keyed per app key so two Gemmein apps on
38
- // one origin never share a token. All storage access is guarded: private
39
- // modes and blocked storage degrade to "signed out", never to a crash.
109
+ // one origin never share a token.
110
+ //
111
+ // READS AND CLEARS ARE LENIENT, WRITES ARE NOT — the same law the two
112
+ // mobile stores keep (`SecureStoreTokenStore` in `@gemmein/sdk/expo`,
113
+ // `KeychainTokenStore` in GemmeinSwift). A store that cannot be READ means
114
+ // signed out, which every app already handles; a store that cannot KEEP
115
+ // the session has broken the promise it exists for, and silence there
116
+ // signs the person out on the next reload with nothing anywhere saying
117
+ // why.
40
118
  export class BrowserTokenStore {
41
119
  constructor(appKey) {
42
120
  this.key = `gemmein_session_${appKey.slice(0, 20)}`;
@@ -49,11 +127,30 @@ export class BrowserTokenStore {
49
127
  return undefined;
50
128
  }
51
129
  }
130
+ /**
131
+ * A blocked `localStorage` — Safari's private mode past its quota, a
132
+ * browser set to block site data, a sandboxed iframe whose access throws
133
+ * — throws `secure_store_unavailable` (status 0), the same code the
134
+ * mobile stores answer, with the browser's own error on `cause`.
135
+ * `auth.verifyEmailCode()` carries it to the caller: the session is real
136
+ * (the server minted it), so an app that would rather run than stop
137
+ * catches this one code and rebuilds its client with a
138
+ * `MemoryTokenStore`, which never throws.
139
+ */
52
140
  set(token) {
53
141
  try {
54
142
  window.localStorage.setItem(this.key, token);
55
143
  }
56
- catch { /* storage blocked — session lives for this page only */ }
144
+ catch (cause) {
145
+ const detail = cause instanceof Error ? cause.message : String(cause);
146
+ throw new GemmeinError({
147
+ status: 0,
148
+ code: "secure_store_unavailable",
149
+ message: `The session could not be stored: this browser refused the localStorage write (${detail}) ` +
150
+ "— pass a tokenStore, e.g. new MemoryTokenStore()",
151
+ cause,
152
+ });
153
+ }
57
154
  }
58
155
  clear() {
59
156
  try {
@@ -104,10 +201,14 @@ export class Gemmein {
104
201
  'copy the key from the Setup page. Then: gemmein("pk_...")'
105
202
  });
106
203
  }
204
+ const apiUrl = options.apiUrl ?? "https://api.gemmein.com";
107
205
  const config = {
108
- apiUrl: options.apiUrl ?? "https://api.gemmein.com",
206
+ apiUrl,
109
207
  appKey: options.appKey,
110
- tokenStore: options.tokenStore ?? defaultTokenStore(options.appKey)
208
+ tokenStore: options.tokenStore ?? defaultTokenStore(options.appKey),
209
+ fetch: resolveFetch(options.fetch, apiUrl),
210
+ visibility: options.visibility,
211
+ clientInfo: clientInfoFor(options.platform)
111
212
  };
112
213
  this.auth = new AuthClient(config);
113
214
  this.storage = new StorageClient(config);
@@ -129,6 +230,7 @@ export class Gemmein {
129
230
  * suggestion attached — always pass it when you aren't certain the
130
231
  * collection exists yet. The cloud ignores it.
131
232
  */
233
+ // route: none
132
234
  collection(name, options = {}) {
133
235
  return this.storage.collection(name, options);
134
236
  }
@@ -153,6 +255,7 @@ export class AuthClient {
153
255
  constructor(config) {
154
256
  this.config = config;
155
257
  }
258
+ // route: POST /auth/email/start
156
259
  async sendEmailCode(email) {
157
260
  await this.request("/auth/email/start", {
158
261
  method: "POST",
@@ -160,6 +263,7 @@ export class AuthClient {
160
263
  headers: { "content-type": "application/json" }
161
264
  });
162
265
  }
266
+ // route: POST /auth/email/verify
163
267
  async verifyEmailCode(input) {
164
268
  const result = await this.request("/auth/email/verify", {
165
269
  method: "POST",
@@ -180,15 +284,33 @@ export class AuthClient {
180
284
  message: "Gemmein auth response did not include a session token"
181
285
  });
182
286
  }
287
+ /**
288
+ * End this device's session. IDEMPOTENT: signing out of a session that is
289
+ * already gone is the outcome asked for, not a failure.
290
+ *
291
+ * W10 row 9, found by driving the Expo app: the commonest way this
292
+ * round-trip fails is `401 auth_expired` — the owner already signed this
293
+ * person out everywhere from the console, which is the remedy for a
294
+ * stolen phone. The session IS ended; throwing there made an app that did
295
+ * everything right show an error for the thing it asked for. Every other
296
+ * failure still throws, and the token store is cleared either way.
297
+ */
298
+ // route: POST /auth/logout
183
299
  async logout() {
184
300
  try {
185
301
  await this.request("/auth/logout", { method: "POST" });
186
302
  }
303
+ catch (err) {
304
+ const alreadyEnded = err instanceof GemmeinError && err.status === 401 && err.code === "auth_expired";
305
+ if (!alreadyEnded)
306
+ throw err;
307
+ }
187
308
  finally {
188
309
  // Even if the network call fails, the user asked to be signed out.
189
310
  await this.config.tokenStore.clear();
190
311
  }
191
312
  }
313
+ // route: GET /auth/current-user
192
314
  async currentUser() {
193
315
  // Contract: "who am I?" never throws for session state — the server
194
316
  // answers 200 {authenticated:false} for a token it won't honour right
@@ -245,6 +367,7 @@ export class PurchasesClient {
245
367
  * or `{ type: "external_url", url }` (a plain handover). A refunded
246
368
  * purchase never carries delivery.
247
369
  */
370
+ // route: GET /auth/purchases
248
371
  async mine() {
249
372
  const result = (await runtimeRequest(this.config, "/auth/purchases"));
250
373
  return result.purchases;
@@ -276,6 +399,7 @@ export class FilesClient {
276
399
  constructor(config) {
277
400
  this.config = config;
278
401
  }
402
+ // route: GET /files/{ref}/link
279
403
  async link(ref, options = {}) {
280
404
  const query = options.intent === "download" ? "?intent=download" : "";
281
405
  return (await runtimeRequest(this.config, `/files/${encodeURIComponent(String(ref))}/link${query}`));
@@ -292,6 +416,7 @@ export class SubscriptionsClient {
292
416
  * nobody is signed in — a data route, not the never-throw current-user
293
417
  * contract.
294
418
  */
419
+ // route: GET /auth/subscription
295
420
  async mine() {
296
421
  const result = (await runtimeRequest(this.config, "/auth/subscription"));
297
422
  return result.subscription;
@@ -305,6 +430,7 @@ export class SubscriptionsClient {
305
430
  * and a plan whose Payment Link the app owner has pasted in their
306
431
  * dashboard; omit `plan` to buy the app's paid plan.
307
432
  */
433
+ // route: GET /auth/checkout
308
434
  async checkout(plan) {
309
435
  const query = plan ? `?plan=${encodeURIComponent(plan)}` : "";
310
436
  const result = (await runtimeRequest(this.config, `/auth/checkout${query}`));
@@ -325,10 +451,16 @@ export class PaymentsClient {
325
451
  * WHAT is being bought when one product covers many things (e.g. a
326
452
  * license tier across a catalog):
327
453
  * `g.payments.buy("premium license", { item: "beat_37" })`.
328
- * A completed payment writes a receipt record addressed to the buyer in
329
- * the owner's receipts collection; gate downloads/fulfilment on that
330
- * receipt, never on the redirect coming back.
454
+ * Gemmein records every completed payment itself: `g.purchases.mine()` is
455
+ * the buyer's proof, and a product that delivers a file carries
456
+ * `delivery` on it. Gate fulfilment on the purchase (or the entitlement it
457
+ * granted), never on the redirect coming back. A receipts collection is
458
+ * optional, for proof records only.
459
+ *
460
+ * A product sold via a RELAY, or not sold yet, has no Payment Link to
461
+ * open: this answers 409 `product_not_sellable`.
331
462
  */
463
+ // route: GET /auth/pay
332
464
  async buy(product, options) {
333
465
  const params = new URLSearchParams({ product });
334
466
  if (options?.item)
@@ -353,6 +485,7 @@ export class AccountClient {
353
485
  * subscription row removed. Irreversible — put a real confirm in front
354
486
  * of it.
355
487
  */
488
+ // route: POST /auth/delete-account
356
489
  async delete() {
357
490
  const result = await runtimeRequest(this.config, "/auth/delete-account", { method: "POST" });
358
491
  await this.config.tokenStore.clear();
@@ -370,6 +503,7 @@ export class CreditsClient {
370
503
  *
371
504
  * const { balance } = await g.credits.balance();
372
505
  */
506
+ // route: GET /auth/credits
373
507
  async balance() {
374
508
  return runtimeRequest(this.config, "/auth/credits");
375
509
  }
@@ -425,12 +559,13 @@ export class AiClient {
425
559
  * `x-gemmein-credit: refunded`). `x-gemmein-tool` names the tool; absent
426
560
  * on the implicit default.
427
561
  */
562
+ // route: POST /ai/chat
428
563
  async chat(body, options = {}) {
429
564
  const payload = options.provider ? { provider: options.provider, ...body } : body;
430
565
  const url = new URL("/ai/chat", this.config.apiUrl);
431
566
  if (options.tool)
432
567
  url.searchParams.set("tool", options.tool);
433
- const response = await fetch(url, {
568
+ const response = await this.config.fetch(url, {
434
569
  method: "POST",
435
570
  body: JSON.stringify(payload),
436
571
  headers: await runtimeHeaders(this.config, { "content-type": "application/json" }),
@@ -479,9 +614,10 @@ export class AiClient {
479
614
  * The provider's own answer — 2xx or not — is returned as it came; read
480
615
  * `res.ok` yourself. `x-gemmein-tool` names the tool.
481
616
  */
617
+ // route: POST /ai/run/{tool}
482
618
  async run(tool, inputs = {}, options = {}) {
483
619
  const url = new URL(`/ai/run/${encodeURIComponent(tool)}`, this.config.apiUrl);
484
- const response = await fetch(url, {
620
+ const response = await this.config.fetch(url, {
485
621
  method: "POST",
486
622
  body: JSON.stringify({ inputs, ...(options.stream ? { stream: true } : {}) }),
487
623
  headers: await runtimeHeaders(this.config, { "content-type": "application/json" }),
@@ -509,6 +645,7 @@ export class AiClient {
509
645
  *
510
646
  * const summary = await g.ai.runText("summarise", { text });
511
647
  */
648
+ // route: POST /ai/run/{tool}
512
649
  async runText(tool, inputs = {}, options = {}) {
513
650
  const response = await this.run(tool, inputs, options);
514
651
  if (!response.ok) {
@@ -528,6 +665,7 @@ export class AiClient {
528
665
  *
529
666
  * const { calls, nextCursor } = await g.ai.calls();
530
667
  */
668
+ // route: GET /auth/ai-calls
531
669
  async calls(options = {}) {
532
670
  const params = new URLSearchParams();
533
671
  if (options.limit)
@@ -550,6 +688,7 @@ export class AiClient {
550
688
  *
551
689
  * const answer = await g.ai.text({ model: "claude-sonnet-4-5", max_tokens: 400, messages });
552
690
  */
691
+ // route: POST /ai/chat
553
692
  async text(body, options = {}) {
554
693
  const response = await this.chat(body, options);
555
694
  if (!response.ok) {
@@ -637,6 +776,7 @@ export class StorageClient {
637
776
  this.config = config;
638
777
  }
639
778
  /** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
779
+ // route: none
640
780
  collection(name, options = {}) {
641
781
  assertCollectionName(name);
642
782
  return new CollectionClient(this.config, name, options);
@@ -680,6 +820,7 @@ export class CollectionClient {
680
820
  * fake drafts with a data field + client-side filtering: on a public
681
821
  * collection the data still reaches everyone.
682
822
  */
823
+ // route: POST /storage/{collection}
683
824
  async create(data, options = {}) {
684
825
  const query = new URLSearchParams();
685
826
  if (options.key !== undefined)
@@ -699,6 +840,7 @@ export class CollectionClient {
699
840
  * rule (the app owner sees everyone's). Returns `{ records, hasMore }` —
700
841
  * an object, not a bare array.
701
842
  */
843
+ // route: GET /storage/{collection}
702
844
  async list(options = {}) {
703
845
  const query = new URLSearchParams();
704
846
  if (options.limit !== undefined)
@@ -739,6 +881,7 @@ export class CollectionClient {
739
881
  * doubling up to 60s, and honours a rate limit's reset time. One watch per
740
882
  * page is the intended shape — share its result, don't stack watchers.
741
883
  */
884
+ // route: GET /storage/{collection}
742
885
  watch(onChange, options = {}) {
743
886
  const asked = options.every;
744
887
  const every = Math.max(5000, Math.min(300000, Number.isFinite(asked) ? asked : 10000));
@@ -746,6 +889,11 @@ export class CollectionClient {
746
889
  let stopped = false;
747
890
  let timer;
748
891
  let delayMs = every;
892
+ // W10 §1 A: the platform's own answer to "is the app in front of the
893
+ // user?" when one was injected — `document` when it wasn't. One
894
+ // `unsubscribe` either way, so every teardown path is the same line.
895
+ const vis = this.config.visibility;
896
+ let unsubscribe;
749
897
  let watermark;
750
898
  // One tick at a time. A visibility flip mid-tick sets resyncPending
751
899
  // instead of racing a second loop into existence (each raced loop would
@@ -811,9 +959,8 @@ export class CollectionClient {
811
959
  clearTimeout(timer);
812
960
  timer = undefined;
813
961
  }
814
- if (typeof document !== "undefined") {
815
- document.removeEventListener("visibilitychange", onVisibility);
816
- }
962
+ unsubscribe?.();
963
+ unsubscribe = undefined;
817
964
  if (typeof console !== "undefined")
818
965
  console.warn(`gemmein watch stopped: ${err.code} — start a new watch after signing in`);
819
966
  return;
@@ -837,7 +984,9 @@ export class CollectionClient {
837
984
  }
838
985
  schedule();
839
986
  };
840
- const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
987
+ const hidden = () => (vis
988
+ ? vis.isHidden()
989
+ : typeof document !== "undefined" && document.visibilityState === "hidden");
841
990
  const schedule = () => {
842
991
  if (stopped || hidden() || timer !== undefined)
843
992
  return;
@@ -871,8 +1020,12 @@ export class CollectionClient {
871
1020
  void tick(true);
872
1021
  }
873
1022
  };
874
- if (typeof document !== "undefined") {
1023
+ if (vis) {
1024
+ unsubscribe = vis.onChange(onVisibility);
1025
+ }
1026
+ else if (typeof document !== "undefined") {
875
1027
  document.addEventListener("visibilitychange", onVisibility);
1028
+ unsubscribe = () => document.removeEventListener("visibilitychange", onVisibility);
876
1029
  }
877
1030
  // Born hidden: wait for the tab — the visibility handler runs the first
878
1031
  // sync when the user actually looks.
@@ -886,12 +1039,12 @@ export class CollectionClient {
886
1039
  if (timer !== undefined)
887
1040
  clearTimeout(timer);
888
1041
  timer = undefined;
889
- if (typeof document !== "undefined") {
890
- document.removeEventListener("visibilitychange", onVisibility);
891
- }
1042
+ unsubscribe?.();
1043
+ unsubscribe = undefined;
892
1044
  },
893
1045
  };
894
1046
  }
1047
+ // route: GET /storage/{collection}/{id}
895
1048
  async get(id, options = {}) {
896
1049
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
897
1050
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -911,6 +1064,7 @@ export class CollectionClient {
911
1064
  * docs), pass `{ ifVersion: record.version }` — a stale save gets a 409
912
1065
  * `conflict` instead of clobbering; re-read, reapply, retry.
913
1066
  */
1067
+ // route: PATCH /storage/{collection}/{id}
914
1068
  async update(id, data, options = {}) {
915
1069
  const query = new URLSearchParams();
916
1070
  if (options.ifVersion !== undefined)
@@ -923,11 +1077,12 @@ export class CollectionClient {
923
1077
  body: JSON.stringify(data)
924
1078
  });
925
1079
  }
1080
+ // route: DELETE /storage/{collection}/{id}
926
1081
  async delete(id) {
927
1082
  await this.request(`/${encodeURIComponent(id)}`, { method: "DELETE" });
928
1083
  }
929
1084
  /**
930
- * Upload a file and get back a REFERENCE — `file:01K…` — not a URL.
1085
+ * Upload a file and get back a REFERENCE — `file:<uuid>` — not a URL.
931
1086
  *
932
1087
  * Store the reference. It never expires, it is safe to log and export, and
933
1088
  * it grants nothing on its own. To show or download the file, call
@@ -954,23 +1109,43 @@ export class CollectionClient {
954
1109
  * There is deliberately no `url` here. A URL that outlives a refund is the
955
1110
  * bug this replaced.
956
1111
  */
1112
+ // route: POST /storage/{collection}/upload
1113
+ // route: POST /storage/{collection}/upload/{fileId}/confirm
957
1114
  async upload(file, options) {
958
- const name = options?.name ?? (file instanceof File ? file.name : "upload");
1115
+ // W10 §1 A: `File` is a browser class. Where it does not exist — React
1116
+ // Native — a bare `instanceof File` is a ReferenceError, not a false,
1117
+ // so the guard comes first. The three fields the presign needs are then
1118
+ // read the same way off every shape: a File, a Blob, an
1119
+ // `expo-file-system` File, or the picker's `{ uri, name, type, size }`.
1120
+ const part = file;
1121
+ const name = options?.name
1122
+ ?? (typeof File !== "undefined" && file instanceof File
1123
+ ? file.name
1124
+ : typeof part.name === "string" ? part.name : "upload");
959
1125
  // A Blob built from bytes has type "" — `contentType` names it. The
960
1126
  // server proves the bytes either way.
961
- const contentType = options?.contentType ?? file.type;
1127
+ const contentType = options?.contentType ?? part.type ?? "";
1128
+ // A presign under one byte is refused ("file must not be empty"): a
1129
+ // Blob knows its own size, a React Native part carries the picker's.
1130
+ const size = typeof part.size === "number" ? part.size : 0;
962
1131
  // Step 1: Get presigned upload URL
963
1132
  const presign = await this.request("/upload", {
964
1133
  method: "POST",
965
- body: JSON.stringify({ name, size: file.size, contentType, ...(options?.for ? { for: options.for } : {}) }),
1134
+ body: JSON.stringify({ name, size, contentType, ...(options?.for ? { for: options.for } : {}) }),
966
1135
  });
967
1136
  // Step 2: Upload directly to S3 via presigned POST
968
1137
  const form = new FormData();
969
1138
  for (const [key, value] of Object.entries(presign.fields)) {
970
1139
  form.append(key, value);
971
1140
  }
972
- form.append("file", file); // Must be last — S3 presigned POST requirement
973
- const s3Response = await fetch(presign.uploadUrl, { method: "POST", body: form });
1141
+ // Must be last — S3 presigned POST requirement. Whatever arrived is
1142
+ // appended AS IT IS: a Blob, a File, or on a client given React
1143
+ // Native's own fetch — the picker's `{ uri, ... }` part, whose bytes
1144
+ // RN's FormData reads off the device when it serialises. Expo's fetch
1145
+ // refuses that part, so `@gemmein/sdk/expo` has already turned it into
1146
+ // an `expo-file-system` `File` by the time it gets here.
1147
+ form.append("file", file);
1148
+ const s3Response = await this.config.fetch(presign.uploadUrl, { method: "POST", body: form });
974
1149
  if (!s3Response.ok) {
975
1150
  throw new GemmeinError({
976
1151
  status: s3Response.status,
@@ -985,7 +1160,7 @@ export class CollectionClient {
985
1160
  return confirmed;
986
1161
  }
987
1162
  async request(suffix, init = {}) {
988
- const response = await fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.config.apiUrl), {
1163
+ const response = await this.config.fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.config.apiUrl), {
989
1164
  ...init,
990
1165
  headers: await runtimeHeaders(this.config, {
991
1166
  "content-type": "application/json",
@@ -1014,10 +1189,12 @@ export class GemmeinServer {
1014
1189
  }
1015
1190
  this.apiUrl = options.apiUrl ?? "https://api.gemmein.com";
1016
1191
  this.secretKey = options.secretKey;
1192
+ this.fetch = resolveFetch(options.fetch, this.apiUrl);
1017
1193
  }
1194
+ // route: none
1018
1195
  collection(name) {
1019
1196
  assertCollectionName(name);
1020
- return new ServerCollectionClient(this.apiUrl, this.secretKey, name);
1197
+ return new ServerCollectionClient(this.apiUrl, this.secretKey, name, this.fetch);
1021
1198
  }
1022
1199
  /**
1023
1200
  * Mint a member session for a test email WITHOUT an OTP round-trip — so a CI
@@ -1027,6 +1204,7 @@ export class GemmeinServer {
1027
1204
  * `sk_live` key, and the server refuses it too. Never ship this in app code.
1028
1205
  * Pass the returned `token` to `gemmein(pk, { tokenStore })` to act as that user.
1029
1206
  */
1207
+ // route: POST /server/test-session
1030
1208
  async testSession(email) {
1031
1209
  if (this.secretKey.startsWith("sk_live")) {
1032
1210
  throw new GemmeinError({
@@ -1035,7 +1213,7 @@ export class GemmeinServer {
1035
1213
  message: "test sessions are only available in a development environment — never with a live (sk_live) key",
1036
1214
  });
1037
1215
  }
1038
- const response = await fetch(new URL("/server/test-session", this.apiUrl), {
1216
+ const response = await this.fetch(new URL("/server/test-session", this.apiUrl), {
1039
1217
  method: "POST",
1040
1218
  headers: { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO, "content-type": "application/json" },
1041
1219
  body: JSON.stringify({ email }),
@@ -1072,8 +1250,9 @@ export class GemmeinServer {
1072
1250
  * (a security notice must never lose to five order emails); misusing it
1073
1251
  * for campaigns is visible in your own audit trail.
1074
1252
  */
1253
+ // route: POST /server/notify
1075
1254
  async notify(personId, input) {
1076
- const response = await fetch(new URL("/server/notify", this.apiUrl), {
1255
+ const response = await this.fetch(new URL("/server/notify", this.apiUrl), {
1077
1256
  method: "POST",
1078
1257
  headers: { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO, "content-type": "application/json" },
1079
1258
  body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
@@ -1110,6 +1289,7 @@ export class GemmeinServer {
1110
1289
  * off until the owner reactivates them in the dashboard) ·
1111
1290
  * `invalid_body` (token missing, not a string, or over 512 chars).
1112
1291
  */
1292
+ // route: POST /server/verify-session
1113
1293
  async verifySession(token) {
1114
1294
  return this.gate("/server/verify-session", {
1115
1295
  method: "POST",
@@ -1139,6 +1319,7 @@ export class GemmeinServer {
1139
1319
  * `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
1140
1320
  * where to write to raise it; `err.resetAt` says when the window ends).
1141
1321
  */
1322
+ // route: POST /server/people
1142
1323
  async invitePerson(email) {
1143
1324
  return this.gate("/server/people", {
1144
1325
  method: "POST",
@@ -1161,6 +1342,7 @@ export class GemmeinServer {
1161
1342
  * `person_not_found` (404 — no person with this id in this app and
1162
1343
  * environment; existence is never leaked).
1163
1344
  */
1345
+ // route: GET /server/people/{personId}/holdings
1164
1346
  async holdings(personId) {
1165
1347
  return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
1166
1348
  }
@@ -1193,6 +1375,7 @@ export class GemmeinServer {
1193
1375
  * `invalid_entitlement` / `unknown_plan` (no plan or product by that
1194
1376
  * name — the owner adds it on the Payments page) · `person_not_found`.
1195
1377
  */
1378
+ // route: POST /server/people/{personId}/grants
1196
1379
  async grantAccess(personId, input) {
1197
1380
  return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
1198
1381
  method: "POST",
@@ -1231,6 +1414,7 @@ export class GemmeinServer {
1231
1414
  * `invalid_reason` / `invalid_key` (400) · `dedupe_conflict` (409 — the
1232
1415
  * key already names a different movement).
1233
1416
  */
1417
+ // route: POST /server/people/{personId}/credits/spend
1234
1418
  async spendCredits(personId, input) {
1235
1419
  return this.gate(`/server/people/${encodeURIComponent(personId)}/credits/spend`, {
1236
1420
  method: "POST",
@@ -1258,6 +1442,7 @@ export class GemmeinServer {
1258
1442
  * `grant_not_found` (404 — not this person's grant, in this app and
1259
1443
  * environment; existence is never leaked) · `already_revoked` (409).
1260
1444
  */
1445
+ // route: POST /server/people/{personId}/grants/{grantId}/revoke
1261
1446
  async revokeAccess(personId, grantId, input = {}) {
1262
1447
  return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
1263
1448
  }
@@ -1269,7 +1454,7 @@ export class GemmeinServer {
1269
1454
  const headers = { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO };
1270
1455
  if (init.body)
1271
1456
  headers["content-type"] = "application/json";
1272
- const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
1457
+ const response = await this.fetch(new URL(path, this.apiUrl), { ...init, headers });
1273
1458
  if (!response.ok) {
1274
1459
  throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
1275
1460
  }
@@ -1277,14 +1462,17 @@ export class GemmeinServer {
1277
1462
  }
1278
1463
  }
1279
1464
  class ServerCollectionClient {
1280
- constructor(apiUrl, secretKey, name) {
1465
+ constructor(apiUrl, secretKey, name, fetchImpl) {
1281
1466
  this.apiUrl = apiUrl;
1282
1467
  this.secretKey = secretKey;
1283
1468
  this.name = name;
1469
+ this.fetch = fetchImpl;
1284
1470
  }
1471
+ // route: GET /storage/{collection}/{id}
1285
1472
  async get(id) {
1286
1473
  return this.request(`/${encodeURIComponent(id)}`);
1287
1474
  }
1475
+ // route: GET /storage/{collection}
1288
1476
  async list(options = {}) {
1289
1477
  const query = new URLSearchParams();
1290
1478
  if (options.limit !== undefined)
@@ -1306,6 +1494,7 @@ class ServerCollectionClient {
1306
1494
  const qs = query.toString();
1307
1495
  return this.request(qs ? `?${qs}` : "");
1308
1496
  }
1497
+ // route: PATCH /storage/{collection}/{id}
1309
1498
  async update(id, data) {
1310
1499
  return this.request(`/${encodeURIComponent(id)}`, {
1311
1500
  method: "PATCH",
@@ -1320,7 +1509,7 @@ class ServerCollectionClient {
1320
1509
  if (init.body) {
1321
1510
  headers["content-type"] = "application/json";
1322
1511
  }
1323
- const response = await fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.apiUrl), { ...init, headers });
1512
+ const response = await this.fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.apiUrl), { ...init, headers });
1324
1513
  if (response.status === 204)
1325
1514
  return undefined;
1326
1515
  if (!response.ok) {
@@ -1380,7 +1569,7 @@ async function readErrorBody(response) {
1380
1569
  // One request path for every runtime client (auth, subscriptions,
1381
1570
  // payments, account) — same headers, same error handling, same signposts.
1382
1571
  async function runtimeRequest(config, path, init = {}) {
1383
- const response = await fetch(new URL(path, config.apiUrl), {
1572
+ const response = await config.fetch(new URL(path, config.apiUrl), {
1384
1573
  ...init,
1385
1574
  headers: await runtimeHeaders(config, init.headers)
1386
1575
  });
@@ -1391,7 +1580,7 @@ async function runtimeHeaders(config, headers) {
1391
1580
  return {
1392
1581
  ...headers,
1393
1582
  "x-app-key": config.appKey,
1394
- "x-client-info": CLIENT_INFO,
1583
+ "x-client-info": config.clientInfo,
1395
1584
  ...(token ? { authorization: `Bearer ${token}` } : {})
1396
1585
  };
1397
1586
  }