@gemmein/sdk 0.8.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 +209 -0
- package/README.md +82 -19
- package/REFERENCE.md +401 -50
- 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 +330 -44
- package/dist/index.d.cts +243 -21
- package/dist/index.d.ts +243 -21
- package/dist/index.js +329 -44
- package/llms.txt +460 -78
- package/migrations/README.md +2 -0
- package/migrations/raw-calls-off.md +51 -0
- package/migrations/secure-store-set-throws.md +75 -0
- package/package.json +23 -2
package/REFERENCE.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# Gemmein SDK — API Reference
|
|
2
2
|
|
|
3
|
-
Complete surface of `@gemmein/sdk`, generated from the type definitions
|
|
3
|
+
Complete surface of `@gemmein/sdk`, generated from the type definitions —
|
|
4
|
+
with, beside each JavaScript method, the `GemmeinSwift` signature and return
|
|
5
|
+
type for the same method, and everything `@gemmein/sdk/expo` adds.
|
|
4
6
|
This is the **reference** (every method, signature, return shape, error);
|
|
5
7
|
`llms.txt` is the **guide** (how the model works and the safe patterns). Both
|
|
6
8
|
are needed. Draft — feeds docs.gemmein.com.
|
|
@@ -16,9 +18,45 @@ const g = gemmein("pk_..."); // browser: your public app key (domain-lock
|
|
|
16
18
|
|
|
17
19
|
### `gemmein(appKey, options?) → Gemmein`
|
|
18
20
|
The browser client. `appKey` is your public `pk_...` key. `options` (optional):
|
|
19
|
-
`{ apiUrl?: string, tokenStore?: TokenStore }`.
|
|
21
|
+
`{ apiUrl?: string, tokenStore?: TokenStore, fetch?: typeof fetch, visibility?: VisibilityHook, platform?: string }`.
|
|
22
|
+
Throws `GemmeinError`
|
|
20
23
|
(`missing_app_key` / `invalid_app_key`) if the key is absent or an `sk_`.
|
|
21
24
|
|
|
25
|
+
The last three are what a non-browser runtime needs and a browser never passes.
|
|
26
|
+
`fetch` is used for **every** request this client makes, in place of
|
|
27
|
+
`globalThis.fetch`. `visibility`
|
|
28
|
+
(`{ isHidden(): boolean; onChange(cb: () => void): () => void }`) is where
|
|
29
|
+
`watch()` learns the app went to the background, in place of
|
|
30
|
+
`document.visibilityState`; `onChange` returns its own unsubscribe. `platform`
|
|
31
|
+
is appended to the `x-client-info` header — `gemmein-sdk/<version> expo-ios` —
|
|
32
|
+
cleaned and capped to the 64 characters the usage ledger stores.
|
|
33
|
+
|
|
34
|
+
On Expo, `createExpoGemmein` from `@gemmein/sdk/expo` fills those three in for
|
|
35
|
+
a phone, and turns `upload()`'s picker shape into bytes Expo's fetch will
|
|
36
|
+
send. Every export that entry adds is in **Expo** below.
|
|
37
|
+
|
|
38
|
+
### `Gemmein(appKey:) → GemmeinSwift.Gemmein`
|
|
39
|
+
The Swift package, for iOS 17+ and macOS 14+, with no dependencies:
|
|
40
|
+
`.package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")`.
|
|
41
|
+
The same HTTP contract and the same method names as `@gemmein/sdk`, in Swift
|
|
42
|
+
idiom — `g.auth`, `g.collection(_:)`, `g.files`, `g.subscriptions`,
|
|
43
|
+
`g.payments`, `g.purchases`, `g.account`, `g.credits`, `g.ai`, and
|
|
44
|
+
`GemmeinServer` for Swift that runs on a server.
|
|
45
|
+
|
|
46
|
+
```swift
|
|
47
|
+
let g = try Gemmein(appKey: "pk_live_…") // KeychainTokenStore by default
|
|
48
|
+
|
|
49
|
+
try await g.auth.sendEmailCode(email)
|
|
50
|
+
_ = try await g.auth.verifyEmailCode(email: email, code: code)
|
|
51
|
+
let page = try await g.collection("notes").list(ListOptions(limit: 20))
|
|
52
|
+
let me = try await g.auth.currentUser() // .storeAccountToken for RevenueCat
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
An `sk_` key is refused here, as in the browser. Every Swift signature sits
|
|
56
|
+
beside its JavaScript twin in the sections below, under **Swift**; the client's
|
|
57
|
+
own initialiser, the shapes that differ, and the session store are in **Swift —
|
|
58
|
+
the package**.
|
|
59
|
+
|
|
22
60
|
### `gemmeinServer(secretKey, options?) → GemmeinServer`
|
|
23
61
|
Server-only client for a `sk_...` secret key — **never ship this to the
|
|
24
62
|
browser.** `options` (optional): `{ apiUrl?: string }` — **the option is
|
|
@@ -49,12 +87,32 @@ happens in the owner's dashboard, deliberately not in this SDK.
|
|
|
49
87
|
| `currentUser` | `()` | `Promise<CurrentUser>` — **never throws** for session state; safe on load |
|
|
50
88
|
| `logout` | `()` | `Promise<void>` — revokes the server session **(the method is `logout`, not `signOut`)** |
|
|
51
89
|
|
|
90
|
+
|
|
91
|
+
**Swift.** `GemmeinSwift` names the same four methods, with Swift's argument
|
|
92
|
+
labels. Everything that leaves the device is `async throws` and throws
|
|
93
|
+
`GemmeinError`.
|
|
94
|
+
|
|
95
|
+
| Swift | Signature | Returns |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| `sendEmailCode(_:)` | `func sendEmailCode(_ email: String) async throws` | `Void` |
|
|
98
|
+
| `verifyEmailCode(email:code:)` | `func verifyEmailCode(email: String, code: String) async throws -> AuthSession` | `AuthSession { token: String, expiresAt: String, user: AuthUser }` |
|
|
99
|
+
| `currentUser()` | `func currentUser() async throws -> CurrentUser` | `CurrentUser { authenticated: Bool, userId: String?, email: String?, storeAccountToken: String? }` — one struct with optionals where JS has a union: read `authenticated` first, then the rest |
|
|
100
|
+
| `logout()` | `func logout() async throws` | `Void` — idempotent |
|
|
101
|
+
|
|
52
102
|
```ts
|
|
53
103
|
type AuthSession = { token: string; expiresAt: string; user: { id: string; email: string } };
|
|
54
104
|
type CurrentUser =
|
|
55
|
-
| { authenticated: true; userId: string; email: string } // note: userId, NOT id
|
|
56
|
-
| { authenticated: false; userId?: undefined; email?: undefined };
|
|
105
|
+
| { authenticated: true; userId: string; email: string; storeAccountToken?: string | null } // note: userId, NOT id
|
|
106
|
+
| { authenticated: false; userId?: undefined; email?: undefined; storeAccountToken?: undefined };
|
|
57
107
|
```
|
|
108
|
+
`storeAccountToken` is the opaque per-person name to hand a store — RevenueCat's
|
|
109
|
+
app user id, Apple's `appAccountToken`, Google's `obfuscatedExternalAccountId`.
|
|
110
|
+
It is minted on the first `currentUser()` call, stable for the life of the
|
|
111
|
+
person, correlated to nothing, and gone when they are erased. Hand a store
|
|
112
|
+
that, never `userId` and never the address: a value inside a third party's
|
|
113
|
+
ledger is outside the erasure cascade. An engine that does not carry one leaves
|
|
114
|
+
the field absent — read that as "not carried", never as "no account". See
|
|
115
|
+
**Mobile** below.
|
|
58
116
|
`verifyEmailCode` resolves the **session** (`user.id`, nested). `currentUser`
|
|
59
117
|
resolves the **identity** (`userId`, flat). Use `currentUser()` for who's
|
|
60
118
|
signed in. Sessions are one-per-user: verifying a new code revokes older ones.
|
|
@@ -72,6 +130,11 @@ screen covers all three.
|
|
|
72
130
|
|--------|-----------|---------|
|
|
73
131
|
| `delete` | `()` | `Promise<unknown>` — erases the signed-in user, clears the stored token |
|
|
74
132
|
|
|
133
|
+
|
|
134
|
+
| Swift | Signature | Returns |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `delete()` | `@discardableResult func delete() async throws -> JSONValue?` | `JSONValue?` — the server's body as a `JSONValue?` where JS has `unknown`; `@discardableResult`, so ignoring it is legal |
|
|
137
|
+
|
|
75
138
|
The "delete my account" screen. Every app it **applies** to needs one (GDPR
|
|
76
139
|
right to erasure; Apple 5.1.1(v) for any app with account creation).
|
|
77
140
|
Server-side it's the full cascade — sessions revoked, records and files
|
|
@@ -103,15 +166,46 @@ certain the collection exists yet.
|
|
|
103
166
|
| `delete` | `(id: string)` | `Promise<void>` |
|
|
104
167
|
| `upload` | `(file: Blob \| File, options?: { name?: string; contentType?: string; for?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
|
|
105
168
|
|
|
169
|
+
|
|
170
|
+
**Swift.** The same seven methods. Three shapes differ on purpose: a record
|
|
171
|
+
body is `[String: JSONValue]` (a literal needs no ceremony —
|
|
172
|
+
`["title": "Ran", "done": false]` — a variable needs its case,
|
|
173
|
+
`["title": .string(typed)]`); `upload` takes `Data`, not a `Blob`; and `watch`
|
|
174
|
+
is not `async` — it hands back a `Watcher` you end with `stop()`.
|
|
175
|
+
|
|
176
|
+
| Swift | Signature | Returns |
|
|
177
|
+
|---|---|---|
|
|
178
|
+
| `create(_:key:for:published:)` | `@discardableResult func create(_ data: [String: JSONValue], key: String? = nil, for recipient: String? = nil, published: Bool? = nil) async throws -> GemmeinRecord` | `GemmeinRecord` |
|
|
179
|
+
| `list(_:)` | `func list(_ options: ListOptions = ListOptions()) async throws -> ListResult` | `ListResult { records: [GemmeinRecord], cursor: String?, hasMore: Bool, deleted: [String], watermark: String? }` |
|
|
180
|
+
| `watch(every:where:search:limit:onChange:)` | `func watch(every: TimeInterval? = nil, where filter: [String: JSONValue]? = nil, search: String? = nil, limit: Int? = nil, onChange: @escaping @Sendable (WatchDelta) -> Void) -> Watcher` | `Watcher` — **not `async`**; `watcher.stop()` ends it. `WatchDelta { records: [GemmeinRecord], deleted: [String], initial: Bool }` |
|
|
181
|
+
| `get(_:expand:)` | `func get(_ id: String, expand: [String] = []) async throws -> GemmeinRecord` | `GemmeinRecord` |
|
|
182
|
+
| `update(_:_:ifVersion:published:)` | `@discardableResult func update(_ id: String, _ data: [String: JSONValue], ifVersion: Int? = nil, published: Bool? = nil) async throws -> GemmeinRecord` | `GemmeinRecord` |
|
|
183
|
+
| `delete(_:)` | `func delete(_ id: String) async throws` | `Void` |
|
|
184
|
+
| `upload(_:name:contentType:for:)` | `func upload(_ data: Data, name: String = "upload", contentType: String = "", for recipient: String? = nil) async throws -> UploadedFile` | `UploadedFile { id: String, ref: String, contentType: String, sizeBytes: Int }` — `ref` is `file:<uuid>` |
|
|
185
|
+
|
|
186
|
+
`ListOptions(limit:sort:where:cursor:search:expand:since:)` — every argument
|
|
187
|
+
optional, `sort` is `ListSort` (`.newest`, `.oldest`, `.updated`), and `where`
|
|
188
|
+
keeps the label `where`, binding a `[String: JSONValue]`. `GemmeinRecord`
|
|
189
|
+
carries `id`, `data: [String: JSONValue]`, `createdAt`, `updatedAt`,
|
|
190
|
+
`ownerUserId: String?`, `collectionId`, `appId`, `environmentId`,
|
|
191
|
+
`version: Int`, `key: String?`, `audienceUserId: String?`, `published: Bool`,
|
|
192
|
+
`expand: [String: GemmeinRecord?]` and `existing: Bool?` — your fields live
|
|
193
|
+
under `data`, read as `note.data["title"]?.string`.
|
|
194
|
+
|
|
106
195
|
### Files
|
|
107
196
|
|
|
108
|
-
`upload()` returns a **reference** (`file
|
|
197
|
+
`upload()` returns a **reference** (`file:<uuid>`), not a URL. Store the reference
|
|
109
198
|
in your record — it never expires and grants nothing on its own.
|
|
110
199
|
|
|
111
200
|
| Method | Signature | Returns |
|
|
112
201
|
|--------|-----------|---------|
|
|
113
202
|
| `g.files.link` | `(ref: FileRef \| string, options?: { intent?: "inline" \| "download" })` | `Promise<{ ref, url, expiresAt?, contentType, sizeBytes?, name? }>` |
|
|
114
203
|
|
|
204
|
+
|
|
205
|
+
| Swift | Signature | Returns |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `g.files.link(_:intent:)` | `func link(_ ref: String, intent: LinkIntent? = nil) async throws -> FileLink` | `FileLink { ref: String, url: String, expiresAt: String?, contentType: String, sizeBytes: Int?, name: String? }` — **`url` is a `String`**, not a `URL`: pass it through `URL(string:)` before handing it to `AsyncImage`. `intent` is `.inline` or `.download` |
|
|
208
|
+
|
|
115
209
|
```ts
|
|
116
210
|
const { ref } = await g.collection("films").upload(file)
|
|
117
211
|
await g.collection("films").create({ title, poster: ref })
|
|
@@ -172,7 +266,7 @@ type GemmeinRecord<T> = {
|
|
|
172
266
|
version: number; // +1 per update; pass as { ifVersion } to guard concurrent edits
|
|
173
267
|
key?: string; // the create-if-absent key, when one was used
|
|
174
268
|
audienceUserId?: string; // recipient on addressed/direct collections (server-stamped)
|
|
175
|
-
published: boolean; // top-level, not under .data;
|
|
269
|
+
published: boolean; // top-level, not under .data; on EVERY record — a private record reads true
|
|
176
270
|
expand?: Record<string, GemmeinRecord | null>; // filled by list/get { expand: [...] }
|
|
177
271
|
existing?: true; // present only when a keyed create returned YOUR existing record
|
|
178
272
|
};
|
|
@@ -201,7 +295,9 @@ retry doubles as the lookup: there is no separate get-by-key call — re-issue
|
|
|
201
295
|
the same `create()` with the same key to fetch your own record (it counts
|
|
202
296
|
as a write; a re-fetch idiom, not a read path). `for` =
|
|
203
297
|
recipient id on `addressed`/`direct`. `published: false` = draft on a public
|
|
204
|
-
rule
|
|
298
|
+
rule; the OPTION is refused anywhere else (400 `invalid_publish`), while the
|
|
299
|
+
FIELD comes back on every record — a private record reads `published: true`,
|
|
300
|
+
and it is not writable there. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
|
|
205
301
|
position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
|
|
206
302
|
on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
|
|
207
303
|
`since` fixes the order (oldest change first) — pairing it with `sort` is a 400
|
|
@@ -233,6 +329,17 @@ Never hand-roll a polling loop; this is the sanctioned one.
|
|
|
233
329
|
| `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
|
|
234
330
|
| `purchases.mine` | `()` | `Promise<Purchase[]>` — the array itself (not wrapped): `{ item, kind, amountMinor, currency, refundedMinor, status, grants, paidAt, delivery? }` — gate one-off fulfilment on this, never the redirect |
|
|
235
331
|
|
|
332
|
+
|
|
333
|
+
**Swift.** Nothing navigates: `checkout` and `buy` answer a url and the app
|
|
334
|
+
opens it (`openURL`, `SFSafariViewController`).
|
|
335
|
+
|
|
336
|
+
| Swift | Signature | Returns |
|
|
337
|
+
|---|---|---|
|
|
338
|
+
| `g.subscriptions.mine()` | `func mine() async throws -> Subscription?` | `Subscription? { plan: String, status: String }` — `nil` when they never paid |
|
|
339
|
+
| `g.subscriptions.checkout(plan:)` | `func checkout(plan: String? = nil) async throws -> CheckoutSession` | `CheckoutSession { url: String, plan: String }` |
|
|
340
|
+
| `g.payments.buy(_:item:)` | `func buy(_ product: String, item: String? = nil) async throws -> PaymentSession` | `PaymentSession { url: String, product: String, item: String? }` |
|
|
341
|
+
| `g.purchases.mine()` | `func mine() async throws -> [Purchase]` | `[Purchase]` — `item`, `kind`, `amountMinor: Int?`, `currency: String?`, `refundedMinor: Int`, `status`, `grants: [String]`, `paidAt`, `delivery: Purchase.Delivery?` (`.gemmeinFile(String)` or `.externalURL(String)` — an enum where JS has a tagged object) |
|
|
342
|
+
|
|
236
343
|
Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
237
344
|
`buy` self-navigate via `window.location` — just `await` them on the click;
|
|
238
345
|
don't also redirect to the returned `url`, and never build a Stripe URL
|
|
@@ -278,6 +385,14 @@ honestly, not hidden.)
|
|
|
278
385
|
carries a footer naming why it arrived; replies land in the owner's Inbox
|
|
279
386
|
as a thread (`threadId`), and `replyRail` states honestly whether
|
|
280
387
|
reply-by-email is on. Every send is on the record.
|
|
388
|
+
- **Your domain, or nothing.** `notify()` sends only from your verified
|
|
389
|
+
sender domain. Until one is verified on the Domains page the send is
|
|
390
|
+
refused `409 sender_domain_required` — nothing leaves, nothing is
|
|
391
|
+
recorded, no cap is spent, and the same `key` sends once you verify. If
|
|
392
|
+
the provider refuses that address at send time you get `502 send_failed`
|
|
393
|
+
and can retry; the email is never re-sent from a Gemmein address. Sign-in
|
|
394
|
+
codes are the one email Gemmein sends on your behalf before a domain is
|
|
395
|
+
verified, as `<App name> (via Gemmein)`.
|
|
281
396
|
|
|
282
397
|
---
|
|
283
398
|
|
|
@@ -297,6 +412,12 @@ the message), never partly. No expiry. No per-token pricing.
|
|
|
297
412
|
| `gemmeinServer(sk).spendCredits` | `(personId, { amount?: number; reason: string; key?: string })` | `Promise<{ ok: true; spent: number; deduped: boolean; balance: { before: number; after: number }; event: { id, reason, actor } }>` — `amount` defaults to 1 (1..10,000); `reason` ≤ 200 chars is what the owner reads on the person's ledger; `key` makes the spend at-most-once per person (a repeat answers `deduped: true`, `spent: 0`, the same `event`, and moves nothing; one key reused for a second person charges that person). Needs **"Spend a person's credits"** ticked on the key (`403 capability_required` otherwise) |
|
|
298
413
|
| `Holdings.credits` | — | `{ balance: number }` on `verifySession()` and `holdings()` from engine 0.8.0 (`null` from an older local engine) — one verify answers who, what they hold and how many |
|
|
299
414
|
|
|
415
|
+
|
|
416
|
+
| Swift | Signature | Returns |
|
|
417
|
+
|---|---|---|
|
|
418
|
+
| `g.credits.balance()` | `func balance() async throws -> Int` | **`Int`** — the balance itself, where JS answers `{ balance }`. Read it directly |
|
|
419
|
+
| `gemmeinServer(sk).spendCredits` | `@discardableResult func spendCredits(_ personId: String, amount: Int? = nil, reason: String, key: String? = nil) async throws -> SpendCreditsResult` | `SpendCreditsResult { spent: Int, deduped: Bool, balanceBefore: Int, balanceAfter: Int, event: Event }` — two flat `Int`s where JS nests `balance: { before, after }` |
|
|
420
|
+
|
|
300
421
|
Where credits come from, and where they go:
|
|
301
422
|
|
|
302
423
|
- **A product with `grantsCredits`** (the "Grants credits" field on the
|
|
@@ -353,30 +474,99 @@ connected.
|
|
|
353
474
|
|
|
354
475
|
---
|
|
355
476
|
|
|
356
|
-
## AI — `g.ai.chat` / `g.ai.text`
|
|
477
|
+
## AI — `g.ai.run` / `g.ai.runText` / `g.ai.calls` (and the raw `g.ai.chat` / `g.ai.text`)
|
|
357
478
|
|
|
358
479
|
Your app talks to **OpenAI, Anthropic or Google** through Gemmein, on the
|
|
359
480
|
**owner's own provider key**, which never reaches the browser, through a
|
|
360
|
-
**named AI tool
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
the
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
481
|
+
**named AI tool whose definition lives on the server** (W9.6): the
|
|
482
|
+
instructions (system prompt), the prompt template, the inputs it accepts,
|
|
483
|
+
the pinned model, the caps — beside the price in credits and the
|
|
484
|
+
entitlement gate. The app sends a **name and inputs**; the server composes
|
|
485
|
+
the provider request in the provider's own grammar, gates it, spends the
|
|
486
|
+
tool's credits, runs it and streams the answer back. The owner prices and
|
|
487
|
+
gates the tool on the dashboard's AI tools page; the implementation is
|
|
488
|
+
written there too, or as a file at `gemmein/ai/tools/<name>.json` that
|
|
489
|
+
`npx gemmein sync` carries (`--live` into production with a sync key). A
|
|
490
|
+
file carries at least `label`, `provider` and `credits` (its name is the
|
|
491
|
+
file name). On every sync the file's implementation (`provider`, `model`,
|
|
492
|
+
`kind`, `instructions`, `promptTemplate`, `inputs`, `bounds`) applies; its
|
|
493
|
+
commerce values (`label`, `credits`, `requires`, `enabled`, `recordCalls`)
|
|
494
|
+
apply once, at creation — after that the dashboard owns them. In
|
|
495
|
+
development, sync also writes a tool that exists only in the cloud back to
|
|
496
|
+
`gemmein/ai/tools/` as a file; in production such a row is left as it is.
|
|
497
|
+
A tool's name is fixed once created. Removing a tool is a step-up action, like
|
|
498
|
+
removing a key.
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
**Swift.** `AiResponse` stands where JS returns the fetch `Response`, and
|
|
502
|
+
`run` / `runText` take the label **`inputs:`** —
|
|
503
|
+
`runText("summary", inputs: ["text": .string(body)])`.
|
|
504
|
+
|
|
505
|
+
| Swift | Signature | Returns |
|
|
506
|
+
|---|---|---|
|
|
507
|
+
| `run(_:inputs:stream:)` | `func run(_ tool: String, inputs: [String: JSONValue] = [:], stream: Bool = false) async throws -> AiResponse` | `AiResponse` — `stream` is a plain `Bool` argument, not an options object |
|
|
508
|
+
| `runText(_:inputs:)` | `func runText(_ tool: String, inputs: [String: JSONValue] = [:]) async throws -> String` | `String` — the label is **`inputs:`** |
|
|
509
|
+
| `calls(limit:before:)` | `func calls(limit: Int? = nil, before: String? = nil) async throws -> AiCallPage` | `AiCallPage { calls: [AiCallRecord], nextCursor: String? }` |
|
|
510
|
+
| `chat(_:provider:tool:)` | `func chat(_ body: [String: JSONValue], provider: AiProvider? = nil, tool: String? = nil) async throws -> AiResponse` | `AiResponse` |
|
|
511
|
+
| `text(_:provider:tool:)` | `func text(_ body: [String: JSONValue], provider: AiProvider? = nil, tool: String? = nil) async throws -> String` | `String` |
|
|
512
|
+
|
|
513
|
+
`AiResponse` carries `status: Int`, `headers: [String: String]`, `ok: Bool`,
|
|
514
|
+
`creditsRemaining: Int?`, `refunded: Bool`, `tool: String?`, `isFake: Bool`,
|
|
515
|
+
`func data() async throws -> Data`, and two streams —
|
|
516
|
+
`func lines() -> AsyncThrowingStream<String, Error>` and
|
|
517
|
+
`func events() -> AsyncThrowingStream<String, Error>` (the SSE `data:`
|
|
518
|
+
payloads). `AiProvider` is `.openai`, `.anthropic`, `.google`.
|
|
519
|
+
|
|
520
|
+
**Raw calls are off by default.** `g.ai.chat(body)` — the browser sending
|
|
521
|
+
the provider's own request body — answers `403 raw_calls_off` unless the
|
|
522
|
+
owner switches **raw calls** on for that provider's key on the AI tools page. Behind the switch, the raw route is what it always was: the body is
|
|
523
|
+
forwarded as sent (minus `provider`, and for Google minus `model` and
|
|
524
|
+
`stream`, which ride the URL), the provider's own status and bytes pass
|
|
525
|
+
straight back, a stream stays a stream, and a call that names no `tool`
|
|
526
|
+
runs as the **default tool** (one credit, the configured provider, any
|
|
527
|
+
allowed model). `gemmein dev` with no key keeps raw calls open — the fake
|
|
528
|
+
answers and there is no switch locally. Both routes are for the browser
|
|
529
|
+
only: a server key is refused (`403 scope_denied`) — a server calls the
|
|
530
|
+
provider directly.
|
|
531
|
+
|
|
532
|
+
**Every call lands on `ai_calls`**, the managed collection: who, which tool,
|
|
533
|
+
kind, provider, model, tokens in/out (when the provider said), credits,
|
|
534
|
+
outcome, refusal code, latency, when. The **prompt and answer** ride the row
|
|
535
|
+
only when the owner switches *record calls* on for that tool — off by
|
|
536
|
+
default. A person reads their own (`g.ai.calls()`), the owner reads by app
|
|
537
|
+
in the back office, erasure removes a person's rows.
|
|
375
538
|
|
|
376
539
|
| Method | Signature | Returns |
|
|
377
540
|
|--------|-----------|---------|
|
|
378
|
-
| `ai.
|
|
379
|
-
| `ai.
|
|
541
|
+
| `ai.run` | `(tool: string, inputs?: Record<string, string \| number \| boolean>, options?: { stream?: boolean; signal?: AbortSignal })` | `Promise<Response>` — the tool's provider's own answer, untouched (SSE when `stream`). A non-2xx from the provider is returned as-is; a Gemmein refusal throws `GemmeinError` |
|
|
542
|
+
| `ai.runText` | `(tool: string, inputs?: …, options?: { signal?: AbortSignal })` | `Promise<string>` — a non-stream run collected to one string, whichever provider answered; a provider's non-2xx throws `provider_error` |
|
|
543
|
+
| `ai.calls` | `(options?: { limit?: number; before?: string })` | `Promise<{ calls: AiCallRecord[]; nextCursor: string \| null }>` — the signed-in person's own calls, newest first |
|
|
544
|
+
| `ai.chat` | `(body: object, options?: { tool?: string; provider?: "openai" \| "anthropic" \| "google"; signal?: AbortSignal })` | `Promise<Response>` — RAW (needs the owner's switch): the fetch `Response`, untouched |
|
|
545
|
+
| `ai.text` | `(body: object, options?: { tool?: string; provider?: …; signal?: AbortSignal })` | `Promise<string>` — RAW, non-stream, one string |
|
|
546
|
+
|
|
547
|
+
- **The tool file.** `name` (the file name; fixed once created), `label`,
|
|
548
|
+
`provider`, `model` (pin it — a composed call needs one; with none, the
|
|
549
|
+
owner's first allowed model, else the provider's small default),
|
|
550
|
+
`credits`, `requires`, `enabled`, `bounds`, plus the implementation:
|
|
551
|
+
`kind` (`chat`, the first), `instructions` (the system turn, ≤ 20,000
|
|
552
|
+
chars — never leaves the server), `promptTemplate` (the user turn with
|
|
553
|
+
`{{input}}` placeholders naming declared inputs; ≤ 20,000; absent = the
|
|
554
|
+
inputs rendered one per line as `name: value`), `inputs` (≤ 20 of
|
|
555
|
+
`{ name, type: text|number|boolean, required?, maxLength? }` — names
|
|
556
|
+
1–40 lowercase letters/digits/underscores, starting with a letter; a
|
|
557
|
+
text input ≤ 4,000 chars
|
|
558
|
+
unless it says, ≤ 20,000), `recordCalls` (off by default). A tool with
|
|
559
|
+
neither a template nor inputs composes nothing: `409 tool_incomplete`.
|
|
560
|
+
- **Inputs are checked by name** before any spend: an unknown input, a
|
|
561
|
+
missing required one, a wrong type or a value over its cap is
|
|
562
|
+
`400 invalid_inputs` — the message names the input and the rule. The run
|
|
563
|
+
body is `{ inputs, stream? }` and at most 64 KB.
|
|
564
|
+
- **Composition** per provider: OpenAI `messages` (system + user),
|
|
565
|
+
`max_completion_tokens`; Anthropic `system` + `messages`, `max_tokens`;
|
|
566
|
+
Google `systemInstruction` + `contents`, `generationConfig.maxOutputTokens`.
|
|
567
|
+
The output ceiling is `bounds.maxOutputTokens` or 4,096 — a composed
|
|
568
|
+
call is never "as long as the model likes". `bounds.stream: false`
|
|
569
|
+
forces a non-stream answer.
|
|
380
570
|
|
|
381
571
|
- `tool` names an `ai_tools` row by its slug; omit it for the default tool.
|
|
382
572
|
An unknown name is `404 unknown_tool`; a tool the owner switched off is
|
|
@@ -397,7 +587,7 @@ calls the provider directly.
|
|
|
397
587
|
OpenAI chat completions, Anthropic messages, Google generateContent. Its
|
|
398
588
|
`model` field, when present, must match `^[A-Za-z0-9._:-]{1,80}$`; Google
|
|
399
589
|
needs it (it rides the URL); when the owner lists allowed models (up to
|
|
400
|
-
20 — the
|
|
590
|
+
20 — the AI tools page's test call uses the first), any other answers
|
|
401
591
|
`403 model_not_allowed`.
|
|
402
592
|
- `provider` is optional when one key is configured and required when more
|
|
403
593
|
than one is (`400 provider_required`); a named tool fixes its own
|
|
@@ -418,31 +608,45 @@ calls the provider directly.
|
|
|
418
608
|
- Limits: 20 calls per person per minute (`429 ai_capped`, `resetAt`), 256 KB
|
|
419
609
|
body by default (`413 payload_too_large`, unless a tool sets a smaller
|
|
420
610
|
`bounds.maxBodyBytes`) nested at most 32 levels (`400 invalid_body`),
|
|
421
|
-
170 s in all and, on a stream, 10 s to the first response headers.
|
|
611
|
+
170 s in all and, on a stream, 10 s to the first response headers. A
|
|
612
|
+
tool's `credits` are 1–10,000; `bounds.maxOutputTokens` is 1–100,000,
|
|
613
|
+
4,096 when the tool sets none. Every
|
|
422
614
|
`/ai/chat` call counts toward the app's `api_requests` band like any other
|
|
423
615
|
request.
|
|
424
616
|
|
|
425
617
|
```ts
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
618
|
+
// gemmein/ai/tools/deep-research.json
|
|
619
|
+
// { "label": "Deep Research", "provider": "openai", "model": "gpt-4o", "credits": 20,
|
|
620
|
+
// "requires": "access:pro-max",
|
|
621
|
+
// "instructions": "You are a careful research assistant. Answer with sources.",
|
|
622
|
+
// "promptTemplate": "Research this for a {{audience}} reader:\n\n{{question}}",
|
|
623
|
+
// "inputs": [{ "name": "question", "type": "text", "required": true, "maxLength": 2000 },
|
|
624
|
+
// { "name": "audience", "type": "text" }],
|
|
625
|
+
// "bounds": { "maxOutputTokens": 4000 } }
|
|
626
|
+
|
|
627
|
+
const res = await g.ai.run("deep-research", { question: text, audience: "beginner" }, { stream: true })
|
|
430
628
|
for await (const chunk of res.body) render(chunk) // the provider's SSE, byte for byte
|
|
431
629
|
|
|
432
|
-
const answer = await g.ai.
|
|
630
|
+
const answer = await g.ai.runText("deep-research", { question: text })
|
|
631
|
+
const { calls } = await g.ai.calls() // the person's own history
|
|
632
|
+
|
|
633
|
+
// RAW — only behind the owner's "raw calls" switch for that provider key:
|
|
634
|
+
const raw = await g.ai.chat({ model: "gpt-4o-mini", stream: true, messages: [{ role: "user", content: text }] })
|
|
433
635
|
```
|
|
434
636
|
|
|
435
637
|
| code | status | meaning · do |
|
|
436
638
|
|------|--------|--------------|
|
|
639
|
+
| `raw_calls_off` | 403 | The browser may not compose provider requests for this provider — call a named tool with `g.ai.run`, or the owner switches raw calls on for the key on the AI tools page |
|
|
640
|
+
| `invalid_inputs` | 400 | An input is unknown, missing, the wrong type or over its cap — the message names it |
|
|
641
|
+
| `tool_incomplete` | 409 | The tool composes nothing (no template, no inputs) — give it one; the owner's or the file's fix |
|
|
437
642
|
| `unknown_tool` | 404 | No AI tool by this name in this environment — check the slug or the environment |
|
|
438
643
|
| `tool_disabled` | 403 | The owner switched this tool off — turn it on, or use another |
|
|
439
644
|
| `entitlement_required` | 403 | The tool's `requires` names a plan or product this person lacks — the message names it |
|
|
440
645
|
| `model_pinned` | 403 | This tool's model is fixed — leave `model` out of the body |
|
|
441
|
-
| `provider_not_configured` | 409 | Creating or updating a tool: no key is set for that provider yet — add one on the Keys page first |
|
|
442
646
|
| `too_many_tools` | 409 | This environment already holds 50 AI tools — delete one before adding another |
|
|
443
647
|
| `invalid_tool` | 400 | Creating or updating a tool with a bad field — the message names which one and its rule |
|
|
444
648
|
| `credits_exhausted` | 402 | The person's balance is below the tool's price — the message names the tool, the price and the balance ("Deep Research costs 20 credits. You have 7.", singular for 1). Show the pack |
|
|
445
|
-
| `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one
|
|
649
|
+
| `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one on the AI tools page. A tool can be defined before its key exists; it runs the moment the key is pasted, and the console shows it as waiting on one until then |
|
|
446
650
|
| `provider_required` | 400 | More than one provider key is set — pass `provider` |
|
|
447
651
|
| `model_not_allowed` | 403 | The owner's allowlist names the models this app may call; the message lists them (only for a tool with no pinned model) |
|
|
448
652
|
| `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
|
|
@@ -451,13 +655,14 @@ const answer = await g.ai.text({ messages: [{ role: "user", content: text }] },
|
|
|
451
655
|
| `session_required` | 401 | No signed-in person — sign in first |
|
|
452
656
|
| `scope_denied` | 403 | A secret key called the route — the route is for the browser; a server calls the provider directly |
|
|
453
657
|
| `provider_unreachable` | 502 | The provider did not answer before the first byte — nothing was charged (the tool's credits are refunded); retry |
|
|
454
|
-
| `provider_error` | the provider's | `g.ai.text`
|
|
455
|
-
| `
|
|
658
|
+
| `provider_error` | the provider's | `g.ai.text` and `g.ai.runText` (client-side): the provider's own non-2xx, its message in `err.message` |
|
|
659
|
+
| `invalid_response` | 0 (client-side) | `g.ai.text` and `g.ai.runText`: a non-stream answer with no text to lift out — for a streaming body read the stream with `g.ai.chat` or `g.ai.run` |
|
|
660
|
+
| `ai_test_capped` | 429 | The AI tools page's test call — one a minute per app |
|
|
456
661
|
|
|
457
662
|
`gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
|
|
458
663
|
fake`, an echo stream), so the loop runs locally; set
|
|
459
664
|
`GEMMEIN_AI_KEY_OPENAI`, `GEMMEIN_AI_KEY_ANTHROPIC` or `GEMMEIN_AI_KEY_GOOGLE`
|
|
460
|
-
in the local rail's environment for a real call. The owner's Usage
|
|
665
|
+
in the local rail's environment for a real call. The owner's Usage & billing page
|
|
461
666
|
counts AI calls for the last 30 days: spent by the customers' credits, priced
|
|
462
667
|
by the provider — Gemmein meters the calls, the provider bills the tokens.
|
|
463
668
|
|
|
@@ -477,6 +682,32 @@ signed in, it can create them by email first.
|
|
|
477
682
|
| `grantAccess` | `(personId, { entitlement, source?, expiresAt?, reason? })` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` (201) — `holdings` is the state **after**; the owner's audit row carries before→after and the key's name |
|
|
478
683
|
| `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
|
|
479
684
|
| `invitePerson` | `(email)` | `Promise<{ person: InvitedPerson, created: boolean }>` — create a person by email **before they sign in** (201 `created: true`), or find them (200 `created: false`); idempotent, case-insensitive, one id. `InvitedPerson = { id, email, role, invited, suspended }` — `invited` stays true until their first sign-in; a suspended person is returned flagged. The one server call that takes an email |
|
|
685
|
+
| `notify` | `(personId, { subject, text, kind?, key? })` | `Promise<{ sent, deduped?, recorded?, id, threadId, replyRail? }>` — email one of your verified people by id; see **Notify** above |
|
|
686
|
+
| `spendCredits` | `(personId, { amount?, reason, key? })` | `Promise<{ ok, spent, deduped, balance: { before, after }, event }>` — one conditional decrement, floored at zero; see **Credits** above |
|
|
687
|
+
| `collection(name).get / .list / .update` | `(as the browser client)` | scoped per collection on the key; no creates, no deletes, no auth access; see **Setup** |
|
|
688
|
+
| `testSession` | `(email)` | `Promise<{ token, expiresAt, user }>` — development only; `sk_live` throws `test_session_forbidden_live`; see **Reaffirm** below |
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
**Swift.** `GemmeinServer` ships in the Swift package for Swift that runs on a
|
|
692
|
+
server. It takes a secret key, and a secret key never belongs in an app bundle
|
|
693
|
+
— `Gemmein` refuses one.
|
|
694
|
+
|
|
695
|
+
| Swift | Signature | Returns |
|
|
696
|
+
|---|---|---|
|
|
697
|
+
| `verifySession(_:)` | `func verifySession(_ token: String) async throws -> PersonHoldings` | `PersonHoldings { person: GatePerson, holdings: Holdings }` — no `ok` field; a refusal throws |
|
|
698
|
+
| `holdings(_:)` | `func holdings(_ personId: String) async throws -> PersonHoldings` | `PersonHoldings` |
|
|
699
|
+
| `grantAccess(_:entitlement:source:expiresAt:reason:)` | `@discardableResult func grantAccess(_ personId: String, entitlement: String, source: ManualGrantSource? = nil, expiresAt: String? = nil, reason: String? = nil) async throws -> GrantResult` | `GrantResult { grant: Grant, holdings: Holdings }` |
|
|
700
|
+
| `revokeAccess(_:_:reason:)` | `@discardableResult func revokeAccess(_ personId: String, grantId: String, reason: String? = nil) async throws -> GrantResult` | `GrantResult` |
|
|
701
|
+
| `invitePerson(_:)` | `func invitePerson(_ email: String) async throws -> InviteResult` | `InviteResult { person: GatePerson, created: Bool }` |
|
|
702
|
+
| `notify(_:subject:text:kind:key:)` | `@discardableResult func notify(_ personId: String, subject: String, text: String, kind: NotifyKind? = nil, key: String? = nil) async throws -> NotifyResult` | `NotifyResult { sent: Bool, deduped: Bool?, id: String?, threadId: String?, replyRail: Bool?, recorded: Bool? }` — `subject` and `text` are required labels, not an options object |
|
|
703
|
+
| `spendCredits(_:amount:reason:key:)` | see **Credits** above | `SpendCreditsResult` |
|
|
704
|
+
| `collection(_:)` | `func collection(_ name: String) throws -> ServerCollectionClient` | `ServerCollectionClient` — `func get(_ id: String) async throws -> GemmeinRecord`, `func list(_ options: ListOptions = ListOptions()) async throws -> ListResult`, `@discardableResult func update(_ id: String, _ data: [String: JSONValue]) async throws -> GemmeinRecord` |
|
|
705
|
+
| `testSession(_:)` | `func testSession(_ email: String) async throws -> AuthSession` | `AuthSession` — development only |
|
|
706
|
+
|
|
707
|
+
`GatePerson { id: String, email: String, role: String, suspended: Bool?, invited: Bool? }`,
|
|
708
|
+
`Holdings { access: [String], grants: [Grant], credits: Int? }` — a bare `Int?`
|
|
709
|
+
where JS nests `{ balance }`. `ManualGrantSource` is `.manual`, `.trial`,
|
|
710
|
+
`.promotion`, `.migration`; `NotifyKind` is `.event` or `.account`.
|
|
480
711
|
|
|
481
712
|
```ts
|
|
482
713
|
type Holdings = {
|
|
@@ -530,7 +761,7 @@ type Grant = {
|
|
|
530
761
|
make two grants — call it once and keep your own retry key.
|
|
531
762
|
- **Nothing silent, within a stated bound.** Every `/server/*` call a **resolved
|
|
532
763
|
secret key** makes, ok or refused, lands in that key's usage ledger — the owner
|
|
533
|
-
reads the summary on their
|
|
764
|
+
reads the summary on their Secret keys page and the full day × route × outcome table in
|
|
534
765
|
the key's own room. A rejected or publishable (`pk_`) key can't be attributed to a
|
|
535
766
|
key row, so its refusal reaches only the request log. Refusals also write one audit
|
|
536
767
|
row per key, route and code each hour (exact counts stay in the ledger); grants and
|
|
@@ -569,8 +800,10 @@ provider's webhook arriving, a clock, a record changing — and one to ten of
|
|
|
569
800
|
Gemmein's **own** verbs, run in order: write a record, grant or revoke access,
|
|
570
801
|
email the person, call your URL. Gemmein runs no code of yours inside one;
|
|
571
802
|
compute lives on your host, behind `call_url`. The definition is a JSON file
|
|
572
|
-
your AI writes
|
|
573
|
-
|
|
803
|
+
your AI writes, or the owner writes in the dashboard, which creates and edits
|
|
804
|
+
it too, pauses, resumes, rotates and deletes it, and shows where it came from
|
|
805
|
+
("from file, synced <when>" / "edited here <when>"), its receiver URL, its
|
|
806
|
+
secrets (shown once), every event with each action's result, and a replay
|
|
574
807
|
button. There is no SDK method: the surface is the file and the dashboard.
|
|
575
808
|
Stripe stays built in; any provider that signs its webhooks — GoCardless, Paddle,
|
|
576
809
|
Lemon Squeezy among them — drives access the same way through a relay, and the
|
|
@@ -673,7 +906,7 @@ authorise on fields they cannot set, or from a receiver.
|
|
|
673
906
|
| `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
|
|
674
907
|
| `fulfil_product` | `product` (name, 1-40 chars), `ref?` (≤ 200, templated) | grants the product's key and credits to the event's person and writes the receipt, exactly as a Stripe purchase does; idempotent on `ref` (an absent `ref` → `rly:<eventId>`; a `ref` you wrote that names nothing on the event is refused, never replaced); a ref must be the provider's unique payment identifier; a ref already used by another purchase is refused and audited; the product's road must be this relay. The relay road binds by name: renaming or deleting the relay stops fulfilment until a relay with that name exists again; the product card shows it. |
|
|
675
908
|
| `refund_product` | `product` (name, 1-40 chars), `ref?` (≤ 200, templated) | takes back what `fulfil_product` under the same `ref` granted; `refund_product` may run from any relay in the environment; it refunds only a purchase a relay fulfilled, for the event's person and the product it names; idempotent on `ref` |
|
|
676
|
-
| `email_person` | `subject` (≤ 300), `text` (≤ 10,000), `kind?: "event" \| "account"` | rides `notify()`'s caps (200 per app per hour, 5 event sends per person per day) and the owner's sends switch; deduped per event |
|
|
909
|
+
| `email_person` | `subject` (≤ 300), `text` (≤ 10,000), `kind?: "event" \| "account"` | rides `notify()`'s caps (200 per app per hour, 5 event sends per person per day) and the owner's sends switch; deduped per event; and rides `notify()`'s sender law — it goes only from your verified sender domain, and until one is verified this action alone is refused `sender_domain_required` and the event's result says so. Nothing is sent via Gemmein |
|
|
677
910
|
| `call_url` | `url` | https only, no template in the URL, no IP literal, never a gemmein.com host, no credentials in the URL; `gemmein dev` allows http to localhost |
|
|
678
911
|
|
|
679
912
|
**The `call_url` contract.** Gemmein POSTs JSON:
|
|
@@ -760,7 +993,16 @@ receiver's local URL), schedules and data changes with the same runner and
|
|
|
760
993
|
prints `RELAY · <name> · <trigger> · <n actions> · ok|failed`;
|
|
761
994
|
`npx gemmein sync` carries the files to the cloud app's development
|
|
762
995
|
environment with the collections — contract, never data; the cloud mints its
|
|
763
|
-
own secrets.
|
|
996
|
+
own secrets. After go-live, `npx gemmein sync --live` carries relays and AI
|
|
997
|
+
tools into production: it asks for a **sync key** (Secret keys → production →
|
|
998
|
+
Sync key — step-up to mint, lives one hour, shown once, never saved by the
|
|
999
|
+
CLI), prints what would change, waits for the word `live`, never deletes, and
|
|
1000
|
+
refuses collections (they move by promotion). A row edited in the dashboard
|
|
1001
|
+
since the last sync is asked about, per item (`--overwrite` answers yes);
|
|
1002
|
+
every relay says where its definition came from — from file, or edited in the
|
|
1003
|
+
dashboard — and when. A relay's name is fixed once created: the receiver URL
|
|
1004
|
+
is built from it, so a rename is refused (`invalid_definition`) — create the
|
|
1005
|
+
new one, delete the old in the dashboard once the provider has moved.
|
|
764
1006
|
|
|
765
1007
|
---
|
|
766
1008
|
|
|
@@ -793,7 +1035,10 @@ line; a wrong-but-well-formed name surfaces as `unknown_collection` and exits
|
|
|
793
1035
|
|
|
794
1036
|
### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
|
|
795
1037
|
**Dev only** — throws `test_session_forbidden_live` on an `sk_live` key, and
|
|
796
|
-
the server refuses it on a live environment too.
|
|
1038
|
+
the server refuses it on a live environment too. Test **people** only: a
|
|
1039
|
+
server key is refused the account owner's or an admin's email
|
|
1040
|
+
(`scope_denied`) — the app's CLI key (Setup page) is the credential that
|
|
1041
|
+
acts as the founder, and it is the only key that links or syncs. Pass the `token` to
|
|
797
1042
|
`gemmein(pk, { tokenStore })` to act as that user. Dev and live enforce the
|
|
798
1043
|
*same* rules, so what is proven in dev holds in live.
|
|
799
1044
|
|
|
@@ -817,6 +1062,102 @@ current. Add a probe whenever you add a feature.
|
|
|
817
1062
|
|
|
818
1063
|
---
|
|
819
1064
|
|
|
1065
|
+
## Swift — the package
|
|
1066
|
+
|
|
1067
|
+
`GemmeinSwift` is the same HTTP contract and the same method names as
|
|
1068
|
+
`@gemmein/sdk`, in Swift idiom, for iOS 17+ and macOS 14+, with no
|
|
1069
|
+
dependencies:
|
|
1070
|
+
|
|
1071
|
+
```swift
|
|
1072
|
+
.package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")
|
|
1073
|
+
```
|
|
1074
|
+
|
|
1075
|
+
Every method is beside its JavaScript twin in the sections above, under
|
|
1076
|
+
**Swift**. Argument labels are as written there; everything that leaves the
|
|
1077
|
+
device is `async throws` and throws `GemmeinError`. The parity check
|
|
1078
|
+
(`tests/security/sdkParity.test.ts`) holds the two method lists equal and
|
|
1079
|
+
`tests/security/docsSwiftReference.test.ts` holds every signature on this page
|
|
1080
|
+
to the package's own source, so a signature here is the signature the compiler
|
|
1081
|
+
sees.
|
|
1082
|
+
|
|
1083
|
+
```swift
|
|
1084
|
+
let g = try Gemmein(appKey: "pk_live_…")
|
|
1085
|
+
let notes = try g.collection("notes")
|
|
1086
|
+
let page = try await notes.list(ListOptions(limit: 20, sort: .newest))
|
|
1087
|
+
let note = try await notes.create(["title": .string(typed), "done": false])
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
### The client
|
|
1091
|
+
|
|
1092
|
+
| Swift | Signature | Returns |
|
|
1093
|
+
|---|---|---|
|
|
1094
|
+
| `Gemmein(appKey:…)` | `init(appKey: String, apiURL: URL = defaultAPIURL, tokenStore: TokenStore? = nil, platform: String? = nil, session: URLSession = .shared) throws` | `Gemmein` — throws `invalid_app_key` on an `sk_`, `missing_app_key` on anything that is not a `pk_` |
|
|
1095
|
+
| `g.collection(_:intent:)` | `func collection(_ name: String, intent: String? = nil) throws -> CollectionClient` | `CollectionClient` — `g.storage.collection(_:intent:)` is the same factory, the same signature |
|
|
1096
|
+
| `GemmeinServer(secretKey:…)` | `init(secretKey: String, apiURL: URL = defaultAPIURL, session: URLSession = .shared) throws` | `GemmeinServer` — see **Server gate** above |
|
|
1097
|
+
|
|
1098
|
+
`apiURL` points at a local `gemmein dev` engine while you build; `tokenStore`
|
|
1099
|
+
defaults to `KeychainTokenStore`, so a relaunch keeps the person signed in;
|
|
1100
|
+
`platform` defaults to this OS and is appended to `x-client-info`.
|
|
1101
|
+
`g.auth`, `g.storage`, `g.subscriptions`, `g.payments`, `g.purchases`,
|
|
1102
|
+
`g.account`, `g.files`, `g.credits`, `g.ai` and `g.tokenStore` are `public let`
|
|
1103
|
+
properties on `Gemmein`.
|
|
1104
|
+
|
|
1105
|
+
### The three shapes that differ from JavaScript
|
|
1106
|
+
|
|
1107
|
+
- `credits.balance()` answers a bare **`Int`** — the number itself, not a
|
|
1108
|
+
`{ balance }` wrapper.
|
|
1109
|
+
- `files.link(_:)` answers a `FileLink` whose `url` is a **`String`**; pass it
|
|
1110
|
+
through `URL(string:)` before `AsyncImage`.
|
|
1111
|
+
- A record body and an AI body are `[String: JSONValue]`. A **literal** needs
|
|
1112
|
+
no ceremony (`["title": "Ran", "done": false, "seats": 4]`); a **variable**
|
|
1113
|
+
needs its case (`["title": .string(typed), "seats": .int(count)]`).
|
|
1114
|
+
|
|
1115
|
+
### The session store
|
|
1116
|
+
|
|
1117
|
+
`TokenStore` is a protocol — `func get() async -> String?`,
|
|
1118
|
+
`func set(_ token: String) async throws`, `func clear() async`.
|
|
1119
|
+
`KeychainTokenStore(appKey:)` is the default and `MemoryTokenStore(token:)` is
|
|
1120
|
+
there for tests and for the app that catches `secure_store_unavailable`.
|
|
1121
|
+
Only `set` throws, on all three of the SDK's stores.
|
|
1122
|
+
|
|
1123
|
+
Everything the package throws is a `GemmeinError`
|
|
1124
|
+
(`status: Int`, `code: String`, `message: String`, `resetAt: String?`,
|
|
1125
|
+
`requires: String?`) — the same table below.
|
|
1126
|
+
|
|
1127
|
+
---
|
|
1128
|
+
|
|
1129
|
+
## Expo — `@gemmein/sdk/expo`
|
|
1130
|
+
|
|
1131
|
+
The same package, a second entry point. `export *` re-exports the whole core
|
|
1132
|
+
surface, so an Expo app imports from one place; what follows is everything the
|
|
1133
|
+
entry adds. The peers install in the app, not in your monorepo:
|
|
1134
|
+
`npx expo install expo-secure-store expo-file-system`.
|
|
1135
|
+
|
|
1136
|
+
```ts
|
|
1137
|
+
import { createExpoGemmein } from "@gemmein/sdk/expo"
|
|
1138
|
+
import { AppState, Platform } from "react-native"
|
|
1139
|
+
|
|
1140
|
+
export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })
|
|
1141
|
+
```
|
|
1142
|
+
|
|
1143
|
+
| Export | Signature | What it is |
|
|
1144
|
+
|---|---|---|
|
|
1145
|
+
| `createExpoGemmein` | `(options: GemmeinOptions, modules?: ExpoModules) => Gemmein` | The client. It **is** `Gemmein` — the four seams a phone has, filled in, plus the picker conversion in front of every `upload()`. Anything you pass in `options` wins over the defaults |
|
|
1146
|
+
| `SecureStoreTokenStore` | `new SecureStoreTokenStore(appKey: string, secureStore?: SecureStoreModule)` | The `TokenStore` backed by `expo-secure-store` — `get(): Promise<string \| undefined>`, `set(token: string): Promise<void>`, `clear(): Promise<void>`. Keyed `gemmein_session_<first 20 of the app key>`, `WHEN_UNLOCKED_THIS_DEVICE_ONLY`, so the token never rides an iCloud backup. Reads and clears degrade to "signed out"; a refused **write** throws `secure_store_unavailable` |
|
|
1147
|
+
| `appStateVisibility` | `(appState?: AppStateModule) => VisibilityHook` | The `visibility` hook `watch()` sleeps on, answered by React Native's `AppState` instead of `document.visibilityState`. Only `"active"` counts as visible; before the module resolves the app is treated as visible |
|
|
1148
|
+
| `expoFetch` | `(injected?: typeof fetch) => typeof fetch` | `expo/fetch`, resolved on the first request, `globalThis.fetch` if it is not there. React Native's own fetch is XHR-backed and cannot give a streaming body, which is what `g.ai.chat({ stream: true })` and `g.ai.run(…, { stream: true })` return |
|
|
1149
|
+
| `expoPlatformTag` | `(platform?: PlatformModule) => string` | The `platform` string on `x-client-info`: `expo-ios`, `expo-android`, `expo-web`, or `expo` when no `Platform` was passed |
|
|
1150
|
+
| `expoUploadPart` | `(file: UploadInput, modules?: ExpoModules) => Promise<UploadInput>` | A picker's `{ uri, name, type, size }` turned into an `expo-file-system` `File`, because Expo's `FormData` refuses a bare picker part. Anything already carrying bytes passes through. Without `expo-file-system` it throws `upload_input_unsupported` |
|
|
1151
|
+
| `SECURE_STORE_VALUE_LIMIT` | `2048` | The byte ceiling on a secure-store value. Over it, `set` throws `token_too_large` rather than letting iOS throw a native error the app cannot read |
|
|
1152
|
+
| `ExpoModules` | `{ SecureStore?: SecureStoreModule; AppState?: AppStateModule; Platform?: PlatformModule; FileSystem?: FileSystemModule; fetch?: typeof fetch }` | The second argument. Every key optional: pass none and the entry resolves each module lazily on the first call that needs it; pass your own — an app's static imports, a test's fakes — and there is no dynamic resolution at all |
|
|
1153
|
+
|
|
1154
|
+
`SecureStoreModule`, `AppStateModule`, `PlatformModule` and `FileSystemModule`
|
|
1155
|
+
are the exported types of those four slices — the little of each module this
|
|
1156
|
+
entry calls, and nothing more. Nothing is imported from Expo or React Native at
|
|
1157
|
+
module load, so the entry resolves in Node too.
|
|
1158
|
+
|
|
1159
|
+
---
|
|
1160
|
+
|
|
820
1161
|
## Errors — `GemmeinError`
|
|
821
1162
|
|
|
822
1163
|
Every failed call throws a `GemmeinError`:
|
|
@@ -844,6 +1185,9 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
844
1185
|
| `entitlement_required` | 403 — signed in, but not on a plan (or holding a product) this collection is unlocked by; `err.requires` is that plan's key (`access:<slug of its name>`) | show your upgrade screen and send them to checkout — the one 403 that succeeds later |
|
|
845
1186
|
| `not_found` | record you can't see (existence not leaked) | treat as absent |
|
|
846
1187
|
| `denied` | 401 (sign in first) or 429 (rate limit — see `resetAt`) | re-auth or wait+retry |
|
|
1188
|
+
| `invalid_body` | 400 — the request body is not a JSON object (an array or a bare value was sent) | send an object |
|
|
1189
|
+
| `missing_params` | 400 — a required field is absent or EMPTY; the message names it (`missing email`, `missing code`). The SDK passes what you give it, so a blank form field reaches the API as missing | check the field before calling — a blank email is not a sign-in attempt |
|
|
1190
|
+
| `field_too_long` | 400 — a sign-in field (`email`, `code`, the test-session fields) is over 500 characters; the message names the field | shorten it |
|
|
847
1191
|
| `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
|
|
848
1192
|
| `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
|
|
849
1193
|
| `invalid_publish` | `{ published }` on a non-public rule | drop it |
|
|
@@ -853,36 +1197,43 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
853
1197
|
| `invalid_limit` | `limit` isn't a whole number from 1 to 100 (the message says so: "limit must be between 1 and 100") | ask for at most 100 and page with `cursor` — nothing is clamped for you |
|
|
854
1198
|
| `not_a_customer` (404) | `notify()`'s recipient isn't a verified person of this app and environment | fix the person id — one code on purpose |
|
|
855
1199
|
| `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
|
|
1200
|
+
| `sender_domain_required` (409) | `notify()` (and a relay's `email_person`) with no verified sender domain — your app's mail goes only from your own domain | verify a sender domain on the Domains page, then retry with the same key. Nothing was sent, recorded or capped |
|
|
856
1201
|
| `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
|
|
857
1202
|
| `unknown_record` | a link field points at a missing record (live only) | fix the id |
|
|
858
|
-
| `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
|
|
1203
|
+
| `payload_too_large` / `file_too_large` | over the size cap (message states it): a record's data over 32 KB, an upload over 25 MB | shrink it — files go through uploads, never inline |
|
|
859
1204
|
| `invalid_file_content` | uploaded bytes aren't the claimed type (usually a renamed file) | send the real file |
|
|
860
1205
|
| `test_session_forbidden_live` | `testSession()` called on a live env / `sk_live` key | reaffirm's Tier B is dev-only — point it at your dev environment |
|
|
861
1206
|
| `unknown_plan` | no plan by that name | use a name from the list in the message |
|
|
862
1207
|
| `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
|
|
863
1208
|
| `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
|
|
864
|
-
| `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes) | scope the key to that collection, or use the right surface |
|
|
1209
|
+
| `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes); a server key asked to sign in as the account owner or an admin, or to link/sync; a sync key asked for anything but relays and AI tools | scope the key to that collection, or use the right surface — linking and dev sync use the CLI key (Setup), production sync a Sync key (Secret keys → production) |
|
|
1210
|
+
| `secret_key_expired` (403) | a sync key past its hour — the message names the instant | mint a new Sync key (Secret keys → production) and run `npx gemmein sync --live` again |
|
|
865
1211
|
| `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
|
|
866
1212
|
| `invalid_key` | a keyed create's `key` breaks the charset/length law — or `spendCredits`' `key` is not text of up to 200 chars | 1-120 chars of letters, numbers, `: _ . @ / -` (a create); text ≤ 200 (a spend) |
|
|
867
1213
|
| `invalid_amount` / `invalid_reason` (400) | `spendCredits`: `amount` outside 1..10,000, or `reason` missing / over 200 chars | fix the field the code names |
|
|
868
1214
|
| `dedupe_conflict` (409) | `spendCredits`: the `key` already names a different movement (another kind or another person) | a key is one movement — reuse it only to retry that same one |
|
|
869
1215
|
| `credits_ceiling` (409) | a credit would carry the balance past 1,000,000,000 (a comp, a pack, a relay grant) — nothing was added | the balance is at its most |
|
|
870
1216
|
| `invalid_body` (400) | on `g.ai.chat`: the body is not the provider's JSON request object, or is nested deeper than 32 levels | send the provider's own request object |
|
|
871
|
-
| `provider_error` (client-side, the provider's status) | `g.ai.text`: the provider answered a non-2xx; `err.message` is the provider's own reason | read it — the credit was refunded when the provider failed before its first byte |
|
|
872
|
-
| `ai_test_capped` (429) | the
|
|
1217
|
+
| `provider_error` (client-side, the provider's status) | `g.ai.text` and `g.ai.runText`: the provider answered a non-2xx; `err.message` is the provider's own reason | read it — the credit was refunded when the provider failed before its first byte |
|
|
1218
|
+
| `ai_test_capped` (429) | the AI tools page's test call — one a minute per app | wait a minute |
|
|
873
1219
|
| `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
|
|
874
|
-
| `invalid_response` (client-side, status 0) | the server answered 200 to `verifyEmailCode` without a session token — a proxy or mock in the path, not Gemmein | check `apiUrl` and anything rewriting responses; the call is safe to retry |
|
|
1220
|
+
| `invalid_response` (client-side, status 0) | the server answered 200 to `verifyEmailCode` without a session token — a proxy or mock in the path, not Gemmein; or `g.ai.text` / `g.ai.runText` got a non-stream provider answer with no text to lift out | check `apiUrl` and anything rewriting responses; the call is safe to retry — for AI, read the stream with `g.ai.chat` or `g.ai.run` |
|
|
1221
|
+
| `network_unreachable` (client-side, status 0) | the request never reached Gemmein — no connection, an offline device, a host that does not resolve, an `apiUrl` pointing at nothing; the message names the host, and `err.cause` carries the fetch's own error. A cancel (`{ signal }`) is not this: it stays an `AbortError` | check the connection and the `apiUrl`; the call is safe to retry. "The server said no" and "the server was never reached" are different problems — branch on them separately |
|
|
1222
|
+
| `upload_input_unsupported` (client-side, status 0) | `@gemmein/sdk/expo`: `upload()` was handed a picker's `{ uri }` and `expo-file-system` is not installed, so there is nothing to read the bytes with | `npx expo install expo-file-system`, or pass a `Blob` / an `expo-file-system` `File` |
|
|
1223
|
+
| `token_too_large` (client-side, status 0) | `@gemmein/sdk/expo`: a value over 2,048 bytes was handed to the device secure store, which iOS refuses with a native throw the app cannot read | store the token, not the session payload — a Gemmein session token is two orders of magnitude smaller |
|
|
1224
|
+
| `secure_store_unavailable` (client-side, status 0) | the token store could not KEEP the session, so sign-in throws rather than handing back a session the next launch will lose. **All three stores.** `BrowserTokenStore` (`@gemmein/sdk`): the browser refused the `localStorage` write — private mode past its quota, site data blocked, a sandboxed iframe. `SecureStoreTokenStore` (`@gemmein/sdk/expo`): `expo-secure-store` is not installed, or the device refused the write. Swift `KeychainTokenStore`: the Keychain refused, and the OSStatus is in the message; `-34018` is an unsigned build with no keychain access group. The store's own error is on `err.cause` (in Swift, in the message). Reads and clears stay lenient on all three — an unreadable store means signed out, never a crash | install `expo-secure-store`, or sign the app (ad-hoc is enough). The session is real — the server minted it — so an app that would rather run than stop catches this one code and rebuilds its client with a `MemoryTokenStore`: signed in until the process ends |
|
|
1225
|
+
| `invalid_collection_name` (client-side, status 0) | `g.collection(name)` was given a name outside the grammar: lowercase letters, digits and underscores, starting with a letter, 2-63 characters (`tasks`, `user_notes`). Thrown synchronously, before any request exists — the message echoes what it got | fix the name. A collection that exists but is not reachable is a different code (`unknown_collection`, 404) |
|
|
875
1226
|
| `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
|
|
876
1227
|
| `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
|
|
877
1228
|
| `product_not_sellable` (409) | `g.payments.buy` on a product sold via a relay or not yet — there is no Payment Link to open | tell the buyer how the product is sold, or ask the owner to wire a road |
|
|
878
1229
|
| `relay_missing` (400) | saving a relay action (or a product's `sold.relay`) names a relay that does not exist in this environment | create that relay first, or name one that exists |
|
|
879
1230
|
| `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
|
|
880
1231
|
| `credits_exhausted` (402) | the person's balance is below the spend — the message carries the balance ("this person has {balance} credits — the spend needs {amount}") | show the pack; never retry the same spend |
|
|
881
|
-
| `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one
|
|
1232
|
+
| `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one on the AI tools page |
|
|
882
1233
|
| `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
|
|
883
1234
|
| `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
|
|
884
1235
|
| `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |
|
|
885
|
-
| `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB | shorten the conversation you send |
|
|
1236
|
+
| `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB; on `g.ai.run`: the inputs are over 64 KB; on a record write: the data is over 32 KB | shorten the conversation you send; send less; move files to uploads |
|
|
886
1237
|
| `session_required` (401) | `g.ai.chat` / `g.credits.balance` without a signed-in person | sign in first |
|
|
887
1238
|
| `scope_denied` (403) | on `g.ai.chat`: a secret key called the route | the route is for the browser — a server calls the provider directly |
|
|
888
1239
|
| `provider_unreachable` (502) | the provider did not answer before the first byte; the credit is refunded | retry |
|