@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 CHANGED
@@ -12,6 +12,215 @@ the same release.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.10.0] — 2026-09-08
16
+
17
+ ### The mobile surface, and the engine behind it (engine 0.11.0, sdk 0.10.0, mcp 0.7.0, GemmeinSwift 0.10.0)
18
+
19
+ ### Added — the mobile surface: an Expo entry point and a Swift SDK (W10)
20
+
21
+ - **`@gemmein/sdk/expo`** — a second entry point of the same package,
22
+ re-exporting the whole core surface with a phone's answers filled in.
23
+ `createExpoGemmein(options, modules)` presets four of them and every one
24
+ stays overridable: `SecureStoreTokenStore` (the session in the iOS Keychain
25
+ / Android Keystore via `expo-secure-store`, keyed per app key exactly as
26
+ `BrowserTokenStore` keys `localStorage`, asked for with
27
+ `WHEN_UNLOCKED_THIS_DEVICE_ONLY` so a session cannot survive an iCloud
28
+ restore onto a device the person never had), `appStateVisibility()` (the
29
+ `watch()` visibility hook answered by React Native's `AppState`; only
30
+ "active" counts as in front of the person), and `expoFetch()` (`expo/fetch`,
31
+ because React Native's own fetch is XHR-backed and cannot give a streaming
32
+ body — which is what `ai.chat({ stream })` and `ai.run({ stream })` return).
33
+ Expo, `expo-secure-store`, `expo-file-system` and `react-native` are
34
+ OPTIONAL peer dependencies; nothing is imported from them at module load.
35
+ - **Four options on the client, for any platform.** `fetch` — every request
36
+ the client makes goes through it, including the S3 upload leg and both AI
37
+ streams; the default reads `globalThis.fetch` at CALL time, so a polyfill
38
+ installed after construction still counts. `visibility`
39
+ (`{ isHidden, onChange }`) — where `watch()` learns the app went to the
40
+ background; absent, it is `document.visibilityState`, unchanged. `platform`
41
+ — a tag appended to `x-client-info` (cleaned and capped at 64 characters
42
+ here, so it cannot arrive truncated and attribute nothing). `tokenStore` as
43
+ before. A browser that passes none behaves exactly as it did.
44
+ - **`UploadInput`** — `collection.upload()` now names what it takes:
45
+ `Blob | File | { uri, name?, type?, size? }`. The `File` check is guarded
46
+ with `typeof File !== "undefined"` (in React Native the bare form is a
47
+ `ReferenceError`, not a `false`), and a picker part is read for name, type
48
+ and size and appended to `FormData` as it is. Through
49
+ `@gemmein/sdk/expo` the picker part is converted first (see below).
50
+ - **`CurrentUser.storeAccountToken`** — an opaque per-person id for the app
51
+ stores, minted lazily by the engine and returned by
52
+ `auth.currentUser()`. Hand it to RevenueCat as the app user id (or to Apple
53
+ as `appAccountToken`, Google as `obfuscatedExternalAccountId`), and a store
54
+ webhook resolves to exactly one person through a relay's `person_token`
55
+ without your app key or the person's email ever entering a third party's
56
+ ledger. Absent, not null, when nobody is signed in.
57
+ - **`token_too_large`** — `SecureStoreTokenStore` refuses a session value over
58
+ the secure store's own size limit here, with a `GemmeinError`, rather than
59
+ by a native throw on a phone you cannot see.
60
+ - **`GemmeinSwift` is the sibling surface** — the Swift SDK (SwiftPM, iOS 17+,
61
+ version 0.9.0 in the tree) speaks the same HTTP contract with the same
62
+ method names in Swift idiom, and the two travel together: a check holds
63
+ every Swift client class, and the Expo entry's re-exports, to the JS SDK's
64
+ own method registry, and resolves every method to a route the engine
65
+ serves. The SDK parity law — every SDK surface is the same — is a test, not
66
+ a habit.
67
+
68
+ ### Added — the engine behind it (engine 0.11.0)
69
+
70
+ - **`GET /auth/current-user` answers `storeAccountToken`** — an opaque uuid,
71
+ unique per person, minted on first ask and correlated to nothing else.
72
+ - **A relay receiver can name the person by id or by store token.** The
73
+ receiver map takes `person_id` and `person_token` beside `person_email`,
74
+ exactly one per map. `person_email` still creates the person when they are
75
+ new; the two lookups are read-only, and a value matching nobody keeps the
76
+ event and reports `person_not_found`, naming the key that was tried and
77
+ never its value. The first relay TEMPLATES ship with it: RevenueCat (app
78
+ stores), as THREE definitions, one webhook each — purchase
79
+ (`INITIAL_PURCHASE` / `RENEWAL` / `NON_RENEWING_PURCHASE`, narrowed in
80
+ RevenueCat's own console) → `fulfil_product`; `EXPIRATION` →
81
+ `revoke_access`; `CANCELLATION` → `revoke_access`. The two that TAKE access
82
+ away carry their own `when` guard, so a purchase posted to one of those URLs
83
+ by mistake can never revoke what it paid for; a trigger matches one exact
84
+ value per field, which is why they are two and not one. Idempotent on the
85
+ event id.
86
+ - **Sign-in sends are counted and watched, never capped.** A per-app,
87
+ per-environment daily counter of ACCEPTED sign-in starts, shown on the Usage
88
+ page ("Sign-in emails · today · this month"), and a founder alert
89
+ `signin_surge` when a day passes max(1,000, 10× the app's trailing 7-day
90
+ mean). No new refusal: the two abuse bounds are the two that always
91
+ existed — 3 per address and 20 per IP per app, each per 15 minutes — and
92
+ growth is never capped.
93
+ - **The pricing unit is monthly ACTIVE people.** A person counts once in a
94
+ month, on their first authenticated request of a UTC day, on web and mobile
95
+ alike — polling counts, anonymous never does, account members never do.
96
+ A new `active_people_daily` table is the one source for the Usage dial, the
97
+ people ceiling and the bill; the unit used to be derived from sign-in audit
98
+ rows, which counted sign-ins rather than use. Every surface says "active
99
+ people" where it said "verified people".
100
+ - **An AI tool may be defined before its provider key.** Saving a tool no
101
+ longer answers `provider_not_configured`; the tool is listed as waiting on a
102
+ key (`providerConfigured`) and the console says so, and a RUN still answers
103
+ `409 ai_not_configured` until a key is pasted, before a single credit moves.
104
+ - **One accepted-session hook, and both doors run it.** Everything that treats
105
+ a bearer token's holder as one of this app's people — the account-role
106
+ enrichment and the active-person write — now runs PAST the app/environment
107
+ match check, in `AuthContextBuilder`'s accepted-session hook rather than in
108
+ the session validator, so a request refused `401 auth_expired` leaves no
109
+ trace on anyone's bill. `POST /server/verify-session` runs the SAME hook: a
110
+ session verified by your own compute is an authenticated request by that
111
+ person too. Two consequences you can see: **`verifySession()` returns the
112
+ EFFECTIVE role** — account membership (owner/admin) wins over the stored
113
+ one, so a founder verifying their own session reads `owner` and not the
114
+ `member` their row was born with — and a person whose only authenticated
115
+ traffic goes through your compute is counted as active, like any other.
116
+ `GET /server/people/:id/holdings` answers with the same effective role: one
117
+ person has one role, whichever door asked for it.
118
+ - Four migrations ship with this engine — `2026-09-08a` (the store token),
119
+ `08b` (the sign-in counter), `08c` (the `signin_surge` alert kind) and `08d`
120
+ (`active_people_daily`) — all additive, and NOT YET APPLIED to production:
121
+ they are applied before the images, never after.
122
+
123
+ ### Fixed — three contract gaps found by driving the Expo reference app (W10 row 9)
124
+
125
+ - **A transport failure is now typed.** A request that never reaches Gemmein
126
+ — connection refused, no DNS, no Wi-Fi, an `apiUrl` pointing at nothing —
127
+ used to come out as whatever the fetch implementation threw (a `TypeError`
128
+ in a browser and on Expo's fetch), so an app branching on `err.code` got
129
+ `undefined` there and nowhere else. Every request the client makes now
130
+ answers `GemmeinError` `network_unreachable`, status 0, its message naming
131
+ the host and `err.cause` carrying the transport's own error. A call
132
+ cancelled with `{ signal }` still comes out as an `AbortError` — cancelling
133
+ is not a failure to reach anything.
134
+ - **`GemmeinServer` has the same seam, and the same answer.** The server
135
+ client takes a `fetch` option (`gemmeinServer({ secretKey, fetch })`) and
136
+ every call it makes goes through it, so a transport failure on the server
137
+ rail is a `GemmeinError` `network_unreachable` (status 0, `err.cause`
138
+ carrying the transport's own error) and never a raw `TypeError` either. One
139
+ seam, both rails — the parity law applied to the thing underneath.
140
+ - **`@gemmein/sdk/expo` accepts the picker's `{ uri, name, type, size }`.**
141
+ `expo/fetch`, which the Expo entry hands the client, refuses that part
142
+ (`Unsupported FormDataPart implementation`); `createExpoGemmein` now turns
143
+ it into an `expo-file-system` `File` before `upload()` sees it. Without
144
+ `expo-file-system` installed the call is refused here, with
145
+ `upload_input_unsupported` and a sentence, instead of crashing inside
146
+ FormData.
147
+ - **`auth.logout()` is idempotent.** A `401 auth_expired` — the owner already
148
+ signed this person out everywhere — resolves rather than throwing, because
149
+ the session being gone is the outcome logout asked for. Every other failure
150
+ still throws, and the token store is cleared either way.
151
+
152
+ The same three land in the Swift SDK (`GemmeinSwift`) in this commit: the SDK
153
+ parity law — every SDK surface is the same.
154
+
155
+ ### Changed — a token store that cannot keep the session says so (W10 rows 9c, 15a)
156
+
157
+ - **`tokenStore.set()` throws `secure_store_unavailable`.** Found by driving
158
+ the Swift reference app on an unsigned simulator build, where an app with
159
+ no `application-identifier` entitlement has no keychain access group and
160
+ every write is refused: the store swallowed it, so the app signed in,
161
+ stored nothing, and reported itself signed out one line later with nothing
162
+ anywhere saying why. `SecureStoreTokenStore.set` (`@gemmein/sdk/expo`) now
163
+ throws `GemmeinError` `secure_store_unavailable`, status 0, when
164
+ `expo-secure-store` cannot be resolved or refuses the write, with the
165
+ device's own throw on `err.cause`; `auth.verifyEmailCode()` carries it to
166
+ the caller. `get()` and `clear()` are unchanged and stay lenient — an
167
+ unreadable store means signed out, which every app already handles, and a
168
+ crash at launch is worse than a sign-in screen. The existing
169
+ `token_too_large` refusal is unchanged.
170
+ - **What to do about it.** The session is real either way — the server minted
171
+ it — so an app that would rather run than stop can catch this one code and
172
+ build its client with a `MemoryTokenStore`: sign-in works, and ends with
173
+ the process. `MemoryTokenStore` never throws.
174
+ - The same law lands in `GemmeinSwift`: the `TokenStore` protocol's `set` is
175
+ `throws`, `KeychainTokenStore.set` carries the OSStatus and
176
+ `SecCopyErrorMessageString`'s words for it out in the sentence, and
177
+ `MemoryTokenStore.set` never throws.
178
+ - **`BrowserTokenStore.set()` throws it too — the law is all three stores
179
+ (row 15a).** A browser that refuses the `localStorage` write — Safari's
180
+ private mode past its quota, a browser set to block site data, a sandboxed
181
+ iframe whose access throws — used to be swallowed with the same silence the
182
+ two mobile stores were fixed for: the app signed in, stored nothing, and
183
+ reported itself signed out on the next reload. It now throws `GemmeinError`
184
+ `secure_store_unavailable`, status 0, with the browser's own error on
185
+ `err.cause` and the fix in the message; `get()` and `clear()` stay lenient.
186
+ Every surface that named the two mobile stores now says all three.
187
+ **ACTION REQUIRED for a web app that relied on the silence:** an app that
188
+ ran on with a session it could not persist — a kiosk, an embedded iframe, a
189
+ private-mode visitor — must catch `secure_store_unavailable` around
190
+ `auth.verifyEmailCode()` and rebuild its client with a `MemoryTokenStore`,
191
+ or pass one from the start. An app in an ordinary browser tab sees no
192
+ difference: nothing that stored the token before throws now.
193
+
194
+ ## [0.9.0] — 2026-09-07
195
+
196
+ ### Keys that know what they are, production sync, and AI tools defined on the server (engine 0.10.0, sdk 0.9.0, mcp 0.6.0)
197
+
198
+ - **`g.ai.run(tool, inputs, { stream? })` / `g.ai.runText(tool, inputs)`** — call a
199
+ named AI tool with inputs only; the server composes the provider request
200
+ from the tool's own instructions and template, gates it, spends its
201
+ credits and streams the answer back in the provider's own shape.
202
+ Refusals: `invalid_inputs` (400), `tool_incomplete` (409), plus the tool
203
+ codes `g.ai.chat` already answered.
204
+ - **`g.ai.calls()`** — the signed-in person's own AI calls, newest first,
205
+ keyset-paged (`AiCallRecord`): tool, provider, model, tokens, credits,
206
+ outcome, latency, and the prompt and answer where the tool keeps them.
207
+ - **Raw calls are off by default.** `g.ai.chat(body)` answers
208
+ `403 raw_calls_off` unless the owner switches raw calls on for that
209
+ provider's key on the AI tools page. Migration note: an app built on
210
+ `g.ai.chat` needs that switch, or a named tool through `g.ai.run`.
211
+ - **Server keys cannot sign in as the account owner or admin.**
212
+ `gemmeinServer(sk).testSession(email)` refuses those two emails with
213
+ `scope_denied`; test people sign in as before. Linking and dev sync use
214
+ the app's CLI key (`sk_cli_…`, Setup page); production sync uses a
215
+ one-hour Sync key (`sk_sync_…`). `secret_key_expired` (403) names an
216
+ expired sync key's instant.
217
+ - CLI: `npx gemmein sync` links with ONE paste (the CLI key); `npx gemmein
218
+ sync --live` carries relays and AI tools into production behind a sync
219
+ key and the typed word `live`, never deletes, asks before replacing a
220
+ row edited in the dashboard (`--overwrite`). A relay's name is fixed once
221
+ created. Tool files gain `kind`, `instructions`, `promptTemplate`,
222
+ `inputs`, `recordCalls`.
223
+
15
224
  ## [0.8.0] — 2026-09-05
16
225
 
17
226
  ### Products are provider-neutral (engine 0.9.0, sdk 0.8.0, mcp 0.5.0)
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @gemmein/sdk
2
2
 
3
- The secure backend for AI-built apps: passwordless login, safe data storage, and Stripe-driven payment flips — with the unsafe paths removed by design. No SQL, no security-rules language, no server to configure.
3
+ The secure backend for AI-built apps: passwordless login, safe data storage, payments, credits and an AI route — with the unsafe paths removed by design. No SQL, no security-rules language, no server to configure. Web, Expo/React Native, and Swift, on one contract.
4
4
 
5
5
  ## Step 0 — get your app key
6
6
 
@@ -30,10 +30,14 @@ await g.collection("tasks").create({ title: "Buy milk", done: false })
30
30
  const { records } = await g.collection("tasks").list()
31
31
  ```
32
32
 
33
- The client has two layers: **your collections** (`g.collection("tasks")` — your app's own data model) and the **business primitives Gemmein runs for you** (`g.auth`, `g.subscriptions`, `g.payments`, `g.account` — self-service surfaces for the signed-in user; managing *other* people's users, subscriptions, and records happens in the owner's dashboard, on purpose).
33
+ The client has two layers: **your collections** (`g.collection("tasks")` — your app's own data model) and the **business primitives Gemmein runs for you** (`g.auth`, `g.subscriptions`, `g.payments`, `g.purchases`, `g.credits`, `g.ai`, `g.files`, `g.account` — self-service surfaces for the signed-in user; managing *other* people's users, subscriptions, and records happens in the owner's dashboard, on purpose).
34
34
 
35
35
  Sessions persist across page reloads automatically (localStorage in browsers, memory elsewhere — override with `tokenStore` if you need custom persistence). One session per user: verifying a new code revokes that email's older sessions — a stale token throws `auth_expired` once, the SDK clears it, and a retry (or re-auth) recovers.
36
36
 
37
+ A store that cannot **keep** the session says so: a browser that refuses the `localStorage` write (private mode past its quota, site data blocked, a sandboxed iframe) makes `verifyEmailCode()` throw `secure_store_unavailable` rather than hand back a session the next reload will lose. Reads and clears stay lenient — an unreadable store means signed out. The session is real either way, so an app that would rather run than stop catches that one code and rebuilds its client with a `MemoryTokenStore`, which never throws.
38
+
39
+ Two reference documents ship beside this one: **[`REFERENCE.md`](./REFERENCE.md)** — every method, signature and return shape — and **`llms.txt`**, the guide to paste into an AI tool. Both are in the package; the same material is at **[docs.gemmein.com](https://docs.gemmein.com)**.
40
+
37
41
  ## Auth
38
42
 
39
43
  ```js
@@ -56,6 +60,38 @@ await g.account.delete()
56
60
 
57
61
  Self-service erasure — the "delete my account" screen. Every app it **applies** to needs one (GDPR right to erasure; Apple 5.1.1(v) for any app with account creation). Server-side it's the full cascade: sessions revoked, the user's records and files deleted, their subscription row removed. Irreversible — put a real confirm in front of it.
58
62
 
63
+ ## Mobile — Expo and Swift
64
+
65
+ A phone app is the same contract as a browser app: the public key identifies the app, the session authorises the person. Verified domains are a browser control — an app sends no `Origin`, so nothing is verified there; build as though any request could come from anywhere and let the session and the server's rules decide.
66
+
67
+ **Expo / React Native.** `@gemmein/sdk/expo` is this same package, a second entry point that fills in the four seams a phone has — where the session lives, how "is the app in front?" is answered, which `fetch` carries the bytes, and what the client tag says. Peers install in the app, not in your monorepo: `npx expo install expo-secure-store expo-file-system`.
68
+
69
+ ```js
70
+ import { createExpoGemmein } from "@gemmein/sdk/expo"
71
+ import { AppState, Platform } from "react-native"
72
+
73
+ export const g = createExpoGemmein({ appKey: "pk_live_..." }, { AppState, Platform })
74
+ ```
75
+
76
+ Every module is optional — pass none and the entry resolves `expo-secure-store`, `react-native` and `expo/fetch` lazily on first use. The injectable keys are `SecureStore`, `AppState`, `Platform`, `FileSystem` and `fetch`; the client tag reads `expo-ios` / `expo-android` / `expo-web` when you pass `Platform`, and `expo` when you don't. `apiUrl` is an option on the client, so pointing the app at a local `gemmein dev` engine is one field: `createExpoGemmein({ appKey, apiUrl: "http://127.0.0.1:4545" }, { AppState, Platform })`.
77
+
78
+ **Swift.** `GemmeinSwift` — iOS 17+ / macOS 14+, no dependencies, the same method names in Swift idiom. One line in `Package.swift`:
79
+
80
+ ```swift
81
+ .package(url: "https://github.com/gemmeinhq/gemmein-swift.git", from: "0.10.0")
82
+ ```
83
+
84
+ **Store purchases, either way.** `currentUser()` carries `storeAccountToken` — an opaque per-person id. Hand it to RevenueCat as the app user id, and a store webhook resolves to exactly one person through a relay, without your app key or the person's email ever entering a third party's ledger.
85
+
86
+ ```js
87
+ const me = await g.auth.currentUser()
88
+ if (me.authenticated && me.storeAccountToken) {
89
+ await Purchases.configure({ apiKey: RC_KEY, appUserID: me.storeAccountToken })
90
+ }
91
+ ```
92
+
93
+ The whole mobile surface — the codes a phone meets, the store road, the two host facts for simulators — is at **[docs.gemmein.com/mobile](https://docs.gemmein.com/mobile)**.
94
+
59
95
  ## Storage
60
96
 
61
97
  Collections are created by the app owner in the dashboard (app.gemmein.com → data → "+ New collection") — the SDK can't create them. A 404 `unknown_collection` means it doesn't exist yet: ask the app owner to add it (one click) and pick its rule from the table below.
@@ -82,7 +118,7 @@ await tasks.delete("rec_abc123")
82
118
 
83
119
  // Images/files: presigned upload, returns a REFERENCE to store in a record
84
120
  const file = await tasks.upload(imageBlob, { name: "avatar.png" })
85
- // { id, ref, contentType, sizeBytes } ref looks like "file:01K…"
121
+ // { id, ref, contentType, sizeBytes } ref looks like "file:<uuid>"
86
122
  await tasks.create({ title: "Profile", avatar: file.ref })
87
123
 
88
124
  // To show or download it — one call, whatever the collection:
@@ -197,26 +233,31 @@ Plan **limits** (note counts, feature caps, seat numbers) are your app's logic
197
233
 
198
234
  ### Selling things (one-off purchases)
199
235
 
200
- Plans are for subscriptions. To sell a **thing** — a poster, a beat, an ebook, a session — the owner adds **products** (name + Stripe Payment Link) on the same Payments page, plus a **receipts** collection (rule `addressed`). Then:
236
+ Plans are for subscriptions. To sell a **thing** — a poster, a beat, an ebook, a session — the owner adds **products** on the same Payments page and sets **how each is sold**: via a Stripe Payment Link, via a **relay** (any provider whose webhook they map — GoCardless, Lemon Squeezy, Paddle, an app store through RevenueCat, a bank transfer), or **not yet** (the product is defined, its grants and credits known, no road wired).
201
237
 
202
238
  ```js
203
239
  // One product covering many items (license tiers over a catalog)? Name the
204
- // item — it's display text on the receipt, the PRICE always comes from the
205
- // product's Payment Link:
240
+ // item — display text only; the PRICE always comes from the provider:
206
241
  await g.payments.buy("premium license", { item: "beat_37" }) // redirects
207
-
208
- // Fulfilment: the completed payment writes a receipt record ADDRESSED to
209
- // the buyer — only they (and the owner) can read it. Gate the download on
210
- // the receipt, never on the redirect coming back (redirects can be faked;
211
- // receipts can't they come from Stripe's signed webhook):
212
- const { records } = await g.collection("receipts").list()
213
- const paid = records.find(r => r.data.product === "premium license" && r.data.status === "paid")
214
- if (paid) { /* unlock — paid.data.deliveryUrl holds the download when the owner set one */ }
242
+ // "product_not_sellable" (409) = sold via a relay, or not yet: there is no
243
+ // Payment Link to open. "unknown_product" (404) lists what the app sells.
244
+
245
+ // Fulfilment: Gemmein records every completed payment itself, so the buyer's
246
+ // proof does not live in your data. Gate on THIS, never on the redirect
247
+ // coming back (redirects can be faked; the record comes from the provider's
248
+ // signed webhook):
249
+ const purchases = await g.purchases.mine()
250
+ const paid = purchases.find(p => p.item === "premium license" && p.status === "paid")
251
+ if (paid?.delivery?.type === "gemmein_file") {
252
+ const { url } = await g.files.link(paid.delivery.file, { intent: "download" })
253
+ }
215
254
  ```
216
255
 
217
- Receipts carry `{ product, item?, status, amountTotal, currency, paidAt, deliveryUrl?, paymentRef }` in `.data``amountTotal` is minor units exactly as Stripe reported, and `paymentRef` is the Stripe payment id the refund flip matches on (read-only; you rarely need it). The owner fulfils orders by editing the receipt from their dashboard (`status: "shipped"`) your app just reads it. Refunds happen in the owner's Stripe dashboard; if they forward `charge.refunded`, the receipt's status flips to `"refunded"`. A receipt is **app-owned** its top-level `ownerUserId` is `null` (the webhook wrote it, not a user); it's the `audienceUserId` that scopes it to the buyer.
256
+ Each purchase is `{ item, kind: "purchase" | "subscription", status: "paid" | "part_refunded" | "refunded", amountMinor, currency, refundedMinor, grants, paidAt, delivery? }`, newest first, refunds already applied amounts are **minor units** (pence, cents) exactly as the provider reported them, so formatting is yours and nothing is rounded away. To sell a **file**, the owner attaches it on the product card: the purchase carries `delivery: { type: "gemmein_file", file }` and *is* the authorization, re-checked on every `g.files.link()` a full refund cuts the download off the moment it lands. `delivery: { type: "external_url", url }` is a plain handover: Gemmein controls who is told, not who can use it.
257
+
258
+ A **receipts** collection (rule `addressed`) is now **optional** — proof records for your own screens, nothing more. Financial truth is `g.purchases.mine()`, and it stays true if you rename or delete that collection.
218
259
 
219
- **No carts, no quantities** — one product per checkout, by design. A "cart" is N checkouts, or one bundled product the owner prices as a bundle. Don't build a cart UI that promises otherwise.
260
+ **No carts, no quantities** — one product per checkout, by design. A "cart" is N checkouts, or one bundled product the owner prices as a bundle. Don't build a cart UI that promises otherwise. Digital access only: physical goods, shipping and inventory are out of scope. Full detail: **[docs.gemmein.com/payments](https://docs.gemmein.com/payments)**.
220
261
 
221
262
  ### Drafts on public collections
222
263
 
@@ -229,7 +270,24 @@ await notes.update(post.id, {}, { published: true }) // now
229
270
 
230
271
  Read the current state back as a **top-level** field — `record.published` (a boolean), NOT `record.data.published` (same place as `id` and `updatedAt`, not inside your fields). Non-authors only ever receive published records, so you'll only ever see `false` on your own drafts — or on everything, as the owner. That's how you render an "unreleased" badge in an owner-only admin list.
231
272
 
232
- Never fake drafts with a `status` field + client-side filtering on a public collection — the data still reaches every reader's network tab. `published` is an option, not a data field; the server rejects it inside `data`.
273
+ Never fake drafts with a `status` field + client-side filtering on a public collection — the data still reaches every reader's network tab. `published` is an option, not a data field; the server rejects it inside `data`. The **field** comes back on every record, whatever the rule: a private record reads `published: true`, and the option is not writable there.
274
+
275
+ ## Credits
276
+
277
+ A **credit** is a quantity the signed-in person holds and your product spends — a pack they bought (a product with "Grants credits"), a comp the owner gave them, a relay's grant. `g.credits.balance()` is the number **right now**, server-resolved, so your meter never does client-side arithmetic on a balance that refuses at zero; a spend from your own server is `gemmeinServer(sk).spendCredits(personId, { amount, reason, key })`, one conditional update floored at 0, and `402 credits_exhausted` carries the balance. There is deliberately no client write path. Full detail: **[REFERENCE.md](./REFERENCE.md)** → *Credits*, and **[docs.gemmein.com/credits](https://docs.gemmein.com/credits)**.
278
+
279
+ ## AI
280
+
281
+ `g.ai.run(tool, inputs)` calls a **named AI tool** the owner defined — the server composes the provider request from the tool's own instructions and template (never the browser), gates it, spends the tool's credits and streams the provider's own answer back; `g.ai.runText(tool, inputs)` is the same call collected to one string, and `g.ai.calls()` is the signed-in person's own history. The provider key lives with Gemmein and never reaches the browser. `g.ai.chat(body)` is the raw path — the provider's own request body, forwarded — and it is **off by default** (`403 raw_calls_off`) until the owner switches raw calls on for that key. Full detail: **[REFERENCE.md](./REFERENCE.md)** → *AI*, and **[docs.gemmein.com/ai](https://docs.gemmein.com/ai)**.
282
+
283
+ ```js
284
+ const summary = await g.ai.runText("summarise", { text })
285
+ const { balance } = await g.credits.balance()
286
+ ```
287
+
288
+ ## Relays
289
+
290
+ A **relay** is one trigger — another provider's webhook arriving, a clock, a record changing — and one to ten of Gemmein's own typed actions, run in order: grant or revoke access, fulfil or refund a product, grant credits, write a record, email a person, call a URL. The owner writes `gemmein/relays/<name>.json` (or picks a template in the console); Gemmein verifies the signature, maps the body to named fields, and runs the actions. Nothing computes inside one — compute lives on your host, behind `call_url`. That is the road an app store purchase travels, and the road a payment provider other than Stripe travels. It is configuration, not SDK surface — your app only ever reads the result (`g.purchases.mine()`, `g.credits.balance()`, a gated collection succeeding). Full detail: **[REFERENCE.md](./REFERENCE.md)** → *Relays*, and **[docs.gemmein.com/relays](https://docs.gemmein.com/relays)**.
233
291
 
234
292
  ## Server-side (API routes, cron jobs)
235
293
 
@@ -244,7 +302,7 @@ const tasks = await server.collection("tasks").list()
244
302
  await server.collection("tasks").update("rec_abc123", { done: true })
245
303
  ```
246
304
 
247
- Secret keys can only `get`/`list`/`update` the collections you scoped them to — no creates, no deletes, no auth or management access. Never put an `sk_` key in browser code (the SDK throws if you try).
305
+ On collections a secret key can only `get`/`list`/`update` the ones you scoped it to — no creates, no deletes. Beyond that it opens **the gate**, for code of yours running on your own host: `verifySession(token)` turns a browser session into the person and what they hold, `holdings(personId)` answers that on its own, `grantAccess` / `revokeAccess` move one grant (a grant ends once — a second revoke is `409 already_revoked`), `spendCredits` takes credits with a reason, `invitePerson(email)` creates or fetches a person by address, and `notify(personId, …)` emails one of your app's own people. Each is a capability ticked on the key when it is minted; an untick is `403 capability_required`. No management access, ever. Never put an `sk_` key in browser code (the SDK throws if you try).
248
306
 
249
307
  ## Errors
250
308
 
@@ -283,6 +341,9 @@ try {
283
341
  | `forbidden` | 403 | The rules refused this — a permission your user doesn't have. **Never retry**: the same call will always be refused. Fix the approach (wrong collection rule, non-admin writing to `admin_write`, secret key out of scope) or show `err.message`. |
284
342
  | `entitlement_required` | 403 | Signed in, but not on a plan (or holding a product) this collection is unlocked by. `err.requires` carries that plan's key (`access:pro` for a plan named pro). The one 403 that succeeds later: show your upgrade screen, send them to checkout, retry after they hold it. |
285
343
  | `denied` | 429 / 401 | The generic refusal for everything retriable or fixable: a rate limit (429 — carries `resetAt`, wait and retry) or a missing sign-in (401 — sign in first via `g.auth.sendEmailCode`). Distinguish by HTTP status; show `err.message`, which reads correctly for each. |
344
+ | `invalid_collection_name` | 0 (client-side) | `g.collection(name)` was given a name outside the grammar — lowercase letters, numbers and underscores, starting with a letter, 2-63 characters. Thrown before a request exists. |
345
+ | `secure_store_unavailable` | 0 (client-side) | The token store could not KEEP the session — a browser refusing the `localStorage` write, `expo-secure-store` absent or refused, a Keychain that said no. `err.cause` carries the store's own error. Pass a `tokenStore` (a `MemoryTokenStore` keeps the session for the life of the process). |
346
+ | `network_unreachable` | 0 (client-side) | The request never reached Gemmein — no connection, a host that doesn't resolve, an `apiUrl` pointing at nothing. The message names the host; `err.cause` carries the transport's error. Branch on it separately from a refusal. |
286
347
 
287
348
  **Branching on error codes:** switch on the *specific named* codes above. The one rule that matters: `forbidden` means stop — retrying can never succeed; `denied` means the request could work later (wait for `resetAt` on 429, sign in on 401). Only rate-limit `denied` carries `resetAt` — that's the reliable signal for a retry-after.
288
349
 
@@ -292,7 +353,9 @@ try {
292
353
  |--------|------------|----------------|
293
354
  | `pk_test_...` | Development | Frontend code — safe to expose |
294
355
  | `pk_live_...` | Production | Frontend code — safe to expose |
295
- | `sk_dev_...` / `sk_live_...` | Dev / Prod | Server env vars only |
356
+ | `sk_dev_...` / `sk_live_...` | Dev / Prod | Server env vars only — runs your app; never links or syncs |
357
+ | `sk_cli_...` | Development | The CLI key, shown on Setup: what `gemmein sync` and `gemmein go-live` act with. Lives in `gemmein/.data/`, never in git |
358
+ | `sk_sync_...` | Production | A one-hour sync key (Secret keys → production): `gemmein sync --live` carries relays and AI tools into production. Pasted, used, never saved |
296
359
 
297
360
  Environments are fully isolated: different data, different users, different collections.
298
361