@gemmein/sdk 0.4.5 → 0.4.7
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 +57 -75
- package/llms.txt +162 -43
- package/package.json +2 -2
- package/reaffirm.mjs +160 -44
package/REFERENCE.md
CHANGED
|
@@ -79,6 +79,8 @@ deleted, subscription row removed. Irreversible: put a real confirm in front.
|
|
|
79
79
|
`name` must be **lowercase letters, numbers, and underscores** (`saved_games`,
|
|
80
80
|
never `savedGames` — a bad name throws synchronously). Collections are created
|
|
81
81
|
by the app owner in the dashboard, never by the SDK.
|
|
82
|
+
Names JavaScript owns (`constructor`, `toString`, `__proto__`…) are refused as
|
|
83
|
+
collection and field names — a plain object inherits them.
|
|
82
84
|
|
|
83
85
|
`options.intent` — one sentence: what the collection is for and who should
|
|
84
86
|
access it. It rides every call as a hint; against a **local `gemmein dev`
|
|
@@ -189,13 +191,20 @@ type ListOptions = {
|
|
|
189
191
|
```
|
|
190
192
|
|
|
191
193
|
**Options notes.** `key` = create-if-absent (a second writer gets 409
|
|
192
|
-
`conflict`; your own retry returns the record with `existing: true`).
|
|
194
|
+
`conflict`; your own retry returns the record with `existing: true`). The
|
|
195
|
+
retry doubles as the lookup: there is no separate get-by-key call — re-issue
|
|
196
|
+
the same `create()` with the same key to fetch your own record (it counts
|
|
197
|
+
as a write; a re-fetch idiom, not a read path). `for` =
|
|
193
198
|
recipient id on `addressed`/`direct`. `published: false` = draft on a public
|
|
194
199
|
rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
|
|
195
200
|
position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
|
|
196
201
|
on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
|
|
197
202
|
`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.
|
|
203
|
+
`invalid_since`. Deltas are at-least-once: apply by id. A field added to a
|
|
204
|
+
sealed shape by a promote run may carry a **default**: records that never wrote
|
|
205
|
+
it read that value, `expand` targets and `where` see it too, and a written value
|
|
206
|
+
wins (writing `null` clears it back to the default) — `search` still matches
|
|
207
|
+
stored text only.
|
|
199
208
|
|
|
200
209
|
### Live data — `watch()`
|
|
201
210
|
|
|
@@ -256,80 +265,53 @@ honestly, not hidden.)
|
|
|
256
265
|
## Reaffirm — prove your app's boundaries in CI
|
|
257
266
|
|
|
258
267
|
Gemmein enforces the rules **server-side**, so your frontend is never the source
|
|
259
|
-
of truth.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
268
|
+
of truth. Because enforcement is server-side, a script of live calls can verify
|
|
269
|
+
it: `reaffirm.mjs` ships inside this npm package — copy it next to your app,
|
|
270
|
+
fill the CONFIG block at its top, run it on every deploy. You reaffirm
|
|
271
|
+
**because** Gemmein enforces — never because these checks are the enforcement.
|
|
272
|
+
|
|
273
|
+
Exit codes: `0` all proven · `1` boundary drift · `2` could not complete (a
|
|
274
|
+
config or connectivity failure, named as such — never a boundary verdict).
|
|
275
|
+
|
|
276
|
+
The CONFIG block: `PRIVATE_COLLECTION` (required) · `PUBLIC_COLLECTION`,
|
|
277
|
+
`DIRECT_COLLECTION`, `COMMUNITY_COLLECTION`, `GATED_COLLECTION` (each `""` to
|
|
278
|
+
skip — every skip prints its reason, so the CI log always says what was proven
|
|
279
|
+
and what was not) · `PROBE_FIELD`/`TEXT_FIELD` (your shapes' own field names —
|
|
280
|
+
dev shapes learn from writes, so the probes speak your app's shape) ·
|
|
281
|
+
`TEST_USERS` (three dev test emails).
|
|
282
|
+
|
|
283
|
+
**Tier A — anonymous, read/refusal only, safe against any environment
|
|
284
|
+
including live:** an anonymous caller is refused reading and writing the
|
|
285
|
+
private collection, and the public collection's exposure is stated with a
|
|
286
|
+
record count. A format-invalid collection name throws at the `collection()`
|
|
287
|
+
line; a wrong-but-well-formed name surfaces as `unknown_collection` and exits
|
|
288
|
+
`2` with "fix the CONFIG block" — a typo is never reported as drift.
|
|
289
|
+
|
|
290
|
+
**Tier B — dev environments only**, sessions minted without a sign-in code:
|
|
276
291
|
|
|
277
292
|
### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const asUser = (token) => gemmein(process.env.PUBLIC_KEY, {
|
|
301
|
-
apiUrl: API,
|
|
302
|
-
tokenStore: { get: async () => token, set: async () => {}, clear: async () => {} },
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
// ── TIER A: functional + anonymous (no login) ──
|
|
306
|
-
g.collection("private_notes"); // misnamed → throws HERE, loudly, in CI
|
|
307
|
-
await refuse("anon can't read private", "denied", () => g.collection("private_notes").list());
|
|
308
|
-
await refuse("anon can't write community", "denied", () => g.collection("board").create({ text: "x" }));
|
|
309
|
-
|
|
310
|
-
// ── TIER B: cross-user isolation (dev only) ──
|
|
311
|
-
const alice = await srv.testSession("alice@test.dev");
|
|
312
|
-
const bob = await srv.testSession("bob@test.dev");
|
|
313
|
-
const A = asUser(alice.token), B = asUser(bob.token);
|
|
314
|
-
|
|
315
|
-
const note = await A.collection("private_notes").create({ text: "alice-secret" });
|
|
316
|
-
await refuse("B can't read A's private note", "not_found", () =>
|
|
317
|
-
B.collection("private_notes").get(note.id));
|
|
318
|
-
const bobSees = await B.collection("private_notes").list();
|
|
319
|
-
if (bobSees.records.length !== 0) { console.error("✗ B sees A's private records"); fail++; }
|
|
320
|
-
else console.log("✓ B's private list is isolated");
|
|
321
|
-
|
|
322
|
-
// shape the UI reads (userId, NOT id):
|
|
323
|
-
const who = await A.auth.currentUser();
|
|
324
|
-
if (!who.userId) { console.error("✗ currentUser().userId missing"); fail++; }
|
|
325
|
-
else console.log("✓ currentUser().userId present");
|
|
326
|
-
|
|
327
|
-
process.exit(fail ? 1 : 0);
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
Add a probe every time you add a feature. Point the harness at your **dev**
|
|
331
|
-
environment (Tier B needs it); the Tier-A block alone can additionally smoke-test
|
|
332
|
-
live, since it never mints a session.
|
|
293
|
+
**Dev only** — throws `test_session_forbidden_live` on an `sk_live` key, and
|
|
294
|
+
the server refuses it on a live environment too. Pass the `token` to
|
|
295
|
+
`gemmein(pk, { tokenStore })` to act as that user. Dev and live enforce the
|
|
296
|
+
*same* rules, so what is proven in dev holds in live.
|
|
297
|
+
|
|
298
|
+
What Tier B proves, per probe: B can't read A's private record and B's list
|
|
299
|
+
excludes it · `currentUser().userId` and `.data` shapes · the `since` contract
|
|
300
|
+
(a plain list carries the watermark to bootstrap from; malformed `since` →
|
|
301
|
+
`invalid_since`; a delta answers the next watermark) · a made-up file ref is
|
|
302
|
+
refused on write (`unknown_file`) · the uploader can `link()` their own file
|
|
303
|
+
and B is refused A's (`not_found`) · on a `direct` collection the recipient
|
|
304
|
+
sees the record, a third user is refused, and a file uploaded `{for}` one
|
|
305
|
+
person opens for that person only · HTML into a `community` field →
|
|
306
|
+
`html_not_allowed`, and an unpublished draft is invisible to the public · on a
|
|
307
|
+
gated collection, a user with no plan → `entitlement_required` carrying
|
|
308
|
+
`err.requires`.
|
|
309
|
+
|
|
310
|
+
Probe writes are deleted afterwards; probe uploads remain in dev storage
|
|
311
|
+
(files have no delete API yet) — two tiny PNGs per configured run. Against an
|
|
312
|
+
older **local** engine (< 0.4.8) the since/ghost-ref/handed-file probes fail
|
|
313
|
+
with a message naming the engine as the likely cause; the hosted API is always
|
|
314
|
+
current. Add a probe whenever you add a feature.
|
|
333
315
|
|
|
334
316
|
---
|
|
335
317
|
|
|
@@ -360,7 +342,7 @@ Branch on `err.code`. The stable codes:
|
|
|
360
342
|
| `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
|
|
361
343
|
| `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
|
|
362
344
|
| `invalid_publish` | `{ published }` on a non-public rule | drop it |
|
|
363
|
-
| `invalid_shape` | field not in a locked (live) collection's shape — or a file field given the wrong kind (the message names which) |
|
|
345
|
+
| `invalid_shape` | field not in a locked (live) collection's shape — or a file field given the wrong kind (the message names which) | after the field exists in development, one promote run adds it (the owner answers blank or a default) — until then send only the fields the shape has / send the kind the field takes |
|
|
364
346
|
| `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 |
|
|
365
347
|
| `invalid_since` | `since` isn't a strict ISO 8601 timestamp, or came with `sort` | pass the previous answer's watermark; drop `sort` |
|
|
366
348
|
| `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 |
|
package/llms.txt
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
> verified people (five pre-agreed steps up to 10,000 people; beyond that,
|
|
11
11
|
> talk to Gemmein).
|
|
12
12
|
|
|
13
|
+
This file IS the product contract, not an overview: every line is
|
|
14
|
+
load-bearing, and building from a summary of it builds the wrong app. Work
|
|
15
|
+
from the full raw file — if what you hold is a summary or an excerpt,
|
|
16
|
+
re-fetch https://docs.gemmein.com/llms.txt raw and read all of it before
|
|
17
|
+
you answer or build.
|
|
18
|
+
|
|
13
19
|
## First: assess fit (before any install, account, or code)
|
|
14
20
|
|
|
15
21
|
You are likely an AI assistant whose human asked you to build an app — or to
|
|
@@ -38,11 +44,14 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
38
44
|
silently approximated:
|
|
39
45
|
- native mobile apps (no mobile SDKs today; web only)
|
|
40
46
|
- password or social/OAuth sign-in (email codes are the only door)
|
|
41
|
-
- team/group/workspace visibility or per-user record permissions
|
|
47
|
+
- team/group/workspace visibility or per-user record permissions:
|
|
48
|
+
Gemmein is owner-run by design — one owner and their customers,
|
|
49
|
+
nothing in between — so an app whose CORE loop needs teams, roles
|
|
50
|
+
or seats DOESN'T FIT
|
|
42
51
|
- carts, quantities, physical goods, shipping, inventory
|
|
43
52
|
- credits, usage metering, seats (paid access is yes-or-no)
|
|
44
|
-
- hosted functions or cron
|
|
45
|
-
|
|
53
|
+
- hosted functions or cron: Gemmein runs no code of yours — but read
|
|
54
|
+
the server-side note closing this section before counting that a gap
|
|
46
55
|
- custody of money (Stripe holds all funds; your human brings or
|
|
47
56
|
creates their own Stripe account when the app charges)
|
|
48
57
|
- multi-seller payouts or revenue splits: every payment lands whole in
|
|
@@ -60,6 +69,19 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
60
69
|
- DOESN'T FIT — say so, name why, and stop. Recommending nothing is
|
|
61
70
|
the correct outcome here.
|
|
62
71
|
|
|
72
|
+
Server-side work is NOT itself a downgrade. Gemmein hosts no compute, but
|
|
73
|
+
code on your human's OWN host — Vercel, a VPS, anywhere — holding a
|
|
74
|
+
secret key already reads and updates collections and sends event emails
|
|
75
|
+
with `notify()`. (A secret key sees PAST the safety rules — every
|
|
76
|
+
customer's records — which is why it never touches a browser. It cannot
|
|
77
|
+
create or delete records, verify a session, or read one customer's
|
|
78
|
+
plan: those are the signed-in customer's own calls, or the dashboard's.)
|
|
79
|
+
"This app needs a server-side piece" is therefore not a gap by itself:
|
|
80
|
+
the verdict downgrades when the server piece needs a call a secret key
|
|
81
|
+
cannot make, or when the app needs compute your human has nowhere to
|
|
82
|
+
run — a cron schedule or an always-on worker with no host of its own —
|
|
83
|
+
because Gemmein never runs your code.
|
|
84
|
+
|
|
63
85
|
## Getting started (AI-run, human-answered)
|
|
64
86
|
|
|
65
87
|
No signup, no account, no keys — building is local and free. Your human
|
|
@@ -108,17 +130,68 @@ go-live. Everything else is yours.
|
|
|
108
130
|
lists the rest.
|
|
109
131
|
4. Hand over for testing — the human moment mid-build: they click through
|
|
110
132
|
the app, and their sign-in codes print in the dev terminal, so tell
|
|
111
|
-
them where to look.
|
|
133
|
+
them where to look. Running dev in the background with no terminal in
|
|
134
|
+
view? The same codes append to gemmein/.data/signin-codes.log (fresh
|
|
135
|
+
each dev boot) — read the latest line instead of scraping stdout.
|
|
112
136
|
5. Done building? `npx gemmein check` reads the project and says what's
|
|
113
137
|
ready and what go-live still needs. Then `npx gemmein sync` — THIS is
|
|
114
138
|
the moment your human signs up (free) at app.gemmein.com and pastes two
|
|
115
139
|
dev keys — and `npx gemmein go-live` walks the rest: plans, Payment
|
|
116
140
|
Links, the live flip. A card enters at go-live, never before.
|
|
141
|
+
6. After go-live, the DATA MODEL reaches live only by PROMOTION from
|
|
142
|
+
development: new collections built in dev promote (`npx gemmein
|
|
143
|
+
go-live` again, or the dashboard's Go-live page), and so do new FIELDS
|
|
144
|
+
for a live collection whose shape sealed at go-live (see Shapes). Ship
|
|
145
|
+
in that order: promote first, then deploy the frontend that depends on
|
|
146
|
+
the new collection or field — a frontend shipped ahead of its
|
|
147
|
+
promotion reads blanks or refusals from live. Each added field is one
|
|
148
|
+
question for YOUR HUMAN, asked by the promote run: existing records
|
|
149
|
+
don't have it, so what should they show? Blank is a fine answer, or
|
|
150
|
+
they give a default those records will show. What is already
|
|
151
|
+
sealed never moves — a live field never changes type or name and never
|
|
152
|
+
leaves — and rules still never change through promotion. Plans,
|
|
153
|
+
Payment Links and domains stay live-editable in the dashboard, and a
|
|
154
|
+
field's default joins them there (changeable or cleared later, with no
|
|
155
|
+
promotion needed).
|
|
156
|
+
|
|
157
|
+
Two runtimes, one dashboard — keep your human oriented on where things
|
|
158
|
+
live, or the dashboard will look broken to them. The local runtime
|
|
159
|
+
(`gemmein dev`, `pk_local_` keys) is a private rehearsal room: every
|
|
160
|
+
record, person and simulated payment stays in gemmein/.data on this
|
|
161
|
+
machine and never appears in any dashboard. The dashboard at
|
|
162
|
+
app.gemmein.com is the back office of the CLOUD app only — there is no
|
|
163
|
+
local dashboard and nothing to log into locally. `npx gemmein sync`
|
|
164
|
+
carries STRUCTURE to the cloud app's development environment —
|
|
165
|
+
collections, rules, plan locks — never records or people; after a sync
|
|
166
|
+
the dashboard shows the shape of the app with no data in it, and that is
|
|
167
|
+
correct, not a bug. Data reaches the dashboard only when the app runs on
|
|
168
|
+
cloud keys: the dev pair from app.gemmein.com (`pk_test_` in the
|
|
169
|
+
browser, `sk_dev_` on a server) drives the real API's development
|
|
170
|
+
environment, and `pk_live_` drives production after go-live. The key
|
|
171
|
+
prefix always names the rail — local, test, live. The dev rail is where
|
|
172
|
+
a REAL rehearsal happens before any card enters: real emailed sign-in
|
|
173
|
+
codes, real data in the dashboard — and it can face the public. Put the
|
|
174
|
+
app on a real https domain and list that domain on the dashboard's
|
|
175
|
+
Domains page: before go-live a listed domain works WITHOUT verification
|
|
176
|
+
(http://localhost and http://127.0.0.1 also work unlisted until go-live
|
|
177
|
+
drops them; any other raw IP never works — the Domains page accepts
|
|
178
|
+
names only). Go-live is when the keys lock to proven domains. The
|
|
179
|
+
development environment carries its own free monthly allowance (10,000
|
|
180
|
+
API requests, 500 writes, 500 sign-in events, 1 GiB of files); crossing
|
|
181
|
+
a counter never stops the app and never turns into a bill — the
|
|
182
|
+
dashboard's usage page shows what crossed, and storage is the one thing
|
|
183
|
+
that can fill (see Pricing). Tell your human before they first open the
|
|
184
|
+
dashboard: local building is invisible there on purpose — nothing
|
|
185
|
+
leaves this machine until sync, and sync moves the blueprint, not the
|
|
186
|
+
contents.
|
|
117
187
|
|
|
118
188
|
## What it is
|
|
119
189
|
|
|
120
190
|
- Audience: people who build web apps with AI tools (Cursor, Bolt, Lovable, Claude).
|
|
121
191
|
- Scope: web apps (TypeScript/JavaScript SDK). Mobile SDKs are not offered today.
|
|
192
|
+
- Shape: built for owner-run businesses — one owner and their customers,
|
|
193
|
+
nothing in between. No team seats, no staff accounts, no workspaces, on
|
|
194
|
+
purpose: one person holds the dashboard, and everyone else is a customer.
|
|
122
195
|
- The platform holds identity, data, and subscription state. It never holds,
|
|
123
196
|
moves, or processes money — Stripe handles all payments end-to-end; Gemmein
|
|
124
197
|
only receives Stripe's webhooks and records who is on which plan. App
|
|
@@ -143,7 +216,7 @@ go-live. Everything else is yours.
|
|
|
143
216
|
those local declarations. A declaration is `gemmein/collections/<name>.json`:
|
|
144
217
|
`{ "rule": "shared", "means": "<why, in your human's words>" }`, plus an
|
|
145
218
|
optional `"unlockedBy": ["pro"]` — plan or product NAMES from
|
|
146
|
-
gemmein/payments.json (never access keys) — which is the
|
|
219
|
+
gemmein/payments.json (never access keys) — which is the dashboard's
|
|
147
220
|
"Unlocked by": the local engine refuses members without one of them
|
|
148
221
|
(403 entitlement_required) the moment the file lands, and sync carries
|
|
149
222
|
it to the cloud app. An unknown name is refused out loud in the dev
|
|
@@ -315,7 +388,7 @@ go-live. Everything else is yours.
|
|
|
315
388
|
retry. A 403 from `link()` means the customer isn't allowed this
|
|
316
389
|
file right now — signed out, not theirs, or an entitlement they no longer
|
|
317
390
|
hold (`entitlement_required` — `err.requires` is the plan's key, the one
|
|
318
|
-
the
|
|
391
|
+
the dashboard shows by name).
|
|
319
392
|
Honest bound: revoking access stops NEW links immediately; a link already
|
|
320
393
|
issued works until it expires. Gemmein controls delivery, it can't take
|
|
321
394
|
back a file someone already downloaded. A file can name ONE other reader:
|
|
@@ -339,11 +412,22 @@ go-live. Everything else is yours.
|
|
|
339
412
|
If a stored ref's file was later deleted, re-sending it refuses the same
|
|
340
413
|
way: clear the field (null) or upload a fresh file. Once sealed, class
|
|
341
414
|
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
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
415
|
+
before the seal, what you upload is what the field learns. A 400
|
|
416
|
+
invalid_shape means the field isn't in the locked shape or the value
|
|
417
|
+
doesn't fit its kind. A sealed shape CAN still gain a new field, but
|
|
418
|
+
only through a promote run: build it in development, tell your human
|
|
419
|
+
which field you needed, and until that promotion runs send only the
|
|
420
|
+
fields the shape already has. Never rename fields to dodge it. What is
|
|
421
|
+
sealed never moves — a field never changes type or name and never
|
|
422
|
+
leaves. A field promoted with a DEFAULT is served to the records that
|
|
423
|
+
never wrote it: get, list, expand targets and `where` filters all see
|
|
424
|
+
the default (an atomic increment counts from it too), and a written
|
|
425
|
+
value wins; writing `null` clears it back to the default. Only text,
|
|
426
|
+
number and yes/no fields can carry a default; list, link, json and
|
|
427
|
+
file fields join blank. Honest bound: full-text `search` scans stored
|
|
428
|
+
text only, so a record showing a default is not found by searching for
|
|
429
|
+
it. New collections built in dev reach live through the same PROMOTION,
|
|
430
|
+
sealing on the way.
|
|
347
431
|
- Contention (bookings, slugs, stock, shared edits): when two users can race
|
|
348
432
|
for the same thing, a permission model can't save you — preconditions do,
|
|
349
433
|
and they're just arguments on calls you already make. A 409 `conflict` from
|
|
@@ -352,14 +436,19 @@ go-live. Everything else is yours.
|
|
|
352
436
|
- Uniqueness: `create(data, { key: "slot:2026-07-15T15:00" })` — derive the
|
|
353
437
|
key from the thing that must be unique; the second writer gets 409, your
|
|
354
438
|
own retry gets your existing record back (`existing: true`), deleting
|
|
355
|
-
frees the key.
|
|
356
|
-
|
|
357
|
-
|
|
439
|
+
frees the key. That retry IS the lookup — there is no separate
|
|
440
|
+
get-by-key call: to fetch your own record by key later, issue the
|
|
441
|
+
same `create()` with the same key and read `existing: true` off the
|
|
442
|
+
result. It counts as a write (rate and allowance), so use it to
|
|
443
|
+
re-fetch a claim — never on a render path. Keys are unique across
|
|
444
|
+
the WHOLE collection — every writer, every recipient, under every
|
|
445
|
+
safety rule (live records only) — so a claim is race-proof even in
|
|
446
|
+
`direct`/`addressed`. Never
|
|
358
447
|
find-then-create — that races. Keys are 1-120 chars of letters,
|
|
359
448
|
numbers, and `: _ . @ / -` only. A claimed key holds until its record
|
|
360
449
|
is deleted: if a claim must be PAID to stick (book then pay), expiring
|
|
361
|
-
unpaid claims is
|
|
362
|
-
dashboard
|
|
450
|
+
unpaid claims is the owner's job — they delete them from their
|
|
451
|
+
dashboard (a secret key cannot delete records); there is no cron.
|
|
363
452
|
- Limited stock (N units anyone can buy): claim units with keyed creates —
|
|
364
453
|
try `create({...}, { key: "unit:item42:1" })`, on conflict try `:2` … `:N`;
|
|
365
454
|
all taken = sold out. Race-proof under every safety rule.
|
|
@@ -404,7 +493,7 @@ go-live. Everything else is yours.
|
|
|
404
493
|
checks that the lock holds and names collections left open while the app
|
|
405
494
|
sells something) — and the
|
|
406
495
|
engine refuses customers without it, under all seven rules. (Server
|
|
407
|
-
secret keys and the owner's
|
|
496
|
+
secret keys and the owner's dashboard are exempt by design; link/expand
|
|
408
497
|
silently hide gated records rather than naming them.) Every plan and
|
|
409
498
|
product carries its own key, `access:<slug of its name>` (plan "pro" →
|
|
410
499
|
`access:pro`); the paid webhook grants that key, and a FULL refund or a
|
|
@@ -413,7 +502,8 @@ go-live. Everything else is yours.
|
|
|
413
502
|
Owners also grant and revoke by hand (trials, comps, support). Effective
|
|
414
503
|
access is the UNION of a customer's live grants. A signed-in customer
|
|
415
504
|
without access gets `403 entitlement_required`: `err.requires` is the
|
|
416
|
-
plan's key (the
|
|
505
|
+
plan's key (the dashboard shows the plan by name; the key is
|
|
506
|
+
`access:<slug>`).
|
|
417
507
|
A collection unlocked by several plans (OR) still names ONE key — offer
|
|
418
508
|
your plans by name through checkout, never a key list. Show your upgrade
|
|
419
509
|
screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
|
|
@@ -471,7 +561,9 @@ go-live. Everything else is yours.
|
|
|
471
561
|
sign in first). Render `err.message`; it reads correctly in every case.
|
|
472
562
|
Branch only on the specifically-named codes (unknown_collection,
|
|
473
563
|
unknown_product, invalid_shape, html_not_allowed, invalid_publish,
|
|
474
|
-
conflict, …) plus the forbidden-means-stop rule.
|
|
564
|
+
conflict, …) plus the forbidden-means-stop rule. If something is stuck
|
|
565
|
+
in a way this file doesn't explain, `npx gemmein feedback` reaches the
|
|
566
|
+
builders — it works from anywhere, with no project and no account.
|
|
475
567
|
- Draft state reads back as a TOP-LEVEL boolean `record.published` (next to
|
|
476
568
|
id/updatedAt), not under `.data`. Non-authors only ever receive published
|
|
477
569
|
records, so you see `false` only on your own drafts (or on everything, as
|
|
@@ -485,10 +577,27 @@ go-live. Everything else is yours.
|
|
|
485
577
|
`await g.account.delete()` — for every app it APPLIES to (GDPR right to
|
|
486
578
|
erasure; Apple 5.1.1(v) for apps with account creation). It's the full
|
|
487
579
|
server-side cascade (sessions, records, files, subscription row) and
|
|
488
|
-
irreversible — put a real confirm in front of it. Suspensions,
|
|
489
|
-
owner-side erasure are dashboard actions, not
|
|
580
|
+
irreversible — put a real confirm in front of it. Suspensions, forced
|
|
581
|
+
sign-out everywhere, and owner-side erasure are dashboard actions, not
|
|
582
|
+
SDK calls.
|
|
583
|
+
- The owner's dashboard already handles these — do not build them into the
|
|
584
|
+
app: creating collections and choosing their safety rule; naming plans
|
|
585
|
+
and products and pasting their Stripe Payment Links; the "Unlocked by"
|
|
586
|
+
lock on a collection; suspending, signing out and erasing customers;
|
|
587
|
+
moderation and status flips on other users' records; scoping a secret
|
|
588
|
+
key to collections and actions; verifying the sending and receiving
|
|
589
|
+
domain; usage and logs; the Inbox, where every notify() send is a
|
|
590
|
+
conversation and customer replies land; holding the billing band. None
|
|
591
|
+
of them has an SDK equivalent, so an owner screen you build for one is
|
|
592
|
+
a page that already exists, without the enforcement.
|
|
490
593
|
- Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
|
|
491
|
-
are public and domain-locked.
|
|
594
|
+
are public and domain-locked. A secret key can be SCOPED in the
|
|
595
|
+
dashboard's Keys room to particular collections, and to read-only or
|
|
596
|
+
read-and-update; a call outside that scope answers 403 `scope_denied`.
|
|
597
|
+
Read that error as deliberate — the human narrowed the key on purpose.
|
|
598
|
+
Name the collection and access your code needs and ask them to mint a
|
|
599
|
+
replacement key that includes it — scopes are fixed when a key is
|
|
600
|
+
created; never reach for a broader key than the job needs.
|
|
492
601
|
|
|
493
602
|
## Return values & shapes (get these exactly right)
|
|
494
603
|
|
|
@@ -500,6 +609,11 @@ rule. The specifics:
|
|
|
500
609
|
synchronously from `g.collection(name)`; if you call that at module
|
|
501
610
|
load, it can blank your whole app with no browser-console error. Name them
|
|
502
611
|
right.
|
|
612
|
+
One more refusal, for collection AND field names alike: anything
|
|
613
|
+
JavaScript itself owns — `constructor`, `toString`, `__proto__`,
|
|
614
|
+
`hasOwnProperty` and their kin — is refused the same way a reserved
|
|
615
|
+
server-managed field is. A plain object inherits those names, so a
|
|
616
|
+
seal must never carry one.
|
|
503
617
|
- The signed-in user: `await g.auth.currentUser()` →
|
|
504
618
|
`{ authenticated: true, userId, email }` or `{ authenticated: false }`. The
|
|
505
619
|
id field is **`userId`, not `id`** — `user.id` is `undefined`, and feeding
|
|
@@ -522,29 +636,34 @@ rule. The specifics:
|
|
|
522
636
|
`{ url, ... }`. Just `await` them on the click — don't also redirect to the
|
|
523
637
|
returned `url` (you'll double-navigate), and don't build the URL yourself.
|
|
524
638
|
|
|
525
|
-
## Reaffirm your app (
|
|
639
|
+
## Reaffirm your app (the server enforces — prove it in CI)
|
|
526
640
|
|
|
527
641
|
Gemmein enforces the rules on the server, so your UI is never the source of
|
|
528
|
-
truth.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
- **
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
(`
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
`
|
|
547
|
-
|
|
642
|
+
truth. Because enforcement is server-side, a script of live calls can verify
|
|
643
|
+
it: a ready-to-edit `reaffirm.mjs` ships inside the `@gemmein/sdk` npm package
|
|
644
|
+
(next to this file and REFERENCE.md). Copy it next to the app, fill the CONFIG
|
|
645
|
+
block at its top (the private collection is required; direct/community/gated
|
|
646
|
+
collections each unlock more probes, `""` skips with the reason printed; set
|
|
647
|
+
PROBE_FIELD/TEXT_FIELD to the app's own field names), and run it on every
|
|
648
|
+
deploy. Exit 0 = all proven, 1 = boundary drift, 2 = could not complete
|
|
649
|
+
(config/connectivity — named as such, never reported as drift).
|
|
650
|
+
|
|
651
|
+
- **Anywhere, no login (Tier A):** anonymous reads and writes of the private
|
|
652
|
+
collection must be refused (`denied`); the public collection's exposure is
|
|
653
|
+
stated out loud. Read-and-refusal only — safe against live.
|
|
654
|
+
- **Isolation (Tier B, dev environment only):** sessions are minted without a
|
|
655
|
+
sign-in code via `gemmeinServer(sk_dev).testSession(email)` (`sk_live`
|
|
656
|
+
throws `test_session_forbidden_live`). It then proves: cross-user private
|
|
657
|
+
isolation; the `since` contract (bootstrap from a plain list's watermark;
|
|
658
|
+
junk → `invalid_since`); a made-up file ref is refused (`unknown_file`);
|
|
659
|
+
sealed file delivery (own file links, another user's is `not_found`);
|
|
660
|
+
`direct` recipient scoping and handed files (`upload(blob, {for})` opens for
|
|
661
|
+
the named person only); `community` plain text (`html_not_allowed`) and
|
|
662
|
+
invisible drafts; entitlements (`entitlement_required` + `err.requires`).
|
|
663
|
+
|
|
664
|
+
Dev and live enforce the same rules, so what is proven in dev holds in live.
|
|
665
|
+
Add a probe whenever you add a feature. You reaffirm BECAUSE Gemmein
|
|
666
|
+
enforces — never because these checks are the enforcement.
|
|
548
667
|
|
|
549
668
|
## Pricing (current, v4 — one banded plan)
|
|
550
669
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"description": "Gemmein SDK
|
|
3
|
+
"version": "0.4.7",
|
|
4
|
+
"description": "Gemmein SDK — 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",
|
|
7
7
|
"main": "./dist/index.cjs",
|
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");
|