@gemmein/sdk 0.4.4 → 0.4.6

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.
Files changed (4) hide show
  1. package/REFERENCE.md +128 -79
  2. package/llms.txt +34 -24
  3. package/package.json +2 -2
  4. package/reaffirm.mjs +160 -44
package/REFERENCE.md CHANGED
@@ -21,8 +21,10 @@ The browser client. `appKey` is your public `pk_...` key. `options` (optional):
21
21
 
22
22
  ### `gemmeinServer(secretKey, options?) → GemmeinServer`
23
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**).
24
+ browser.** Exposes read/update on collections without a signed-in user,
25
+ `notify()` to email one of your app's own verified people (see **Notify**),
26
+ plus `testSession()` for CI self-tests (dev environments only — see
27
+ **Reaffirm**).
26
28
 
27
29
  The client has two layers. **Your app's collections** — `g.collection(name)`
28
30
  (the canonical spelling; `g.storage.collection(name)` is the same client). And
@@ -88,10 +90,11 @@ certain the collection exists yet.
88
90
  |--------|-----------|---------|
89
91
  | `create` | `(data: T, options?: { key?: string; for?: string; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
90
92
  | `list` | `(options?: ListOptions)` | `Promise<ListResult<T>>` |
93
+ | `watch` | `(onChange: (delta: { records; deleted; initial }) => void, options?: { every?: number; where?; search?; limit? })` | `{ stop(): void }` — see **Live data** |
91
94
  | `get` | `(id: string, options?: { expand?: string[] })` | `Promise<GemmeinRecord<T>>` |
92
95
  | `update` | `(id: string, data: Partial<T> \| { field: { increment\|decrement, floor?, ceiling? } }, options?: { ifVersion?: number; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
93
96
  | `delete` | `(id: string)` | `Promise<void>` |
94
- | `upload` | `(file: Blob \| File, options?: { name?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
97
+ | `upload` | `(file: Blob \| File, options?: { name?: string; contentType?: string; for?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
95
98
 
96
99
  ### Files
97
100
 
@@ -126,6 +129,29 @@ immediately; a link already issued works until it expires, and a download that
126
129
  started before expiry may finish after it. Controlled delivery, not DRM —
127
130
  nothing takes back a file someone already downloaded.
128
131
 
132
+ **What uploads take.** Images — JPEG, PNG, WebP, GIF, HEIC — and documents —
133
+ PDF, ZIP, EPUB — at **25 MB per file**; nothing else (no video, audio, SVG,
134
+ HTML or office files — zip an office file). The server checks the actual
135
+ bytes, not the filename. A document always **downloads** (served as an
136
+ attachment; it never opens inside the page) — link it with
137
+ `{ intent: "download" }` — and pass `contentType` to `upload()` when a Blob
138
+ doesn't carry its own type.
139
+
140
+ **Handing a file to one person.** On `addressed`/`direct` collections,
141
+ `upload(file, { for: userId })` stamps that person as the file's audience at
142
+ birth — immutable, and only they (and you, the owner) can ever `link()` it.
143
+ That's how a direct message carries an attachment. A field holding file
144
+ references is learned as a **file field** — image or document, from the real
145
+ uploads. The file law is UNIVERSAL: on every rule, locked or not, any
146
+ top-level field value matching the ref grammar must name a real file you
147
+ could read — a made-up ref is refused (`unknown_file`). Once the shape is
148
+ sealed, the field's class is law too: the wrong kind teaches on the wire
149
+ (`invalid_shape`, the message names which kind the field takes); before the
150
+ seal, learning owns class (seen-both widens to "any"). A stored ref whose
151
+ file was later deleted refuses the same way on re-write — clear the field
152
+ (`null`) or upload a fresh file. Refs inside `list`/`json` values aren't
153
+ judged at write; they still grant nothing at read.
154
+
129
155
  ```ts
130
156
  type GemmeinRecord<T> = {
131
157
  id: string;
@@ -144,7 +170,11 @@ type GemmeinRecord<T> = {
144
170
  existing?: true; // present only when a keyed create returned YOUR existing record
145
171
  };
146
172
 
147
- type ListResult<T> = { records: GemmeinRecord<T>[]; cursor?: string; hasMore: boolean };
173
+ type ListResult<T> = {
174
+ records: GemmeinRecord<T>[]; cursor?: string; hasMore: boolean;
175
+ deleted?: string[]; // on a `since` read: ids deleted after that instant — your rule scope only
176
+ watermark?: string; // pass as the next `since`; paging a plain list in full, adopt the FIRST page's
177
+ };
148
178
 
149
179
  type ListOptions = {
150
180
  limit?: number;
@@ -153,6 +183,8 @@ type ListOptions = {
153
183
  cursor?: string; // from a previous ListResult
154
184
  search?: string; // free-text across data
155
185
  expand?: string[]; // link fields to embed (≤3), only on community/shared/direct
186
+ since?: string; // everything changed OR deleted after this instant, oldest first
187
+ // — the previous answer's watermark; incompatible with sort
156
188
  };
157
189
  ```
158
190
 
@@ -162,6 +194,19 @@ recipient id on `addressed`/`direct`. `published: false` = draft on a public
162
194
  rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
163
195
  position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
164
196
  on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
197
+ `since` fixes the order (oldest change first) — pairing it with `sort` is a 400
198
+ `invalid_since`. Deltas are at-least-once: apply by id.
199
+
200
+ ### Live data — `watch()`
201
+
202
+ `watch(onChange, { every? })` keeps a list fresh by polling `list({ since })`
203
+ — every 10 s by default, clamped to 5 s–300 s. `onChange` receives
204
+ `{ records, deleted, initial }`: the first call is the full list
205
+ (`initial: true`); every later call only what changed or was deleted. It
206
+ sleeps while the tab is hidden and resyncs in full on return, backs off on
207
+ rate limits (honouring `resetAt`), never overlaps its own requests, and stops
208
+ itself on 401/403. Returns `{ stop() }` — call it when the view unmounts.
209
+ Never hand-roll a polling loop; this is the sanctioned one.
165
210
 
166
211
  ---
167
212
 
@@ -172,6 +217,7 @@ on `private`/`public_read`/`admin_write` (no link shape) — join in memory ther
172
217
  | `subscriptions.mine` | `()` | `Promise<{ plan: string; status: "active" \| "cancelled" } \| null>` |
173
218
  | `subscriptions.checkout` | `(plan?: string)` | `Promise<{ url: string; plan: string }>` — **navigates the browser to Stripe** and resolves the url |
174
219
  | `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
220
+ | `purchases.mine` | `()` | `Promise<Purchase[]>` — the array itself (not wrapped): `{ item, kind, amountMinor, currency, refundedMinor, status, grants, paidAt, delivery? }` — gate one-off fulfilment on this, never the redirect |
175
221
 
176
222
  Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
177
223
  `buy` self-navigate via `window.location` — just `await` them on the click;
@@ -181,83 +227,82 @@ gate one-off fulfilment on the receipt record, never the redirect.
181
227
 
182
228
  ---
183
229
 
184
- ## Reaffirmprove your app's boundaries in CI
185
-
186
- Gemmein enforces the rules **server-side**, so your frontend is never the source
187
- of truth. That's exactly why you can move fast: a `reaffirm` script *exercises*
188
- your boundaries with live calls and fails the build on drift so if a rule ever
189
- stopped matching what your UI assumes, you catch it on deploy, not in front of a
190
- user. You reaffirm **because** Gemmein enforces never because these checks are
191
- the enforcement.
230
+ ## Notify`gemmeinServer(sk).notify(personId, input)`
231
+
232
+ Your app's **server** emails one of its **own verified people** — by person
233
+ id, never an address. A missing, cross-environment, or suspended id is one
234
+ `404 not_a_customer` on purpose: existence is never leaked.
235
+
236
+ `input`: `{ subject: string; text: string; kind?: "event" | "account"; key?: string }`
237
+ `Promise<{ sent: boolean; deduped?: boolean; recorded?: boolean; id: string | null; threadId: string | null; replyRail?: boolean }>`
238
+ (`recorded: false` means the email went but Inbox recording failed — reported
239
+ honestly, not hidden.)
240
+
241
+ - `kind: "event"` (the default) — order shipped, booking confirmed. Capped at
242
+ **5 per person per day** so a bug can never flood an inbox, and 200 per app
243
+ per hour (`resetAt` rides the 429). `kind: "account"` — sign-in, access,
244
+ billing trouble — is exempt from the per-person cap (a security notice never
245
+ loses to order emails), same hourly app cap.
246
+ - `key` makes the send at-most-once through retries: a delivered send answers
247
+ idempotently (`deduped: true`); a concurrent twin gets `409 in_flight` —
248
+ retry in a moment.
249
+ - Plain text only — this is a notification, not a campaign. Every email
250
+ carries a footer naming why it arrived; replies land in the owner's Inbox
251
+ as a thread (`threadId`), and `replyRail` states honestly whether
252
+ reply-by-email is on. Every send is on the record.
192
253
 
193
- Two tiers:
254
+ ---
194
255
 
195
- **Tier Aanonymous + shape (zero setup, runs against any environment incl. live).**
196
- No session needed. Catches the loudest drift: a private collection an anon can
197
- read, a renamed/mis-cased collection (`collection("savedGames")` throws
198
- *synchronously*), the error-code contract, and the deliberate reminder that a
199
- `community`/`public_read` collection is readable by anyone.
256
+ ## Reaffirmprove your app's boundaries in CI
200
257
 
201
- **Tier B cross-user isolation (dev environments only).**
202
- Proving "user B genuinely can't read user A's private record" needs two real
203
- sessions. Mint them without an OTP round-trip:
258
+ Gemmein enforces the rules **server-side**, so your frontend is never the source
259
+ of truth. Because enforcement is server-side, a script of live calls can verify
260
+ it: `reaffirm.mjs` ships inside this npm package — copy it next to your app,
261
+ fill the CONFIG block at its top, run it on every deploy. You reaffirm
262
+ **because** Gemmein enforces — never because these checks are the enforcement.
263
+
264
+ Exit codes: `0` all proven · `1` boundary drift · `2` could not complete (a
265
+ config or connectivity failure, named as such — never a boundary verdict).
266
+
267
+ The CONFIG block: `PRIVATE_COLLECTION` (required) · `PUBLIC_COLLECTION`,
268
+ `DIRECT_COLLECTION`, `COMMUNITY_COLLECTION`, `GATED_COLLECTION` (each `""` to
269
+ skip — every skip prints its reason, so the CI log always says what was proven
270
+ and what was not) · `PROBE_FIELD`/`TEXT_FIELD` (your shapes' own field names —
271
+ dev shapes learn from writes, so the probes speak your app's shape) ·
272
+ `TEST_USERS` (three dev test emails).
273
+
274
+ **Tier A — anonymous, read/refusal only, safe against any environment
275
+ including live:** an anonymous caller is refused reading and writing the
276
+ private collection, and the public collection's exposure is stated with a
277
+ record count. A format-invalid collection name throws at the `collection()`
278
+ line; a wrong-but-well-formed name surfaces as `unknown_collection` and exits
279
+ `2` with "fix the CONFIG block" — a typo is never reported as drift.
280
+
281
+ **Tier B — dev environments only**, sessions minted without a sign-in code:
204
282
 
205
283
  ### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
206
- Mints a **member** session for a test email. **Dev only** — throws
207
- `test_session_forbidden_live` on an `sk_live` key, and the server refuses it on a
208
- live environment too (the invariant that keeps it off real user data). Pass the
209
- returned `token` to `gemmein(pk, { tokenStore })` to act as that user. Dev and
210
- live enforce the *same* rules, so isolation proven in dev holds in live.
211
-
212
- This exact harness ships as **`reaffirm.mjs` inside the npm package** copy it
213
- out, edit the CONFIG block, run in CI.
214
-
215
- ```js
216
- // reaffirm.mjs run in CI: `node reaffirm.mjs` (exits non-zero on any drift).
217
- import { gemmein, gemmeinServer } from "@gemmein/sdk";
218
-
219
- const API = process.env.GEMMEIN_API_URL; // your dev API url
220
- const g = gemmein(process.env.PUBLIC_KEY, { apiUrl: API });
221
- const srv = gemmeinServer(process.env.SECRET_KEY, { apiUrl: API }); // sk_dev only
222
- let fail = 0;
223
- const refuse = async (label, code, fn) => { // must throw `code`
224
- try { await fn(); console.error("✗", label, "expected", code, "got success"); fail++; }
225
- catch (e) { e.code === code ? console.log("✓", label)
226
- : (console.error("✗", label, "— got", e.code), fail++); }
227
- };
228
- const asUser = (token) => gemmein(process.env.PUBLIC_KEY, {
229
- apiUrl: API,
230
- tokenStore: { get: async () => token, set: async () => {}, clear: async () => {} },
231
- });
232
-
233
- // ── TIER A: functional + anonymous (no login) ──
234
- g.collection("private_notes"); // misnamed → throws HERE, loudly, in CI
235
- await refuse("anon can't read private", "denied", () => g.collection("private_notes").list());
236
- await refuse("anon can't write community", "denied", () => g.collection("board").create({ text: "x" }));
237
-
238
- // ── TIER B: cross-user isolation (dev only) ──
239
- const alice = await srv.testSession("alice@test.dev");
240
- const bob = await srv.testSession("bob@test.dev");
241
- const A = asUser(alice.token), B = asUser(bob.token);
242
-
243
- const note = await A.collection("private_notes").create({ text: "alice-secret" });
244
- await refuse("B can't read A's private note", "not_found", () =>
245
- B.collection("private_notes").get(note.id));
246
- const bobSees = await B.collection("private_notes").list();
247
- if (bobSees.records.length !== 0) { console.error("✗ B sees A's private records"); fail++; }
248
- else console.log("✓ B's private list is isolated");
249
-
250
- // shape the UI reads (userId, NOT id):
251
- const who = await A.auth.currentUser();
252
- if (!who.userId) { console.error("✗ currentUser().userId missing"); fail++; }
253
- else console.log("✓ currentUser().userId present");
254
-
255
- process.exit(fail ? 1 : 0);
256
- ```
257
-
258
- Add a probe every time you add a feature. Point the harness at your **dev**
259
- environment (Tier B needs it); the Tier-A block alone can additionally smoke-test
260
- live, since it never mints a session.
284
+ **Dev only** throws `test_session_forbidden_live` on an `sk_live` key, and
285
+ the server refuses it on a live environment too. Pass the `token` to
286
+ `gemmein(pk, { tokenStore })` to act as that user. Dev and live enforce the
287
+ *same* rules, so what is proven in dev holds in live.
288
+
289
+ What Tier B proves, per probe: B can't read A's private record and B's list
290
+ excludes it · `currentUser().userId` and `.data` shapes · the `since` contract
291
+ (a plain list carries the watermark to bootstrap from; malformed `since` →
292
+ `invalid_since`; a delta answers the next watermark) · a made-up file ref is
293
+ refused on write (`unknown_file`) · the uploader can `link()` their own file
294
+ and B is refused A's (`not_found`) · on a `direct` collection the recipient
295
+ sees the record, a third user is refused, and a file uploaded `{for}` one
296
+ person opens for that person only · HTML into a `community` field →
297
+ `html_not_allowed`, and an unpublished draft is invisible to the public · on a
298
+ gated collection, a user with no plan → `entitlement_required` carrying
299
+ `err.requires`.
300
+
301
+ Probe writes are deleted afterwards; probe uploads remain in dev storage
302
+ (files have no delete API yet)two tiny PNGs per configured run. Against an
303
+ older **local** engine (< 0.4.8) the since/ghost-ref/handed-file probes fail
304
+ with a message naming the engine as the likely cause; the hosted API is always
305
+ current. Add a probe whenever you add a feature.
261
306
 
262
307
  ---
263
308
 
@@ -288,17 +333,21 @@ Branch on `err.code`. The stable codes:
288
333
  | `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
289
334
  | `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
290
335
  | `invalid_publish` | `{ published }` on a non-public rule | drop it |
291
- | `invalid_shape` | field not in a locked (live) collection's shape | ask the owner to add it |
336
+ | `invalid_shape` | field not in a locked (live) collection's shape — or a file field given the wrong kind (the message names which) | ask the owner to add it / send the kind the field takes |
337
+ | `unknown_file` | a ref-shaped value points at a file that doesn't exist or isn't yours to hand — every rule, every write | fix the ref — never invent one |
338
+ | `invalid_since` | `since` isn't a strict ISO 8601 timestamp, or came with `sort` | pass the previous answer's watermark; drop `sort` |
339
+ | `not_a_customer` (404) | `notify()`'s recipient isn't a verified person of this app and environment | fix the person id — one code on purpose |
340
+ | `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
292
341
  | `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
293
342
  | `unknown_record` | a link field points at a missing record (live only) | fix the id |
294
343
  | `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
295
- | `invalid_file_content` | uploaded bytes aren't the claimed image type | send the real image |
344
+ | `invalid_file_content` | uploaded bytes aren't the claimed type (usually a renamed file) | send the real file |
296
345
  | `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 |
297
346
  | `unknown_plan` | no plan by that name | use a name from the list in the message |
298
347
  | `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
299
348
  | `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
300
349
  | `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 |
301
- | `unsupported_file_type` (415) | upload isn't one of the allowed image types | send JPEG/PNG/WebP/GIF/HEIC |
350
+ | `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
302
351
  | `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
303
352
  | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
304
353
  | `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
package/llms.txt CHANGED
@@ -330,11 +330,16 @@ go-live. Everything else is yours.
330
330
  live it locks. Field kinds the shape learns: text, number, yes/no, list,
331
331
  link (a stored record id), json, and FILE — a field that held an
332
332
  upload()'s ref learns as a file field — and WHICH class (an image field
333
- vs a document field, from what you actually uploaded). Once locked, the
334
- field accepts only a reference to a real, confirmed file of this app
335
- that the writer may read, of the right class a ZIP into a profile
336
- photo field is refused with the teach, as is a made-up or foreign ref
337
- ("file not found"). A 400 invalid_shape means the field isn't in the locked
333
+ vs a document field, from what you actually uploaded). The file law is
334
+ UNIVERSAL: on every rule, locked or not, any top-level field value
335
+ matching the ref grammar must name a real, confirmed file of this app
336
+ that the writer may read a made-up or foreign ref is refused ("file
337
+ not found", 400 unknown_file). Never invent a ref. (Refs inside list or
338
+ json values aren't judged at write — they still grant nothing at read.)
339
+ If a stored ref's file was later deleted, re-sending it refuses the same
340
+ way: clear the field (null) or upload a fresh file. Once sealed, class
341
+ is law too: a ZIP into a profile photo field is refused with the teach;
342
+ before the seal, what you upload is what the field learns. A 400 invalid_shape means the field isn't in the locked
338
343
  shape or the value doesn't fit its kind. A live shape is sealed and
339
344
  cannot take new fields — stop, tell your human which field you needed,
340
345
  and send only the fields the shape already has. Never rename fields to
@@ -517,29 +522,34 @@ rule. The specifics:
517
522
  `{ url, ... }`. Just `await` them on the click — don't also redirect to the
518
523
  returned `url` (you'll double-navigate), and don't build the URL yourself.
519
524
 
520
- ## Reaffirm your app (don't trust your frontend — prove it)
525
+ ## Reaffirm your app (the server enforces — prove it in CI)
521
526
 
522
527
  Gemmein enforces the rules on the server, so your UI is never the source of
523
- truth. That's what lets you move fast: ship a small `reaffirm` script that
524
- *exercises* your boundaries with live calls and run it in CI. When a rule ever
525
- stops matching what your screens assume, this catches it on deploy not in
526
- front of a user.
528
+ truth. Because enforcement is server-side, a script of live calls can verify
529
+ it: a ready-to-edit `reaffirm.mjs` ships inside the `@gemmein/sdk` npm package
530
+ (next to this file and REFERENCE.md). Copy it next to the app, fill the CONFIG
531
+ block at its top (the private collection is required; direct/community/gated
532
+ collections each unlock more probes, `""` skips with the reason printed; set
533
+ PROBE_FIELD/TEXT_FIELD to the app's own field names), and run it on every
534
+ deploy. Exit 0 = all proven, 1 = boundary drift, 2 = could not complete
535
+ (config/connectivity — named as such, never reported as drift).
527
536
 
528
- - **Anywhere, no login:** anonymous reads/writes that must be refused (an anon
529
- reading a `private` collection `denied`; an anon writing `community`
530
- `denied`), plus the shapes your UI reads (`currentUser().userId`,
531
- `record.data.*`) and that your collection names don't throw.
532
- - **Isolation (dev environment):** to prove one user can't see another's
533
- private records, sign in two test users without an OTP round-trip
534
- `gemmeinServer(sk_dev).testSession(email)` returns a session `token` you pass
535
- to `gemmein(pk, { tokenStore })`. It works in a **dev environment only**
536
- (`sk_live` throws `test_session_forbidden_live`), and dev and live enforce the
537
- same rules, so isolation proven in dev holds in live.
537
+ - **Anywhere, no login (Tier A):** anonymous reads and writes of the private
538
+ collection must be refused (`denied`); the public collection's exposure is
539
+ stated out loud. Read-and-refusal only safe against live.
540
+ - **Isolation (Tier B, dev environment only):** sessions are minted without a
541
+ sign-in code via `gemmeinServer(sk_dev).testSession(email)` (`sk_live`
542
+ throws `test_session_forbidden_live`). It then proves: cross-user private
543
+ isolation; the `since` contract (bootstrap from a plain list's watermark;
544
+ junk `invalid_since`); a made-up file ref is refused (`unknown_file`);
545
+ sealed file delivery (own file links, another user's is `not_found`);
546
+ `direct` recipient scoping and handed files (`upload(blob, {for})` opens for
547
+ the named person only); `community` plain text (`html_not_allowed`) and
548
+ invisible drafts; entitlements (`entitlement_required` + `err.requires`).
538
549
 
539
- Add a probe whenever you add a feature. You reaffirm **because** Gemmein
540
- enforces never because these checks are the enforcement. A ready-to-edit
541
- `reaffirm.mjs` ships inside the `@gemmein/sdk` npm package (next to this file and
542
- REFERENCE.md) — copy it out, name your collections, run it in CI.
550
+ Dev and live enforce the same rules, so what is proven in dev holds in live.
551
+ Add a probe whenever you add a feature. You reaffirm BECAUSE Gemmein
552
+ enforces never because these checks are the enforcement.
543
553
 
544
554
  ## Pricing (current, v4 — one banded plan)
545
555
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
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
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,4 +53,4 @@
53
53
  "bugs": {
54
54
  "email": "hello@gemmein.com"
55
55
  }
56
- }
56
+ }
package/reaffirm.mjs CHANGED
@@ -5,19 +5,39 @@
5
5
  //
6
6
  // PUBLIC_KEY=pk_test_... SECRET_KEY=sk_dev_... node reaffirm.mjs
7
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.
8
+ // Exits 0 all-proven · 1 boundary drift · 2 could-not-complete (config or
9
+ // connectivity NOT a boundary verdict). Gemmein enforces the rules
10
+ // server-side you reaffirm BECAUSE the server enforces, never because
11
+ // these checks are the enforcement. Tier A (anonymous) runs against any
12
+ // environment, live included. Tier B (cross-user isolation) mints sessions
13
+ // via testSession, which works ONLY in a development environment (sk_live
14
+ // is refused, by design) — dev and live enforce the same rules, so what is
15
+ // proven in dev holds in live.
16
+ //
17
+ // Every probe past the first is optional: name a collection to prove its
18
+ // surface, leave it "" and the probe is skipped WITH ITS REASON PRINTED —
19
+ // your CI log always says what was proven and what was not.
20
+ //
21
+ // Probes write only to the collections you name, using YOUR field names
22
+ // (dev shapes learn from writes — the probes must speak your app's shape).
23
+ // Probe uploads stay in dev storage (files have no delete API yet): two
24
+ // tiny PNGs per configured run, a known cost.
25
+ //
26
+ // The since/ghost-ref/handed-file probes need engine ≥ 0.4.8 when pointed
27
+ // at a LOCAL runtime (GEMMEIN_API_URL) — an older engine fails them even
28
+ // though your hosted app is fine. `npx -y gemmein@latest dev` updates.
14
29
 
15
30
  import { gemmein, gemmeinServer } from "@gemmein/sdk";
16
31
 
17
32
  // ── 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"];
33
+ const PRIVATE_COLLECTION = "notes"; // a collection with the `private` rule (required)
34
+ const PUBLIC_COLLECTION = ""; // a `community`/`public_read` collection, e.g. "board" ("" to skip)
35
+ const DIRECT_COLLECTION = ""; // a `direct` collection — proves recipient scoping + handed files
36
+ const COMMUNITY_COLLECTION = ""; // a `community` collection — proves plain-text + drafts
37
+ const GATED_COLLECTION = ""; // a collection unlocked by a paid plan — proves entitlements
38
+ const PROBE_FIELD = "probe"; // a field YOUR private collection's shape allows
39
+ const TEXT_FIELD = "text"; // the text field YOUR direct/community shapes use
40
+ const TEST_USERS = ["reaffirm-a@test.dev", "reaffirm-b@test.dev", "reaffirm-c@test.dev"];
21
41
  // ────────────────────────────────────────────────────────────────────────────
22
42
 
23
43
  const API = process.env.GEMMEIN_API_URL; // omit for production api
@@ -30,46 +50,142 @@ const g = gemmein(PK, opts);
30
50
  let fail = 0;
31
51
 
32
52
  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++); }
53
+ try { await fn(); console.error("✗", label, "— expected", code, "but it succeeded"); fail++; return null; }
54
+ catch (e) {
55
+ if (e.code === code) { console.log("", label); return e; }
56
+ console.error("✗", label, "— expected", code, "got", e.code ?? "(no code)", "—", e.message); fail++; return e;
57
+ }
36
58
  };
37
- const check = (label, cond) => cond ? console.log("✓", label) : (console.error("✗", label), fail++);
59
+ const check = (label, cond, got) => cond ? console.log("✓", label)
60
+ : (console.error("✗", label, got !== undefined ? `— got ${JSON.stringify(got)}` : ""), fail++);
61
+ const skip = (what, why) => console.log(`· ${what} skipped — ${why}`);
38
62
  const asUser = (token) => gemmein(PK, { ...opts, tokenStore: {
39
63
  get: async () => token, set: async () => {}, clear: async () => {} } });
64
+ // A tiny real PNG head: enough for the server's content check, no meaning.
65
+ const PNG = new Blob([Uint8Array.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,
66
+ 0,0,0,0x0d,0x49,0x48,0x44,0x52, ...new Array(64).fill(0)])], { type: "image/png" });
67
+ // A well-formed reference no upload ever returned — the write must refuse it.
68
+ const GHOST_REF = "file:99999999-9999-4999-8999-999999999999";
40
69
 
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
- }
70
+ try {
71
+ // ── TIER A functional + anonymous. No login; read/refusal only. ─────────
72
+ // A format-invalid name (uppercase, spaces) throws right here. A
73
+ // wrong-but-well-formed name surfaces below as `unknown_collection` —
74
+ // that is a CONFIG fix, not a boundary failure.
75
+ g.collection(PRIVATE_COLLECTION);
76
+ await refuse("anon can't read the private collection", "denied",
77
+ () => g.collection(PRIVATE_COLLECTION).list());
78
+ await refuse("anon can't write the private collection", "denied",
79
+ () => g.collection(PRIVATE_COLLECTION).create({ [PROBE_FIELD]: "x" }));
80
+ if (PUBLIC_COLLECTION) {
81
+ const open = await g.collection(PUBLIC_COLLECTION).list({ limit: 100 });
82
+ const shown = open.hasMore ? `${open.records.length}+` : `${open.records.length}`;
83
+ console.log(`ℹ "${PUBLIC_COLLECTION}" is public by rule — ${shown} records visible to ANYONE. Never put secrets in it.`);
84
+ } else skip("public-collection reminder", "set PUBLIC_COLLECTION");
85
+
86
+ // ── TIER B — cross-user isolation. Dev environments only. ─────────────────
87
+ if (SK && !SK.startsWith("sk_live")) {
88
+ const srv = gemmeinServer(SK, opts);
89
+ const [a, b] = await Promise.all(TEST_USERS.slice(0, 2).map((e) => srv.testSession(e)));
90
+ const A = asUser(a.token), B = asUser(b.token);
91
+
92
+ // The original core: private means private.
93
+ const note = await A.collection(PRIVATE_COLLECTION).create({ [PROBE_FIELD]: "a-secret" });
94
+ await refuse("B can't read A's private record", "not_found",
95
+ () => B.collection(PRIVATE_COLLECTION).get(note.id));
96
+ const bSees = await B.collection(PRIVATE_COLLECTION).list();
97
+ check("B's private list contains none of A's records",
98
+ !bSees.records.some((r) => r.id === note.id));
99
+
100
+ const who = await A.auth.currentUser(); // the shape your UI reads
101
+ check("currentUser() exposes userId (not id)", !!who.userId, who);
102
+ check("record fields live under .data", note.data?.[PROBE_FIELD] === "a-secret", note.data);
103
+
104
+ // Live data: bootstrap `since` from a plain list's watermark (that is
105
+ // the real pattern — never an ancient timestamp, which pages through
106
+ // history) and prove junk is refused.
107
+ const seed = await A.collection(PRIVATE_COLLECTION).list({ limit: 1 });
108
+ check("a plain list carries the watermark to start from", typeof seed.watermark === "string", seed.watermark);
109
+ await refuse("a malformed `since` is refused", "invalid_since",
110
+ () => A.collection(PRIVATE_COLLECTION).list({ since: "not-a-timestamp" }));
111
+ const delta = await A.collection(PRIVATE_COLLECTION).list({ since: seed.watermark });
112
+ check("a delta read returns the next watermark", typeof delta.watermark === "string", delta.watermark);
113
+
114
+ // The file law: a made-up reference never lands in a record. (If this
115
+ // SUCCEEDS against a local runtime, your engine predates 0.4.8 —
116
+ // update it; the hosted API always enforces this.)
117
+ try {
118
+ const polluted = await A.collection(PRIVATE_COLLECTION).create({ [PROBE_FIELD]: GHOST_REF });
119
+ console.error("✗ a made-up file ref is refused on write — it SUCCEEDED (old local engine? run `npx -y gemmein@latest dev`)"); fail++;
120
+ await A.collection(PRIVATE_COLLECTION).delete(polluted.id); // never leave the ghost behind
121
+ } catch (e) {
122
+ e.code === "unknown_file" ? console.log("✓ a made-up file ref is refused on write")
123
+ : (console.error("✗ a made-up file ref is refused on write — expected unknown_file got", e.code ?? "(no code)", "—", e.message), fail++);
124
+ }
125
+
126
+ // Sealed delivery: your file is not their file.
127
+ try {
128
+ const up = await A.collection(PRIVATE_COLLECTION).upload(PNG, { name: "probe.png" });
129
+ const mine = await A.files.link(up.ref);
130
+ check("the uploader can link their own file", typeof mine.url === "string");
131
+ await refuse("B can't link A's file", "not_found", () => B.files.link(up.ref));
132
+ } catch (e) {
133
+ if (e.code === "unsupported_file_type") skip("file-delivery probes", `"${PRIVATE_COLLECTION}" doesn't accept PNG uploads — point the probes at a collection that takes images`);
134
+ else throw e;
135
+ }
136
+
137
+ await A.collection(PRIVATE_COLLECTION).delete(note.id); // leave dev tidy
138
+
139
+ // direct: the recipient reads it; nobody else does. And a file handed
140
+ // to one person opens for that person only.
141
+ if (DIRECT_COLLECTION) {
142
+ const [cSess] = await Promise.all([srv.testSession(TEST_USERS[2])]);
143
+ const C = asUser(cSess.token);
144
+ const msg = await A.collection(DIRECT_COLLECTION).create({ [TEXT_FIELD]: "for b" }, { for: b.user.id });
145
+ const bBox = await B.collection(DIRECT_COLLECTION).list({ limit: 100 });
146
+ check("the recipient sees the direct record", bBox.records.some((r) => r.id === msg.id));
147
+ await refuse("a third user can't read it", "not_found",
148
+ () => C.collection(DIRECT_COLLECTION).get(msg.id));
149
+ try {
150
+ const handed = await A.collection(DIRECT_COLLECTION).upload(PNG, { name: "handed.png", for: b.user.id });
151
+ const bGets = await B.files.link(handed.ref);
152
+ check("the person a file was handed to can open it", typeof bGets.url === "string");
153
+ await refuse("anyone else is refused the handed file", "not_found",
154
+ () => C.files.link(handed.ref));
155
+ } catch (e) {
156
+ if (e.code === "unsupported_file_type") skip("handed-file probes", `"${DIRECT_COLLECTION}" doesn't accept PNG uploads`);
157
+ else throw e;
158
+ }
159
+ await A.collection(DIRECT_COLLECTION).delete(msg.id);
160
+ } else skip("direct-rule probes (recipient scoping, handed files)", "set DIRECT_COLLECTION");
161
+
162
+ // community: other people's screens get text, never markup — and
163
+ // drafts stay invisible until published.
164
+ if (COMMUNITY_COLLECTION) {
165
+ await refuse("HTML into a community field is refused", "html_not_allowed",
166
+ () => A.collection(COMMUNITY_COLLECTION).create({ [TEXT_FIELD]: "<b>hi</b>" }));
167
+ const draft = await A.collection(COMMUNITY_COLLECTION).create({ [TEXT_FIELD]: "draft probe" }, { published: false });
168
+ const anon = await g.collection(COMMUNITY_COLLECTION).list({ limit: 100 });
169
+ check("an unpublished draft is invisible to the public", !anon.records.some((r) => r.id === draft.id));
170
+ await A.collection(COMMUNITY_COLLECTION).delete(draft.id);
171
+ } else skip("community probes (plain text, drafts)", "set COMMUNITY_COLLECTION");
51
172
 
52
- // ── TIER Bcross-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");
173
+ // entitlements: no plan, no access and the error names the plan.
174
+ if (GATED_COLLECTION) {
175
+ const e = await refuse("no plan → entitlement_required", "entitlement_required",
176
+ () => B.collection(GATED_COLLECTION).list());
177
+ if (e?.code === "entitlement_required")
178
+ check("the refusal names the plan key (err.requires)", typeof e.requires === "string" && e.requires.length > 0, e.requires);
179
+ } else skip("entitlement probe (paid access)", "set GATED_COLLECTION");
180
+ } else {
181
+ console.log(SK ? "· Tier B skipped — sk_live can never mint test sessions (by design)"
182
+ : Tier B skipped — set SECRET_KEY (sk_dev) to prove cross-user isolation");
183
+ }
184
+ } catch (e) {
185
+ console.error(`\nreaffirm could not complete — this is a config or connectivity failure, NOT a boundary verdict:`);
186
+ console.error(` ${e.code ?? "(no code)"} — ${e.message}`);
187
+ if (e.code === "unknown_collection") console.error(" → a CONFIG name doesn't exist in this app. Fix the CONFIG block at the top of this file.");
188
+ process.exit(2);
73
189
  }
74
190
 
75
191
  console.log(fail ? `\n${fail} boundary check(s) FAILED` : "\nall boundaries reaffirmed");