@gemmein/sdk 0.4.5 → 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 +45 -72
  2. package/llms.txt +24 -19
  3. package/package.json +1 -1
  4. package/reaffirm.mjs +160 -44
package/REFERENCE.md CHANGED
@@ -256,80 +256,53 @@ honestly, not hidden.)
256
256
  ## Reaffirm — prove your app's boundaries in CI
257
257
 
258
258
  Gemmein enforces the rules **server-side**, so your frontend is never the source
259
- of truth. That's exactly why you can move fast: a `reaffirm` script *exercises*
260
- your boundaries with live calls and fails the build on drift so if a rule ever
261
- stopped matching what your UI assumes, you catch it on deploy, not in front of a
262
- user. You reaffirm **because** Gemmein enforces — never because these checks are
263
- the enforcement.
264
-
265
- Two tiers:
266
-
267
- **Tier A anonymous + shape (zero setup, runs against any environment incl. live).**
268
- No session needed. Catches the loudest drift: a private collection an anon can
269
- read, a renamed/mis-cased collection (`collection("savedGames")` throws
270
- *synchronously*), the error-code contract, and the deliberate reminder that a
271
- `community`/`public_read` collection is readable by anyone.
272
-
273
- **Tier B — cross-user isolation (dev environments only).**
274
- Proving "user B genuinely can't read user A's private record" needs two real
275
- sessions. Mint them without an OTP round-trip:
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:
276
282
 
277
283
  ### `gemmeinServer(sk).testSession(email) → { token, expiresAt, user }`
278
- Mints a **member** session for a test email. **Dev only** — throws
279
- `test_session_forbidden_live` on an `sk_live` key, and the server refuses it on a
280
- live environment too (the invariant that keeps it off real user data). Pass the
281
- returned `token` to `gemmein(pk, { tokenStore })` to act as that user. Dev and
282
- live enforce the *same* rules, so isolation proven in dev holds in live.
283
-
284
- This exact harness ships as **`reaffirm.mjs` inside the npm package** copy it
285
- out, edit the CONFIG block, run in CI.
286
-
287
- ```js
288
- // reaffirm.mjs run in CI: `node reaffirm.mjs` (exits non-zero on any drift).
289
- import { gemmein, gemmeinServer } from "@gemmein/sdk";
290
-
291
- const API = process.env.GEMMEIN_API_URL; // your dev API url
292
- const g = gemmein(process.env.PUBLIC_KEY, { apiUrl: API });
293
- const srv = gemmeinServer(process.env.SECRET_KEY, { apiUrl: API }); // sk_dev only
294
- let fail = 0;
295
- const refuse = async (label, code, fn) => { // must throw `code`
296
- try { await fn(); console.error("✗", label, "expected", code, "got success"); fail++; }
297
- catch (e) { e.code === code ? console.log("✓", label)
298
- : (console.error("✗", label, "— got", e.code), fail++); }
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.
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.
333
306
 
334
307
  ---
335
308
 
package/llms.txt CHANGED
@@ -522,29 +522,34 @@ rule. The specifics:
522
522
  `{ url, ... }`. Just `await` them on the click — don't also redirect to the
523
523
  returned `url` (you'll double-navigate), and don't build the URL yourself.
524
524
 
525
- ## Reaffirm your app (don't trust your frontend — prove it)
525
+ ## Reaffirm your app (the server enforces — prove it in CI)
526
526
 
527
527
  Gemmein enforces the rules on the server, so your UI is never the source of
528
- truth. That's what lets you move fast: ship a small `reaffirm` script that
529
- *exercises* your boundaries with live calls and run it in CI. When a rule ever
530
- stops matching what your screens assume, this catches it on deploy not in
531
- 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).
532
536
 
533
- - **Anywhere, no login:** anonymous reads/writes that must be refused (an anon
534
- reading a `private` collection `denied`; an anon writing `community`
535
- `denied`), plus the shapes your UI reads (`currentUser().userId`,
536
- `record.data.*`) and that your collection names don't throw.
537
- - **Isolation (dev environment):** to prove one user can't see another's
538
- private records, sign in two test users without an OTP round-trip
539
- `gemmeinServer(sk_dev).testSession(email)` returns a session `token` you pass
540
- to `gemmein(pk, { tokenStore })`. It works in a **dev environment only**
541
- (`sk_live` throws `test_session_forbidden_live`), and dev and live enforce the
542
- 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`).
543
549
 
544
- Add a probe whenever you add a feature. You reaffirm **because** Gemmein
545
- enforces never because these checks are the enforcement. A ready-to-edit
546
- `reaffirm.mjs` ships inside the `@gemmein/sdk` npm package (next to this file and
547
- 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.
548
553
 
549
554
  ## Pricing (current, v4 — one banded plan)
550
555
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.4.5",
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",
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");