@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.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AiClient = exports.CreditsClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = exports.CLIENT_INFO = exports.SDK_VERSION = void 0;
4
+ exports.clientInfoFor = clientInfoFor;
4
5
  exports.gemmein = gemmein;
5
6
  exports.gemmeinServer = gemmeinServer;
6
7
  /** This build's version — the value `package.json` carries. Kept inline
@@ -9,13 +10,83 @@ exports.gemmeinServer = gemmeinServer;
9
10
  * second module). `scripts/sync-version.mjs` rewrites the literal from
10
11
  * package.json before every build (`prebuild`), and a test pins the two
11
12
  * equal, so a bump can never ship with a stale header. */
12
- exports.SDK_VERSION = "0.9.0"; // synced from package.json — do not edit by hand
13
+ exports.SDK_VERSION = "0.10.0"; // synced from package.json — do not edit by hand
13
14
  /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
14
15
  * `x-client-info: gemmein-sdk/<version>`. The server records it on the
15
16
  * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
16
17
  * misbehaving integration can be attributed to an SDK version from day
17
18
  * one. It is a report, not a proof — any caller can set it. */
18
19
  exports.CLIENT_INFO = `gemmein-sdk/${exports.SDK_VERSION}`;
20
+ /** W10 §1 A: a mobile entry adds its platform — `gemmein-sdk/<version>
21
+ * expo-ios`. The ledger that records this header caps it at 64 characters
22
+ * and strips control characters (`MAX_CLIENT_LENGTH` / `capClient`,
23
+ * packages/db/src/keyUsageStore.ts), so the value is cleaned and capped
24
+ * HERE: a tag that arrives truncated attributes nothing. Anything outside
25
+ * `[A-Za-z0-9._/-]` collapses to a hyphen, so the label can never carry a
26
+ * newline — or a second space — into the ledger. */
27
+ function clientInfoFor(platform) {
28
+ if (typeof platform !== "string")
29
+ return exports.CLIENT_INFO;
30
+ const clean = platform.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "");
31
+ if (clean.length === 0)
32
+ return exports.CLIENT_INFO;
33
+ return `${exports.CLIENT_INFO} ${clean}`.slice(0, 64);
34
+ }
35
+ /** W10 §1 A: every request goes through ONE function, so a platform that
36
+ * needs its own fetch injects it once. The default reads `globalThis.fetch`
37
+ * at call time (a polyfill installed later still counts), and both forms
38
+ * are invoked as plain calls — which is what keeps a browser's
39
+ * `window.fetch` legal when it is handed over as a bare reference.
40
+ *
41
+ * W10 row 9, found by driving the Expo app with the engine stopped: a
42
+ * transport failure — connection refused, no DNS, no Wi-Fi, an `apiUrl`
43
+ * pointing at nothing — never reaches `handleResponse`, so whatever the
44
+ * fetch implementation threw came out unchanged (a `TypeError` in a
45
+ * browser and on Expo's fetch). An app branching on `err.code`, which is
46
+ * what every teaching tells it to do, got `undefined` there and nowhere
47
+ * else. EVERY request the client makes — the runtime clients, a
48
+ * collection, the S3 upload POST, both AI streams — goes through this one
49
+ * wrapper, so every one of them now answers a `GemmeinError`
50
+ * `network_unreachable` (status 0, the SDK's convention for "no HTTP
51
+ * status applies"), with the fetch's own error kept on `err.cause`.
52
+ *
53
+ * Two throws pass through untouched: an `AbortError` — the caller
54
+ * cancelled on purpose, and `{ signal }` is a documented option on the AI
55
+ * calls — and a `GemmeinError` an injected fetch raised itself. */
56
+ function resolveFetch(injected, apiUrl) {
57
+ const call = injected
58
+ ? (input, init) => injected(input, init)
59
+ : (input, init) => globalThis.fetch(input, init);
60
+ // The host is read ONCE, here, and only for the sentence: a client whose
61
+ // `apiUrl` is unparseable still gets a readable error, never a second throw.
62
+ const host = (() => {
63
+ try {
64
+ return new URL(apiUrl).host;
65
+ }
66
+ catch {
67
+ return apiUrl;
68
+ }
69
+ })();
70
+ return (async (input, init) => {
71
+ try {
72
+ return await call(input, init);
73
+ }
74
+ catch (cause) {
75
+ // The caller's own cancel, and an SDK refusal an injected fetch chose
76
+ // to raise, are both already typed — re-typing them would lie.
77
+ if (cause instanceof GemmeinError)
78
+ throw cause;
79
+ if (typeof cause === "object" && cause !== null && cause.name === "AbortError")
80
+ throw cause;
81
+ throw new GemmeinError({
82
+ status: 0,
83
+ code: "network_unreachable",
84
+ message: `Gemmein could not be reached — check the connection and the apiUrl (${host})`,
85
+ cause,
86
+ });
87
+ }
88
+ });
89
+ }
19
90
  class GemmeinError extends Error {
20
91
  constructor(input) {
21
92
  super(input.message);
@@ -24,6 +95,7 @@ class GemmeinError extends Error {
24
95
  this.code = input.code;
25
96
  this.resetAt = input.resetAt;
26
97
  this.requires = input.requires;
98
+ this.cause = input.cause;
27
99
  }
28
100
  }
29
101
  exports.GemmeinError = GemmeinError;
@@ -42,8 +114,15 @@ exports.MemoryTokenStore = MemoryTokenStore;
42
114
  // Persists the session across page reloads — the default in browsers.
43
115
  // Sessions are long-lived server-side; a memory-only default would log
44
116
  // users out on every refresh. Keyed per app key so two Gemmein apps on
45
- // one origin never share a token. All storage access is guarded: private
46
- // modes and blocked storage degrade to "signed out", never to a crash.
117
+ // one origin never share a token.
118
+ //
119
+ // READS AND CLEARS ARE LENIENT, WRITES ARE NOT — the same law the two
120
+ // mobile stores keep (`SecureStoreTokenStore` in `@gemmein/sdk/expo`,
121
+ // `KeychainTokenStore` in GemmeinSwift). A store that cannot be READ means
122
+ // signed out, which every app already handles; a store that cannot KEEP
123
+ // the session has broken the promise it exists for, and silence there
124
+ // signs the person out on the next reload with nothing anywhere saying
125
+ // why.
47
126
  class BrowserTokenStore {
48
127
  constructor(appKey) {
49
128
  this.key = `gemmein_session_${appKey.slice(0, 20)}`;
@@ -56,11 +135,30 @@ class BrowserTokenStore {
56
135
  return undefined;
57
136
  }
58
137
  }
138
+ /**
139
+ * A blocked `localStorage` — Safari's private mode past its quota, a
140
+ * browser set to block site data, a sandboxed iframe whose access throws
141
+ * — throws `secure_store_unavailable` (status 0), the same code the
142
+ * mobile stores answer, with the browser's own error on `cause`.
143
+ * `auth.verifyEmailCode()` carries it to the caller: the session is real
144
+ * (the server minted it), so an app that would rather run than stop
145
+ * catches this one code and rebuilds its client with a
146
+ * `MemoryTokenStore`, which never throws.
147
+ */
59
148
  set(token) {
60
149
  try {
61
150
  window.localStorage.setItem(this.key, token);
62
151
  }
63
- catch { /* storage blocked — session lives for this page only */ }
152
+ catch (cause) {
153
+ const detail = cause instanceof Error ? cause.message : String(cause);
154
+ throw new GemmeinError({
155
+ status: 0,
156
+ code: "secure_store_unavailable",
157
+ message: `The session could not be stored: this browser refused the localStorage write (${detail}) ` +
158
+ "— pass a tokenStore, e.g. new MemoryTokenStore()",
159
+ cause,
160
+ });
161
+ }
64
162
  }
65
163
  clear() {
66
164
  try {
@@ -112,10 +210,14 @@ class Gemmein {
112
210
  'copy the key from the Setup page. Then: gemmein("pk_...")'
113
211
  });
114
212
  }
213
+ const apiUrl = options.apiUrl ?? "https://api.gemmein.com";
115
214
  const config = {
116
- apiUrl: options.apiUrl ?? "https://api.gemmein.com",
215
+ apiUrl,
117
216
  appKey: options.appKey,
118
- tokenStore: options.tokenStore ?? defaultTokenStore(options.appKey)
217
+ tokenStore: options.tokenStore ?? defaultTokenStore(options.appKey),
218
+ fetch: resolveFetch(options.fetch, apiUrl),
219
+ visibility: options.visibility,
220
+ clientInfo: clientInfoFor(options.platform)
119
221
  };
120
222
  this.auth = new AuthClient(config);
121
223
  this.storage = new StorageClient(config);
@@ -137,6 +239,7 @@ class Gemmein {
137
239
  * suggestion attached — always pass it when you aren't certain the
138
240
  * collection exists yet. The cloud ignores it.
139
241
  */
242
+ // route: none
140
243
  collection(name, options = {}) {
141
244
  return this.storage.collection(name, options);
142
245
  }
@@ -162,6 +265,7 @@ class AuthClient {
162
265
  constructor(config) {
163
266
  this.config = config;
164
267
  }
268
+ // route: POST /auth/email/start
165
269
  async sendEmailCode(email) {
166
270
  await this.request("/auth/email/start", {
167
271
  method: "POST",
@@ -169,6 +273,7 @@ class AuthClient {
169
273
  headers: { "content-type": "application/json" }
170
274
  });
171
275
  }
276
+ // route: POST /auth/email/verify
172
277
  async verifyEmailCode(input) {
173
278
  const result = await this.request("/auth/email/verify", {
174
279
  method: "POST",
@@ -189,15 +294,33 @@ class AuthClient {
189
294
  message: "Gemmein auth response did not include a session token"
190
295
  });
191
296
  }
297
+ /**
298
+ * End this device's session. IDEMPOTENT: signing out of a session that is
299
+ * already gone is the outcome asked for, not a failure.
300
+ *
301
+ * W10 row 9, found by driving the Expo app: the commonest way this
302
+ * round-trip fails is `401 auth_expired` — the owner already signed this
303
+ * person out everywhere from the console, which is the remedy for a
304
+ * stolen phone. The session IS ended; throwing there made an app that did
305
+ * everything right show an error for the thing it asked for. Every other
306
+ * failure still throws, and the token store is cleared either way.
307
+ */
308
+ // route: POST /auth/logout
192
309
  async logout() {
193
310
  try {
194
311
  await this.request("/auth/logout", { method: "POST" });
195
312
  }
313
+ catch (err) {
314
+ const alreadyEnded = err instanceof GemmeinError && err.status === 401 && err.code === "auth_expired";
315
+ if (!alreadyEnded)
316
+ throw err;
317
+ }
196
318
  finally {
197
319
  // Even if the network call fails, the user asked to be signed out.
198
320
  await this.config.tokenStore.clear();
199
321
  }
200
322
  }
323
+ // route: GET /auth/current-user
201
324
  async currentUser() {
202
325
  // Contract: "who am I?" never throws for session state — the server
203
326
  // answers 200 {authenticated:false} for a token it won't honour right
@@ -255,6 +378,7 @@ class PurchasesClient {
255
378
  * or `{ type: "external_url", url }` (a plain handover). A refunded
256
379
  * purchase never carries delivery.
257
380
  */
381
+ // route: GET /auth/purchases
258
382
  async mine() {
259
383
  const result = (await runtimeRequest(this.config, "/auth/purchases"));
260
384
  return result.purchases;
@@ -287,6 +411,7 @@ class FilesClient {
287
411
  constructor(config) {
288
412
  this.config = config;
289
413
  }
414
+ // route: GET /files/{ref}/link
290
415
  async link(ref, options = {}) {
291
416
  const query = options.intent === "download" ? "?intent=download" : "";
292
417
  return (await runtimeRequest(this.config, `/files/${encodeURIComponent(String(ref))}/link${query}`));
@@ -304,6 +429,7 @@ class SubscriptionsClient {
304
429
  * nobody is signed in — a data route, not the never-throw current-user
305
430
  * contract.
306
431
  */
432
+ // route: GET /auth/subscription
307
433
  async mine() {
308
434
  const result = (await runtimeRequest(this.config, "/auth/subscription"));
309
435
  return result.subscription;
@@ -317,6 +443,7 @@ class SubscriptionsClient {
317
443
  * and a plan whose Payment Link the app owner has pasted in their
318
444
  * dashboard; omit `plan` to buy the app's paid plan.
319
445
  */
446
+ // route: GET /auth/checkout
320
447
  async checkout(plan) {
321
448
  const query = plan ? `?plan=${encodeURIComponent(plan)}` : "";
322
449
  const result = (await runtimeRequest(this.config, `/auth/checkout${query}`));
@@ -338,10 +465,16 @@ class PaymentsClient {
338
465
  * WHAT is being bought when one product covers many things (e.g. a
339
466
  * license tier across a catalog):
340
467
  * `g.payments.buy("premium license", { item: "beat_37" })`.
341
- * A completed payment writes a receipt record addressed to the buyer in
342
- * the owner's receipts collection; gate downloads/fulfilment on that
343
- * receipt, never on the redirect coming back.
468
+ * Gemmein records every completed payment itself: `g.purchases.mine()` is
469
+ * the buyer's proof, and a product that delivers a file carries
470
+ * `delivery` on it. Gate fulfilment on the purchase (or the entitlement it
471
+ * granted), never on the redirect coming back. A receipts collection is
472
+ * optional, for proof records only.
473
+ *
474
+ * A product sold via a RELAY, or not sold yet, has no Payment Link to
475
+ * open: this answers 409 `product_not_sellable`.
344
476
  */
477
+ // route: GET /auth/pay
345
478
  async buy(product, options) {
346
479
  const params = new URLSearchParams({ product });
347
480
  if (options?.item)
@@ -367,6 +500,7 @@ class AccountClient {
367
500
  * subscription row removed. Irreversible — put a real confirm in front
368
501
  * of it.
369
502
  */
503
+ // route: POST /auth/delete-account
370
504
  async delete() {
371
505
  const result = await runtimeRequest(this.config, "/auth/delete-account", { method: "POST" });
372
506
  await this.config.tokenStore.clear();
@@ -385,6 +519,7 @@ class CreditsClient {
385
519
  *
386
520
  * const { balance } = await g.credits.balance();
387
521
  */
522
+ // route: GET /auth/credits
388
523
  async balance() {
389
524
  return runtimeRequest(this.config, "/auth/credits");
390
525
  }
@@ -441,12 +576,13 @@ class AiClient {
441
576
  * `x-gemmein-credit: refunded`). `x-gemmein-tool` names the tool; absent
442
577
  * on the implicit default.
443
578
  */
579
+ // route: POST /ai/chat
444
580
  async chat(body, options = {}) {
445
581
  const payload = options.provider ? { provider: options.provider, ...body } : body;
446
582
  const url = new URL("/ai/chat", this.config.apiUrl);
447
583
  if (options.tool)
448
584
  url.searchParams.set("tool", options.tool);
449
- const response = await fetch(url, {
585
+ const response = await this.config.fetch(url, {
450
586
  method: "POST",
451
587
  body: JSON.stringify(payload),
452
588
  headers: await runtimeHeaders(this.config, { "content-type": "application/json" }),
@@ -495,9 +631,10 @@ class AiClient {
495
631
  * The provider's own answer — 2xx or not — is returned as it came; read
496
632
  * `res.ok` yourself. `x-gemmein-tool` names the tool.
497
633
  */
634
+ // route: POST /ai/run/{tool}
498
635
  async run(tool, inputs = {}, options = {}) {
499
636
  const url = new URL(`/ai/run/${encodeURIComponent(tool)}`, this.config.apiUrl);
500
- const response = await fetch(url, {
637
+ const response = await this.config.fetch(url, {
501
638
  method: "POST",
502
639
  body: JSON.stringify({ inputs, ...(options.stream ? { stream: true } : {}) }),
503
640
  headers: await runtimeHeaders(this.config, { "content-type": "application/json" }),
@@ -525,6 +662,7 @@ class AiClient {
525
662
  *
526
663
  * const summary = await g.ai.runText("summarise", { text });
527
664
  */
665
+ // route: POST /ai/run/{tool}
528
666
  async runText(tool, inputs = {}, options = {}) {
529
667
  const response = await this.run(tool, inputs, options);
530
668
  if (!response.ok) {
@@ -544,6 +682,7 @@ class AiClient {
544
682
  *
545
683
  * const { calls, nextCursor } = await g.ai.calls();
546
684
  */
685
+ // route: GET /auth/ai-calls
547
686
  async calls(options = {}) {
548
687
  const params = new URLSearchParams();
549
688
  if (options.limit)
@@ -566,6 +705,7 @@ class AiClient {
566
705
  *
567
706
  * const answer = await g.ai.text({ model: "claude-sonnet-4-5", max_tokens: 400, messages });
568
707
  */
708
+ // route: POST /ai/chat
569
709
  async text(body, options = {}) {
570
710
  const response = await this.chat(body, options);
571
711
  if (!response.ok) {
@@ -654,6 +794,7 @@ class StorageClient {
654
794
  this.config = config;
655
795
  }
656
796
  /** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
797
+ // route: none
657
798
  collection(name, options = {}) {
658
799
  assertCollectionName(name);
659
800
  return new CollectionClient(this.config, name, options);
@@ -698,6 +839,7 @@ class CollectionClient {
698
839
  * fake drafts with a data field + client-side filtering: on a public
699
840
  * collection the data still reaches everyone.
700
841
  */
842
+ // route: POST /storage/{collection}
701
843
  async create(data, options = {}) {
702
844
  const query = new URLSearchParams();
703
845
  if (options.key !== undefined)
@@ -717,6 +859,7 @@ class CollectionClient {
717
859
  * rule (the app owner sees everyone's). Returns `{ records, hasMore }` —
718
860
  * an object, not a bare array.
719
861
  */
862
+ // route: GET /storage/{collection}
720
863
  async list(options = {}) {
721
864
  const query = new URLSearchParams();
722
865
  if (options.limit !== undefined)
@@ -757,6 +900,7 @@ class CollectionClient {
757
900
  * doubling up to 60s, and honours a rate limit's reset time. One watch per
758
901
  * page is the intended shape — share its result, don't stack watchers.
759
902
  */
903
+ // route: GET /storage/{collection}
760
904
  watch(onChange, options = {}) {
761
905
  const asked = options.every;
762
906
  const every = Math.max(5000, Math.min(300000, Number.isFinite(asked) ? asked : 10000));
@@ -764,6 +908,11 @@ class CollectionClient {
764
908
  let stopped = false;
765
909
  let timer;
766
910
  let delayMs = every;
911
+ // W10 §1 A: the platform's own answer to "is the app in front of the
912
+ // user?" when one was injected — `document` when it wasn't. One
913
+ // `unsubscribe` either way, so every teardown path is the same line.
914
+ const vis = this.config.visibility;
915
+ let unsubscribe;
767
916
  let watermark;
768
917
  // One tick at a time. A visibility flip mid-tick sets resyncPending
769
918
  // instead of racing a second loop into existence (each raced loop would
@@ -829,9 +978,8 @@ class CollectionClient {
829
978
  clearTimeout(timer);
830
979
  timer = undefined;
831
980
  }
832
- if (typeof document !== "undefined") {
833
- document.removeEventListener("visibilitychange", onVisibility);
834
- }
981
+ unsubscribe?.();
982
+ unsubscribe = undefined;
835
983
  if (typeof console !== "undefined")
836
984
  console.warn(`gemmein watch stopped: ${err.code} — start a new watch after signing in`);
837
985
  return;
@@ -855,7 +1003,9 @@ class CollectionClient {
855
1003
  }
856
1004
  schedule();
857
1005
  };
858
- const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
1006
+ const hidden = () => (vis
1007
+ ? vis.isHidden()
1008
+ : typeof document !== "undefined" && document.visibilityState === "hidden");
859
1009
  const schedule = () => {
860
1010
  if (stopped || hidden() || timer !== undefined)
861
1011
  return;
@@ -889,8 +1039,12 @@ class CollectionClient {
889
1039
  void tick(true);
890
1040
  }
891
1041
  };
892
- if (typeof document !== "undefined") {
1042
+ if (vis) {
1043
+ unsubscribe = vis.onChange(onVisibility);
1044
+ }
1045
+ else if (typeof document !== "undefined") {
893
1046
  document.addEventListener("visibilitychange", onVisibility);
1047
+ unsubscribe = () => document.removeEventListener("visibilitychange", onVisibility);
894
1048
  }
895
1049
  // Born hidden: wait for the tab — the visibility handler runs the first
896
1050
  // sync when the user actually looks.
@@ -904,12 +1058,12 @@ class CollectionClient {
904
1058
  if (timer !== undefined)
905
1059
  clearTimeout(timer);
906
1060
  timer = undefined;
907
- if (typeof document !== "undefined") {
908
- document.removeEventListener("visibilitychange", onVisibility);
909
- }
1061
+ unsubscribe?.();
1062
+ unsubscribe = undefined;
910
1063
  },
911
1064
  };
912
1065
  }
1066
+ // route: GET /storage/{collection}/{id}
913
1067
  async get(id, options = {}) {
914
1068
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
915
1069
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -929,6 +1083,7 @@ class CollectionClient {
929
1083
  * docs), pass `{ ifVersion: record.version }` — a stale save gets a 409
930
1084
  * `conflict` instead of clobbering; re-read, reapply, retry.
931
1085
  */
1086
+ // route: PATCH /storage/{collection}/{id}
932
1087
  async update(id, data, options = {}) {
933
1088
  const query = new URLSearchParams();
934
1089
  if (options.ifVersion !== undefined)
@@ -941,11 +1096,12 @@ class CollectionClient {
941
1096
  body: JSON.stringify(data)
942
1097
  });
943
1098
  }
1099
+ // route: DELETE /storage/{collection}/{id}
944
1100
  async delete(id) {
945
1101
  await this.request(`/${encodeURIComponent(id)}`, { method: "DELETE" });
946
1102
  }
947
1103
  /**
948
- * Upload a file and get back a REFERENCE — `file:01K…` — not a URL.
1104
+ * Upload a file and get back a REFERENCE — `file:<uuid>` — not a URL.
949
1105
  *
950
1106
  * Store the reference. It never expires, it is safe to log and export, and
951
1107
  * it grants nothing on its own. To show or download the file, call
@@ -972,23 +1128,43 @@ class CollectionClient {
972
1128
  * There is deliberately no `url` here. A URL that outlives a refund is the
973
1129
  * bug this replaced.
974
1130
  */
1131
+ // route: POST /storage/{collection}/upload
1132
+ // route: POST /storage/{collection}/upload/{fileId}/confirm
975
1133
  async upload(file, options) {
976
- const name = options?.name ?? (file instanceof File ? file.name : "upload");
1134
+ // W10 §1 A: `File` is a browser class. Where it does not exist — React
1135
+ // Native — a bare `instanceof File` is a ReferenceError, not a false,
1136
+ // so the guard comes first. The three fields the presign needs are then
1137
+ // read the same way off every shape: a File, a Blob, an
1138
+ // `expo-file-system` File, or the picker's `{ uri, name, type, size }`.
1139
+ const part = file;
1140
+ const name = options?.name
1141
+ ?? (typeof File !== "undefined" && file instanceof File
1142
+ ? file.name
1143
+ : typeof part.name === "string" ? part.name : "upload");
977
1144
  // A Blob built from bytes has type "" — `contentType` names it. The
978
1145
  // server proves the bytes either way.
979
- const contentType = options?.contentType ?? file.type;
1146
+ const contentType = options?.contentType ?? part.type ?? "";
1147
+ // A presign under one byte is refused ("file must not be empty"): a
1148
+ // Blob knows its own size, a React Native part carries the picker's.
1149
+ const size = typeof part.size === "number" ? part.size : 0;
980
1150
  // Step 1: Get presigned upload URL
981
1151
  const presign = await this.request("/upload", {
982
1152
  method: "POST",
983
- body: JSON.stringify({ name, size: file.size, contentType, ...(options?.for ? { for: options.for } : {}) }),
1153
+ body: JSON.stringify({ name, size, contentType, ...(options?.for ? { for: options.for } : {}) }),
984
1154
  });
985
1155
  // Step 2: Upload directly to S3 via presigned POST
986
1156
  const form = new FormData();
987
1157
  for (const [key, value] of Object.entries(presign.fields)) {
988
1158
  form.append(key, value);
989
1159
  }
990
- form.append("file", file); // Must be last — S3 presigned POST requirement
991
- const s3Response = await fetch(presign.uploadUrl, { method: "POST", body: form });
1160
+ // Must be last — S3 presigned POST requirement. Whatever arrived is
1161
+ // appended AS IT IS: a Blob, a File, or on a client given React
1162
+ // Native's own fetch — the picker's `{ uri, ... }` part, whose bytes
1163
+ // RN's FormData reads off the device when it serialises. Expo's fetch
1164
+ // refuses that part, so `@gemmein/sdk/expo` has already turned it into
1165
+ // an `expo-file-system` `File` by the time it gets here.
1166
+ form.append("file", file);
1167
+ const s3Response = await this.config.fetch(presign.uploadUrl, { method: "POST", body: form });
992
1168
  if (!s3Response.ok) {
993
1169
  throw new GemmeinError({
994
1170
  status: s3Response.status,
@@ -1003,7 +1179,7 @@ class CollectionClient {
1003
1179
  return confirmed;
1004
1180
  }
1005
1181
  async request(suffix, init = {}) {
1006
- const response = await fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.config.apiUrl), {
1182
+ const response = await this.config.fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.config.apiUrl), {
1007
1183
  ...init,
1008
1184
  headers: await runtimeHeaders(this.config, {
1009
1185
  "content-type": "application/json",
@@ -1033,10 +1209,12 @@ class GemmeinServer {
1033
1209
  }
1034
1210
  this.apiUrl = options.apiUrl ?? "https://api.gemmein.com";
1035
1211
  this.secretKey = options.secretKey;
1212
+ this.fetch = resolveFetch(options.fetch, this.apiUrl);
1036
1213
  }
1214
+ // route: none
1037
1215
  collection(name) {
1038
1216
  assertCollectionName(name);
1039
- return new ServerCollectionClient(this.apiUrl, this.secretKey, name);
1217
+ return new ServerCollectionClient(this.apiUrl, this.secretKey, name, this.fetch);
1040
1218
  }
1041
1219
  /**
1042
1220
  * Mint a member session for a test email WITHOUT an OTP round-trip — so a CI
@@ -1046,6 +1224,7 @@ class GemmeinServer {
1046
1224
  * `sk_live` key, and the server refuses it too. Never ship this in app code.
1047
1225
  * Pass the returned `token` to `gemmein(pk, { tokenStore })` to act as that user.
1048
1226
  */
1227
+ // route: POST /server/test-session
1049
1228
  async testSession(email) {
1050
1229
  if (this.secretKey.startsWith("sk_live")) {
1051
1230
  throw new GemmeinError({
@@ -1054,7 +1233,7 @@ class GemmeinServer {
1054
1233
  message: "test sessions are only available in a development environment — never with a live (sk_live) key",
1055
1234
  });
1056
1235
  }
1057
- const response = await fetch(new URL("/server/test-session", this.apiUrl), {
1236
+ const response = await this.fetch(new URL("/server/test-session", this.apiUrl), {
1058
1237
  method: "POST",
1059
1238
  headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
1060
1239
  body: JSON.stringify({ email }),
@@ -1091,8 +1270,9 @@ class GemmeinServer {
1091
1270
  * (a security notice must never lose to five order emails); misusing it
1092
1271
  * for campaigns is visible in your own audit trail.
1093
1272
  */
1273
+ // route: POST /server/notify
1094
1274
  async notify(personId, input) {
1095
- const response = await fetch(new URL("/server/notify", this.apiUrl), {
1275
+ const response = await this.fetch(new URL("/server/notify", this.apiUrl), {
1096
1276
  method: "POST",
1097
1277
  headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
1098
1278
  body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
@@ -1129,6 +1309,7 @@ class GemmeinServer {
1129
1309
  * off until the owner reactivates them in the dashboard) ·
1130
1310
  * `invalid_body` (token missing, not a string, or over 512 chars).
1131
1311
  */
1312
+ // route: POST /server/verify-session
1132
1313
  async verifySession(token) {
1133
1314
  return this.gate("/server/verify-session", {
1134
1315
  method: "POST",
@@ -1158,6 +1339,7 @@ class GemmeinServer {
1158
1339
  * `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
1159
1340
  * where to write to raise it; `err.resetAt` says when the window ends).
1160
1341
  */
1342
+ // route: POST /server/people
1161
1343
  async invitePerson(email) {
1162
1344
  return this.gate("/server/people", {
1163
1345
  method: "POST",
@@ -1180,6 +1362,7 @@ class GemmeinServer {
1180
1362
  * `person_not_found` (404 — no person with this id in this app and
1181
1363
  * environment; existence is never leaked).
1182
1364
  */
1365
+ // route: GET /server/people/{personId}/holdings
1183
1366
  async holdings(personId) {
1184
1367
  return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
1185
1368
  }
@@ -1212,6 +1395,7 @@ class GemmeinServer {
1212
1395
  * `invalid_entitlement` / `unknown_plan` (no plan or product by that
1213
1396
  * name — the owner adds it on the Payments page) · `person_not_found`.
1214
1397
  */
1398
+ // route: POST /server/people/{personId}/grants
1215
1399
  async grantAccess(personId, input) {
1216
1400
  return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
1217
1401
  method: "POST",
@@ -1250,6 +1434,7 @@ class GemmeinServer {
1250
1434
  * `invalid_reason` / `invalid_key` (400) · `dedupe_conflict` (409 — the
1251
1435
  * key already names a different movement).
1252
1436
  */
1437
+ // route: POST /server/people/{personId}/credits/spend
1253
1438
  async spendCredits(personId, input) {
1254
1439
  return this.gate(`/server/people/${encodeURIComponent(personId)}/credits/spend`, {
1255
1440
  method: "POST",
@@ -1277,6 +1462,7 @@ class GemmeinServer {
1277
1462
  * `grant_not_found` (404 — not this person's grant, in this app and
1278
1463
  * environment; existence is never leaked) · `already_revoked` (409).
1279
1464
  */
1465
+ // route: POST /server/people/{personId}/grants/{grantId}/revoke
1280
1466
  async revokeAccess(personId, grantId, input = {}) {
1281
1467
  return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
1282
1468
  }
@@ -1288,7 +1474,7 @@ class GemmeinServer {
1288
1474
  const headers = { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO };
1289
1475
  if (init.body)
1290
1476
  headers["content-type"] = "application/json";
1291
- const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
1477
+ const response = await this.fetch(new URL(path, this.apiUrl), { ...init, headers });
1292
1478
  if (!response.ok) {
1293
1479
  throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
1294
1480
  }
@@ -1297,14 +1483,17 @@ class GemmeinServer {
1297
1483
  }
1298
1484
  exports.GemmeinServer = GemmeinServer;
1299
1485
  class ServerCollectionClient {
1300
- constructor(apiUrl, secretKey, name) {
1486
+ constructor(apiUrl, secretKey, name, fetchImpl) {
1301
1487
  this.apiUrl = apiUrl;
1302
1488
  this.secretKey = secretKey;
1303
1489
  this.name = name;
1490
+ this.fetch = fetchImpl;
1304
1491
  }
1492
+ // route: GET /storage/{collection}/{id}
1305
1493
  async get(id) {
1306
1494
  return this.request(`/${encodeURIComponent(id)}`);
1307
1495
  }
1496
+ // route: GET /storage/{collection}
1308
1497
  async list(options = {}) {
1309
1498
  const query = new URLSearchParams();
1310
1499
  if (options.limit !== undefined)
@@ -1326,6 +1515,7 @@ class ServerCollectionClient {
1326
1515
  const qs = query.toString();
1327
1516
  return this.request(qs ? `?${qs}` : "");
1328
1517
  }
1518
+ // route: PATCH /storage/{collection}/{id}
1329
1519
  async update(id, data) {
1330
1520
  return this.request(`/${encodeURIComponent(id)}`, {
1331
1521
  method: "PATCH",
@@ -1340,7 +1530,7 @@ class ServerCollectionClient {
1340
1530
  if (init.body) {
1341
1531
  headers["content-type"] = "application/json";
1342
1532
  }
1343
- const response = await fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.apiUrl), { ...init, headers });
1533
+ const response = await this.fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.apiUrl), { ...init, headers });
1344
1534
  if (response.status === 204)
1345
1535
  return undefined;
1346
1536
  if (!response.ok) {
@@ -1400,7 +1590,7 @@ async function readErrorBody(response) {
1400
1590
  // One request path for every runtime client (auth, subscriptions,
1401
1591
  // payments, account) — same headers, same error handling, same signposts.
1402
1592
  async function runtimeRequest(config, path, init = {}) {
1403
- const response = await fetch(new URL(path, config.apiUrl), {
1593
+ const response = await config.fetch(new URL(path, config.apiUrl), {
1404
1594
  ...init,
1405
1595
  headers: await runtimeHeaders(config, init.headers)
1406
1596
  });
@@ -1411,7 +1601,7 @@ async function runtimeHeaders(config, headers) {
1411
1601
  return {
1412
1602
  ...headers,
1413
1603
  "x-app-key": config.appKey,
1414
- "x-client-info": exports.CLIENT_INFO,
1604
+ "x-client-info": config.clientInfo,
1415
1605
  ...(token ? { authorization: `Bearer ${token}` } : {})
1416
1606
  };
1417
1607
  }