@gemmein/sdk 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +179 -0
- package/README.md +79 -18
- package/REFERENCE.md +282 -14
- package/dist/expo.cjs +423 -0
- package/dist/expo.d.cts +152 -0
- package/dist/expo.d.ts +152 -0
- package/dist/expo.js +387 -0
- package/dist/index.cjs +224 -34
- package/dist/index.d.cts +143 -9
- package/dist/index.d.ts +143 -9
- package/dist/index.js +223 -34
- package/llms.txt +320 -34
- package/migrations/README.md +1 -0
- package/migrations/secure-store-set-throws.md +75 -0
- package/package.json +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,185 @@ 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
|
+
|
|
15
194
|
## [0.9.0] — 2026-09-07
|
|
16
195
|
|
|
17
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)
|
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
|
|
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
|
|
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**
|
|
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 —
|
|
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
|
-
//
|
|
209
|
-
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|