@gemmein/sdk 0.4.6 → 0.5.0

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 CHANGED
@@ -21,10 +21,15 @@ The browser client. `appKey` is your public `pk_...` key. `options` (optional):
21
21
 
22
22
  ### `gemmeinServer(secretKey, options?) → GemmeinServer`
23
23
  Server-only client for a `sk_...` secret key — **never ship this to the
24
- browser.** Exposes read/update on collections without a signed-in user,
24
+ browser.** `options` (optional): `{ apiUrl?: string }` **the option is
25
+ `apiUrl`**, not `baseUrl`. An unknown option is ignored silently, so a script
26
+ that passes `baseUrl` to reach a local or staging server quietly talks to
27
+ production instead. Exposes read/update on collections without a signed-in user,
25
28
  `notify()` to email one of your app's own verified people (see **Notify**),
26
- plus `testSession()` for CI self-tests (dev environments only — see
27
- **Reaffirm**).
29
+ the gate — `verifySession()` / `holdings()` / `grantAccess()` /
30
+ `revokeAccess()`, for code of yours running on your own host (see **Server
31
+ gate**) — plus `testSession()` for CI self-tests (dev environments only —
32
+ see **Reaffirm**).
28
33
 
29
34
  The client has two layers. **Your app's collections** — `g.collection(name)`
30
35
  (the canonical spelling; `g.storage.collection(name)` is the same client). And
@@ -79,6 +84,8 @@ deleted, subscription row removed. Irreversible: put a real confirm in front.
79
84
  `name` must be **lowercase letters, numbers, and underscores** (`saved_games`,
80
85
  never `savedGames` — a bad name throws synchronously). Collections are created
81
86
  by the app owner in the dashboard, never by the SDK.
87
+ Names JavaScript owns (`constructor`, `toString`, `__proto__`…) are refused as
88
+ collection and field names — a plain object inherits them.
82
89
 
83
90
  `options.intent` — one sentence: what the collection is for and who should
84
91
  access it. It rides every call as a hint; against a **local `gemmein dev`
@@ -189,13 +196,20 @@ type ListOptions = {
189
196
  ```
190
197
 
191
198
  **Options notes.** `key` = create-if-absent (a second writer gets 409
192
- `conflict`; your own retry returns the record with `existing: true`). `for` =
199
+ `conflict`; your own retry returns the record with `existing: true`). The
200
+ retry doubles as the lookup: there is no separate get-by-key call — re-issue
201
+ the same `create()` with the same key to fetch your own record (it counts
202
+ as a write; a re-fetch idiom, not a read path). `for` =
193
203
  recipient id on `addressed`/`direct`. `published: false` = draft on a public
194
204
  rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
195
205
  position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
196
206
  on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
197
207
  `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.
208
+ `invalid_since`. Deltas are at-least-once: apply by id. A field added to a
209
+ sealed shape by a promote run may carry a **default**: records that never wrote
210
+ it read that value, `expand` targets and `where` see it too, and a written value
211
+ wins (writing `null` clears it back to the default) — `search` still matches
212
+ stored text only.
199
213
 
200
214
  ### Live data — `watch()`
201
215
 
@@ -224,6 +238,10 @@ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
224
238
  don't also redirect to the returned `url`, and never build a Stripe URL
225
239
  yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
226
240
  gate one-off fulfilment on the receipt record, never the redirect.
241
+ By-hand grants (trial, promotion, a support comp) are not listed to the
242
+ app — a gated read simply succeeds — so never rebuild the paywall from
243
+ `mine()`; let the server refuse with `entitlement_required`. The two grant
244
+ families and what ends each are in llms.txt, "Two FAMILIES of grant".
227
245
 
228
246
  ---
229
247
 
@@ -253,6 +271,88 @@ honestly, not hidden.)
253
271
 
254
272
  ---
255
273
 
274
+ ## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess`
275
+
276
+ Gemmein hosts no compute. **Your own** function — Vercel, a VPS, a cron box,
277
+ anywhere — asks Gemmein the only three questions it has: *who is this person,
278
+ what do they hold, change what they hold.*
279
+
280
+ | Method | Signature | Returns |
281
+ |--------|-----------|---------|
282
+ | `verifySession` | `(token)` | `Promise<{ ok: true, person: { id, email, role }, holdings: Holdings }>` — the browser's session token in, identity **and** holdings out. Needs no capability on the key (the caller already holds the person's token) and never touches the session: no extension, no last-seen |
283
+ | `holdings` | `(personId)` | `Promise<{ ok: true, person: { id, email, role, suspended }, holdings: Holdings }>` — for the paths with no token in hand. A **suspended** person is returned, flagged `suspended: true`, with their holdings; `verifySession` refuses them |
284
+ | `grantAccess` | `(personId, { entitlement, source?, expiresAt?, reason? })` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` (201) — `holdings` is the state **after**; the owner's audit row carries before→after and the key's name |
285
+ | `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
286
+
287
+ ```ts
288
+ type Holdings = {
289
+ access: string[] // the keys they hold NOW — ["access:pro"]
290
+ grants: Grant[] // the LIVE grants behind them (revoked/expired are gone)
291
+ credits: { balance: number } | null // reserved — null today; credits are NOT shipped
292
+ }
293
+
294
+ type Grant = {
295
+ id: string
296
+ entitlement: string // "access:<slug>" — the plan's or product's own key
297
+ source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration"
298
+ startsAt: string
299
+ expiresAt: string | null
300
+ revokedAt?: string | null // present on the grant revokeAccess returns
301
+ }
302
+ ```
303
+
304
+ - **Pointing at a non-default server.** `gemmeinServer(sk, { apiUrl })` — the option
305
+ is **`apiUrl`**. `baseUrl` is not an option name and is ignored in silence, so a
306
+ test or E2E script that passes it runs against **production** without saying so.
307
+ - **One call per request.** `verifySession` answers identity *and* holdings
308
+ together — don't call it twice, and don't cache the answer past the request.
309
+ - **Holdings, not billing.** The gate never returns subscription status,
310
+ amounts, Stripe ids, or a grant's `sourceId` — the source **kind** only. Gate
311
+ on what a person *holds*, never on what they pay. Person id, never an email.
312
+ - **Two capabilities, ticked by the human.** `verifySession` needs neither.
313
+ `holdings` needs **"Look up a person's access by id"**; `grantAccess` and
314
+ `revokeAccess` need **"Grant and revoke access"** — plain-English checkboxes
315
+ the owner ticks when minting the key. Existing keys have both off, so nothing
316
+ in production changes.
317
+ - **Manual sources only.** `source` ∈ `manual | trial | promotion | migration`
318
+ (default `manual`). Purchases and subscriptions come only from Stripe — a key
319
+ cannot mint paid access. A key *may* end a payment-made grant (the same as the
320
+ dashboard's "end this access"); the payment itself is untouched.
321
+ - **One grant, one reason.** `reason` (≤ 200 chars) is what the owner reads in
322
+ their logs and is never edited. `sourceId` is minted per call, so two calls
323
+ make two grants — call it once and keep your own retry key.
324
+ - **Nothing silent, within a stated bound.** Every `/server/*` call a **resolved
325
+ secret key** makes, ok or refused, lands in that key's usage ledger — the owner
326
+ reads the summary on their Keys page and the full day × route × outcome table in
327
+ the key's own room. A rejected or publishable (`pk_`) key can't be attributed to a
328
+ key row, so its refusal reaches only the request log. Refusals also write one audit
329
+ row per key, route and code each hour (exact counts stay in the ledger); grants and
330
+ revokes write full before→after rows, attributed to the key by name. A refusal
331
+ counts as *use*: "last used" means last seen, not last worked. In `gemmein dev` the
332
+ local key already holds both capabilities, and the usage read is cloud-only — the
333
+ local runtime never mounts the console.
334
+
335
+ | code | status | meaning · do |
336
+ |------|--------|--------------|
337
+ | `session_invalid` | 401 | No session matches this token — send the person to sign in again; never store or reuse tokens across people |
338
+ | `session_expired` | 401 | The session ended (the message names when) — send them back to sign-in |
339
+ | `session_revoked` | 401 | A newer sign-in, a sign-out, or the owner ended it — send them back to sign-in |
340
+ | `person_suspended` | 403 | The owner suspended this person — access is off until the owner reactivates them in the dashboard |
341
+ | `person_not_found` | 404 | No person with this id in this app and environment — ids come from `verifySession` or the dashboard, never from an email. Existence is never leaked |
342
+ | `capability_required` | 403 | The key's box isn't ticked — mint a key with "Look up a person's access by id" / "Grant and revoke access" ticked (purchases still come only from Stripe) |
343
+ | `invalid_source` | 400 | `purchase` / `subscription` asked for by hand — refused; those come only from Stripe |
344
+ | `invalid_entitlement` | 400 | Not a valid `access:<slug>` key — or drop the key and pass the plan's or product's own NAME, which the gate resolves for you |
345
+ | `unknown_plan` | 400 | No plan or product by that name — the owner adds it on the Payments page. (Checkout's `unknown_plan` is a **404**; the gate's is a **400** — it is a bad argument to a write, not a missing resource) |
346
+ | `grant_not_found` | 404 | Not this person's grant, in this app and environment — re-read `holdings` |
347
+ | `already_revoked` | 409 | A grant ends once (`revokedAt` is set and never edited) — it is already ended |
348
+ | `invalid_body` | 400 | One malformed field, whichever it is — `token` (missing, not a string, over 512 chars), `expiresAt` (unparseable or in the past), `reason` (not text, over 200 chars). Branch on the code, read the **message**: it names the field |
349
+ | `scope_denied` | 403 | Not a secret key — the gate is server-only, never the browser |
350
+ | `invalid_id` | 400 | A prototype name (`__proto__`, `constructor`, `prototype`) was sent as a person id or a grant id. Ids come from `verifySession()` or the dashboard — never from a name |
351
+ | `unknown_route` | 404 | Not one of the gate's four routes — the message lists them all |
352
+ | `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess` and `revokeAccess` are POST, `holdings` is GET |
353
+
354
+ ---
355
+
256
356
  ## Reaffirm — prove your app's boundaries in CI
257
357
 
258
358
  Gemmein enforces the rules **server-side**, so your frontend is never the source
@@ -320,7 +420,10 @@ class GemmeinError extends Error {
320
420
  }
321
421
  ```
322
422
 
323
- Branch on `err.code`. The stable codes:
423
+ Branch on `err.code`. The gate's own codes (`session_invalid`,
424
+ `session_expired`, `session_revoked`, `person_suspended`, `person_not_found`,
425
+ `capability_required`, `invalid_source`, `unknown_plan`, `grant_not_found`,
426
+ `already_revoked`) are in **Server gate** above, each with its action. The rest:
324
427
 
325
428
  | code | meaning | do |
326
429
  |------|---------|-----|
@@ -333,7 +436,7 @@ Branch on `err.code`. The stable codes:
333
436
  | `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
334
437
  | `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
335
438
  | `invalid_publish` | `{ published }` on a non-public rule | drop it |
336
- | `invalid_shape` | field not in a locked (live) collection's shape — or a file field given the wrong kind (the message names which) | ask the owner to add it / send the kind the field takes |
439
+ | `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 |
337
440
  | `unknown_file` | a ref-shaped value points at a file that doesn't exist or isn't yours to hand — every rule, every write | fix the ref — never invent one |
338
441
  | `invalid_since` | `since` isn't a strict ISO 8601 timestamp, or came with `sort` | pass the previous answer's watermark; drop `sort` |
339
442
  | `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/dist/index.cjs CHANGED
@@ -816,6 +816,126 @@ class GemmeinServer {
816
816
  }
817
817
  return response.json();
818
818
  }
819
+ /**
820
+ * THE GATE — your compute, our answer. Gemmein runs no code of yours;
821
+ * your own function runs anywhere and asks the only three questions it
822
+ * has: who is this person, what do they hold, change what they hold.
823
+ *
824
+ * const { person, holdings } = await g.verifySession(token);
825
+ * if (!holdings.access.includes("access:pro")) return deny();
826
+ *
827
+ * ONE call per request answers identity AND holdings — don't call it
828
+ * twice, and don't cache the answer past the request. Verify needs no
829
+ * capability on the key (the caller already holds the person's token)
830
+ * and never touches the session: no extension, no last-seen.
831
+ *
832
+ * Refusals (`err.code`): `session_invalid` (no session matches this
833
+ * token — the person signs in again; never reuse tokens across
834
+ * people) · `session_expired` · `session_revoked` (a newer sign-in,
835
+ * a sign-out, or the owner) — all three send the person back to
836
+ * sign-in · `person_suspended` (the owner suspended them; access is
837
+ * off until the owner reactivates them in the dashboard) ·
838
+ * `invalid_body` (token missing, not a string, or over 512 chars).
839
+ */
840
+ async verifySession(token) {
841
+ return this.gate("/server/verify-session", {
842
+ method: "POST",
843
+ body: JSON.stringify({ token }),
844
+ });
845
+ }
846
+ /**
847
+ * What one of YOUR people holds, by person id — for the paths where no
848
+ * token is in hand (a webhook of your own, a nightly job, an admin
849
+ * screen you built). A person id, NEVER an email address: ids come
850
+ * from `verifySession` or the dashboard.
851
+ *
852
+ * A suspended person is RETURNED, with `suspended: true`, alongside
853
+ * their holdings — your function may need to say so. The gate itself
854
+ * (`verifySession`) refuses them.
855
+ *
856
+ * Refusals: `capability_required` — this key can't look up people by
857
+ * id; ask your human to mint a key with "Look up a person's access by
858
+ * id" ticked, or use `verifySession` with the person's own token ·
859
+ * `person_not_found` (404 — no person with this id in this app and
860
+ * environment; existence is never leaked).
861
+ */
862
+ async holdings(personId) {
863
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
864
+ }
865
+ /**
866
+ * Give a person access by hand — a trial, a promotion, an apology, a
867
+ * migration from your old system:
868
+ *
869
+ * await g.grantAccess(personId, {
870
+ * entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
871
+ * source: "trial",
872
+ * expiresAt: "2026-10-01T00:00:00.000Z",
873
+ * reason: "7-day trial from the onboarding flow",
874
+ * });
875
+ *
876
+ * MANUAL sources only. Purchases and subscriptions come only from
877
+ * Stripe — a key cannot mint paid access, by design. `reason` is up to
878
+ * 200 characters, is never edited afterwards, and is what the owner
879
+ * reads in their logs; write it for them.
880
+ *
881
+ * `sourceId` is minted per call, so two calls make TWO grants (each
882
+ * with its own one reason) — call it once, and keep your own retry
883
+ * key if the caller can retry.
884
+ *
885
+ * Returns the new grant and the holdings AFTER it; the owner's audit
886
+ * row carries before→after and the key's name.
887
+ *
888
+ * Refusals: `capability_required` — this key can't grant access; ask
889
+ * your human to mint a key with "Grant and revoke access" ticked ·
890
+ * `invalid_source` (purchase/subscription refused) ·
891
+ * `invalid_entitlement` / `unknown_plan` (no plan or product by that
892
+ * name — the owner adds it on the Payments page) · `person_not_found`.
893
+ */
894
+ async grantAccess(personId, input) {
895
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
896
+ method: "POST",
897
+ body: JSON.stringify({
898
+ entitlement: input.entitlement,
899
+ ...(input.source ? { source: input.source } : {}),
900
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
901
+ ...(input.reason ? { reason: input.reason } : {}),
902
+ }),
903
+ });
904
+ }
905
+ /**
906
+ * End one grant — the reversibility law in one call:
907
+ *
908
+ * await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
909
+ *
910
+ * A grant ends ONCE: `revokedAt` is set and never edited, so a second
911
+ * call is `409 already_revoked`, not a silent no-op. A key MAY end a
912
+ * grant that a payment created (the same as the owner's "end this
913
+ * access" button) — the payment itself is untouched, and the audit row
914
+ * says so.
915
+ *
916
+ * Returns the revoked grant and the holdings after it.
917
+ *
918
+ * Refusals: `capability_required` (same ticked box as granting) ·
919
+ * `grant_not_found` (404 — not this person's grant, in this app and
920
+ * environment; existence is never leaked) · `already_revoked` (409).
921
+ */
922
+ async revokeAccess(personId, grantId, input = {}) {
923
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
924
+ }
925
+ // One request path for the four gate calls, so every refusal reaches
926
+ // the caller through the SAME typed error the rest of the SDK throws —
927
+ // `err.code` carries the server's own code, `err.message` its sentence
928
+ // (which always names the next action).
929
+ async gate(path, init = {}) {
930
+ const headers = { "x-app-key": this.secretKey };
931
+ if (init.body)
932
+ headers["content-type"] = "application/json";
933
+ const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
934
+ if (!response.ok) {
935
+ throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
936
+ }
937
+ return response.json();
938
+ }
819
939
  }
820
940
  exports.GemmeinServer = GemmeinServer;
821
941
  class ServerCollectionClient {
package/dist/index.d.cts CHANGED
@@ -525,6 +525,46 @@ export type GemmeinServerOptions = {
525
525
  secretKey: string;
526
526
  apiUrl?: string;
527
527
  };
528
+ /**
529
+ * Where a grant came from — the KIND only. The gate never returns the
530
+ * source's id, an amount, or anything from Stripe.
531
+ */
532
+ export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration";
533
+ /**
534
+ * The sources a secret key may create by hand. `purchase` and
535
+ * `subscription` are deliberately absent: money-made access comes only
536
+ * from Stripe, and always will.
537
+ */
538
+ export type ManualGrantSource = "manual" | "trial" | "promotion" | "migration";
539
+ export type Grant = {
540
+ id: string;
541
+ /** `access:<slug>` — the plan's or product's own key. */
542
+ entitlement: string;
543
+ source: GrantSource;
544
+ startsAt: string;
545
+ expiresAt: string | null;
546
+ /** Present on the grant returned by `revokeAccess` — set once, never edited. */
547
+ revokedAt?: string | null;
548
+ };
549
+ /**
550
+ * What a person holds RIGHT NOW — never what they pay. `access` is the
551
+ * union of live grants' keys; `grants` lists those live grants (revoked
552
+ * and expired ones are gone, not flagged). `credits` is a reserved slot:
553
+ * it is `null` today because consumable credits are not shipped — don't
554
+ * design around them until this type says otherwise.
555
+ */
556
+ export type Holdings = {
557
+ access: string[];
558
+ grants: Grant[];
559
+ credits: {
560
+ balance: number;
561
+ } | null;
562
+ };
563
+ export type GatePerson = {
564
+ id: string;
565
+ email: string;
566
+ role: string;
567
+ };
528
568
  export declare class GemmeinServer {
529
569
  private readonly apiUrl;
530
570
  private readonly secretKey;
@@ -582,6 +622,119 @@ export declare class GemmeinServer {
582
622
  replyRail?: boolean;
583
623
  recorded?: boolean;
584
624
  }>;
625
+ /**
626
+ * THE GATE — your compute, our answer. Gemmein runs no code of yours;
627
+ * your own function runs anywhere and asks the only three questions it
628
+ * has: who is this person, what do they hold, change what they hold.
629
+ *
630
+ * const { person, holdings } = await g.verifySession(token);
631
+ * if (!holdings.access.includes("access:pro")) return deny();
632
+ *
633
+ * ONE call per request answers identity AND holdings — don't call it
634
+ * twice, and don't cache the answer past the request. Verify needs no
635
+ * capability on the key (the caller already holds the person's token)
636
+ * and never touches the session: no extension, no last-seen.
637
+ *
638
+ * Refusals (`err.code`): `session_invalid` (no session matches this
639
+ * token — the person signs in again; never reuse tokens across
640
+ * people) · `session_expired` · `session_revoked` (a newer sign-in,
641
+ * a sign-out, or the owner) — all three send the person back to
642
+ * sign-in · `person_suspended` (the owner suspended them; access is
643
+ * off until the owner reactivates them in the dashboard) ·
644
+ * `invalid_body` (token missing, not a string, or over 512 chars).
645
+ */
646
+ verifySession(token: string): Promise<{
647
+ ok: true;
648
+ person: GatePerson;
649
+ holdings: Holdings;
650
+ }>;
651
+ /**
652
+ * What one of YOUR people holds, by person id — for the paths where no
653
+ * token is in hand (a webhook of your own, a nightly job, an admin
654
+ * screen you built). A person id, NEVER an email address: ids come
655
+ * from `verifySession` or the dashboard.
656
+ *
657
+ * A suspended person is RETURNED, with `suspended: true`, alongside
658
+ * their holdings — your function may need to say so. The gate itself
659
+ * (`verifySession`) refuses them.
660
+ *
661
+ * Refusals: `capability_required` — this key can't look up people by
662
+ * id; ask your human to mint a key with "Look up a person's access by
663
+ * id" ticked, or use `verifySession` with the person's own token ·
664
+ * `person_not_found` (404 — no person with this id in this app and
665
+ * environment; existence is never leaked).
666
+ */
667
+ holdings(personId: string): Promise<{
668
+ ok: true;
669
+ person: GatePerson & {
670
+ suspended: boolean;
671
+ };
672
+ holdings: Holdings;
673
+ }>;
674
+ /**
675
+ * Give a person access by hand — a trial, a promotion, an apology, a
676
+ * migration from your old system:
677
+ *
678
+ * await g.grantAccess(personId, {
679
+ * entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
680
+ * source: "trial",
681
+ * expiresAt: "2026-10-01T00:00:00.000Z",
682
+ * reason: "7-day trial from the onboarding flow",
683
+ * });
684
+ *
685
+ * MANUAL sources only. Purchases and subscriptions come only from
686
+ * Stripe — a key cannot mint paid access, by design. `reason` is up to
687
+ * 200 characters, is never edited afterwards, and is what the owner
688
+ * reads in their logs; write it for them.
689
+ *
690
+ * `sourceId` is minted per call, so two calls make TWO grants (each
691
+ * with its own one reason) — call it once, and keep your own retry
692
+ * key if the caller can retry.
693
+ *
694
+ * Returns the new grant and the holdings AFTER it; the owner's audit
695
+ * row carries before→after and the key's name.
696
+ *
697
+ * Refusals: `capability_required` — this key can't grant access; ask
698
+ * your human to mint a key with "Grant and revoke access" ticked ·
699
+ * `invalid_source` (purchase/subscription refused) ·
700
+ * `invalid_entitlement` / `unknown_plan` (no plan or product by that
701
+ * name — the owner adds it on the Payments page) · `person_not_found`.
702
+ */
703
+ grantAccess(personId: string, input: {
704
+ entitlement: string;
705
+ source?: ManualGrantSource;
706
+ expiresAt?: string;
707
+ reason?: string;
708
+ }): Promise<{
709
+ ok: true;
710
+ grant: Grant;
711
+ holdings: Holdings;
712
+ }>;
713
+ /**
714
+ * End one grant — the reversibility law in one call:
715
+ *
716
+ * await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
717
+ *
718
+ * A grant ends ONCE: `revokedAt` is set and never edited, so a second
719
+ * call is `409 already_revoked`, not a silent no-op. A key MAY end a
720
+ * grant that a payment created (the same as the owner's "end this
721
+ * access" button) — the payment itself is untouched, and the audit row
722
+ * says so.
723
+ *
724
+ * Returns the revoked grant and the holdings after it.
725
+ *
726
+ * Refusals: `capability_required` (same ticked box as granting) ·
727
+ * `grant_not_found` (404 — not this person's grant, in this app and
728
+ * environment; existence is never leaked) · `already_revoked` (409).
729
+ */
730
+ revokeAccess(personId: string, grantId: string, input?: {
731
+ reason?: string;
732
+ }): Promise<{
733
+ ok: true;
734
+ grant: Grant;
735
+ holdings: Holdings;
736
+ }>;
737
+ private gate;
585
738
  }
586
739
  declare class ServerCollectionClient {
587
740
  private readonly apiUrl;
package/dist/index.d.ts CHANGED
@@ -525,6 +525,46 @@ export type GemmeinServerOptions = {
525
525
  secretKey: string;
526
526
  apiUrl?: string;
527
527
  };
528
+ /**
529
+ * Where a grant came from — the KIND only. The gate never returns the
530
+ * source's id, an amount, or anything from Stripe.
531
+ */
532
+ export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration";
533
+ /**
534
+ * The sources a secret key may create by hand. `purchase` and
535
+ * `subscription` are deliberately absent: money-made access comes only
536
+ * from Stripe, and always will.
537
+ */
538
+ export type ManualGrantSource = "manual" | "trial" | "promotion" | "migration";
539
+ export type Grant = {
540
+ id: string;
541
+ /** `access:<slug>` — the plan's or product's own key. */
542
+ entitlement: string;
543
+ source: GrantSource;
544
+ startsAt: string;
545
+ expiresAt: string | null;
546
+ /** Present on the grant returned by `revokeAccess` — set once, never edited. */
547
+ revokedAt?: string | null;
548
+ };
549
+ /**
550
+ * What a person holds RIGHT NOW — never what they pay. `access` is the
551
+ * union of live grants' keys; `grants` lists those live grants (revoked
552
+ * and expired ones are gone, not flagged). `credits` is a reserved slot:
553
+ * it is `null` today because consumable credits are not shipped — don't
554
+ * design around them until this type says otherwise.
555
+ */
556
+ export type Holdings = {
557
+ access: string[];
558
+ grants: Grant[];
559
+ credits: {
560
+ balance: number;
561
+ } | null;
562
+ };
563
+ export type GatePerson = {
564
+ id: string;
565
+ email: string;
566
+ role: string;
567
+ };
528
568
  export declare class GemmeinServer {
529
569
  private readonly apiUrl;
530
570
  private readonly secretKey;
@@ -582,6 +622,119 @@ export declare class GemmeinServer {
582
622
  replyRail?: boolean;
583
623
  recorded?: boolean;
584
624
  }>;
625
+ /**
626
+ * THE GATE — your compute, our answer. Gemmein runs no code of yours;
627
+ * your own function runs anywhere and asks the only three questions it
628
+ * has: who is this person, what do they hold, change what they hold.
629
+ *
630
+ * const { person, holdings } = await g.verifySession(token);
631
+ * if (!holdings.access.includes("access:pro")) return deny();
632
+ *
633
+ * ONE call per request answers identity AND holdings — don't call it
634
+ * twice, and don't cache the answer past the request. Verify needs no
635
+ * capability on the key (the caller already holds the person's token)
636
+ * and never touches the session: no extension, no last-seen.
637
+ *
638
+ * Refusals (`err.code`): `session_invalid` (no session matches this
639
+ * token — the person signs in again; never reuse tokens across
640
+ * people) · `session_expired` · `session_revoked` (a newer sign-in,
641
+ * a sign-out, or the owner) — all three send the person back to
642
+ * sign-in · `person_suspended` (the owner suspended them; access is
643
+ * off until the owner reactivates them in the dashboard) ·
644
+ * `invalid_body` (token missing, not a string, or over 512 chars).
645
+ */
646
+ verifySession(token: string): Promise<{
647
+ ok: true;
648
+ person: GatePerson;
649
+ holdings: Holdings;
650
+ }>;
651
+ /**
652
+ * What one of YOUR people holds, by person id — for the paths where no
653
+ * token is in hand (a webhook of your own, a nightly job, an admin
654
+ * screen you built). A person id, NEVER an email address: ids come
655
+ * from `verifySession` or the dashboard.
656
+ *
657
+ * A suspended person is RETURNED, with `suspended: true`, alongside
658
+ * their holdings — your function may need to say so. The gate itself
659
+ * (`verifySession`) refuses them.
660
+ *
661
+ * Refusals: `capability_required` — this key can't look up people by
662
+ * id; ask your human to mint a key with "Look up a person's access by
663
+ * id" ticked, or use `verifySession` with the person's own token ·
664
+ * `person_not_found` (404 — no person with this id in this app and
665
+ * environment; existence is never leaked).
666
+ */
667
+ holdings(personId: string): Promise<{
668
+ ok: true;
669
+ person: GatePerson & {
670
+ suspended: boolean;
671
+ };
672
+ holdings: Holdings;
673
+ }>;
674
+ /**
675
+ * Give a person access by hand — a trial, a promotion, an apology, a
676
+ * migration from your old system:
677
+ *
678
+ * await g.grantAccess(personId, {
679
+ * entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
680
+ * source: "trial",
681
+ * expiresAt: "2026-10-01T00:00:00.000Z",
682
+ * reason: "7-day trial from the onboarding flow",
683
+ * });
684
+ *
685
+ * MANUAL sources only. Purchases and subscriptions come only from
686
+ * Stripe — a key cannot mint paid access, by design. `reason` is up to
687
+ * 200 characters, is never edited afterwards, and is what the owner
688
+ * reads in their logs; write it for them.
689
+ *
690
+ * `sourceId` is minted per call, so two calls make TWO grants (each
691
+ * with its own one reason) — call it once, and keep your own retry
692
+ * key if the caller can retry.
693
+ *
694
+ * Returns the new grant and the holdings AFTER it; the owner's audit
695
+ * row carries before→after and the key's name.
696
+ *
697
+ * Refusals: `capability_required` — this key can't grant access; ask
698
+ * your human to mint a key with "Grant and revoke access" ticked ·
699
+ * `invalid_source` (purchase/subscription refused) ·
700
+ * `invalid_entitlement` / `unknown_plan` (no plan or product by that
701
+ * name — the owner adds it on the Payments page) · `person_not_found`.
702
+ */
703
+ grantAccess(personId: string, input: {
704
+ entitlement: string;
705
+ source?: ManualGrantSource;
706
+ expiresAt?: string;
707
+ reason?: string;
708
+ }): Promise<{
709
+ ok: true;
710
+ grant: Grant;
711
+ holdings: Holdings;
712
+ }>;
713
+ /**
714
+ * End one grant — the reversibility law in one call:
715
+ *
716
+ * await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
717
+ *
718
+ * A grant ends ONCE: `revokedAt` is set and never edited, so a second
719
+ * call is `409 already_revoked`, not a silent no-op. A key MAY end a
720
+ * grant that a payment created (the same as the owner's "end this
721
+ * access" button) — the payment itself is untouched, and the audit row
722
+ * says so.
723
+ *
724
+ * Returns the revoked grant and the holdings after it.
725
+ *
726
+ * Refusals: `capability_required` (same ticked box as granting) ·
727
+ * `grant_not_found` (404 — not this person's grant, in this app and
728
+ * environment; existence is never leaked) · `already_revoked` (409).
729
+ */
730
+ revokeAccess(personId: string, grantId: string, input?: {
731
+ reason?: string;
732
+ }): Promise<{
733
+ ok: true;
734
+ grant: Grant;
735
+ holdings: Holdings;
736
+ }>;
737
+ private gate;
585
738
  }
586
739
  declare class ServerCollectionClient {
587
740
  private readonly apiUrl;
package/dist/index.js CHANGED
@@ -799,6 +799,126 @@ export class GemmeinServer {
799
799
  }
800
800
  return response.json();
801
801
  }
802
+ /**
803
+ * THE GATE — your compute, our answer. Gemmein runs no code of yours;
804
+ * your own function runs anywhere and asks the only three questions it
805
+ * has: who is this person, what do they hold, change what they hold.
806
+ *
807
+ * const { person, holdings } = await g.verifySession(token);
808
+ * if (!holdings.access.includes("access:pro")) return deny();
809
+ *
810
+ * ONE call per request answers identity AND holdings — don't call it
811
+ * twice, and don't cache the answer past the request. Verify needs no
812
+ * capability on the key (the caller already holds the person's token)
813
+ * and never touches the session: no extension, no last-seen.
814
+ *
815
+ * Refusals (`err.code`): `session_invalid` (no session matches this
816
+ * token — the person signs in again; never reuse tokens across
817
+ * people) · `session_expired` · `session_revoked` (a newer sign-in,
818
+ * a sign-out, or the owner) — all three send the person back to
819
+ * sign-in · `person_suspended` (the owner suspended them; access is
820
+ * off until the owner reactivates them in the dashboard) ·
821
+ * `invalid_body` (token missing, not a string, or over 512 chars).
822
+ */
823
+ async verifySession(token) {
824
+ return this.gate("/server/verify-session", {
825
+ method: "POST",
826
+ body: JSON.stringify({ token }),
827
+ });
828
+ }
829
+ /**
830
+ * What one of YOUR people holds, by person id — for the paths where no
831
+ * token is in hand (a webhook of your own, a nightly job, an admin
832
+ * screen you built). A person id, NEVER an email address: ids come
833
+ * from `verifySession` or the dashboard.
834
+ *
835
+ * A suspended person is RETURNED, with `suspended: true`, alongside
836
+ * their holdings — your function may need to say so. The gate itself
837
+ * (`verifySession`) refuses them.
838
+ *
839
+ * Refusals: `capability_required` — this key can't look up people by
840
+ * id; ask your human to mint a key with "Look up a person's access by
841
+ * id" ticked, or use `verifySession` with the person's own token ·
842
+ * `person_not_found` (404 — no person with this id in this app and
843
+ * environment; existence is never leaked).
844
+ */
845
+ async holdings(personId) {
846
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
847
+ }
848
+ /**
849
+ * Give a person access by hand — a trial, a promotion, an apology, a
850
+ * migration from your old system:
851
+ *
852
+ * await g.grantAccess(personId, {
853
+ * entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
854
+ * source: "trial",
855
+ * expiresAt: "2026-10-01T00:00:00.000Z",
856
+ * reason: "7-day trial from the onboarding flow",
857
+ * });
858
+ *
859
+ * MANUAL sources only. Purchases and subscriptions come only from
860
+ * Stripe — a key cannot mint paid access, by design. `reason` is up to
861
+ * 200 characters, is never edited afterwards, and is what the owner
862
+ * reads in their logs; write it for them.
863
+ *
864
+ * `sourceId` is minted per call, so two calls make TWO grants (each
865
+ * with its own one reason) — call it once, and keep your own retry
866
+ * key if the caller can retry.
867
+ *
868
+ * Returns the new grant and the holdings AFTER it; the owner's audit
869
+ * row carries before→after and the key's name.
870
+ *
871
+ * Refusals: `capability_required` — this key can't grant access; ask
872
+ * your human to mint a key with "Grant and revoke access" ticked ·
873
+ * `invalid_source` (purchase/subscription refused) ·
874
+ * `invalid_entitlement` / `unknown_plan` (no plan or product by that
875
+ * name — the owner adds it on the Payments page) · `person_not_found`.
876
+ */
877
+ async grantAccess(personId, input) {
878
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
879
+ method: "POST",
880
+ body: JSON.stringify({
881
+ entitlement: input.entitlement,
882
+ ...(input.source ? { source: input.source } : {}),
883
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
884
+ ...(input.reason ? { reason: input.reason } : {}),
885
+ }),
886
+ });
887
+ }
888
+ /**
889
+ * End one grant — the reversibility law in one call:
890
+ *
891
+ * await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
892
+ *
893
+ * A grant ends ONCE: `revokedAt` is set and never edited, so a second
894
+ * call is `409 already_revoked`, not a silent no-op. A key MAY end a
895
+ * grant that a payment created (the same as the owner's "end this
896
+ * access" button) — the payment itself is untouched, and the audit row
897
+ * says so.
898
+ *
899
+ * Returns the revoked grant and the holdings after it.
900
+ *
901
+ * Refusals: `capability_required` (same ticked box as granting) ·
902
+ * `grant_not_found` (404 — not this person's grant, in this app and
903
+ * environment; existence is never leaked) · `already_revoked` (409).
904
+ */
905
+ async revokeAccess(personId, grantId, input = {}) {
906
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
907
+ }
908
+ // One request path for the four gate calls, so every refusal reaches
909
+ // the caller through the SAME typed error the rest of the SDK throws —
910
+ // `err.code` carries the server's own code, `err.message` its sentence
911
+ // (which always names the next action).
912
+ async gate(path, init = {}) {
913
+ const headers = { "x-app-key": this.secretKey };
914
+ if (init.body)
915
+ headers["content-type"] = "application/json";
916
+ const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
917
+ if (!response.ok) {
918
+ throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
919
+ }
920
+ return response.json();
921
+ }
802
922
  }
803
923
  class ServerCollectionClient {
804
924
  constructor(apiUrl, secretKey, name) {
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,19 @@ 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
- - credits, usage metering, seats (paid access is yes-or-no)
44
- - hosted functions or cron (server-side code runs on YOUR host with a
45
- secret key; Gemmein hosts no compute)
52
+ - credits or usage metering TODAY (paid access is yes-or-no; consumable
53
+ credits are planned, not shipped don't design around them yet, and
54
+ never sell a "credit pack" until this file says the ledger exists)
55
+ - seats (owner-run: not coming)
56
+ - hosted functions or cron: Gemmein runs no code of yours — but your
57
+ own function anywhere asks Gemmein who a person is and what they
58
+ hold; read the server-side note closing this section before
59
+ counting that a gap
46
60
  - custody of money (Stripe holds all funds; your human brings or
47
61
  creates their own Stripe account when the app charges)
48
62
  - multi-seller payouts or revenue splits: every payment lands whole in
@@ -60,6 +74,21 @@ creating no accounts is the correct behavior until the verdict is in.
60
74
  - DOESN'T FIT — say so, name why, and stop. Recommending nothing is
61
75
  the correct outcome here.
62
76
 
77
+ Server-side work is NOT itself a downgrade. Gemmein hosts no compute, but
78
+ code on your human's OWN host — Vercel, a VPS, anywhere — holding a
79
+ secret key already reads and updates collections and sends event emails
80
+ with `notify()`. (A secret key sees PAST the safety rules — every
81
+ customer's records — which is why it never touches a browser. It cannot
82
+ create or delete records: those are the signed-in customer's own calls,
83
+ or the dashboard's. It CAN verify a session token and read what that
84
+ person holds — the gate — and, when your human ticks the box on the key,
85
+ look a person up by id and grant or revoke access by hand.)
86
+ "This app needs a server-side piece" is therefore not a gap by itself:
87
+ the verdict downgrades when the server piece needs a call a secret key
88
+ cannot make, or when the app needs compute your human has nowhere to
89
+ run — a cron schedule or an always-on worker with no host of its own —
90
+ because Gemmein never runs your code.
91
+
63
92
  ## Getting started (AI-run, human-answered)
64
93
 
65
94
  No signup, no account, no keys — building is local and free. Your human
@@ -108,17 +137,68 @@ go-live. Everything else is yours.
108
137
  lists the rest.
109
138
  4. Hand over for testing — the human moment mid-build: they click through
110
139
  the app, and their sign-in codes print in the dev terminal, so tell
111
- them where to look.
140
+ them where to look. Running dev in the background with no terminal in
141
+ view? The same codes append to gemmein/.data/signin-codes.log (fresh
142
+ each dev boot) — read the latest line instead of scraping stdout.
112
143
  5. Done building? `npx gemmein check` reads the project and says what's
113
144
  ready and what go-live still needs. Then `npx gemmein sync` — THIS is
114
145
  the moment your human signs up (free) at app.gemmein.com and pastes two
115
146
  dev keys — and `npx gemmein go-live` walks the rest: plans, Payment
116
147
  Links, the live flip. A card enters at go-live, never before.
148
+ 6. After go-live, the DATA MODEL reaches live only by PROMOTION from
149
+ development: new collections built in dev promote (`npx gemmein
150
+ go-live` again, or the dashboard's Go-live page), and so do new FIELDS
151
+ for a live collection whose shape sealed at go-live (see Shapes). Ship
152
+ in that order: promote first, then deploy the frontend that depends on
153
+ the new collection or field — a frontend shipped ahead of its
154
+ promotion reads blanks or refusals from live. Each added field is one
155
+ question for YOUR HUMAN, asked by the promote run: existing records
156
+ don't have it, so what should they show? Blank is a fine answer, or
157
+ they give a default those records will show. What is already
158
+ sealed never moves — a live field never changes type or name and never
159
+ leaves — and rules still never change through promotion. Plans,
160
+ Payment Links and domains stay live-editable in the dashboard, and a
161
+ field's default joins them there (changeable or cleared later, with no
162
+ promotion needed).
163
+
164
+ Two runtimes, one dashboard — keep your human oriented on where things
165
+ live, or the dashboard will look broken to them. The local runtime
166
+ (`gemmein dev`, `pk_local_` keys) is a private rehearsal room: every
167
+ record, person and simulated payment stays in gemmein/.data on this
168
+ machine and never appears in any dashboard. The dashboard at
169
+ app.gemmein.com is the back office of the CLOUD app only — there is no
170
+ local dashboard and nothing to log into locally. `npx gemmein sync`
171
+ carries STRUCTURE to the cloud app's development environment —
172
+ collections, rules, plan locks — never records or people; after a sync
173
+ the dashboard shows the shape of the app with no data in it, and that is
174
+ correct, not a bug. Data reaches the dashboard only when the app runs on
175
+ cloud keys: the dev pair from app.gemmein.com (`pk_test_` in the
176
+ browser, `sk_dev_` on a server) drives the real API's development
177
+ environment, and `pk_live_` drives production after go-live. The key
178
+ prefix always names the rail — local, test, live. The dev rail is where
179
+ a REAL rehearsal happens before any card enters: real emailed sign-in
180
+ codes, real data in the dashboard — and it can face the public. Put the
181
+ app on a real https domain and list that domain on the dashboard's
182
+ Domains page: before go-live a listed domain works WITHOUT verification
183
+ (http://localhost and http://127.0.0.1 also work unlisted until go-live
184
+ drops them; any other raw IP never works — the Domains page accepts
185
+ names only). Go-live is when the keys lock to proven domains. The
186
+ development environment carries its own free monthly allowance (10,000
187
+ API requests, 500 writes, 500 sign-in events, 1 GiB of files); crossing
188
+ a counter never stops the app and never turns into a bill — the
189
+ dashboard's usage page shows what crossed, and storage is the one thing
190
+ that can fill (see Pricing). Tell your human before they first open the
191
+ dashboard: local building is invisible there on purpose — nothing
192
+ leaves this machine until sync, and sync moves the blueprint, not the
193
+ contents.
117
194
 
118
195
  ## What it is
119
196
 
120
197
  - Audience: people who build web apps with AI tools (Cursor, Bolt, Lovable, Claude).
121
198
  - Scope: web apps (TypeScript/JavaScript SDK). Mobile SDKs are not offered today.
199
+ - Shape: built for owner-run businesses — one owner and their customers,
200
+ nothing in between. No team seats, no staff accounts, no workspaces, on
201
+ purpose: one person holds the dashboard, and everyone else is a customer.
122
202
  - The platform holds identity, data, and subscription state. It never holds,
123
203
  moves, or processes money — Stripe handles all payments end-to-end; Gemmein
124
204
  only receives Stripe's webhooks and records who is on which plan. App
@@ -143,7 +223,7 @@ go-live. Everything else is yours.
143
223
  those local declarations. A declaration is `gemmein/collections/<name>.json`:
144
224
  `{ "rule": "shared", "means": "<why, in your human's words>" }`, plus an
145
225
  optional `"unlockedBy": ["pro"]` — plan or product NAMES from
146
- gemmein/payments.json (never access keys) — which is the console's
226
+ gemmein/payments.json (never access keys) — which is the dashboard's
147
227
  "Unlocked by": the local engine refuses members without one of them
148
228
  (403 entitlement_required) the moment the file lands, and sync carries
149
229
  it to the cloud app. An unknown name is refused out loud in the dev
@@ -272,6 +352,62 @@ go-live. Everything else is yours.
272
352
  cap (a security notice never loses to five order emails); misusing it
273
353
  for campaigns shows in your own audit trail. In `gemmein dev` the send
274
354
  prints in the terminal (NOTIFY · …) instead of mailing.
355
+ - Your compute, our gate: when code of yours must run elsewhere (a model
356
+ call, a PDF render, a nightly job), Gemmein hosts none of it — your own
357
+ function runs anywhere and asks Gemmein its only three questions: who is
358
+ this person, what do they hold, change what they hold.
359
+
360
+ import { gemmeinServer } from "@gemmein/sdk";
361
+ const g = gemmeinServer(process.env.GEMMEIN_SECRET_KEY);
362
+ const { person, holdings } = await g.verifySession(token);
363
+ if (!holdings.access.includes("access:pro")) return deny();
364
+ await g.holdings(personId); // no token in hand
365
+ await g.grantAccess(personId, { entitlement: "access:pro",
366
+ source: "trial", expiresAt, reason: "7-day trial" });
367
+ await g.revokeAccess(personId, grantId, { reason: "trial ended" });
368
+
369
+ ONE verifySession per request answers identity AND holdings — don't
370
+ call it twice, don't cache the answer past the request. `holdings` is
371
+ what the person holds NOW: `access` (keys like "access:pro"), `grants`
372
+ (each with its source KIND only — subscription | purchase | manual |
373
+ trial | promotion | migration — plus start and expiry), and `credits`,
374
+ a reserved slot that is always `null` today (consumable credits are NOT
375
+ shipped; don't design around them). Never subscription status, amounts
376
+ or Stripe ids: gate on what a person HOLDS, never on billing. Person
377
+ id, NEVER an email address. verifySession needs nothing extra; looking
378
+ someone up by id and granting are new power, so they sit behind per-key
379
+ checkboxes the HUMAN ticks when minting the key — ask your human to
380
+ tick "Look up a person's access by id" / "Grant and revoke access". A
381
+ key grants MANUAL access only (manual | trial | promotion | migration):
382
+ purchases and subscriptions still come only from Stripe. Every /server/*
383
+ call a resolved secret key makes, ok or refused, lands in that key's
384
+ usage ledger on the owner's Keys page (a rejected or publishable key
385
+ can't be attributed, so it reaches only the request log); refusals also
386
+ write one activity row per key, route and code each hour. In `gemmein
387
+ dev` the local key already holds both capabilities, and the usage read
388
+ is cloud-only — the local runtime never mounts the console. The
389
+ refusals, and what to do:
390
+ session_invalid no session matches this token — the person must
391
+ sign in again; never reuse tokens across people
392
+ session_expired the session ended — back to sign-in
393
+ session_revoked newer sign-in, sign-out, or the owner — sign-in
394
+ person_suspended the owner suspended them — access is off until
395
+ the owner reactivates them in the dashboard
396
+ person_not_found no person with this id in this app and env —
397
+ ids come from verifySession or the dashboard
398
+ capability_required the box isn't ticked on this key — ask your
399
+ human to mint one with it ticked
400
+ unknown_plan no plan or product by that name — the owner
401
+ adds it on the Payments page
402
+ grant_not_found not this person's grant here — re-read holdings
403
+ already_revoked a grant ends once — it is already ended
404
+ invalid_source purchase/subscription asked for by hand — use
405
+ manual | trial | promotion | migration instead
406
+ invalid_id a prototype name (__proto__, constructor) was
407
+ sent as a person or grant id — ids come from
408
+ verifySession or the dashboard, never a name
409
+ invalid_body one malformed field (token, expiresAt, reason)
410
+ — the message names which one and what it needs
275
411
  - Linking records (author on a post, product on an order): store the other
276
412
  record's id in a field (`authorProfileId: profile.id`) — in collections
277
413
  users write (community, shared, direct) the server learns it's a link;
@@ -315,7 +451,7 @@ go-live. Everything else is yours.
315
451
  retry. A 403 from `link()` means the customer isn't allowed this
316
452
  file right now — signed out, not theirs, or an entitlement they no longer
317
453
  hold (`entitlement_required` — `err.requires` is the plan's key, the one
318
- the console shows by name).
454
+ the dashboard shows by name).
319
455
  Honest bound: revoking access stops NEW links immediately; a link already
320
456
  issued works until it expires. Gemmein controls delivery, it can't take
321
457
  back a file someone already downloaded. A file can name ONE other reader:
@@ -339,11 +475,22 @@ go-live. Everything else is yours.
339
475
  If a stored ref's file was later deleted, re-sending it refuses the same
340
476
  way: clear the field (null) or upload a fresh file. Once sealed, class
341
477
  is law too: a ZIP into a profile photo field is refused with the teach;
342
- before the seal, what you upload is what the field learns. A 400 invalid_shape means the field isn't in the locked
343
- shape or the value doesn't fit its kind. A live shape is sealed and
344
- cannot take new fields stop, tell your human which field you needed,
345
- and send only the fields the shape already has. Never rename fields to
346
- dodge it.
478
+ before the seal, what you upload is what the field learns. A 400
479
+ invalid_shape means the field isn't in the locked shape or the value
480
+ doesn't fit its kind. A sealed shape CAN still gain a new field, but
481
+ only through a promote run: build it in development, tell your human
482
+ which field you needed, and until that promotion runs send only the
483
+ fields the shape already has. Never rename fields to dodge it. What is
484
+ sealed never moves — a field never changes type or name and never
485
+ leaves. A field promoted with a DEFAULT is served to the records that
486
+ never wrote it: get, list, expand targets and `where` filters all see
487
+ the default (an atomic increment counts from it too), and a written
488
+ value wins; writing `null` clears it back to the default. Only text,
489
+ number and yes/no fields can carry a default; list, link, json and
490
+ file fields join blank. Honest bound: full-text `search` scans stored
491
+ text only, so a record showing a default is not found by searching for
492
+ it. New collections built in dev reach live through the same PROMOTION,
493
+ sealing on the way.
347
494
  - Contention (bookings, slugs, stock, shared edits): when two users can race
348
495
  for the same thing, a permission model can't save you — preconditions do,
349
496
  and they're just arguments on calls you already make. A 409 `conflict` from
@@ -352,14 +499,19 @@ go-live. Everything else is yours.
352
499
  - Uniqueness: `create(data, { key: "slot:2026-07-15T15:00" })` — derive the
353
500
  key from the thing that must be unique; the second writer gets 409, your
354
501
  own retry gets your existing record back (`existing: true`), deleting
355
- frees the key. Keys are unique across the WHOLE collection every
356
- writer, every recipient, under every safety rule (live records only)
357
- so a claim is race-proof even in `direct`/`addressed`. Never
502
+ frees the key. That retry IS the lookupthere is no separate
503
+ get-by-key call: to fetch your own record by key later, issue the
504
+ same `create()` with the same key and read `existing: true` off the
505
+ result. It counts as a write (rate and allowance), so use it to
506
+ re-fetch a claim — never on a render path. Keys are unique across
507
+ the WHOLE collection — every writer, every recipient, under every
508
+ safety rule (live records only) — so a claim is race-proof even in
509
+ `direct`/`addressed`. Never
358
510
  find-then-create — that races. Keys are 1-120 chars of letters,
359
511
  numbers, and `: _ . @ / -` only. A claimed key holds until its record
360
512
  is deleted: if a claim must be PAID to stick (book then pay), expiring
361
- unpaid claims is your app's job — the owner deletes them from their
362
- dashboard, or your own server does with a secret key; there is no cron.
513
+ unpaid claims is the owner's job — they delete them from their
514
+ dashboard (a secret key cannot delete records); there is no cron.
363
515
  - Limited stock (N units anyone can buy): claim units with keyed creates —
364
516
  try `create({...}, { key: "unit:item42:1" })`, on conflict try `:2` … `:N`;
365
517
  all taken = sold out. Race-proof under every safety rule.
@@ -404,7 +556,7 @@ go-live. Everything else is yours.
404
556
  checks that the lock holds and names collections left open while the app
405
557
  sells something) — and the
406
558
  engine refuses customers without it, under all seven rules. (Server
407
- secret keys and the owner's console are exempt by design; link/expand
559
+ secret keys and the owner's dashboard are exempt by design; link/expand
408
560
  silently hide gated records rather than naming them.) Every plan and
409
561
  product carries its own key, `access:<slug of its name>` (plan "pro" →
410
562
  `access:pro`); the paid webhook grants that key, and a FULL refund or a
@@ -413,13 +565,47 @@ go-live. Everything else is yours.
413
565
  Owners also grant and revoke by hand (trials, comps, support). Effective
414
566
  access is the UNION of a customer's live grants. A signed-in customer
415
567
  without access gets `403 entitlement_required`: `err.requires` is the
416
- plan's key (the console shows the plan by name; the key is `access:<slug>`).
568
+ plan's key (the dashboard shows the plan by name; the key is
569
+ `access:<slug>`).
417
570
  A collection unlocked by several plans (OR) still names ONE key — offer
418
571
  your plans by name through checkout, never a key list. Show your upgrade
419
572
  screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
420
573
  (everything they paid for, refunds applied, with the `grants` each purchase
421
574
  carries) and `await g.subscriptions.mine()`.
422
- NO credits, NO usage limits, NO seats — access is yes-or-no by design.
575
+ NO credits or usage limits YET — access is yes-or-no today. Consumable
576
+ credits (buy a pack, spend atomically, a zero floor) are planned, not
577
+ shipped: until this file teaches them, a "100 credit pack" is NOT a
578
+ pattern (the webhook can only SET a number, so a second pack erases the
579
+ first). NO seats, by design — not coming.
580
+ - Two FAMILIES of grant, and the line between them is law. Every grant is
581
+ one key + one reason + a start + maybe an end, never edited (an extension
582
+ is a NEW grant). PURCHASE-TIED (`subscription`, `purchase`): written only
583
+ by Stripe's signed webhook when money arrives — never by the owner's
584
+ hand, never by your app; a subscription grant ends on cancellation or
585
+ lapse, a purchase grant on a FULL refund (partial leaves it); the only
586
+ grants that count as revenue. Revoking one by hand stops the access and
587
+ refunds nothing. BY-HAND (`manual`, `trial`, `promotion`, plus
588
+ `migration` for customers imported from elsewhere): the owner writes them
589
+ from a customer's page — plan or product BY NAME, optional end date, a
590
+ reason — and they end on that date or when revoked; never revenue; a
591
+ refund never touches them. Effective access is the union of both
592
+ families. WHEN A REFUND MEETS ACCESS, THE OWNER DECIDES: the platform
593
+ revokes only on events that plainly mean it (subscription cancelled or
594
+ lapsed; one-off purchase refunded IN FULL). A partial refund leaves
595
+ access; a refund on a subscription invoice touches no access at all
596
+ (grants are revoked by purchase reference, subscriptions by customer).
597
+ A cancel-at-period-end keeps access until the period ends. If the owner
598
+ wants access gone NOW, they cancel the subscription immediately in
599
+ Stripe or revoke by hand on the customer's page — both audited; every
600
+ refund the webhook chose not to act on is written to the activity view.
601
+ Tell your human this when they ask "I refunded them, why can they still
602
+ get in" — never write refund logic in the app. Your app sees purchases (`g.purchases.mine()`, each with its
603
+ `grants`) and the subscription (`g.subscriptions.mine()`) — by-hand
604
+ grants are NOT listed to the app; a gated read simply succeeds. So never
605
+ rebuild the paywall client-side from what you can list: let the server
606
+ refuse and show the upgrade prompt on `entitlement_required`. "Give X a
607
+ free month" is a trial grant on their customer page, never code — ask
608
+ your human.
423
609
  - Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
424
610
  access only — physical goods, shipping, inventory and carts are out of
425
611
  scope, said out loud): plans are for subscriptions; products are for
@@ -471,7 +657,9 @@ go-live. Everything else is yours.
471
657
  sign in first). Render `err.message`; it reads correctly in every case.
472
658
  Branch only on the specifically-named codes (unknown_collection,
473
659
  unknown_product, invalid_shape, html_not_allowed, invalid_publish,
474
- conflict, …) plus the forbidden-means-stop rule.
660
+ conflict, …) plus the forbidden-means-stop rule. If something is stuck
661
+ in a way this file doesn't explain, `npx gemmein feedback` reaches the
662
+ builders — it works from anywhere, with no project and no account.
475
663
  - Draft state reads back as a TOP-LEVEL boolean `record.published` (next to
476
664
  id/updatedAt), not under `.data`. Non-authors only ever receive published
477
665
  records, so you see `false` only on your own drafts (or on everything, as
@@ -485,10 +673,27 @@ go-live. Everything else is yours.
485
673
  `await g.account.delete()` — for every app it APPLIES to (GDPR right to
486
674
  erasure; Apple 5.1.1(v) for apps with account creation). It's the full
487
675
  server-side cascade (sessions, records, files, subscription row) and
488
- irreversible — put a real confirm in front of it. Suspensions, bans, and
489
- owner-side erasure are dashboard actions, not SDK calls.
676
+ irreversible — put a real confirm in front of it. Suspensions, forced
677
+ sign-out everywhere, and owner-side erasure are dashboard actions, not
678
+ SDK calls.
679
+ - The owner's dashboard already handles these — do not build them into the
680
+ app: creating collections and choosing their safety rule; naming plans
681
+ and products and pasting their Stripe Payment Links; the "Unlocked by"
682
+ lock on a collection; suspending, signing out and erasing customers;
683
+ moderation and status flips on other users' records; scoping a secret
684
+ key to collections and actions; verifying the sending and receiving
685
+ domain; usage and logs; the Inbox, where every notify() send is a
686
+ conversation and customer replies land; holding the billing band. None
687
+ of them has an SDK equivalent, so an owner screen you build for one is
688
+ a page that already exists, without the enforcement.
490
689
  - Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
491
- are public and domain-locked.
690
+ are public and domain-locked. A secret key can be SCOPED in the
691
+ dashboard's Keys room to particular collections, and to read-only or
692
+ read-and-update; a call outside that scope answers 403 `scope_denied`.
693
+ Read that error as deliberate — the human narrowed the key on purpose.
694
+ Name the collection and access your code needs and ask them to mint a
695
+ replacement key that includes it — scopes are fixed when a key is
696
+ created; never reach for a broader key than the job needs.
492
697
 
493
698
  ## Return values & shapes (get these exactly right)
494
699
 
@@ -500,6 +705,11 @@ rule. The specifics:
500
705
  synchronously from `g.collection(name)`; if you call that at module
501
706
  load, it can blank your whole app with no browser-console error. Name them
502
707
  right.
708
+ One more refusal, for collection AND field names alike: anything
709
+ JavaScript itself owns — `constructor`, `toString`, `__proto__`,
710
+ `hasOwnProperty` and their kin — is refused the same way a reserved
711
+ server-managed field is. A plain object inherits those names, so a
712
+ seal must never carry one.
503
713
  - The signed-in user: `await g.auth.currentUser()` →
504
714
  `{ authenticated: true, userId, email }` or `{ authenticated: false }`. The
505
715
  id field is **`userId`, not `id`** — `user.id` is `undefined`, and feeding
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.4.6",
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.",
3
+ "version": "0.5.0",
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",