@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/llms.txt
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# Gemmein
|
|
2
|
+
|
|
3
|
+
> Gemmein is the backend for AI-built web apps: passwordless authentication,
|
|
4
|
+
> structured data storage with plain-English safety rules, and built-in Stripe
|
|
5
|
+
> subscription handling. Security is on by default for every app — tenant
|
|
6
|
+
> isolation, rate limiting, audit logs, domain-locked keys — with nothing to
|
|
7
|
+
> configure. Free while building; $14.99/mo when an app goes live (first
|
|
8
|
+
> 1,000 monthly signed-in people included; +$5 per additional 1,000).
|
|
9
|
+
|
|
10
|
+
## What it is
|
|
11
|
+
|
|
12
|
+
- Audience: people who build web apps with AI tools (Cursor, Bolt, Lovable, Claude).
|
|
13
|
+
- Scope: web apps (TypeScript/JavaScript SDK). Mobile SDKs are not offered today.
|
|
14
|
+
- The platform holds identity, data, and subscription state. It never holds,
|
|
15
|
+
moves, or processes money — Stripe handles all payments end-to-end; Gemmein
|
|
16
|
+
only receives Stripe's webhooks and records who is on which plan. App
|
|
17
|
+
compute/hosting
|
|
18
|
+
belongs anywhere (Vercel, Cloudflare, any static host). There are no hosted
|
|
19
|
+
functions — server-side needs use a secret key from any server.
|
|
20
|
+
|
|
21
|
+
## The contract an AI builder follows
|
|
22
|
+
|
|
23
|
+
- Sign in: passwordless email codes — `g.auth.sendEmailCode(email)` then
|
|
24
|
+
`g.auth.verifyEmailCode({ email, code })`; sessions are long-lived but
|
|
25
|
+
ONE per user — verifying a new code revokes that email's older sessions
|
|
26
|
+
(other tabs/devices sign out; a stale token makes the next call throw
|
|
27
|
+
`auth_expired` once, then the SDK clears it — just retry or re-auth).
|
|
28
|
+
`g.auth.currentUser()` is safe to call on page load and never throws for
|
|
29
|
+
session state.
|
|
30
|
+
- Data: records live in collections. Collections are created by YOUR HUMAN in
|
|
31
|
+
their dashboard (app.gemmein.com → data → "+ New collection"), never by you
|
|
32
|
+
or the SDK. Best practice: at planning time, list the collections your app
|
|
33
|
+
will need and ask your human up front. If one is missing at runtime
|
|
34
|
+
(404 `unknown_collection`), stop and ask them to create it, telling them
|
|
35
|
+
the name and which safety rule to pick. Each collection has exactly one
|
|
36
|
+
safety rule:
|
|
37
|
+
- `private` — each signed-in user sees and edits only their own records
|
|
38
|
+
(right for notes, tasks, anything personal).
|
|
39
|
+
- `shared` — every signed-in user reads every record and adds their own;
|
|
40
|
+
each user edits/deletes ONLY the records they created (a non-author
|
|
41
|
+
write comes back 404, same as any record you can't touch). Right for a
|
|
42
|
+
team feed everyone posts to; WRONG for personal data — it leaks.
|
|
43
|
+
- `admin_write` — everyone signed in can read, only the owner can write
|
|
44
|
+
(right for announcements and settings your human curates).
|
|
45
|
+
- `public_read` — readable without signing in, ONLY the app owner writes
|
|
46
|
+
(right for catalogs, menus, single-author blogs — strangers can never
|
|
47
|
+
inject records into it).
|
|
48
|
+
- `community` — readable without signing in, any signed-in user posts and
|
|
49
|
+
edits their OWN records (right for multi-author blogs, public boards,
|
|
50
|
+
user profiles). Everything in it is PUBLIC — keep record data minimal
|
|
51
|
+
(a booking needs a slot and a first name, not a phone number). Community
|
|
52
|
+
stores PLAIN TEXT: string fields containing HTML tags are refused with
|
|
53
|
+
400 html_not_allowed — store plain text or tag-free markdown.
|
|
54
|
+
- `addressed` — the app sends to one user: the OWNER creates records
|
|
55
|
+
naming a recipient (`create(data, { for: userId })`); each user's
|
|
56
|
+
`.list()` returns ONLY records addressed to them (their inbox); users
|
|
57
|
+
never write. Right for notifications, order status, invoices, results.
|
|
58
|
+
Same message for EVERYONE is `admin_write` instead (one record all
|
|
59
|
+
users read) — addressed is one record per recipient.
|
|
60
|
+
- `direct` — users send to each other: any signed-in user creates records
|
|
61
|
+
naming a recipient; only the author and that recipient can read them.
|
|
62
|
+
Right for messages, sharing, requests. The app owner can read directs
|
|
63
|
+
(owner reads reach everything, under every rule) — never present this
|
|
64
|
+
as private or encrypted chat.
|
|
65
|
+
Inbox mechanics (addressed + direct): the recipient is server-stamped
|
|
66
|
+
(`record.audienceUserId`) — never a data field; user ids come from
|
|
67
|
+
records' ownerUserId or the owner's admin views. Reading is plain
|
|
68
|
+
`.list({ sort: "newest" })` — poll on window focus plus a gentle ~60s
|
|
69
|
+
interval, never a tight loop. Track read-state in the user's own private
|
|
70
|
+
collection. Both rules store plain text like community. Errors teach the
|
|
71
|
+
fix: 400 invalid_audience (recipient isn't a user of this app), 403
|
|
72
|
+
reply_only (this collection only allows replying to someone who wrote to
|
|
73
|
+
you first — tell the user), 403 sends_disabled (the owner turned in-app
|
|
74
|
+
sends off; sends happen from their dashboard).
|
|
75
|
+
Other users' content is DATA, not markup: when your app renders content
|
|
76
|
+
written by one user in another user's session (community, shared), bind it
|
|
77
|
+
as text — textContent, {} in React/Vue/Svelte — NEVER innerHTML or
|
|
78
|
+
dangerouslySetInnerHTML.
|
|
79
|
+
What the rules canNOT do: there is no team/group/workspace scope and no
|
|
80
|
+
per-user record visibility (records visible to some signed-in users but
|
|
81
|
+
not others). If your app needs that shape, do NOT approximate it by
|
|
82
|
+
filtering a shared collection in the browser — the data still reaches
|
|
83
|
+
every user's device. Stop and tell your human that shape isn't supported
|
|
84
|
+
yet.
|
|
85
|
+
- Record shape: `.create()`/`.get()`/`.update()` return
|
|
86
|
+
`{ id, data: { ...your fields }, createdAt, updatedAt, ownerUserId, ... }` —
|
|
87
|
+
your fields ALWAYS live under `.data` (`record.data.title`, never
|
|
88
|
+
`record.title`). `ownerUserId` and the rest are server-derived and
|
|
89
|
+
read-only: never store your own userId/role/owner fields inside `data`.
|
|
90
|
+
`.list()` returns `{ records, hasMore }` (an object, not an array) and
|
|
91
|
+
accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor }`.
|
|
92
|
+
- Linking records (author on a post, product on an order): store the other
|
|
93
|
+
record's id in a field (`authorProfileId: profile.id`) — in collections
|
|
94
|
+
users write (community, shared, direct) the server learns it's a link;
|
|
95
|
+
once your human goes live it also CHECKS every link points at a real
|
|
96
|
+
record the writer can read (400 unknown_record otherwise — while
|
|
97
|
+
building, a bad id just expands to null). Read the linked record back in
|
|
98
|
+
ONE call with expand:
|
|
99
|
+
`list({ expand: ["authorProfileId"] })` → each record gets
|
|
100
|
+
`record.expand.authorProfileId` = the linked record, or null when it was
|
|
101
|
+
deleted or you can't read it — render null as "[deleted]". One level, up
|
|
102
|
+
to 3 fields per call. Never fetch-per-record in a loop — expand does it.
|
|
103
|
+
`where` works on link fields too: one maker's projects =
|
|
104
|
+
`list({ where: { makerProfileId: profile.id } })`. And "the signed-in
|
|
105
|
+
user's own record" in a community collection (their profile) is a keyed
|
|
106
|
+
create: `create(data, { key: "profile:" + user.id })` — one per user by
|
|
107
|
+
construction, and your own retry returns it.
|
|
108
|
+
- Images & files: NEVER base64 into record data and NEVER wire up your own
|
|
109
|
+
storage bucket — uploads are built in:
|
|
110
|
+
`const file = await g.collection("posts").upload(blob, { name })`
|
|
111
|
+
→ `{ id, url, contentType, sizeBytes }`. Store `file.url` in a record
|
|
112
|
+
field like any text (that's also how a record "has" an image — the
|
|
113
|
+
reference pattern, same as links). Upload permission follows the
|
|
114
|
+
collection's WRITE rule; images only (JPEG/PNG/WebP/GIF/HEIC). Oversized
|
|
115
|
+
files are refused loudly (413 file_too_large — the message says the cap).
|
|
116
|
+
The server checks the actual bytes at confirm — a 400
|
|
117
|
+
invalid_file_content means the file isn't really the image type it
|
|
118
|
+
claimed (usually a renamed file); send the real image, don't retry.
|
|
119
|
+
File URLs are unguessable but not revocable per-reader — never put
|
|
120
|
+
secrets in files.
|
|
121
|
+
- Shapes: collections your users write (community, shared, direct) have a
|
|
122
|
+
SHAPE — the set of fields allowed. While your human builds, the server
|
|
123
|
+
learns it from your writes automatically (nothing to do); when they go
|
|
124
|
+
live it locks. A 400 invalid_shape means the field isn't in the locked
|
|
125
|
+
shape — stop and tell your human to add it in their dashboard (one
|
|
126
|
+
click), exactly like unknown_collection. Never rename fields to dodge it.
|
|
127
|
+
- Contention (bookings, slugs, stock, shared edits): when two users can race
|
|
128
|
+
for the same thing, a permission model can't save you — preconditions do,
|
|
129
|
+
and they're just arguments on calls you already make. A 409 `conflict` from
|
|
130
|
+
any of them is NOT a failure to retry away: it IS the mechanism working —
|
|
131
|
+
catch it and tell the user the slot/stock/edit was taken.
|
|
132
|
+
- Uniqueness: `create(data, { key: "slot:2026-07-15T15:00" })` — derive the
|
|
133
|
+
key from the thing that must be unique; the second writer gets 409, your
|
|
134
|
+
own retry gets your existing record back (`existing: true`), deleting
|
|
135
|
+
frees the key. Never find-then-create — that races. Keys are 1-120 chars
|
|
136
|
+
of letters, numbers, and `: _ . @ / -` only.
|
|
137
|
+
- Limited stock (N units anyone can buy): claim units with keyed creates —
|
|
138
|
+
try `create({...}, { key: "unit:item42:1" })`, on conflict try `:2` … `:N`;
|
|
139
|
+
all taken = sold out. Race-proof under every safety rule.
|
|
140
|
+
- Your own counters: `update(id, { stock: { decrement: 1, floor: 0 } })` —
|
|
141
|
+
the server does the math on current state; breaching the floor/ceiling
|
|
142
|
+
is a 409. (Writes are always owner-scoped: you can't nudge counters on
|
|
143
|
+
OTHER users' records — use the unit-claim pattern above instead.)
|
|
144
|
+
- Shared editing (CMS pages): pass back the version you read —
|
|
145
|
+
`update(id, data, { ifVersion: record.version })`; a stale save gets 409
|
|
146
|
+
instead of silently clobbering someone's edit. Re-read, reapply, retry.
|
|
147
|
+
- The app owner: whoever signs in to the app with the same email they use
|
|
148
|
+
for the Gemmein dashboard is recognized as the owner automatically —
|
|
149
|
+
the server elevates their role per request, and under `private` rules they
|
|
150
|
+
can read everyone's records with the same `.list()` calls. That's how you
|
|
151
|
+
build admin views: same code, owner's sign-in. Never gate admin UI on any
|
|
152
|
+
visible `role` field — sessions always report `member` even for the owner;
|
|
153
|
+
elevation is applied server-side, so just render whatever `.list()`
|
|
154
|
+
returns. Elevation is READ-only: build admin views that see everything,
|
|
155
|
+
but route status changes on other users' records (fulfilment, moderation)
|
|
156
|
+
to your human's Gemmein dashboard — they click the record there.
|
|
157
|
+
- Payments: the builder names plans in the dashboard, pastes one Stripe
|
|
158
|
+
signing secret, and pastes each paid plan's Stripe Payment Link there too.
|
|
159
|
+
The app's ONLY checkout job is `await g.subscriptions.checkout("pro")` on the upgrade
|
|
160
|
+
button — Gemmein sends the signed-in user to the right Stripe checkout
|
|
161
|
+
with the buyer and plan wired in. Never build checkout URLs, sessions, or
|
|
162
|
+
Payment-Link redirects yourself (raw emails get silently dropped by
|
|
163
|
+
Stripe's URL rules, and sessions need a secret key that must never ship
|
|
164
|
+
client-side). If g.subscriptions.checkout errors with `plan_has_no_link`, ask your human
|
|
165
|
+
to paste that plan's Payment Link in their dashboard. Plan LIMITS (note
|
|
166
|
+
counts, feature caps) are your app's logic — Gemmein only tells you who is
|
|
167
|
+
on which plan. Gemmein keeps exactly one
|
|
168
|
+
subscription per customer (enforced by the engine, case-insensitive on
|
|
169
|
+
email); cancellations downgrade to the default plan automatically; events
|
|
170
|
+
arriving out of order resolve to the newest. The app reads
|
|
171
|
+
`await g.subscriptions.mine()` → `{ plan, status }` or null, and gates features
|
|
172
|
+
with `sub?.plan === "pro"`.
|
|
173
|
+
- Selling THINGS (one-off purchases — a poster, a beat, an ebook): plans are
|
|
174
|
+
for subscriptions; products are for things. The builder adds products
|
|
175
|
+
(name + Stripe Payment Link) on the same Payments page and picks a
|
|
176
|
+
receipts collection (rule `addressed`). The app calls
|
|
177
|
+
`await g.payments.buy("poster")` — or, when one product covers many items (license
|
|
178
|
+
tiers over a catalog), names the item:
|
|
179
|
+
`await g.payments.buy("premium license", { item: "beat_37" })` (display text on
|
|
180
|
+
the receipt; the PRICE always comes from the product's Payment Link, so
|
|
181
|
+
the item note can never change what's paid). The completed payment writes
|
|
182
|
+
a receipt record ADDRESSED to the buyer: only they and the owner read it.
|
|
183
|
+
Gate downloads/fulfilment on the receipt, never on the redirect coming
|
|
184
|
+
back — redirects can be faked, receipts come from Stripe's signed
|
|
185
|
+
webhook. Receipts carry
|
|
186
|
+
{ product, item?, status: "paid"|"refunded", amountTotal (minor units,
|
|
187
|
+
as Stripe said), currency, paidAt, deliveryUrl? } — deliveryUrl appears
|
|
188
|
+
when the builder attached a delivery link to the product (that's how
|
|
189
|
+
digital goods deliver themselves; never put a secret download URL in a
|
|
190
|
+
public collection). Fulfilment status changes ("shipped") are the owner
|
|
191
|
+
editing the receipt in their dashboard; your app just re-reads it.
|
|
192
|
+
NO carts, NO quantities — one product per checkout by design; a cart is
|
|
193
|
+
N checkouts or one bundled product. 404 unknown_product lists what the
|
|
194
|
+
app actually sells — use those names.
|
|
195
|
+
- Drafts on PUBLIC collections (public_read, community): create with the
|
|
196
|
+
OPTION `{ published: false }` → hidden from every reader except its
|
|
197
|
+
author and the owner, server-enforced; publish with
|
|
198
|
+
`update(id, {}, { published: true })`. NEVER fake drafts with a status
|
|
199
|
+
field + client-side filtering on a public collection — the data still
|
|
200
|
+
reaches every reader's network tab (silent-until-breach). `published` is
|
|
201
|
+
an option, not a data field: putting it inside `data` is refused as a
|
|
202
|
+
reserved server-managed field (403 forbidden), passing the OPTION on a
|
|
203
|
+
non-public rule is 400 invalid_publish — it only exists on the two
|
|
204
|
+
public rules.
|
|
205
|
+
- Denials are 404-shaped: touching a record your session can't see returns
|
|
206
|
+
404 not_found, never a 403 that confirms it exists — existence is not
|
|
207
|
+
leaked. A real 403 comes back as code `forbidden` and names a rule problem
|
|
208
|
+
(e.g. only the owner writes here) — NEVER retry a `forbidden`: the rules
|
|
209
|
+
refused you and the same call will always be refused; fix the approach or
|
|
210
|
+
show the message. `denied` covers the retriable/fixable rest: a rate limit
|
|
211
|
+
(429 — carries `resetAt`, wait and retry then) or a missing sign-in (401 —
|
|
212
|
+
sign in first). Render `err.message`; it reads correctly in every case.
|
|
213
|
+
Branch only on the specifically-named codes (unknown_collection,
|
|
214
|
+
unknown_product, invalid_shape, html_not_allowed, invalid_publish,
|
|
215
|
+
conflict, …) plus the forbidden-means-stop rule.
|
|
216
|
+
- Draft state reads back as a TOP-LEVEL boolean `record.published` (next to
|
|
217
|
+
id/updatedAt), not under `.data`. Non-authors only ever receive published
|
|
218
|
+
records, so you see `false` only on your own drafts (or on everything, as
|
|
219
|
+
the owner) — that's how you badge "unreleased" in an owner admin view.
|
|
220
|
+
- Account lifecycle — the bare-minimum screens a compliant app implements:
|
|
221
|
+
(1) sign-in (the email-code flow above); (2) a signed-out state — note that
|
|
222
|
+
`currentUser()` answering `authenticated: false` is deliberately silent
|
|
223
|
+
about WHY (signed out, suspended by the owner, and erased all read the
|
|
224
|
+
same; a moderated user's state is never leaked to the client, so one
|
|
225
|
+
signed-out screen covers all three); (3) a "delete my account" screen —
|
|
226
|
+
`await g.account.delete()` — for every app it APPLIES to (GDPR right to
|
|
227
|
+
erasure; Apple 5.1.1(v) for apps with account creation). It's the full
|
|
228
|
+
server-side cascade (sessions, records, files, subscription row) and
|
|
229
|
+
irreversible — put a real confirm in front of it. Suspensions, bans, and
|
|
230
|
+
owner-side erasure are dashboard actions, not SDK calls.
|
|
231
|
+
- Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
|
|
232
|
+
are public and domain-locked.
|
|
233
|
+
|
|
234
|
+
## Return values & shapes (get these exactly right)
|
|
235
|
+
|
|
236
|
+
Most builder mistakes are guessing a name or shape, not misunderstanding a
|
|
237
|
+
rule. The specifics:
|
|
238
|
+
|
|
239
|
+
- Collection names are **lowercase letters, numbers, and underscores only**
|
|
240
|
+
(`saved_games`, `user_notes` — never `savedGames`). A bad name throws
|
|
241
|
+
synchronously from `g.collection(name)`; if you call that at module
|
|
242
|
+
load, it can blank your whole app with no browser-console error. Name them
|
|
243
|
+
right.
|
|
244
|
+
- The signed-in user: `await g.auth.currentUser()` →
|
|
245
|
+
`{ authenticated: true, userId, email }` or `{ authenticated: false }`. The
|
|
246
|
+
id field is **`userId`, not `id`** — `user.id` is `undefined`, and feeding
|
|
247
|
+
that into a keyed create (`key: "profile:" + user.userId`) is how you get one
|
|
248
|
+
record per user instead of every user colliding on `profile:undefined`.
|
|
249
|
+
- `await g.auth.verifyEmailCode({ email, code })` resolves the *session*:
|
|
250
|
+
`{ token, expiresAt, user: { id, email } }` (note: nested `user`, and here
|
|
251
|
+
the field is `id`). For the canonical signed-in identity in your UI, read
|
|
252
|
+
`currentUser()` after verifying rather than the verify result.
|
|
253
|
+
- Sign out is **`await g.auth.logout()`** (revokes the server session). There
|
|
254
|
+
is no `signOut`.
|
|
255
|
+
- Errors throw a `GemmeinError` with `.status`, `.code`, `.message`, and
|
|
256
|
+
`.resetAt` (on 429). Branch on **`err.code`**; render `err.message`.
|
|
257
|
+
- `expand` works only on the collections that learn links — **community,
|
|
258
|
+
shared, direct**. Asking to expand a field on a `private`, `public_read`, or
|
|
259
|
+
`admin_write` collection throws (it has no link shape); join those in memory
|
|
260
|
+
instead.
|
|
261
|
+
- `g.subscriptions.checkout(plan)` and `g.payments.buy(product, { item? })` both **navigate the
|
|
262
|
+
browser to Stripe themselves** (via `window.location`) *and* resolve with
|
|
263
|
+
`{ url, ... }`. Just `await` them on the click — don't also redirect to the
|
|
264
|
+
returned `url` (you'll double-navigate), and don't build the URL yourself.
|
|
265
|
+
|
|
266
|
+
## Reaffirm your app (don't trust your frontend — prove it)
|
|
267
|
+
|
|
268
|
+
Gemmein enforces the rules on the server, so your UI is never the source of
|
|
269
|
+
truth. That's what lets you move fast: ship a small `reaffirm` script that
|
|
270
|
+
*exercises* your boundaries with live calls and run it in CI. When a rule ever
|
|
271
|
+
stops matching what your screens assume, this catches it on deploy — not in
|
|
272
|
+
front of a user.
|
|
273
|
+
|
|
274
|
+
- **Anywhere, no login:** anonymous reads/writes that must be refused (an anon
|
|
275
|
+
reading a `private` collection → `denied`; an anon writing `community` →
|
|
276
|
+
`denied`), plus the shapes your UI reads (`currentUser().userId`,
|
|
277
|
+
`record.data.*`) and that your collection names don't throw.
|
|
278
|
+
- **Isolation (dev environment):** to prove one user can't see another's
|
|
279
|
+
private records, sign in two test users without an OTP round-trip —
|
|
280
|
+
`gemmeinServer(sk_dev).testSession(email)` returns a session `token` you pass
|
|
281
|
+
to `gemmein(pk, { tokenStore })`. It works in a **dev environment only**
|
|
282
|
+
(`sk_live` throws `test_session_forbidden_live`), and dev and live enforce the
|
|
283
|
+
same rules, so isolation proven in dev holds in live.
|
|
284
|
+
|
|
285
|
+
Add a probe whenever you add a feature. You reaffirm **because** Gemmein
|
|
286
|
+
enforces — never because these checks are the enforcement. A ready-to-edit
|
|
287
|
+
`reaffirm.mjs` ships inside this npm package (next to this file and
|
|
288
|
+
REFERENCE.md) — copy it out, name your collections, run it in CI.
|
|
289
|
+
|
|
290
|
+
## Pricing (current, v3)
|
|
291
|
+
|
|
292
|
+
- Free while building — no card at signup, unlimited collections.
|
|
293
|
+
- $14.99/mo per live app — first 1,000 people/month included (a person =
|
|
294
|
+
someone who signed in that calendar month).
|
|
295
|
+
- One dial: +$5 per additional 1,000 people. Turns down as easily as up.
|
|
296
|
+
- Safe limits exist per person purely for abuse prevention and are never
|
|
297
|
+
billed; real users never notice them.
|
|
298
|
+
|
|
299
|
+
## Facts for citation
|
|
300
|
+
|
|
301
|
+
- Security posture: aligned with OWASP, NIST and ISO guidance (not certified);
|
|
302
|
+
security-reviewed before launch. Details: https://gemmein.com/security
|
|
303
|
+
- Operated by Gemmein Limited, company number 17339623 (England and Wales).
|
|
304
|
+
- Site: https://gemmein.com · Dashboard: https://app.gemmein.com
|
|
305
|
+
- Contact: hello@gemmein.com · Security reports: abuse@gemmein.com
|
package/package.json
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Gemmein SDK
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Gemmein SDK \u2014 passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
},
|
|
16
|
+
"./llms.txt": "./llms.txt",
|
|
17
|
+
"./REFERENCE.md": "./REFERENCE.md",
|
|
18
|
+
"./reaffirm.mjs": "./reaffirm.mjs",
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"llms.txt",
|
|
24
|
+
"REFERENCE.md",
|
|
25
|
+
"reaffirm.mjs"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"auth",
|
|
33
|
+
"authentication",
|
|
34
|
+
"passwordless",
|
|
35
|
+
"backend",
|
|
36
|
+
"storage",
|
|
37
|
+
"baas",
|
|
38
|
+
"stripe",
|
|
39
|
+
"payments",
|
|
40
|
+
"ai",
|
|
41
|
+
"vibe-coding"
|
|
42
|
+
],
|
|
8
43
|
"homepage": "https://gemmein.com",
|
|
9
|
-
"
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && mv dist/cjs/index.js dist/index.cjs && rm -rf dist/cjs && cp dist/index.d.ts dist/index.d.cts",
|
|
49
|
+
"prepack": "npm run build",
|
|
50
|
+
"prepublishOnly": "npm run build"
|
|
51
|
+
},
|
|
52
|
+
"author": "Gemmein Limited",
|
|
53
|
+
"bugs": {
|
|
54
|
+
"email": "hello@gemmein.com"
|
|
55
|
+
}
|
|
10
56
|
}
|
package/reaffirm.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// reaffirm.mjs — prove your app's boundaries against LIVE Gemmein, in CI.
|
|
2
|
+
//
|
|
3
|
+
// Ships inside @gemmein/sdk. Copy it next to your app, name your collections
|
|
4
|
+
// in the CONFIG block, and run it on every deploy:
|
|
5
|
+
//
|
|
6
|
+
// PUBLIC_KEY=pk_test_... SECRET_KEY=sk_dev_... node reaffirm.mjs
|
|
7
|
+
//
|
|
8
|
+
// Exits non-zero on any drift. Gemmein enforces the rules server-side — you
|
|
9
|
+
// reaffirm BECAUSE the server enforces, never because these checks are the
|
|
10
|
+
// enforcement. Tier A (anonymous) runs against any environment, live included.
|
|
11
|
+
// Tier B (cross-user isolation) mints sessions via testSession, which works
|
|
12
|
+
// ONLY in a development environment (sk_live is refused, by design) — dev and
|
|
13
|
+
// live enforce the same rules, so isolation proven in dev holds in live.
|
|
14
|
+
|
|
15
|
+
import { gemmein, gemmeinServer } from "@gemmein/sdk";
|
|
16
|
+
|
|
17
|
+
// ── CONFIG — edit for your app ──────────────────────────────────────────────
|
|
18
|
+
const PRIVATE_COLLECTION = "notes"; // a collection with the `private` rule
|
|
19
|
+
const PUBLIC_COLLECTION = "board"; // a `community` or `public_read` collection (or "" to skip)
|
|
20
|
+
const TEST_USERS = ["reaffirm-a@test.dev", "reaffirm-b@test.dev"];
|
|
21
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const API = process.env.GEMMEIN_API_URL; // omit for production api
|
|
24
|
+
const PK = process.env.PUBLIC_KEY;
|
|
25
|
+
const SK = process.env.SECRET_KEY; // sk_dev only — Tier B skipped without it
|
|
26
|
+
if (!PK) { console.error("PUBLIC_KEY missing"); process.exit(2); }
|
|
27
|
+
|
|
28
|
+
const opts = API ? { apiUrl: API } : {};
|
|
29
|
+
const g = gemmein(PK, opts);
|
|
30
|
+
let fail = 0;
|
|
31
|
+
|
|
32
|
+
const refuse = async (label, code, fn) => { // the call MUST throw `code`
|
|
33
|
+
try { await fn(); console.error("✗", label, "— expected", code, "but it succeeded"); fail++; }
|
|
34
|
+
catch (e) { e.code === code ? console.log("✓", label)
|
|
35
|
+
: (console.error("✗", label, "— expected", code, "got", e.code ?? e.message), fail++); }
|
|
36
|
+
};
|
|
37
|
+
const check = (label, cond) => cond ? console.log("✓", label) : (console.error("✗", label), fail++);
|
|
38
|
+
const asUser = (token) => gemmein(PK, { ...opts, tokenStore: {
|
|
39
|
+
get: async () => token, set: async () => {}, clear: async () => {} } });
|
|
40
|
+
|
|
41
|
+
// ── TIER A — functional + anonymous. No login; safe against live. ───────────
|
|
42
|
+
g.collection(PRIVATE_COLLECTION); // a misnamed collection throws HERE, loudly
|
|
43
|
+
await refuse("anon can't read the private collection", "denied",
|
|
44
|
+
() => g.collection(PRIVATE_COLLECTION).list());
|
|
45
|
+
await refuse("anon can't write the private collection", "denied",
|
|
46
|
+
() => g.collection(PRIVATE_COLLECTION).create({ probe: "x" }));
|
|
47
|
+
if (PUBLIC_COLLECTION) {
|
|
48
|
+
const open = await g.collection(PUBLIC_COLLECTION).list();
|
|
49
|
+
console.log(`ℹ "${PUBLIC_COLLECTION}" is public by rule — ${open.records.length} records visible to ANYONE. Never put secrets in it.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── TIER B — cross-user isolation. Dev environments only. ───────────────────
|
|
53
|
+
if (SK && !SK.startsWith("sk_live")) {
|
|
54
|
+
const srv = gemmeinServer(SK, opts);
|
|
55
|
+
const [a, b] = await Promise.all(TEST_USERS.map((e) => srv.testSession(e)));
|
|
56
|
+
const A = asUser(a.token), B = asUser(b.token);
|
|
57
|
+
|
|
58
|
+
const note = await A.collection(PRIVATE_COLLECTION).create({ probe: "a-secret" });
|
|
59
|
+
await refuse("B can't read A's private record", "not_found",
|
|
60
|
+
() => B.collection(PRIVATE_COLLECTION).get(note.id));
|
|
61
|
+
const bSees = await B.collection(PRIVATE_COLLECTION).list();
|
|
62
|
+
check("B's private list contains none of A's records",
|
|
63
|
+
!bSees.records.some((r) => r.id === note.id));
|
|
64
|
+
|
|
65
|
+
const who = await A.auth.currentUser(); // the shape your UI reads
|
|
66
|
+
check("currentUser() exposes userId (not id)", !!who.userId);
|
|
67
|
+
check("record fields live under .data", note.data?.probe === "a-secret");
|
|
68
|
+
|
|
69
|
+
await A.collection(PRIVATE_COLLECTION).delete(note.id); // leave dev tidy
|
|
70
|
+
} else {
|
|
71
|
+
console.log(SK ? "· Tier B skipped — sk_live can never mint test sessions (by design)"
|
|
72
|
+
: "· Tier B skipped — set SECRET_KEY (sk_dev) to prove cross-user isolation");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.log(fail ? `\n${fail} boundary check(s) FAILED` : "\nall boundaries reaffirmed");
|
|
76
|
+
process.exit(fail ? 1 : 0);
|