@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/CHANGELOG.md +179 -0
- package/README.md +79 -18
- package/REFERENCE.md +282 -14
- package/dist/expo.cjs +423 -0
- package/dist/expo.d.cts +152 -0
- package/dist/expo.d.ts +152 -0
- package/dist/expo.js +387 -0
- package/dist/index.cjs +224 -34
- package/dist/index.d.cts +143 -9
- package/dist/index.d.ts +143 -9
- package/dist/index.js +223 -34
- package/llms.txt +320 -34
- package/migrations/README.md +1 -0
- package/migrations/secure-store-set-throws.md +75 -0
- package/package.json +23 -2
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.
|
|
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.
|
|
39
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
329
|
-
* the
|
|
330
|
-
*
|
|
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
|
-
|
|
815
|
-
|
|
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 = () =>
|
|
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 (
|
|
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
|
-
|
|
890
|
-
|
|
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
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
973
|
-
|
|
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":
|
|
1583
|
+
"x-client-info": config.clientInfo,
|
|
1395
1584
|
...(token ? { authorization: `Bearer ${token}` } : {})
|
|
1396
1585
|
};
|
|
1397
1586
|
}
|