@gemmein/sdk 0.4.7 → 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
@@ -233,6 +238,10 @@ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
233
238
  don't also redirect to the returned `url`, and never build a Stripe URL
234
239
  yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
235
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".
236
245
 
237
246
  ---
238
247
 
@@ -262,6 +271,88 @@ honestly, not hidden.)
262
271
 
263
272
  ---
264
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
+
265
356
  ## Reaffirm — prove your app's boundaries in CI
266
357
 
267
358
  Gemmein enforces the rules **server-side**, so your frontend is never the source
@@ -329,7 +420,10 @@ class GemmeinError extends Error {
329
420
  }
330
421
  ```
331
422
 
332
- 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:
333
427
 
334
428
  | code | meaning | do |
335
429
  |------|---------|-----|
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
@@ -49,9 +49,14 @@ creating no accounts is the correct behavior until the verdict is in.
49
49
  nothing in between — so an app whose CORE loop needs teams, roles
50
50
  or seats DOESN'T FIT
51
51
  - carts, quantities, physical goods, shipping, inventory
52
- - credits, usage metering, seats (paid access is yes-or-no)
53
- - hosted functions or cron: Gemmein runs no code of yours — but read
54
- the server-side note closing this section before counting that a gap
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
55
60
  - custody of money (Stripe holds all funds; your human brings or
56
61
  creates their own Stripe account when the app charges)
57
62
  - multi-seller payouts or revenue splits: every payment lands whole in
@@ -74,8 +79,10 @@ code on your human's OWN host — Vercel, a VPS, anywhere — holding a
74
79
  secret key already reads and updates collections and sends event emails
75
80
  with `notify()`. (A secret key sees PAST the safety rules — every
76
81
  customer's records — which is why it never touches a browser. It cannot
77
- create or delete records, verify a session, or read one customer's
78
- plan: those are the signed-in customer's own calls, or the dashboard's.)
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.)
79
86
  "This app needs a server-side piece" is therefore not a gap by itself:
80
87
  the verdict downgrades when the server piece needs a call a secret key
81
88
  cannot make, or when the app needs compute your human has nowhere to
@@ -345,6 +352,62 @@ contents.
345
352
  cap (a security notice never loses to five order emails); misusing it
346
353
  for campaigns shows in your own audit trail. In `gemmein dev` the send
347
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
348
411
  - Linking records (author on a post, product on an order): store the other
349
412
  record's id in a field (`authorProfileId: profile.id`) — in collections
350
413
  users write (community, shared, direct) the server learns it's a link;
@@ -509,7 +572,40 @@ contents.
509
572
  screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
510
573
  (everything they paid for, refunds applied, with the `grants` each purchase
511
574
  carries) and `await g.subscriptions.mine()`.
512
- 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.
513
609
  - Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
514
610
  access only — physical goods, shipping, inventory and carts are out of
515
611
  scope, said out loud): plans are for subscriptions; products are for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
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",