@gemmein/sdk 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gemmein Limited (Company No. 17339623, England and Wales)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,8 +1,276 @@
1
1
  # @gemmein/sdk
2
2
 
3
- The official Gemmein SDK passwordless auth and safe storage for AI-built apps, small enough to be taught by one prompt.
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.
4
4
 
5
- Launching soon. This version reserves the package name.
5
+ ## Step 0 get your app key
6
6
 
7
- - Product: https://gemmein.com
8
- - Docs: https://docs.gemmein.com
7
+ Every call needs an app key (`pk_...`). Get one in 30 seconds: sign in at **[app.gemmein.com](https://app.gemmein.com)** with an email code (free, no card), and copy the key from the Setup page. The Setup page also gives you a ready-made prompt that teaches this whole SDK to your AI tool.
8
+
9
+ ## Install
10
+
11
+ ```
12
+ npm i @gemmein/sdk
13
+ ```
14
+
15
+ No bundler? The SDK is dependency-free pure ESM — copy `dist/index.js` from the package next to your HTML and `import { gemmein } from "./index.js"` in a `<script type="module">`. Works on any static host.
16
+
17
+ ## Quick start
18
+
19
+ ```js
20
+ import { gemmein } from "@gemmein/sdk"
21
+
22
+ const g = gemmein("pk_test_...")
23
+
24
+ // Login — email code, no passwords
25
+ await g.auth.sendEmailCode("user@example.com")
26
+ await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
27
+
28
+ // Store data — rules enforced server-side
29
+ await g.storage.collection("tasks").create({ title: "Buy milk", done: false })
30
+ const { records } = await g.storage.collection("tasks").list()
31
+ ```
32
+
33
+ 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.
34
+
35
+ ## Auth
36
+
37
+ ```js
38
+ await g.auth.sendEmailCode("user@example.com") // sends an 8-digit code
39
+
40
+ const session = await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
41
+ // { token, expiresAt, user: { id, email } } — token stored automatically
42
+
43
+ const user = await g.auth.currentUser()
44
+ // { authenticated: true, userId: "usr_...", email: "user@example.com" }
45
+
46
+ await g.auth.logout()
47
+ await g.auth.deleteAccount() // GDPR erasure: revokes sessions, deletes the user's records
48
+ ```
49
+
50
+ ## Storage
51
+
52
+ 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.
53
+
54
+ ```js
55
+ const tasks = g.storage.collection("tasks")
56
+
57
+ const task = await tasks.create({ title: "Buy milk", done: false })
58
+
59
+ // Records come back WRAPPED — your fields live under .data:
60
+ // { id, data: { title, done }, createdAt, updatedAt }
61
+ // Read task.data.title, NOT task.title. This trips up AI-generated code
62
+ // more than anything else — if your UI shows blanks, this is why.
63
+ console.log(task.id, task.data.title)
64
+
65
+ // list() returns { records, cursor, hasMore } — records is the array
66
+ const { records } = await tasks.list()
67
+ records.forEach(r => console.log(r.data.title))
68
+ const recent = await tasks.list({ limit: 10, sort: "newest" })
69
+ const filtered = await tasks.list({ where: { done: false } }) // exact-match filters
70
+ const one = await tasks.get("rec_abc123")
71
+ await tasks.update("rec_abc123", { done: true })
72
+ await tasks.delete("rec_abc123")
73
+
74
+ // Images/files: presigned upload, returns a CDN URL to store in a record
75
+ const file = await tasks.upload(imageBlob, { name: "avatar.png" })
76
+ // { id, url, contentType, sizeBytes }
77
+ ```
78
+
79
+ ### Collection rules
80
+
81
+ Each collection has one rule, set in the dashboard. The server enforces it — your app never implements authorization:
82
+
83
+ | Rule | Who can read | Who can write | Use case |
84
+ |------|-------------|---------------|----------|
85
+ | `private` | Owner only — plus the app owner/admin, who reads everything | Owner only | User's personal data (tasks, notes, settings) |
86
+ | `shared` | All authenticated users | Each user: own records only | Feeds, communities, team boards |
87
+ | `public_read` | Anyone (no login needed) | App owner only | Catalogs, menus, marketing pages, single-author blogs |
88
+ | `community` | Anyone (no login needed) | Each signed-in user: own records only — plain text | Multi-author blogs, public boards, profiles |
89
+ | `addressed` | Each user: only records addressed to them (owner sees all) | Owner only, naming a recipient | Notifications, invoices, order status |
90
+ | `direct` | Author + the named recipient | Each signed-in user, naming a recipient | Messages, sharing, requests |
91
+ | `admin_write` | All authenticated users | Admin/owner only | App settings, announcements |
92
+
93
+ **Owner scoping is automatic.** For `private` collections each user only sees their own records; when the signed-in user is the app's owner or admin, reads return every user's records (build your admin screen with the same `list()` calls — no server needed). Don't gate admin UI on any visible `role` field — sessions always report `member`, even for the owner; elevation happens server-side per request, so render whatever `list()` returns. For `shared` collections everyone reads everything but update/delete only touch the caller's own records. Never filter by userId, never set `userId`/`owner`/`role` fields. The server derives them from the session and rejects reserved fields if you try to set them:
94
+
95
+ `id`, `appId`, `app_id`, `environmentId`, `environment_id`, `userId`, `user_id`, `ownerId`, `ownerUserId`, `owner_user_id`, `tenantId`, `tenant_id`, `role`, `isAdmin`, `is_admin`, `createdAt`, `created_at`, `updatedAt`, `updated_at`, `deletedAt`, `deleted_at`
96
+
97
+ **User content is data, not markup.** When one user's content renders in another user's session (`community`, `shared`), render it with text bindings — `textContent`, `{}` in React/Vue/Svelte — never `innerHTML`. `community` collections enforce this server-side: any string field containing HTML tags is refused with `400 html_not_allowed`. Store plain text, or markdown written without raw tags.
98
+
99
+ ### Sending to people — inboxes and messages
100
+
101
+ `addressed` and `direct` records carry a recipient, named on the call you already make:
102
+
103
+ ```js
104
+ // addressed (owner → user): the "Mark shipped" button in your admin view
105
+ await updates.create({ text: "Your order shipped 🎉" }, { for: userId })
106
+
107
+ // direct (user → user): DMs, sharing, requests
108
+ await messages.create({ text: "hey!" }, { for: otherUserId })
109
+
110
+ // The reader's side is just list() — the server returns only THEIR inbox:
111
+ const { records } = await updates.list({ sort: "newest", limit: 20 })
112
+ ```
113
+
114
+ The recipient is server-stamped (`record.audienceUserId`) — never a data field. User ids come from records' `ownerUserId` or the owner's admin lists. Rules of the road:
115
+
116
+ - **Same message for everyone → `admin_write`** (one record all users read). **A specific thing for a specific person → `addressed`** (one record per recipient).
117
+ - Inboxes are poll-or-refresh: `list()` on window focus plus a gentle ~60s interval — never a tight loop. Read-state ("seen") lives in the user's own private collection.
118
+ - `direct` is messaging inside someone's app, and the app owner can read it (owner reads reach everything, under every rule) — never present it as private or encrypted chat.
119
+ - Errors teach the fix: `invalid_audience` (recipient isn't a user of this app), `reply_only` (this collection only allows replying to people who wrote to you first — tell the user), `sends_disabled` (the owner turned sends off; addressed sends then happen in their dashboard).
120
+ - Both rules store plain text like `community` — render other users' content as text, never `innerHTML`.
121
+
122
+ ### Contention — when users race for the same thing
123
+
124
+ A permission model can't stop two people booking the same 3pm slot. Preconditions can, and each is just an argument on a call you already make. **A 409 `conflict` from any of them is the mechanism working, not an error to retry away** — catch it and tell the user the slot/stock/edit was taken.
125
+
126
+ ```js
127
+ // Uniqueness: derive the key from the thing that must be unique.
128
+ // Second writer → 409. Your own retry → your record back (existing: true).
129
+ // Deleting the record frees the key.
130
+ await bookings.create({ who: email }, { key: "slot:2026-07-15T15:00" })
131
+
132
+ // Limited stock, N units anyone can buy: claim units with keyed creates —
133
+ // on conflict try the next unit; all taken = sold out. Race-proof under
134
+ // every safety rule (writes on OTHER users' records are never allowed).
135
+ await orders.create({ item: 42 }, { key: "unit:item42:1" }) // then :2 … :N
136
+
137
+ // Your own counters: the server does the math on current state.
138
+ // Breaching the floor/ceiling → 409, record untouched.
139
+ await products.update(id, { stock: { decrement: 1, floor: 0 } })
140
+
141
+ // Shared editing: pass back the version you read — a stale save gets 409
142
+ // instead of silently clobbering someone's edit. Re-read, reapply, retry.
143
+ await pages.update(id, { body }, { ifVersion: page.version })
144
+ ```
145
+
146
+ An object in a patch is treated as an atomic op ONLY when its keys are exactly `increment`|`decrement` (+ optional `floor`|`ceiling`), all numbers — anything else is stored as plain data. Keys are 1-120 chars of letters, numbers, and `: _ . @ / -`. Never find-then-create and never compute counters client-side: both race, and both fail only when real users collide.
147
+
148
+ ## Payments (Stripe) — you don't build the webhook
149
+
150
+ Gemmein hosts your Stripe webhook and manages subscriptions for you: exactly one per customer (case-insensitive on email), created by the payment itself, downgraded to your default plan on cancellation, out-of-order Stripe events resolved to the newest. The app owner names the plans and pastes one Stripe signing secret in the dashboard's **Payments** page — nothing to seed. Your app has exactly two jobs:
151
+
152
+ ```js
153
+ // 1. Send the buyer to checkout — one call, Gemmein does the rest. The app
154
+ // owner pasted each paid plan's Stripe Payment Link in their dashboard;
155
+ // g.checkout() picks the right one and wires the signed-in buyer in.
156
+ // Never build checkout URLs or sessions yourself: raw emails are
157
+ // silently dropped by Stripe's URL rules, and sessions need a secret
158
+ // key that must never ship client-side.
159
+ await g.checkout("pro") // redirects; omit the arg to buy the paid plan
160
+ // GemmeinError codes worth handling: "authentication_required" (sign in
161
+ // first), "plan_has_no_link" (owner hasn't pasted that plan's link yet)
162
+
163
+ // 2. Gate paid features by reading the managed subscription:
164
+ const sub = await g.subscription() // { plan, status } or null (never paid / payments off)
165
+ if (sub?.plan === "pro") { /* unlock */ }
166
+ ```
167
+
168
+ Do **not** write a webhook handler. Do **not** poll Stripe. Do **not** store plan/subscription state in your own collections — `g.subscription()` is the single source of truth, and there is deliberately no client write path to it.
169
+
170
+ Plan **limits** (note counts, feature caps, seat numbers) are your app's logic — Gemmein only tells you who is on which plan; the dashboard's plan names carry no quotas.
171
+
172
+ ### Selling things (one-off purchases)
173
+
174
+ 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:
175
+
176
+ ```js
177
+ // One product covering many items (license tiers over a catalog)? Name the
178
+ // item — it's display text on the receipt, the PRICE always comes from the
179
+ // product's Payment Link:
180
+ await g.pay("premium license", { item: "beat_37" }) // redirects
181
+
182
+ // Fulfilment: the completed payment writes a receipt record ADDRESSED to
183
+ // the buyer — only they (and the owner) can read it. Gate the download on
184
+ // the receipt, never on the redirect coming back (redirects can be faked;
185
+ // receipts can't — they come from Stripe's signed webhook):
186
+ const { records } = await g.storage.collection("receipts").list()
187
+ const paid = records.find(r => r.data.product === "premium license" && r.data.status === "paid")
188
+ if (paid) { /* unlock — paid.data.deliveryUrl holds the download when the owner set one */ }
189
+ ```
190
+
191
+ 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.
192
+
193
+ **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.
194
+
195
+ ### Drafts on public collections
196
+
197
+ On `public_read` and `community` collections, `{ published: false }` saves a **draft** the public can't see — server-enforced, the author still sees their own, the owner sees all:
198
+
199
+ ```js
200
+ const post = await notes.create({ title: "wip" }, { published: false }) // hidden
201
+ await notes.update(post.id, {}, { published: true }) // now live
202
+ ```
203
+
204
+ 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.
205
+
206
+ 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`.
207
+
208
+ ## Server-side (API routes, cron jobs)
209
+
210
+ For trusted server code, use `gemmeinServer` with a scoped secret key (`sk_...`) — created in the dashboard, scoped per collection:
211
+
212
+ ```js
213
+ import { gemmeinServer } from "@gemmein/sdk"
214
+
215
+ const server = gemmeinServer(process.env.GEMMEIN_SECRET_KEY)
216
+
217
+ const tasks = await server.collection("tasks").list()
218
+ await server.collection("tasks").update("rec_abc123", { done: true })
219
+ ```
220
+
221
+ 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).
222
+
223
+ ## Errors
224
+
225
+ Every method throws `GemmeinError`:
226
+
227
+ ```js
228
+ import { GemmeinError } from "@gemmein/sdk"
229
+
230
+ try {
231
+ await g.storage.collection("tasks").create({ title: "Test" })
232
+ } catch (err) {
233
+ if (err instanceof GemmeinError) {
234
+ err.code // "auth_expired", "unknown_collection", "scope_denied",
235
+ // "not_found" (also when touching a record you don't own —
236
+ // existence is never leaked), ...
237
+ err.status // HTTP status
238
+ err.message // human-readable, includes what to do next
239
+ err.resetAt // rate limits: when to retry
240
+ }
241
+ }
242
+ ```
243
+
244
+ | Code | Status | Meaning |
245
+ |------|--------|---------|
246
+ | `missing_app_key` | 401 | No app key — get one at app.gemmein.com |
247
+ | `invalid_app_key` | 403 | Key not recognized (typo, or wrong environment) |
248
+ | `auth_expired` | 401 | Session expired — SDK auto-clears the token |
249
+ | `unknown_collection` | 404 | Collection doesn't exist — create it in the dashboard |
250
+ | `scope_denied` | 403 | Secret key not scoped for this collection/action |
251
+ | `html_not_allowed` | 400 | Community collections store plain text — remove HTML tags |
252
+ | `invalid_file_content` | 400 | Uploaded bytes aren't the image type they claimed — upload the actual image, not a renamed file |
253
+ | `unknown_product` | 404 | No product by that name — the message lists what the app sells |
254
+ | `invalid_publish` | 400 | `published` is an option on public collections only — not a data field, not for scoped rules |
255
+ | `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`. |
256
+ | `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 — call `g.signIn`). Distinguish by HTTP status; show `err.message`, which reads correctly for each. |
257
+
258
+ **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.
259
+
260
+ ## Keys & environments
261
+
262
+ | Prefix | Environment | Where it lives |
263
+ |--------|------------|----------------|
264
+ | `pk_test_...` | Development | Frontend code — safe to expose |
265
+ | `pk_live_...` | Production | Frontend code — safe to expose |
266
+ | `sk_dev_...` / `sk_live_...` | Dev / Prod | Server env vars only |
267
+
268
+ Environments are fully isolated: different data, different users, different collections.
269
+
270
+ ## Management is dashboard-only
271
+
272
+ Collections, domains, keys, payments config, logs, and usage live in the [dashboard](https://app.gemmein.com) — deliberately not in the SDK, so management credentials can never leak from app code.
273
+
274
+ ---
275
+
276
+ Gemmein is an early release: the core above is live, security-audited, and safe to build on; the wider feature set rolls out in stages. [gemmein.com](https://gemmein.com) · hello@gemmein.com
package/REFERENCE.md ADDED
@@ -0,0 +1,235 @@
1
+ # Gemmein SDK — API Reference
2
+
3
+ Complete surface of `@gemmein/sdk`, generated from the type definitions.
4
+ This is the **reference** (every method, signature, return shape, error);
5
+ `llms.txt` is the **guide** (how the model works and the safe patterns). Both
6
+ are needed. Draft — feeds docs.gemmein.com.
7
+
8
+ ```ts
9
+ import { gemmein, GemmeinError } from "@gemmein/sdk";
10
+ const g = gemmein("pk_..."); // browser: your public app key (domain-locked, safe to ship)
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Setup
16
+
17
+ ### `gemmein(appKey, options?) → Gemmein`
18
+ The browser client. `appKey` is your public `pk_...` key. `options` (optional):
19
+ `{ apiUrl?: string, tokenStore?: TokenStore }`. Throws `GemmeinError`
20
+ (`missing_app_key` / `invalid_app_key`) if the key is absent or an `sk_`.
21
+
22
+ ### `gemmeinServer(secretKey, options?) → GemmeinServer`
23
+ Server-only client for a `sk_...` secret key — **never ship this to the
24
+ browser.** Exposes read/update on collections without a signed-in user, plus
25
+ `testSession()` for CI self-tests (dev environments only — see **Reaffirm**).
26
+
27
+ The client exposes two namespaces: **`g.auth`** and **`g.storage`**, plus the
28
+ payment shortcuts `g.subscription()`, `g.checkout()`, `g.pay()`.
29
+
30
+ ---
31
+
32
+ ## Authentication — `g.auth`
33
+
34
+ | Method | Signature | Returns |
35
+ |--------|-----------|---------|
36
+ | `sendEmailCode` | `(email: string)` | `Promise<void>` — emails a sign-in code |
37
+ | `verifyEmailCode` | `({ email, code }: { email: string; code: string })` | `Promise<AuthSession>` |
38
+ | `currentUser` | `()` | `Promise<CurrentUser>` — **never throws** for session state; safe on load |
39
+ | `logout` | `()` | `Promise<void>` — revokes the server session **(the method is `logout`, not `signOut`)** |
40
+ | `deleteAccount` | `()` | `Promise<unknown>` — erases the signed-in user |
41
+
42
+ ```ts
43
+ type AuthSession = { token: string; expiresAt: string; user: { id: string; email: string } };
44
+ type CurrentUser =
45
+ | { authenticated: true; userId: string; email: string } // note: userId, NOT id
46
+ | { authenticated: false; userId?: undefined; email?: undefined };
47
+ ```
48
+ `verifyEmailCode` resolves the **session** (`user.id`, nested). `currentUser`
49
+ resolves the **identity** (`userId`, flat). Use `currentUser()` for who's
50
+ signed in. Sessions are one-per-user: verifying a new code revokes older ones.
51
+
52
+ ---
53
+
54
+ ## Data — `g.storage.collection<T>(name)`
55
+
56
+ `name` must be **lowercase letters, numbers, and underscores** (`saved_games`,
57
+ never `savedGames` — a bad name throws synchronously). Collections are created
58
+ by the app owner in the dashboard, never by the SDK.
59
+
60
+ | Method | Signature | Returns |
61
+ |--------|-----------|---------|
62
+ | `create` | `(data: T, options?: { key?: string; for?: string; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
63
+ | `list` | `(options?: ListOptions)` | `Promise<ListResult<T>>` |
64
+ | `get` | `(id: string, options?: { expand?: string[] })` | `Promise<GemmeinRecord<T>>` |
65
+ | `update` | `(id: string, data: Partial<T> \| { field: { increment\|decrement, floor?, ceiling? } }, options?: { ifVersion?: number; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
66
+ | `delete` | `(id: string)` | `Promise<void>` |
67
+ | `upload` | `(file: Blob \| File, options?: { name?: string })` | `Promise<{ id: string; url: string; contentType: string; sizeBytes: number }>` |
68
+
69
+ ```ts
70
+ type GemmeinRecord<T> = {
71
+ id: string;
72
+ data: T; // YOUR fields live here (record.data.title, never record.title)
73
+ createdAt: string;
74
+ updatedAt: string;
75
+ ownerUserId: string | null; // server-set; null for app-owned (receipts, dashboard-created)
76
+ collectionId: string;
77
+ appId: string;
78
+ environmentId: string;
79
+ version: number; // +1 per update; pass as { ifVersion } to guard concurrent edits
80
+ key?: string; // the create-if-absent key, when one was used
81
+ audienceUserId?: string; // recipient on addressed/direct collections (server-stamped)
82
+ published: boolean; // top-level, not under .data; only meaningful on public rules
83
+ expand?: Record<string, GemmeinRecord | null>; // filled by list/get { expand: [...] }
84
+ existing?: true; // present only when a keyed create returned YOUR existing record
85
+ };
86
+
87
+ type ListResult<T> = { records: GemmeinRecord<T>[]; cursor?: string; hasMore: boolean };
88
+
89
+ type ListOptions = {
90
+ limit?: number;
91
+ sort?: "newest" | "oldest" | "updated";
92
+ where?: Record<string, unknown>; // exact-match on data fields (and link fields)
93
+ cursor?: string; // from a previous ListResult
94
+ search?: string; // free-text across data
95
+ expand?: string[]; // link fields to embed (≤3), only on community/shared/direct
96
+ };
97
+ ```
98
+
99
+ **Options notes.** `key` = create-if-absent (a second writer gets 409
100
+ `conflict`; your own retry returns the record with `existing: true`). `for` =
101
+ recipient id on `addressed`/`direct`. `published: false` = draft on a public
102
+ rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
103
+ position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
104
+ on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
105
+
106
+ ---
107
+
108
+ ## Payments — `g.subscription()` / `g.checkout()` / `g.pay()`
109
+
110
+ | Method | Signature | Returns |
111
+ |--------|-----------|---------|
112
+ | `subscription` | `()` | `Promise<{ plan: string; status: "active" \| "cancelled" } \| null>` |
113
+ | `checkout` | `(plan?: string)` | `Promise<{ url: string; plan: string }>` — **navigates the browser to Stripe** and resolves the url |
114
+ | `pay` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
115
+
116
+ `checkout` and `pay` self-navigate via `window.location` — just `await` them on
117
+ the click; don't also redirect to the returned `url`, and never build a Stripe
118
+ URL yourself. Gate features on `(await g.subscription())?.plan === "pro"`; gate
119
+ one-off fulfilment on the receipt record, never the redirect.
120
+
121
+ ---
122
+
123
+ ## Reaffirm — prove your app's boundaries in CI
124
+
125
+ Gemmein enforces the rules **server-side**, so your frontend is never the source
126
+ of truth. That's exactly why you can move fast: a `reaffirm` script *exercises*
127
+ your boundaries with live calls and fails the build on drift — so if a rule ever
128
+ stopped matching what your UI assumes, you catch it on deploy, not in front of a
129
+ user. You reaffirm **because** Gemmein enforces — never because these checks are
130
+ the enforcement.
131
+
132
+ Two tiers:
133
+
134
+ **Tier A — anonymous + shape (zero setup, runs against any environment incl. live).**
135
+ No session needed. Catches the loudest drift: a private collection an anon can
136
+ read, a renamed/mis-cased collection (`collection("savedGames")` throws
137
+ *synchronously*), the error-code contract, and the deliberate reminder that a
138
+ `community`/`public_read` collection is readable by anyone.
139
+
140
+ **Tier B — cross-user isolation (dev environments only).**
141
+ Proving "user B genuinely can't read user A's private record" needs two real
142
+ sessions. Mint them without an OTP round-trip:
143
+
144
+ ### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
145
+ Mints a **member** session for a test email. **Dev only** — throws
146
+ `test_session_forbidden_live` on an `sk_live` key, and the server refuses it on a
147
+ live environment too (the invariant that keeps it off real user data). Pass the
148
+ returned `token` to `gemmein(pk, { tokenStore })` to act as that user. Dev and
149
+ live enforce the *same* rules, so isolation proven in dev holds in live.
150
+
151
+ This exact harness ships as **`reaffirm.mjs` inside the npm package** — copy it
152
+ out, edit the CONFIG block, run in CI.
153
+
154
+ ```js
155
+ // reaffirm.mjs — run in CI: `node reaffirm.mjs` (exits non-zero on any drift).
156
+ import { gemmein, gemmeinServer } from "@gemmein/sdk";
157
+
158
+ const API = process.env.GEMMEIN_API_URL; // your dev API url
159
+ const g = gemmein(process.env.PUBLIC_KEY, { apiUrl: API });
160
+ const srv = gemmeinServer(process.env.SECRET_KEY, { apiUrl: API }); // sk_dev only
161
+ let fail = 0;
162
+ const refuse = async (label, code, fn) => { // must throw `code`
163
+ try { await fn(); console.error("✗", label, "— expected", code, "got success"); fail++; }
164
+ catch (e) { e.code === code ? console.log("✓", label)
165
+ : (console.error("✗", label, "— got", e.code), fail++); }
166
+ };
167
+ const asUser = (token) => gemmein(process.env.PUBLIC_KEY, {
168
+ apiUrl: API,
169
+ tokenStore: { get: async () => token, set: async () => {}, clear: async () => {} },
170
+ });
171
+
172
+ // ── TIER A: functional + anonymous (no login) ──
173
+ g.storage.collection("private_notes"); // misnamed → throws HERE, loudly, in CI
174
+ await refuse("anon can't read private", "denied", () => g.storage.collection("private_notes").list());
175
+ await refuse("anon can't write community", "denied", () => g.storage.collection("board").create({ text: "x" }));
176
+
177
+ // ── TIER B: cross-user isolation (dev only) ──
178
+ const alice = await srv.testSession("alice@test.dev");
179
+ const bob = await srv.testSession("bob@test.dev");
180
+ const A = asUser(alice.token), B = asUser(bob.token);
181
+
182
+ const note = await A.storage.collection("private_notes").create({ text: "alice-secret" });
183
+ await refuse("B can't read A's private note", "not_found", () =>
184
+ B.storage.collection("private_notes").get(note.id));
185
+ const bobSees = await B.storage.collection("private_notes").list();
186
+ if (bobSees.records.length !== 0) { console.error("✗ B sees A's private records"); fail++; }
187
+ else console.log("✓ B's private list is isolated");
188
+
189
+ // shape the UI reads (userId, NOT id):
190
+ const who = await A.auth.currentUser();
191
+ if (!who.userId) { console.error("✗ currentUser().userId missing"); fail++; }
192
+ else console.log("✓ currentUser().userId present");
193
+
194
+ process.exit(fail ? 1 : 0);
195
+ ```
196
+
197
+ Add a probe every time you add a feature. Point the harness at your **dev**
198
+ environment (Tier B needs it); the Tier-A block alone can additionally smoke-test
199
+ live, since it never mints a session.
200
+
201
+ ---
202
+
203
+ ## Errors — `GemmeinError`
204
+
205
+ Every failed call throws a `GemmeinError`:
206
+
207
+ ```ts
208
+ class GemmeinError extends Error {
209
+ status: number; // HTTP status
210
+ code: string; // branch on this
211
+ message: string; // render this — reads correctly for users
212
+ resetAt?: string; // present on 429 — wait until then, retry
213
+ }
214
+ ```
215
+
216
+ Branch on `err.code`. The stable codes:
217
+
218
+ | code | meaning | do |
219
+ |------|---------|-----|
220
+ | `unknown_collection` | collection doesn't exist | ask the owner to create it — don't retry |
221
+ | `unknown_product` | product not sold | use a name from the list in the message |
222
+ | `forbidden` | the rules refused you (e.g. only the owner writes) | **stop** — the same call always fails |
223
+ | `not_found` | record you can't see (existence not leaked) | treat as absent |
224
+ | `denied` | 401 (sign in first) or 429 (rate limit — see `resetAt`) | re-auth or wait+retry |
225
+ | `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
226
+ | `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
227
+ | `invalid_publish` | `{ published }` on a non-public rule | drop it |
228
+ | `invalid_shape` | field not in a locked (live) collection's shape | ask the owner to add it |
229
+ | `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
230
+ | `unknown_record` | a link field points at a missing record (live only) | fix the id |
231
+ | `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
232
+ | `invalid_file_content` | uploaded bytes aren't the claimed image type | send the real image |
233
+ | `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 |
234
+
235
+ Keys: `pk_` (public, domain-locked, browser-safe) vs `sk_` (secret, server only).