@gemmein/sdk 0.0.1 → 0.2.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/LICENSE +21 -0
- package/README.md +281 -4
- package/REFERENCE.md +264 -0
- package/dist/index.cjs +627 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.js +611 -0
- package/llms.txt +305 -0
- package/package.json +52 -6
- package/reaffirm.mjs +76 -0
- package/index.js +0 -6
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = void 0;
|
|
4
|
+
exports.gemmein = gemmein;
|
|
5
|
+
exports.gemmeinServer = gemmeinServer;
|
|
6
|
+
class GemmeinError extends Error {
|
|
7
|
+
constructor(input) {
|
|
8
|
+
super(input.message);
|
|
9
|
+
this.name = "GemmeinError";
|
|
10
|
+
this.status = input.status;
|
|
11
|
+
this.code = input.code;
|
|
12
|
+
this.resetAt = input.resetAt;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.GemmeinError = GemmeinError;
|
|
16
|
+
class MemoryTokenStore {
|
|
17
|
+
get() {
|
|
18
|
+
return this.token;
|
|
19
|
+
}
|
|
20
|
+
set(token) {
|
|
21
|
+
this.token = token;
|
|
22
|
+
}
|
|
23
|
+
clear() {
|
|
24
|
+
this.token = undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.MemoryTokenStore = MemoryTokenStore;
|
|
28
|
+
// Persists the session across page reloads — the default in browsers.
|
|
29
|
+
// Sessions are long-lived server-side; a memory-only default would log
|
|
30
|
+
// users out on every refresh. Keyed per app key so two Gemmein apps on
|
|
31
|
+
// one origin never share a token. All storage access is guarded: private
|
|
32
|
+
// modes and blocked storage degrade to "signed out", never to a crash.
|
|
33
|
+
class BrowserTokenStore {
|
|
34
|
+
constructor(appKey) {
|
|
35
|
+
this.key = `gemmein_session_${appKey.slice(0, 20)}`;
|
|
36
|
+
}
|
|
37
|
+
get() {
|
|
38
|
+
try {
|
|
39
|
+
return window.localStorage.getItem(this.key) ?? undefined;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
set(token) {
|
|
46
|
+
try {
|
|
47
|
+
window.localStorage.setItem(this.key, token);
|
|
48
|
+
}
|
|
49
|
+
catch { /* storage blocked — session lives for this page only */ }
|
|
50
|
+
}
|
|
51
|
+
clear() {
|
|
52
|
+
try {
|
|
53
|
+
window.localStorage.removeItem(this.key);
|
|
54
|
+
}
|
|
55
|
+
catch { /* nothing to clear */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.BrowserTokenStore = BrowserTokenStore;
|
|
59
|
+
function defaultTokenStore(appKey) {
|
|
60
|
+
try {
|
|
61
|
+
if (typeof window !== "undefined" && window.localStorage)
|
|
62
|
+
return new BrowserTokenStore(appKey);
|
|
63
|
+
}
|
|
64
|
+
catch { /* SSR / Node / storage blocked */ }
|
|
65
|
+
return new MemoryTokenStore();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The client — two layers:
|
|
69
|
+
*
|
|
70
|
+
* - `g.collection("notes")` — YOUR app's collections. Records, files,
|
|
71
|
+
* safety rules. This is where your app's own data model lives.
|
|
72
|
+
* - Business primitives Gemmein runs for you: `g.auth` (sign-in),
|
|
73
|
+
* `g.subscriptions` (who's on which plan), `g.payments` (one-off
|
|
74
|
+
* purchases), `g.account` (the user's own account). These are
|
|
75
|
+
* SELF-SERVICE surfaces for the signed-in user — reads and Stripe
|
|
76
|
+
* hand-offs, never admin powers. Managing other people's users,
|
|
77
|
+
* subscriptions, or records happens in the owner's dashboard
|
|
78
|
+
* (app.gemmein.com), on purpose.
|
|
79
|
+
*/
|
|
80
|
+
class Gemmein {
|
|
81
|
+
constructor(options) {
|
|
82
|
+
// A missing key must land on the signpost below, not a TypeError —
|
|
83
|
+
// gemmein(undefined) is exactly the "env var didn't load" case.
|
|
84
|
+
if (options?.appKey?.startsWith("sk_")) {
|
|
85
|
+
throw new GemmeinError({
|
|
86
|
+
status: 0,
|
|
87
|
+
code: "invalid_app_key",
|
|
88
|
+
message: "Secret keys (sk_) must not be used in the client SDK — use gemmeinServer() instead"
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// The signpost: an AI building without a key gets routed, not stuck.
|
|
92
|
+
if (!options?.appKey || !options.appKey.startsWith("pk_")) {
|
|
93
|
+
throw new GemmeinError({
|
|
94
|
+
status: 0,
|
|
95
|
+
code: "missing_app_key",
|
|
96
|
+
message: 'Gemmein needs your app key (it starts with "pk_"). Get one in 30 seconds: ' +
|
|
97
|
+
"sign in at https://app.gemmein.com with an email code (free, no card) and " +
|
|
98
|
+
'copy the key from the Setup page. Then: gemmein("pk_...")'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const config = {
|
|
102
|
+
apiUrl: options.apiUrl ?? "https://api.gemmein.com",
|
|
103
|
+
appKey: options.appKey,
|
|
104
|
+
tokenStore: options.tokenStore ?? defaultTokenStore(options.appKey)
|
|
105
|
+
};
|
|
106
|
+
this.auth = new AuthClient(config);
|
|
107
|
+
this.storage = new StorageClient(config);
|
|
108
|
+
this.subscriptions = new SubscriptionsClient(config);
|
|
109
|
+
this.payments = new PaymentsClient(config);
|
|
110
|
+
this.account = new AccountClient(config);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Your app's data — `g.collection<{ title: string }>("notes")`. The
|
|
114
|
+
* canonical spelling; `g.storage.collection(name)` is the same client.
|
|
115
|
+
*/
|
|
116
|
+
collection(name) {
|
|
117
|
+
return this.storage.collection(name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
exports.Gemmein = Gemmein;
|
|
121
|
+
// Factory forms — what the copied prompts teach. `gemmein("pk_...")` reads
|
|
122
|
+
// better in an AI prompt than a class instantiation, and both accept an
|
|
123
|
+
// options object when more than a key is needed.
|
|
124
|
+
// ROUND-8 FIND: `gemmein(key, { apiUrl })` is the call shape people (and
|
|
125
|
+
// AIs) write naturally — silently dropping the second argument misrouted a
|
|
126
|
+
// local app to production. Both forms are now first-class.
|
|
127
|
+
function gemmein(appKeyOrOptions, options) {
|
|
128
|
+
return new Gemmein(typeof appKeyOrOptions === "string"
|
|
129
|
+
? { appKey: appKeyOrOptions, ...(options ?? {}) }
|
|
130
|
+
: appKeyOrOptions);
|
|
131
|
+
}
|
|
132
|
+
function gemmeinServer(secretKeyOrOptions, options) {
|
|
133
|
+
return new GemmeinServer(typeof secretKeyOrOptions === "string"
|
|
134
|
+
? { secretKey: secretKeyOrOptions, ...(options ?? {}) }
|
|
135
|
+
: secretKeyOrOptions);
|
|
136
|
+
}
|
|
137
|
+
class AuthClient {
|
|
138
|
+
constructor(config) {
|
|
139
|
+
this.config = config;
|
|
140
|
+
}
|
|
141
|
+
async sendEmailCode(email) {
|
|
142
|
+
await this.request("/auth/email/start", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: JSON.stringify({ email }),
|
|
145
|
+
headers: { "content-type": "application/json" }
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async verifyEmailCode(input) {
|
|
149
|
+
const result = await this.request("/auth/email/verify", {
|
|
150
|
+
method: "POST",
|
|
151
|
+
body: JSON.stringify(input),
|
|
152
|
+
headers: { "content-type": "application/json" }
|
|
153
|
+
});
|
|
154
|
+
if (isSessionResponse(result)) {
|
|
155
|
+
await this.config.tokenStore.set(result.token);
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
throw new Error("Gemmein auth response did not include a session token");
|
|
159
|
+
}
|
|
160
|
+
async logout() {
|
|
161
|
+
try {
|
|
162
|
+
await this.request("/auth/logout", { method: "POST" });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
// Even if the network call fails, the user asked to be signed out.
|
|
166
|
+
await this.config.tokenStore.clear();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async currentUser() {
|
|
170
|
+
// Contract: "who am I?" never throws for session state — the server
|
|
171
|
+
// answers 200 {authenticated:false} for a token it won't honour right
|
|
172
|
+
// now, same as no token. We deliberately do NOT clear the stored token
|
|
173
|
+
// here: authenticated:false also covers a SUSPENDED user, whose session
|
|
174
|
+
// is intact and restored on unsuspend — clearing it would make a
|
|
175
|
+
// reversible suspension permanently sign them out. A genuinely expired
|
|
176
|
+
// token is harmless to keep (the next data-plane call 401s and the app
|
|
177
|
+
// re-auths); logout() and g.account.delete() are the deliberate clears.
|
|
178
|
+
//
|
|
179
|
+
// Moderation note: authenticated:false is deliberately SILENT about the
|
|
180
|
+
// why — signed out, suspended, and erased all read the same here, so a
|
|
181
|
+
// moderated user's state is never leaked to the client. Build the
|
|
182
|
+
// signed-out screen for all three.
|
|
183
|
+
return this.request("/auth/current-user");
|
|
184
|
+
}
|
|
185
|
+
request(path, init = {}) {
|
|
186
|
+
return runtimeRequest(this.config, path, init);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
exports.AuthClient = AuthClient;
|
|
190
|
+
/**
|
|
191
|
+
* SUBS: the subscription primitive, self-service side. Gemmein keeps
|
|
192
|
+
* exactly one subscription per customer — created by the payment itself,
|
|
193
|
+
* updated by Stripe's signed webhooks, overridable by the owner in their
|
|
194
|
+
* dashboard. The client surface is deliberately read-plus-checkout only:
|
|
195
|
+
* there is no client write path to plan or status, by design.
|
|
196
|
+
*/
|
|
197
|
+
class SubscriptionsClient {
|
|
198
|
+
constructor(config) {
|
|
199
|
+
this.config = config;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The signed-in user's subscription — gate features with
|
|
203
|
+
* `(await g.subscriptions.mine())?.plan === "pro"`. Null when payments
|
|
204
|
+
* are off or this user has never paid; throws GemmeinError (401) when
|
|
205
|
+
* nobody is signed in — a data route, not the never-throw current-user
|
|
206
|
+
* contract.
|
|
207
|
+
*/
|
|
208
|
+
async mine() {
|
|
209
|
+
const result = (await runtimeRequest(this.config, "/auth/subscription"));
|
|
210
|
+
return result.subscription;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Start a Stripe checkout for a plan — Gemmein mints the URL with the
|
|
214
|
+
* signed-in buyer and the plan already wired in (never build checkout
|
|
215
|
+
* URLs yourself; raw emails get silently dropped by Stripe's URL rules).
|
|
216
|
+
* In a browser this redirects immediately; it also resolves with the URL
|
|
217
|
+
* (for non-browser callers or custom handling). Requires a signed-in user
|
|
218
|
+
* and a plan whose Payment Link the app owner has pasted in their
|
|
219
|
+
* dashboard; omit `plan` to buy the app's paid plan.
|
|
220
|
+
*/
|
|
221
|
+
async checkout(plan) {
|
|
222
|
+
const query = plan ? `?plan=${encodeURIComponent(plan)}` : "";
|
|
223
|
+
const result = (await runtimeRequest(this.config, `/auth/checkout${query}`));
|
|
224
|
+
if (typeof window !== "undefined" && window.location) {
|
|
225
|
+
window.location.assign(result.url);
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
exports.SubscriptionsClient = SubscriptionsClient;
|
|
231
|
+
/** The one-off purchase primitive — things, not plans (plans are `g.subscriptions`). */
|
|
232
|
+
class PaymentsClient {
|
|
233
|
+
constructor(config) {
|
|
234
|
+
this.config = config;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Buy a one-off product — `g.payments.buy("poster")`. Redirects in
|
|
238
|
+
* browsers, and resolves with the URL. The optional `item` note names
|
|
239
|
+
* WHAT is being bought when one product covers many things (e.g. a
|
|
240
|
+
* license tier across a catalog):
|
|
241
|
+
* `g.payments.buy("premium license", { item: "beat_37" })`.
|
|
242
|
+
* A completed payment writes a receipt record addressed to the buyer in
|
|
243
|
+
* the owner's receipts collection; gate downloads/fulfilment on that
|
|
244
|
+
* receipt, never on the redirect coming back.
|
|
245
|
+
*/
|
|
246
|
+
async buy(product, options) {
|
|
247
|
+
const params = new URLSearchParams({ product });
|
|
248
|
+
if (options?.item)
|
|
249
|
+
params.set("item", options.item);
|
|
250
|
+
const result = (await runtimeRequest(this.config, `/auth/pay?${params.toString()}`));
|
|
251
|
+
if (typeof window !== "undefined" && window.location) {
|
|
252
|
+
window.location.assign(result.url);
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
exports.PaymentsClient = PaymentsClient;
|
|
258
|
+
/** The signed-in user's own account — self-service, one deliberate power. */
|
|
259
|
+
class AccountClient {
|
|
260
|
+
constructor(config) {
|
|
261
|
+
this.config = config;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Self-service erasure — the "delete my account" screen. Every app it
|
|
265
|
+
* APPLIES to needs one (GDPR right to erasure; Apple 5.1.1(v) requires it
|
|
266
|
+
* for any app with account creation). Server-side this is the full
|
|
267
|
+
* cascade: sessions revoked, the user's records and files deleted, their
|
|
268
|
+
* subscription row removed. Irreversible — put a real confirm in front
|
|
269
|
+
* of it.
|
|
270
|
+
*/
|
|
271
|
+
async delete() {
|
|
272
|
+
const result = await runtimeRequest(this.config, "/auth/delete-account", { method: "POST" });
|
|
273
|
+
await this.config.tokenStore.clear();
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
exports.AccountClient = AccountClient;
|
|
278
|
+
function isSessionResponse(value) {
|
|
279
|
+
return (typeof value === "object" &&
|
|
280
|
+
value !== null &&
|
|
281
|
+
"token" in value &&
|
|
282
|
+
typeof value.token === "string");
|
|
283
|
+
}
|
|
284
|
+
class StorageClient {
|
|
285
|
+
constructor(config) {
|
|
286
|
+
this.config = config;
|
|
287
|
+
}
|
|
288
|
+
/** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
|
|
289
|
+
collection(name) {
|
|
290
|
+
assertCollectionName(name);
|
|
291
|
+
return new CollectionClient(this.config, name);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
exports.StorageClient = StorageClient;
|
|
295
|
+
/**
|
|
296
|
+
* Talks to one collection. Collections themselves are created by the app
|
|
297
|
+
* owner in their dashboard (app.gemmein.com → data) — a 404
|
|
298
|
+
* `unknown_collection` means it doesn't exist yet: ask the owner to create
|
|
299
|
+
* it there, don't retry.
|
|
300
|
+
*/
|
|
301
|
+
class CollectionClient {
|
|
302
|
+
constructor(config, name) {
|
|
303
|
+
this.config = config;
|
|
304
|
+
this.name = name;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Create a record from your fields. The signed-in user becomes its owner.
|
|
308
|
+
*
|
|
309
|
+
* For anything two users can race for (a booking slot, a unique slug, a
|
|
310
|
+
* limited drop), pass a deterministic `key` derived from the thing that
|
|
311
|
+
* must be unique: `create(data, { key: "slot:2026-07-15T15:00" })`.
|
|
312
|
+
* The second writer gets a 409 GemmeinError `conflict` — that error IS
|
|
313
|
+
* the booking system working: catch it and tell the user it's taken.
|
|
314
|
+
* Your own retry with the same key returns the record you already made
|
|
315
|
+
* (`existing: true`) instead of a duplicate. Deleting a keyed record
|
|
316
|
+
* frees its key.
|
|
317
|
+
*
|
|
318
|
+
* On `addressed` and `direct` collections every create names its
|
|
319
|
+
* recipient: `create(data, { for: userId })` — the server stamps it,
|
|
320
|
+
* and only that user (plus the sender/owner) will ever read the record.
|
|
321
|
+
* Sending to a non-user is a 400 `invalid_audience`; a 403 `reply_only`
|
|
322
|
+
* means this collection only allows replies to people who wrote to you
|
|
323
|
+
* first — tell the user, don't retry.
|
|
324
|
+
*
|
|
325
|
+
* On the PUBLIC rules (public_read, community) pass `{ published: false }`
|
|
326
|
+
* to save a DRAFT the public can't see (the author still sees their own;
|
|
327
|
+
* the owner sees all). Publish later with
|
|
328
|
+
* `update(id, {}, { published: true })`. This is server-enforced — never
|
|
329
|
+
* fake drafts with a data field + client-side filtering: on a public
|
|
330
|
+
* collection the data still reaches everyone.
|
|
331
|
+
*/
|
|
332
|
+
async create(data, options = {}) {
|
|
333
|
+
const query = new URLSearchParams();
|
|
334
|
+
if (options.key !== undefined)
|
|
335
|
+
query.set("key", options.key);
|
|
336
|
+
if (options.for !== undefined)
|
|
337
|
+
query.set("for", options.for);
|
|
338
|
+
if (options.published !== undefined)
|
|
339
|
+
query.set("published", String(options.published));
|
|
340
|
+
const qs = query.toString();
|
|
341
|
+
return this.request(qs ? `?${qs}` : "", {
|
|
342
|
+
method: "POST",
|
|
343
|
+
body: JSON.stringify(data)
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* List records this user is allowed to see under the collection's safety
|
|
348
|
+
* rule (the app owner sees everyone's). Returns `{ records, hasMore }` —
|
|
349
|
+
* an object, not a bare array.
|
|
350
|
+
*/
|
|
351
|
+
async list(options = {}) {
|
|
352
|
+
const query = new URLSearchParams();
|
|
353
|
+
if (options.limit !== undefined)
|
|
354
|
+
query.set("limit", String(options.limit));
|
|
355
|
+
if (options.sort)
|
|
356
|
+
query.set("sort", options.sort);
|
|
357
|
+
if (options.where)
|
|
358
|
+
query.set("where", JSON.stringify(options.where));
|
|
359
|
+
if (options.cursor)
|
|
360
|
+
query.set("cursor", options.cursor);
|
|
361
|
+
if (options.search)
|
|
362
|
+
query.set("search", options.search);
|
|
363
|
+
if (options.expand && options.expand.length > 0)
|
|
364
|
+
query.set("expand", options.expand.join(","));
|
|
365
|
+
// NOT query.size — absent on Node <19.8 / older browsers, where
|
|
366
|
+
// `undefined > 0` would silently drop every filter.
|
|
367
|
+
const qs = query.toString();
|
|
368
|
+
return this.request(qs ? `?${qs}` : "");
|
|
369
|
+
}
|
|
370
|
+
async get(id, options = {}) {
|
|
371
|
+
const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
|
|
372
|
+
return this.request(`/${encodeURIComponent(id)}${qs}`);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Merge-updates `data` fields; returns the full updated record.
|
|
376
|
+
*
|
|
377
|
+
* Counters that users race for (stock, seats) must never be computed
|
|
378
|
+
* client-side — put an atomic op in value position and the server does
|
|
379
|
+
* the math on current state: `update(id, { stock: { decrement: 1,
|
|
380
|
+
* floor: 0 } })`. Breaching the floor/ceiling → 409 `conflict` ("out of
|
|
381
|
+
* stock" — the limit working, not a bug). An object is treated as an op
|
|
382
|
+
* ONLY when its keys are exactly increment|decrement (+ optional
|
|
383
|
+
* floor|ceiling), all numbers.
|
|
384
|
+
*
|
|
385
|
+
* When different people can edit the same record (CMS pages, shared
|
|
386
|
+
* docs), pass `{ ifVersion: record.version }` — a stale save gets a 409
|
|
387
|
+
* `conflict` instead of clobbering; re-read, reapply, retry.
|
|
388
|
+
*/
|
|
389
|
+
async update(id, data, options = {}) {
|
|
390
|
+
const query = new URLSearchParams();
|
|
391
|
+
if (options.ifVersion !== undefined)
|
|
392
|
+
query.set("ifVersion", String(options.ifVersion));
|
|
393
|
+
if (options.published !== undefined)
|
|
394
|
+
query.set("published", String(options.published));
|
|
395
|
+
const qs = query.toString();
|
|
396
|
+
return this.request(`/${encodeURIComponent(id)}${qs ? `?${qs}` : ""}`, {
|
|
397
|
+
method: "PATCH",
|
|
398
|
+
body: JSON.stringify(data)
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
async delete(id) {
|
|
402
|
+
await this.request(`/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
403
|
+
}
|
|
404
|
+
async upload(file, options) {
|
|
405
|
+
const name = options?.name ?? (file instanceof File ? file.name : "upload");
|
|
406
|
+
// Step 1: Get presigned upload URL
|
|
407
|
+
const presign = await this.request("/upload", {
|
|
408
|
+
method: "POST",
|
|
409
|
+
body: JSON.stringify({ name, size: file.size, contentType: file.type }),
|
|
410
|
+
});
|
|
411
|
+
// Step 2: Upload directly to S3 via presigned POST
|
|
412
|
+
const form = new FormData();
|
|
413
|
+
for (const [key, value] of Object.entries(presign.fields)) {
|
|
414
|
+
form.append(key, value);
|
|
415
|
+
}
|
|
416
|
+
form.append("file", file); // Must be last — S3 presigned POST requirement
|
|
417
|
+
const s3Response = await fetch(presign.uploadUrl, { method: "POST", body: form });
|
|
418
|
+
if (!s3Response.ok) {
|
|
419
|
+
throw new GemmeinError({
|
|
420
|
+
status: s3Response.status,
|
|
421
|
+
code: "upload_failed",
|
|
422
|
+
message: `Upload failed: ${s3Response.status}`,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
// Step 3: Confirm upload
|
|
426
|
+
const confirmed = await this.request(`/upload/${encodeURIComponent(presign.fileId)}/confirm`, {
|
|
427
|
+
method: "POST",
|
|
428
|
+
});
|
|
429
|
+
return confirmed;
|
|
430
|
+
}
|
|
431
|
+
async request(suffix, init = {}) {
|
|
432
|
+
const response = await fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.config.apiUrl), {
|
|
433
|
+
...init,
|
|
434
|
+
headers: await runtimeHeaders(this.config, {
|
|
435
|
+
"content-type": "application/json",
|
|
436
|
+
...init.headers
|
|
437
|
+
})
|
|
438
|
+
});
|
|
439
|
+
return handleResponse(response, this.config);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
exports.CollectionClient = CollectionClient;
|
|
443
|
+
class GemmeinServer {
|
|
444
|
+
constructor(options) {
|
|
445
|
+
// Same signpost law as the client: a missing key (env var didn't load)
|
|
446
|
+
// must be a typed error, never a TypeError.
|
|
447
|
+
if (!options?.secretKey || !options.secretKey.startsWith("sk_")) {
|
|
448
|
+
throw new GemmeinError({
|
|
449
|
+
status: 0,
|
|
450
|
+
code: "invalid_secret_key",
|
|
451
|
+
message: options?.secretKey?.startsWith("pk_")
|
|
452
|
+
? "Public keys (pk_) must not be used with GemmeinServer — use the Gemmein client SDK instead"
|
|
453
|
+
: 'GemmeinServer needs a secret key (it starts with "sk_") — create one in the dashboard at https://app.gemmein.com and pass it from a server env var'
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
this.apiUrl = options.apiUrl ?? "https://api.gemmein.com";
|
|
457
|
+
this.secretKey = options.secretKey;
|
|
458
|
+
}
|
|
459
|
+
collection(name) {
|
|
460
|
+
assertCollectionName(name);
|
|
461
|
+
return new ServerCollectionClient(this.apiUrl, this.secretKey, name);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Mint a member session for a test email WITHOUT an OTP round-trip — so a CI
|
|
465
|
+
* self-test ("reaffirm") can sign in as N test users and prove your app's
|
|
466
|
+
* isolation boundaries hold (user B genuinely can't read user A's private
|
|
467
|
+
* records). DEV ENVIRONMENTS ONLY: throws `test_session_forbidden_live` on an
|
|
468
|
+
* `sk_live` key, and the server refuses it too. Never ship this in app code.
|
|
469
|
+
* Pass the returned `token` to `gemmein(pk, { tokenStore })` to act as that user.
|
|
470
|
+
*/
|
|
471
|
+
async testSession(email) {
|
|
472
|
+
if (this.secretKey.startsWith("sk_live")) {
|
|
473
|
+
throw new GemmeinError({
|
|
474
|
+
status: 0,
|
|
475
|
+
code: "test_session_forbidden_live",
|
|
476
|
+
message: "test sessions are only available in a development environment — never with a live (sk_live) key",
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const response = await fetch(new URL("/server/test-session", this.apiUrl), {
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
482
|
+
body: JSON.stringify({ email }),
|
|
483
|
+
});
|
|
484
|
+
if (!response.ok) {
|
|
485
|
+
const body = await response.json().catch(() => ({ code: "request_failed", message: `Request failed: ${response.status}` }));
|
|
486
|
+
throw new GemmeinError({
|
|
487
|
+
status: response.status,
|
|
488
|
+
code: body.code ?? "request_failed",
|
|
489
|
+
message: body.message ?? `Request failed: ${response.status}`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return response.json();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
exports.GemmeinServer = GemmeinServer;
|
|
496
|
+
class ServerCollectionClient {
|
|
497
|
+
constructor(apiUrl, secretKey, name) {
|
|
498
|
+
this.apiUrl = apiUrl;
|
|
499
|
+
this.secretKey = secretKey;
|
|
500
|
+
this.name = name;
|
|
501
|
+
}
|
|
502
|
+
async get(id) {
|
|
503
|
+
return this.request(`/${encodeURIComponent(id)}`);
|
|
504
|
+
}
|
|
505
|
+
async list(options = {}) {
|
|
506
|
+
const query = new URLSearchParams();
|
|
507
|
+
if (options.limit !== undefined)
|
|
508
|
+
query.set("limit", String(options.limit));
|
|
509
|
+
if (options.sort)
|
|
510
|
+
query.set("sort", options.sort);
|
|
511
|
+
if (options.where)
|
|
512
|
+
query.set("where", JSON.stringify(options.where));
|
|
513
|
+
if (options.cursor)
|
|
514
|
+
query.set("cursor", options.cursor);
|
|
515
|
+
if (options.search)
|
|
516
|
+
query.set("search", options.search);
|
|
517
|
+
if (options.expand && options.expand.length > 0)
|
|
518
|
+
query.set("expand", options.expand.join(","));
|
|
519
|
+
// NOT query.size — absent on Node <19.8 / older browsers, where
|
|
520
|
+
// `undefined > 0` would silently drop every filter.
|
|
521
|
+
const qs = query.toString();
|
|
522
|
+
return this.request(qs ? `?${qs}` : "");
|
|
523
|
+
}
|
|
524
|
+
async update(id, data) {
|
|
525
|
+
return this.request(`/${encodeURIComponent(id)}`, {
|
|
526
|
+
method: "PATCH",
|
|
527
|
+
body: JSON.stringify(data),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async request(suffix, init = {}) {
|
|
531
|
+
const headers = {
|
|
532
|
+
"x-app-key": this.secretKey,
|
|
533
|
+
};
|
|
534
|
+
if (init.body) {
|
|
535
|
+
headers["content-type"] = "application/json";
|
|
536
|
+
}
|
|
537
|
+
const response = await fetch(new URL(`/storage/${encodeURIComponent(this.name)}${suffix}`, this.apiUrl), { ...init, headers });
|
|
538
|
+
if (response.status === 204)
|
|
539
|
+
return undefined;
|
|
540
|
+
if (!response.ok) {
|
|
541
|
+
const body = await response.json().catch(() => ({ code: "request_failed", message: `Request failed: ${response.status}` }));
|
|
542
|
+
throw new GemmeinError({
|
|
543
|
+
status: response.status,
|
|
544
|
+
code: body.code ?? "request_failed",
|
|
545
|
+
message: body.message ?? `Request failed: ${response.status}`,
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
return response.json();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async function handleResponse(response, config) {
|
|
552
|
+
if (response.status === 204)
|
|
553
|
+
return undefined;
|
|
554
|
+
if (!response.ok) {
|
|
555
|
+
const errorBody = await readErrorBody(response);
|
|
556
|
+
if (errorBody.code === "auth_expired") {
|
|
557
|
+
await config.tokenStore.clear();
|
|
558
|
+
}
|
|
559
|
+
// Signpost: a wrong/revoked key routes the builder (or their AI) to
|
|
560
|
+
// where a working key comes from, instead of dead-ending.
|
|
561
|
+
if (errorBody.code === "invalid_app_key" || errorBody.code === "missing_app_key") {
|
|
562
|
+
errorBody.message += " — get your app key from the Setup page at https://app.gemmein.com (sign in with an email code, free, no card)";
|
|
563
|
+
}
|
|
564
|
+
throw new GemmeinError({
|
|
565
|
+
status: response.status,
|
|
566
|
+
code: errorBody.code,
|
|
567
|
+
message: errorBody.message,
|
|
568
|
+
resetAt: errorBody.resetAt
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return response.json();
|
|
572
|
+
}
|
|
573
|
+
async function readErrorBody(response) {
|
|
574
|
+
try {
|
|
575
|
+
const value = await response.json();
|
|
576
|
+
if (typeof value === "object" && value !== null) {
|
|
577
|
+
const code = typeof value.code === "string"
|
|
578
|
+
? value.code
|
|
579
|
+
: typeof value.error === "string"
|
|
580
|
+
? value.error
|
|
581
|
+
: "request_failed";
|
|
582
|
+
return {
|
|
583
|
+
code,
|
|
584
|
+
message: typeof value.message === "string"
|
|
585
|
+
? value.message
|
|
586
|
+
: `Gemmein request failed: ${response.status}`,
|
|
587
|
+
resetAt: typeof value.resetAt === "string"
|
|
588
|
+
? value.resetAt
|
|
589
|
+
: undefined
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Fall through to a stable typed error.
|
|
595
|
+
}
|
|
596
|
+
return {
|
|
597
|
+
code: "request_failed",
|
|
598
|
+
message: `Gemmein request failed: ${response.status}`
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
// One request path for every runtime client (auth, subscriptions,
|
|
602
|
+
// payments, account) — same headers, same error handling, same signposts.
|
|
603
|
+
async function runtimeRequest(config, path, init = {}) {
|
|
604
|
+
const response = await fetch(new URL(path, config.apiUrl), {
|
|
605
|
+
...init,
|
|
606
|
+
headers: await runtimeHeaders(config, init.headers)
|
|
607
|
+
});
|
|
608
|
+
return handleResponse(response, config);
|
|
609
|
+
}
|
|
610
|
+
async function runtimeHeaders(config, headers) {
|
|
611
|
+
const token = await config.tokenStore.get();
|
|
612
|
+
return {
|
|
613
|
+
...headers,
|
|
614
|
+
"x-app-key": config.appKey,
|
|
615
|
+
...(token ? { authorization: `Bearer ${token}` } : {})
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
const COLLECTION_NAME_RE = /^[a-z][a-z0-9_]{1,62}$/;
|
|
619
|
+
function assertCollectionName(name) {
|
|
620
|
+
if (!COLLECTION_NAME_RE.test(name)) {
|
|
621
|
+
throw new GemmeinError({
|
|
622
|
+
status: 0,
|
|
623
|
+
code: "invalid_collection_name",
|
|
624
|
+
message: `Collection name must be lowercase letters, numbers, and underscores (e.g. "tasks", "user_notes"). Got: "${name}"`
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
}
|