@gemmein/sdk 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 }`. Throws `GemmeinError`
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:01K…`), not a URL. Store the reference
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; only meaningful on public rules
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. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
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
@@ -376,6 +497,26 @@ development, sync also writes a tool that exists only in the cloud back to
376
497
  A tool's name is fixed once created. Removing a tool is a step-up action, like
377
498
  removing a key.
378
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
+
379
520
  **Raw calls are off by default.** `g.ai.chat(body)` — the browser sending
380
521
  the provider's own request body — answers `403 raw_calls_off` unless the
381
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
@@ -502,11 +643,10 @@ const raw = await g.ai.chat({ model: "gpt-4o-mini", stream: true, messages: [{ r
502
643
  | `tool_disabled` | 403 | The owner switched this tool off — turn it on, or use another |
503
644
  | `entitlement_required` | 403 | The tool's `requires` names a plan or product this person lacks — the message names it |
504
645
  | `model_pinned` | 403 | This tool's model is fixed — leave `model` out of the body |
505
- | `provider_not_configured` | 409 | Creating or updating a tool: no key is set for that provider yet — add one on the AI tools page first |
506
646
  | `too_many_tools` | 409 | This environment already holds 50 AI tools — delete one before adding another |
507
647
  | `invalid_tool` | 400 | Creating or updating a tool with a bad field — the message names which one and its rule |
508
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 |
509
- | `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one on the AI tools page |
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 |
510
650
  | `provider_required` | 400 | More than one provider key is set — pass `provider` |
511
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) |
512
652
  | `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
@@ -522,7 +662,7 @@ const raw = await g.ai.chat({ model: "gpt-4o-mini", stream: true, messages: [{ r
522
662
  `gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
523
663
  fake`, an echo stream), so the loop runs locally; set
524
664
  `GEMMEIN_AI_KEY_OPENAI`, `GEMMEIN_AI_KEY_ANTHROPIC` or `GEMMEIN_AI_KEY_GOOGLE`
525
- in the local rail's environment for a real call. The owner's Usage room
665
+ in the local rail's environment for a real call. The owner's Usage & billing page
526
666
  counts AI calls for the last 30 days: spent by the customers' credits, priced
527
667
  by the provider — Gemmein meters the calls, the provider bills the tokens.
528
668
 
@@ -542,6 +682,32 @@ signed in, it can create them by email first.
542
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 |
543
683
  | `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
544
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`.
545
711
 
546
712
  ```ts
547
713
  type Holdings = {
@@ -595,7 +761,7 @@ type Grant = {
595
761
  make two grants — call it once and keep your own retry key.
596
762
  - **Nothing silent, within a stated bound.** Every `/server/*` call a **resolved
597
763
  secret key** makes, ok or refused, lands in that key's usage ledger — the owner
598
- reads the summary on their Keys page and the full day × route × outcome table in
764
+ reads the summary on their Secret keys page and the full day × route × outcome table in
599
765
  the key's own room. A rejected or publishable (`pk_`) key can't be attributed to a
600
766
  key row, so its refusal reaches only the request log. Refusals also write one audit
601
767
  row per key, route and code each hour (exact counts stay in the ledger); grants and
@@ -740,7 +906,7 @@ authorise on fields they cannot set, or from a receiver.
740
906
  | `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
741
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. |
742
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` |
743
- | `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 |
744
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 |
745
911
 
746
912
  **The `call_url` contract.** Gemmein POSTs JSON:
@@ -896,6 +1062,102 @@ current. Add a probe whenever you add a feature.
896
1062
 
897
1063
  ---
898
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
+
899
1161
  ## Errors — `GemmeinError`
900
1162
 
901
1163
  Every failed call throws a `GemmeinError`:
@@ -935,9 +1197,10 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
935
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 |
936
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 |
937
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 |
938
1201
  | `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
939
1202
  | `unknown_record` | a link field points at a missing record (live only) | fix the id |
940
- | `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 |
941
1204
  | `invalid_file_content` | uploaded bytes aren't the claimed type (usually a renamed file) | send the real file |
942
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 |
943
1206
  | `unknown_plan` | no plan by that name | use a name from the list in the message |
@@ -955,6 +1218,11 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
955
1218
  | `ai_test_capped` (429) | the AI tools page's test call — one a minute per app | wait a minute |
956
1219
  | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
957
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) |
958
1226
  | `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
959
1227
  | `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
960
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 |
@@ -965,7 +1233,7 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
965
1233
  | `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
966
1234
  | `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
967
1235
  | `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |
968
- | `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 |
969
1237
  | `session_required` (401) | `g.ai.chat` / `g.credits.balance` without a signed-in person | sign in first |
970
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 |
971
1239
  | `provider_unreachable` (502) | the provider did not answer before the first byte; the credit is refunded | retry |