@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/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.1",
4
- "description": "Gemmein SDK auth + storage for AI-built apps. Launching soon.",
5
- "main": "index.js",
6
- "files": ["index.js", "README.md"],
7
- "keywords": ["gemmein", "auth", "storage", "backend", "ai"],
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
- "license": "MIT"
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);
package/index.js DELETED
@@ -1,6 +0,0 @@
1
- "use strict";
2
- module.exports = function gemmein() {
3
- throw new Error(
4
- "@gemmein/sdk is launching soon — this version reserves the name. Watch https://gemmein.com"
5
- );
6
- };