@gemmein/sdk 0.0.1 → 0.2.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 +21 -0
- package/README.md +281 -4
- package/REFERENCE.md +264 -0
- package/dist/index.cjs +627 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.js +611 -0
- package/llms.txt +305 -0
- package/package.json +52 -6
- package/reaffirm.mjs +76 -0
- package/index.js +0 -6
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,285 @@
|
|
|
1
1
|
# @gemmein/sdk
|
|
2
2
|
|
|
3
|
-
The
|
|
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
|
-
|
|
5
|
+
## Step 0 — get your app key
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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.collection("tasks").create({ title: "Buy milk", done: false })
|
|
30
|
+
const { records } = await g.collection("tasks").list()
|
|
31
|
+
```
|
|
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).
|
|
34
|
+
|
|
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
|
+
|
|
37
|
+
## Auth
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
await g.auth.sendEmailCode("user@example.com") // sends an 8-digit code
|
|
41
|
+
|
|
42
|
+
const session = await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
|
|
43
|
+
// { token, expiresAt, user: { id, email } } — token stored automatically
|
|
44
|
+
|
|
45
|
+
const user = await g.auth.currentUser()
|
|
46
|
+
// { authenticated: true, userId: "usr_...", email: "user@example.com" }
|
|
47
|
+
|
|
48
|
+
await g.auth.logout()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Deleting an account
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
await g.account.delete()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
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
|
+
|
|
59
|
+
## Storage
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
const tasks = g.collection("tasks")
|
|
65
|
+
|
|
66
|
+
const task = await tasks.create({ title: "Buy milk", done: false })
|
|
67
|
+
|
|
68
|
+
// Records come back WRAPPED — your fields live under .data:
|
|
69
|
+
// { id, data: { title, done }, createdAt, updatedAt }
|
|
70
|
+
// Read task.data.title, NOT task.title. This trips up AI-generated code
|
|
71
|
+
// more than anything else — if your UI shows blanks, this is why.
|
|
72
|
+
console.log(task.id, task.data.title)
|
|
73
|
+
|
|
74
|
+
// list() returns { records, cursor, hasMore } — records is the array
|
|
75
|
+
const { records } = await tasks.list()
|
|
76
|
+
records.forEach(r => console.log(r.data.title))
|
|
77
|
+
const recent = await tasks.list({ limit: 10, sort: "newest" })
|
|
78
|
+
const filtered = await tasks.list({ where: { done: false } }) // exact-match filters
|
|
79
|
+
const one = await tasks.get("rec_abc123")
|
|
80
|
+
await tasks.update("rec_abc123", { done: true })
|
|
81
|
+
await tasks.delete("rec_abc123")
|
|
82
|
+
|
|
83
|
+
// Images/files: presigned upload, returns a CDN URL to store in a record
|
|
84
|
+
const file = await tasks.upload(imageBlob, { name: "avatar.png" })
|
|
85
|
+
// { id, url, contentType, sizeBytes }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Collection rules
|
|
89
|
+
|
|
90
|
+
Each collection has one rule, set in the dashboard. The server enforces it — your app never implements authorization:
|
|
91
|
+
|
|
92
|
+
| Rule | Who can read | Who can write | Use case |
|
|
93
|
+
|------|-------------|---------------|----------|
|
|
94
|
+
| `private` | Owner only — plus the app owner/admin, who reads everything | Owner only | User's personal data (tasks, notes, settings) |
|
|
95
|
+
| `shared` | All authenticated users | Each user: own records only | Feeds, communities, team boards |
|
|
96
|
+
| `public_read` | Anyone (no login needed) | App owner only | Catalogs, menus, marketing pages, single-author blogs |
|
|
97
|
+
| `community` | Anyone (no login needed) | Each signed-in user: own records only — plain text | Multi-author blogs, public boards, profiles |
|
|
98
|
+
| `addressed` | Each user: only records addressed to them (owner sees all) | Owner only, naming a recipient | Notifications, invoices, order status |
|
|
99
|
+
| `direct` | Author + the named recipient | Each signed-in user, naming a recipient | Messages, sharing, requests |
|
|
100
|
+
| `admin_write` | All authenticated users | Admin/owner only | App settings, announcements |
|
|
101
|
+
|
|
102
|
+
**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:
|
|
103
|
+
|
|
104
|
+
`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`
|
|
105
|
+
|
|
106
|
+
**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.
|
|
107
|
+
|
|
108
|
+
### Sending to people — inboxes and messages
|
|
109
|
+
|
|
110
|
+
`addressed` and `direct` records carry a recipient, named on the call you already make:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// addressed (owner → user): the "Mark shipped" button in your admin view
|
|
114
|
+
await updates.create({ text: "Your order shipped 🎉" }, { for: userId })
|
|
115
|
+
|
|
116
|
+
// direct (user → user): DMs, sharing, requests
|
|
117
|
+
await messages.create({ text: "hey!" }, { for: otherUserId })
|
|
118
|
+
|
|
119
|
+
// The reader's side is just list() — the server returns only THEIR inbox:
|
|
120
|
+
const { records } = await updates.list({ sort: "newest", limit: 20 })
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
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:
|
|
124
|
+
|
|
125
|
+
- **Same message for everyone → `admin_write`** (one record all users read). **A specific thing for a specific person → `addressed`** (one record per recipient).
|
|
126
|
+
- 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.
|
|
127
|
+
- `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.
|
|
128
|
+
- 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).
|
|
129
|
+
- Both rules store plain text like `community` — render other users' content as text, never `innerHTML`.
|
|
130
|
+
|
|
131
|
+
### Contention — when users race for the same thing
|
|
132
|
+
|
|
133
|
+
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.
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
// Uniqueness: derive the key from the thing that must be unique.
|
|
137
|
+
// Second writer → 409. Your own retry → your record back (existing: true).
|
|
138
|
+
// Deleting the record frees the key.
|
|
139
|
+
await bookings.create({ who: email }, { key: "slot:2026-07-15T15:00" })
|
|
140
|
+
|
|
141
|
+
// Limited stock, N units anyone can buy: claim units with keyed creates —
|
|
142
|
+
// on conflict try the next unit; all taken = sold out. Race-proof under
|
|
143
|
+
// every safety rule (writes on OTHER users' records are never allowed).
|
|
144
|
+
await orders.create({ item: 42 }, { key: "unit:item42:1" }) // then :2 … :N
|
|
145
|
+
|
|
146
|
+
// Your own counters: the server does the math on current state.
|
|
147
|
+
// Breaching the floor/ceiling → 409, record untouched.
|
|
148
|
+
await products.update(id, { stock: { decrement: 1, floor: 0 } })
|
|
149
|
+
|
|
150
|
+
// Shared editing: pass back the version you read — a stale save gets 409
|
|
151
|
+
// instead of silently clobbering someone's edit. Re-read, reapply, retry.
|
|
152
|
+
await pages.update(id, { body }, { ifVersion: page.version })
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
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.
|
|
156
|
+
|
|
157
|
+
## Payments (Stripe) — you don't build the webhook
|
|
158
|
+
|
|
159
|
+
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:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
// 1. Send the buyer to checkout — one call, Gemmein does the rest. The app
|
|
163
|
+
// owner pasted each paid plan's Stripe Payment Link in their dashboard;
|
|
164
|
+
// g.subscriptions.checkout() picks the right one and wires the signed-in buyer in.
|
|
165
|
+
// Never build checkout URLs or sessions yourself: raw emails are
|
|
166
|
+
// silently dropped by Stripe's URL rules, and sessions need a secret
|
|
167
|
+
// key that must never ship client-side.
|
|
168
|
+
await g.subscriptions.checkout("pro") // redirects; omit the arg to buy the paid plan
|
|
169
|
+
// GemmeinError codes worth handling: "authentication_required" (sign in
|
|
170
|
+
// first), "plan_has_no_link" (owner hasn't pasted that plan's link yet)
|
|
171
|
+
|
|
172
|
+
// 2. Gate paid features by reading the managed subscription:
|
|
173
|
+
const sub = await g.subscriptions.mine() // { plan, status } or null (never paid / payments off)
|
|
174
|
+
if (sub?.plan === "pro") { /* unlock */ }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Do **not** write a webhook handler. Do **not** poll Stripe. Do **not** store plan/subscription state in your own collections — `g.subscriptions.mine()` is the single source of truth, and there is deliberately no client write path to it.
|
|
178
|
+
|
|
179
|
+
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.
|
|
180
|
+
|
|
181
|
+
### Selling things (one-off purchases)
|
|
182
|
+
|
|
183
|
+
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:
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
// One product covering many items (license tiers over a catalog)? Name the
|
|
187
|
+
// item — it's display text on the receipt, the PRICE always comes from the
|
|
188
|
+
// product's Payment Link:
|
|
189
|
+
await g.payments.buy("premium license", { item: "beat_37" }) // redirects
|
|
190
|
+
|
|
191
|
+
// Fulfilment: the completed payment writes a receipt record ADDRESSED to
|
|
192
|
+
// the buyer — only they (and the owner) can read it. Gate the download on
|
|
193
|
+
// the receipt, never on the redirect coming back (redirects can be faked;
|
|
194
|
+
// receipts can't — they come from Stripe's signed webhook):
|
|
195
|
+
const { records } = await g.collection("receipts").list()
|
|
196
|
+
const paid = records.find(r => r.data.product === "premium license" && r.data.status === "paid")
|
|
197
|
+
if (paid) { /* unlock — paid.data.deliveryUrl holds the download when the owner set one */ }
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
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.
|
|
201
|
+
|
|
202
|
+
**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.
|
|
203
|
+
|
|
204
|
+
### Drafts on public collections
|
|
205
|
+
|
|
206
|
+
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:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
const post = await notes.create({ title: "wip" }, { published: false }) // hidden
|
|
210
|
+
await notes.update(post.id, {}, { published: true }) // now live
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
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.
|
|
214
|
+
|
|
215
|
+
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`.
|
|
216
|
+
|
|
217
|
+
## Server-side (API routes, cron jobs)
|
|
218
|
+
|
|
219
|
+
For trusted server code, use `gemmeinServer` with a scoped secret key (`sk_...`) — created in the dashboard, scoped per collection:
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
import { gemmeinServer } from "@gemmein/sdk"
|
|
223
|
+
|
|
224
|
+
const server = gemmeinServer(process.env.GEMMEIN_SECRET_KEY)
|
|
225
|
+
|
|
226
|
+
const tasks = await server.collection("tasks").list()
|
|
227
|
+
await server.collection("tasks").update("rec_abc123", { done: true })
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
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).
|
|
231
|
+
|
|
232
|
+
## Errors
|
|
233
|
+
|
|
234
|
+
Every method throws `GemmeinError`:
|
|
235
|
+
|
|
236
|
+
```js
|
|
237
|
+
import { GemmeinError } from "@gemmein/sdk"
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await g.collection("tasks").create({ title: "Test" })
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (err instanceof GemmeinError) {
|
|
243
|
+
err.code // "auth_expired", "unknown_collection", "scope_denied",
|
|
244
|
+
// "not_found" (also when touching a record you don't own —
|
|
245
|
+
// existence is never leaked), ...
|
|
246
|
+
err.status // HTTP status
|
|
247
|
+
err.message // human-readable, includes what to do next
|
|
248
|
+
err.resetAt // rate limits: when to retry
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Code | Status | Meaning |
|
|
254
|
+
|------|--------|---------|
|
|
255
|
+
| `missing_app_key` | 401 | No app key — get one at app.gemmein.com |
|
|
256
|
+
| `invalid_app_key` | 403 | Key not recognized (typo, or wrong environment) |
|
|
257
|
+
| `auth_expired` | 401 | Session expired — SDK auto-clears the token |
|
|
258
|
+
| `unknown_collection` | 404 | Collection doesn't exist — create it in the dashboard |
|
|
259
|
+
| `scope_denied` | 403 | Secret key not scoped for this collection/action |
|
|
260
|
+
| `html_not_allowed` | 400 | Community collections store plain text — remove HTML tags |
|
|
261
|
+
| `invalid_file_content` | 400 | Uploaded bytes aren't the image type they claimed — upload the actual image, not a renamed file |
|
|
262
|
+
| `unknown_product` | 404 | No product by that name — the message lists what the app sells |
|
|
263
|
+
| `invalid_publish` | 400 | `published` is an option on public collections only — not a data field, not for scoped rules |
|
|
264
|
+
| `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`. |
|
|
265
|
+
| `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. |
|
|
266
|
+
|
|
267
|
+
**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.
|
|
268
|
+
|
|
269
|
+
## Keys & environments
|
|
270
|
+
|
|
271
|
+
| Prefix | Environment | Where it lives |
|
|
272
|
+
|--------|------------|----------------|
|
|
273
|
+
| `pk_test_...` | Development | Frontend code — safe to expose |
|
|
274
|
+
| `pk_live_...` | Production | Frontend code — safe to expose |
|
|
275
|
+
| `sk_dev_...` / `sk_live_...` | Dev / Prod | Server env vars only |
|
|
276
|
+
|
|
277
|
+
Environments are fully isolated: different data, different users, different collections.
|
|
278
|
+
|
|
279
|
+
## Management is dashboard-only
|
|
280
|
+
|
|
281
|
+
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.
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
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,264 @@
|
|
|
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 has two layers. **Your app's collections** — `g.collection(name)`
|
|
28
|
+
(the canonical spelling; `g.storage.collection(name)` is the same client). And
|
|
29
|
+
the **business primitives Gemmein runs for you** — `g.auth`,
|
|
30
|
+
`g.subscriptions`, `g.payments`, `g.account`: self-service surfaces for the
|
|
31
|
+
signed-in user. Managing *other* people's users, subscriptions, and records
|
|
32
|
+
happens in the owner's dashboard, deliberately not in this SDK.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Authentication — `g.auth`
|
|
37
|
+
|
|
38
|
+
| Method | Signature | Returns |
|
|
39
|
+
|--------|-----------|---------|
|
|
40
|
+
| `sendEmailCode` | `(email: string)` | `Promise<void>` — emails a sign-in code |
|
|
41
|
+
| `verifyEmailCode` | `({ email, code }: { email: string; code: string })` | `Promise<AuthSession>` |
|
|
42
|
+
| `currentUser` | `()` | `Promise<CurrentUser>` — **never throws** for session state; safe on load |
|
|
43
|
+
| `logout` | `()` | `Promise<void>` — revokes the server session **(the method is `logout`, not `signOut`)** |
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
type AuthSession = { token: string; expiresAt: string; user: { id: string; email: string } };
|
|
47
|
+
type CurrentUser =
|
|
48
|
+
| { authenticated: true; userId: string; email: string } // note: userId, NOT id
|
|
49
|
+
| { authenticated: false; userId?: undefined; email?: undefined };
|
|
50
|
+
```
|
|
51
|
+
`verifyEmailCode` resolves the **session** (`user.id`, nested). `currentUser`
|
|
52
|
+
resolves the **identity** (`userId`, flat). Use `currentUser()` for who's
|
|
53
|
+
signed in. Sessions are one-per-user: verifying a new code revokes older ones.
|
|
54
|
+
|
|
55
|
+
`currentUser()` answering `{ authenticated: false }` is deliberately silent
|
|
56
|
+
about *why* — signed out, suspended by the owner, and erased all read the
|
|
57
|
+
same, so a moderated user's state never leaks to the client. One signed-out
|
|
58
|
+
screen covers all three.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Account — `g.account`
|
|
63
|
+
|
|
64
|
+
| Method | Signature | Returns |
|
|
65
|
+
|--------|-----------|---------|
|
|
66
|
+
| `delete` | `()` | `Promise<unknown>` — erases the signed-in user, clears the stored token |
|
|
67
|
+
|
|
68
|
+
The "delete my account" screen. Every app it **applies** to needs one (GDPR
|
|
69
|
+
right to erasure; Apple 5.1.1(v) for any app with account creation).
|
|
70
|
+
Server-side it's the full cascade — sessions revoked, records and files
|
|
71
|
+
deleted, subscription row removed. Irreversible: put a real confirm in front.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Data — `g.collection<T>(name)`
|
|
76
|
+
|
|
77
|
+
`name` must be **lowercase letters, numbers, and underscores** (`saved_games`,
|
|
78
|
+
never `savedGames` — a bad name throws synchronously). Collections are created
|
|
79
|
+
by the app owner in the dashboard, never by the SDK.
|
|
80
|
+
|
|
81
|
+
| Method | Signature | Returns |
|
|
82
|
+
|--------|-----------|---------|
|
|
83
|
+
| `create` | `(data: T, options?: { key?: string; for?: string; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
|
|
84
|
+
| `list` | `(options?: ListOptions)` | `Promise<ListResult<T>>` |
|
|
85
|
+
| `get` | `(id: string, options?: { expand?: string[] })` | `Promise<GemmeinRecord<T>>` |
|
|
86
|
+
| `update` | `(id: string, data: Partial<T> \| { field: { increment\|decrement, floor?, ceiling? } }, options?: { ifVersion?: number; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
|
|
87
|
+
| `delete` | `(id: string)` | `Promise<void>` |
|
|
88
|
+
| `upload` | `(file: Blob \| File, options?: { name?: string })` | `Promise<{ id: string; url: string; contentType: string; sizeBytes: number }>` |
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
type GemmeinRecord<T> = {
|
|
92
|
+
id: string;
|
|
93
|
+
data: T; // YOUR fields live here (record.data.title, never record.title)
|
|
94
|
+
createdAt: string;
|
|
95
|
+
updatedAt: string;
|
|
96
|
+
ownerUserId: string | null; // server-set; null for app-owned (receipts, dashboard-created)
|
|
97
|
+
collectionId: string;
|
|
98
|
+
appId: string;
|
|
99
|
+
environmentId: string;
|
|
100
|
+
version: number; // +1 per update; pass as { ifVersion } to guard concurrent edits
|
|
101
|
+
key?: string; // the create-if-absent key, when one was used
|
|
102
|
+
audienceUserId?: string; // recipient on addressed/direct collections (server-stamped)
|
|
103
|
+
published: boolean; // top-level, not under .data; only meaningful on public rules
|
|
104
|
+
expand?: Record<string, GemmeinRecord | null>; // filled by list/get { expand: [...] }
|
|
105
|
+
existing?: true; // present only when a keyed create returned YOUR existing record
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type ListResult<T> = { records: GemmeinRecord<T>[]; cursor?: string; hasMore: boolean };
|
|
109
|
+
|
|
110
|
+
type ListOptions = {
|
|
111
|
+
limit?: number;
|
|
112
|
+
sort?: "newest" | "oldest" | "updated";
|
|
113
|
+
where?: Record<string, unknown>; // exact-match on data fields (and link fields)
|
|
114
|
+
cursor?: string; // from a previous ListResult
|
|
115
|
+
search?: string; // free-text across data
|
|
116
|
+
expand?: string[]; // link fields to embed (≤3), only on community/shared/direct
|
|
117
|
+
};
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Options notes.** `key` = create-if-absent (a second writer gets 409
|
|
121
|
+
`conflict`; your own retry returns the record with `existing: true`). `for` =
|
|
122
|
+
recipient id on `addressed`/`direct`. `published: false` = draft on a public
|
|
123
|
+
rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
|
|
124
|
+
position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
|
|
125
|
+
on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Payments — `g.subscriptions` / `g.payments`
|
|
130
|
+
|
|
131
|
+
| Method | Signature | Returns |
|
|
132
|
+
|--------|-----------|---------|
|
|
133
|
+
| `subscriptions.mine` | `()` | `Promise<{ plan: string; status: "active" \| "cancelled" } \| null>` |
|
|
134
|
+
| `subscriptions.checkout` | `(plan?: string)` | `Promise<{ url: string; plan: string }>` — **navigates the browser to Stripe** and resolves the url |
|
|
135
|
+
| `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
|
|
136
|
+
|
|
137
|
+
Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
138
|
+
`buy` self-navigate via `window.location` — just `await` them on the click;
|
|
139
|
+
don't also redirect to the returned `url`, and never build a Stripe URL
|
|
140
|
+
yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
|
|
141
|
+
gate one-off fulfilment on the receipt record, never the redirect.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Reaffirm — prove your app's boundaries in CI
|
|
146
|
+
|
|
147
|
+
Gemmein enforces the rules **server-side**, so your frontend is never the source
|
|
148
|
+
of truth. That's exactly why you can move fast: a `reaffirm` script *exercises*
|
|
149
|
+
your boundaries with live calls and fails the build on drift — so if a rule ever
|
|
150
|
+
stopped matching what your UI assumes, you catch it on deploy, not in front of a
|
|
151
|
+
user. You reaffirm **because** Gemmein enforces — never because these checks are
|
|
152
|
+
the enforcement.
|
|
153
|
+
|
|
154
|
+
Two tiers:
|
|
155
|
+
|
|
156
|
+
**Tier A — anonymous + shape (zero setup, runs against any environment incl. live).**
|
|
157
|
+
No session needed. Catches the loudest drift: a private collection an anon can
|
|
158
|
+
read, a renamed/mis-cased collection (`collection("savedGames")` throws
|
|
159
|
+
*synchronously*), the error-code contract, and the deliberate reminder that a
|
|
160
|
+
`community`/`public_read` collection is readable by anyone.
|
|
161
|
+
|
|
162
|
+
**Tier B — cross-user isolation (dev environments only).**
|
|
163
|
+
Proving "user B genuinely can't read user A's private record" needs two real
|
|
164
|
+
sessions. Mint them without an OTP round-trip:
|
|
165
|
+
|
|
166
|
+
### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
|
|
167
|
+
Mints a **member** session for a test email. **Dev only** — throws
|
|
168
|
+
`test_session_forbidden_live` on an `sk_live` key, and the server refuses it on a
|
|
169
|
+
live environment too (the invariant that keeps it off real user data). Pass the
|
|
170
|
+
returned `token` to `gemmein(pk, { tokenStore })` to act as that user. Dev and
|
|
171
|
+
live enforce the *same* rules, so isolation proven in dev holds in live.
|
|
172
|
+
|
|
173
|
+
This exact harness ships as **`reaffirm.mjs` inside the npm package** — copy it
|
|
174
|
+
out, edit the CONFIG block, run in CI.
|
|
175
|
+
|
|
176
|
+
```js
|
|
177
|
+
// reaffirm.mjs — run in CI: `node reaffirm.mjs` (exits non-zero on any drift).
|
|
178
|
+
import { gemmein, gemmeinServer } from "@gemmein/sdk";
|
|
179
|
+
|
|
180
|
+
const API = process.env.GEMMEIN_API_URL; // your dev API url
|
|
181
|
+
const g = gemmein(process.env.PUBLIC_KEY, { apiUrl: API });
|
|
182
|
+
const srv = gemmeinServer(process.env.SECRET_KEY, { apiUrl: API }); // sk_dev only
|
|
183
|
+
let fail = 0;
|
|
184
|
+
const refuse = async (label, code, fn) => { // must throw `code`
|
|
185
|
+
try { await fn(); console.error("✗", label, "— expected", code, "got success"); fail++; }
|
|
186
|
+
catch (e) { e.code === code ? console.log("✓", label)
|
|
187
|
+
: (console.error("✗", label, "— got", e.code), fail++); }
|
|
188
|
+
};
|
|
189
|
+
const asUser = (token) => gemmein(process.env.PUBLIC_KEY, {
|
|
190
|
+
apiUrl: API,
|
|
191
|
+
tokenStore: { get: async () => token, set: async () => {}, clear: async () => {} },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ── TIER A: functional + anonymous (no login) ──
|
|
195
|
+
g.collection("private_notes"); // misnamed → throws HERE, loudly, in CI
|
|
196
|
+
await refuse("anon can't read private", "denied", () => g.collection("private_notes").list());
|
|
197
|
+
await refuse("anon can't write community", "denied", () => g.collection("board").create({ text: "x" }));
|
|
198
|
+
|
|
199
|
+
// ── TIER B: cross-user isolation (dev only) ──
|
|
200
|
+
const alice = await srv.testSession("alice@test.dev");
|
|
201
|
+
const bob = await srv.testSession("bob@test.dev");
|
|
202
|
+
const A = asUser(alice.token), B = asUser(bob.token);
|
|
203
|
+
|
|
204
|
+
const note = await A.collection("private_notes").create({ text: "alice-secret" });
|
|
205
|
+
await refuse("B can't read A's private note", "not_found", () =>
|
|
206
|
+
B.collection("private_notes").get(note.id));
|
|
207
|
+
const bobSees = await B.collection("private_notes").list();
|
|
208
|
+
if (bobSees.records.length !== 0) { console.error("✗ B sees A's private records"); fail++; }
|
|
209
|
+
else console.log("✓ B's private list is isolated");
|
|
210
|
+
|
|
211
|
+
// shape the UI reads (userId, NOT id):
|
|
212
|
+
const who = await A.auth.currentUser();
|
|
213
|
+
if (!who.userId) { console.error("✗ currentUser().userId missing"); fail++; }
|
|
214
|
+
else console.log("✓ currentUser().userId present");
|
|
215
|
+
|
|
216
|
+
process.exit(fail ? 1 : 0);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Add a probe every time you add a feature. Point the harness at your **dev**
|
|
220
|
+
environment (Tier B needs it); the Tier-A block alone can additionally smoke-test
|
|
221
|
+
live, since it never mints a session.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Errors — `GemmeinError`
|
|
226
|
+
|
|
227
|
+
Every failed call throws a `GemmeinError`:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
class GemmeinError extends Error {
|
|
231
|
+
status: number; // HTTP status
|
|
232
|
+
code: string; // branch on this
|
|
233
|
+
message: string; // render this — reads correctly for users
|
|
234
|
+
resetAt?: string; // present on 429 — wait until then, retry
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Branch on `err.code`. The stable codes:
|
|
239
|
+
|
|
240
|
+
| code | meaning | do |
|
|
241
|
+
|------|---------|-----|
|
|
242
|
+
| `unknown_collection` | collection doesn't exist | ask the owner to create it — don't retry |
|
|
243
|
+
| `unknown_product` | product not sold | use a name from the list in the message |
|
|
244
|
+
| `forbidden` | the rules refused you (e.g. only the owner writes) | **stop** — the same call always fails |
|
|
245
|
+
| `not_found` | record you can't see (existence not leaked) | treat as absent |
|
|
246
|
+
| `denied` | 401 (sign in first) or 429 (rate limit — see `resetAt`) | re-auth or wait+retry |
|
|
247
|
+
| `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
|
|
248
|
+
| `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
|
|
249
|
+
| `invalid_publish` | `{ published }` on a non-public rule | drop it |
|
|
250
|
+
| `invalid_shape` | field not in a locked (live) collection's shape | ask the owner to add it |
|
|
251
|
+
| `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
|
|
252
|
+
| `unknown_record` | a link field points at a missing record (live only) | fix the id |
|
|
253
|
+
| `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
|
|
254
|
+
| `invalid_file_content` | uploaded bytes aren't the claimed image type | send the real image |
|
|
255
|
+
| `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 |
|
|
256
|
+
| `unknown_plan` | no plan by that name | use a name from the list in the message |
|
|
257
|
+
| `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
|
|
258
|
+
| `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
|
|
259
|
+
| `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes) | scope the key to that collection, or use the right surface |
|
|
260
|
+
| `unsupported_file_type` (415) | upload isn't one of the allowed image types | send JPEG/PNG/WebP/GIF/HEIC |
|
|
261
|
+
| `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
|
|
262
|
+
| `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
|
|
263
|
+
|
|
264
|
+
Keys: `pk_` (public, domain-locked, browser-safe) vs `sk_` (secret, server only).
|