@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.
- package/REFERENCE.md +128 -79
- package/llms.txt +34 -24
- package/package.json +2 -2
- 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,
|
|
25
|
-
`
|
|
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> = {
|
|
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
|
-
##
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
254
|
+
---
|
|
194
255
|
|
|
195
|
-
|
|
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
|
+
## Reaffirm — prove your app's boundaries in CI
|
|
200
257
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
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
|
|
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).
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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 (
|
|
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.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
-
|
|
533
|
-
|
|
534
|
-
`
|
|
535
|
-
|
|
536
|
-
(
|
|
537
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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.
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
|
19
|
-
const PUBLIC_COLLECTION
|
|
20
|
-
const
|
|
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) {
|
|
35
|
-
|
|
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)
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
//
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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");
|