@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.
- package/REFERENCE.md +45 -72
- package/llms.txt +24 -19
- package/package.json +1 -1
- 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.
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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.
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
-
|
|
538
|
-
|
|
539
|
-
`
|
|
540
|
-
|
|
541
|
-
(
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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.
|
|
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
|
|
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");
|