@gemmein/sdk 0.0.1 → 0.1.0

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