@gemmein/sdk 0.4.7 → 0.6.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/dist/index.cjs CHANGED
@@ -1,8 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = void 0;
3
+ exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = exports.CLIENT_INFO = exports.SDK_VERSION = void 0;
4
4
  exports.gemmein = gemmein;
5
5
  exports.gemmeinServer = gemmeinServer;
6
+ /** This build's version — the value `package.json` carries. Kept inline
7
+ * because the package is one source file and this file is imported
8
+ * straight from source by the security suite (no runtime file read, no
9
+ * second module). `scripts/sync-version.mjs` rewrites the literal from
10
+ * package.json before every build (`prebuild`), and a test pins the two
11
+ * equal, so a bump can never ship with a stale header. */
12
+ exports.SDK_VERSION = "0.6.0"; // synced from package.json — do not edit by hand
13
+ /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
14
+ * `x-client-info: gemmein-sdk/<version>`. The server records it on the
15
+ * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
16
+ * misbehaving integration can be attributed to an SDK version from day
17
+ * one. It is a report, not a proof — any caller can set it. */
18
+ exports.CLIENT_INFO = `gemmein-sdk/${exports.SDK_VERSION}`;
6
19
  class GemmeinError extends Error {
7
20
  constructor(input) {
8
21
  super(input.message);
@@ -164,7 +177,15 @@ class AuthClient {
164
177
  await this.config.tokenStore.set(result.token);
165
178
  return result;
166
179
  }
167
- throw new Error("Gemmein auth response did not include a session token");
180
+ // W9.1: the one place the SDK threw a bare Error. Every refusal the SDK
181
+ // raises is a GemmeinError so `err.code` is always there to branch on;
182
+ // status 0 is the SDK's own convention for "no HTTP status applies"
183
+ // (see invalid_collection_name, missing_app_key).
184
+ throw new GemmeinError({
185
+ status: 0,
186
+ code: "invalid_response",
187
+ message: "Gemmein auth response did not include a session token"
188
+ });
168
189
  }
169
190
  async logout() {
170
191
  try {
@@ -764,7 +785,7 @@ class GemmeinServer {
764
785
  }
765
786
  const response = await fetch(new URL("/server/test-session", this.apiUrl), {
766
787
  method: "POST",
767
- headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
788
+ headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
768
789
  body: JSON.stringify({ email }),
769
790
  });
770
791
  if (!response.ok) {
@@ -802,7 +823,7 @@ class GemmeinServer {
802
823
  async notify(personId, input) {
803
824
  const response = await fetch(new URL("/server/notify", this.apiUrl), {
804
825
  method: "POST",
805
- headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
826
+ headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
806
827
  body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
807
828
  });
808
829
  if (!response.ok) {
@@ -816,6 +837,155 @@ class GemmeinServer {
816
837
  }
817
838
  return response.json();
818
839
  }
840
+ /**
841
+ * THE GATE — your compute, our answer. Gemmein runs no code of yours;
842
+ * your own function runs anywhere and asks the only three questions it
843
+ * has: who is this person, what do they hold, change what they hold.
844
+ *
845
+ * const { person, holdings } = await g.verifySession(token);
846
+ * if (!holdings.access.includes("access:pro")) return deny();
847
+ *
848
+ * ONE call per request answers identity AND holdings — don't call it
849
+ * twice, and don't cache the answer past the request. Verify needs no
850
+ * capability on the key (the caller already holds the person's token)
851
+ * and never touches the session: no extension, no last-seen.
852
+ *
853
+ * Refusals (`err.code`): `session_invalid` (no session matches this
854
+ * token — the person signs in again; never reuse tokens across
855
+ * people) · `session_expired` · `session_revoked` (a newer sign-in,
856
+ * a sign-out, or the owner) — all three send the person back to
857
+ * sign-in · `person_suspended` (the owner suspended them; access is
858
+ * off until the owner reactivates them in the dashboard) ·
859
+ * `invalid_body` (token missing, not a string, or over 512 chars).
860
+ */
861
+ async verifySession(token) {
862
+ return this.gate("/server/verify-session", {
863
+ method: "POST",
864
+ body: JSON.stringify({ token }),
865
+ });
866
+ }
867
+ /**
868
+ * W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
869
+ * The envelope, the invoice, the client portal, the booking-winner
870
+ * email — all address someone who has never signed in and so has no
871
+ * id yet. This is the one server call that takes an email:
872
+ *
873
+ * const { person, created } = await g.invitePerson("client@example.com");
874
+ * await g.notify(person.id, { subject: "Your contract", text: "…" });
875
+ *
876
+ * Create-or-fetch, idempotent, case-insensitive: the first call makes
877
+ * the person (`created: true`, HTTP 201), every later call finds them
878
+ * (`created: false`, 200) — one id either way, the address returned
879
+ * lowercased. Their first sign-in lands on this account: records and
880
+ * files you addressed to `person.id` are already theirs. `person.invited`
881
+ * stays true until that sign-in; a suspended person is returned with
882
+ * `suspended: true`, never refused.
883
+ *
884
+ * Needs the key's "Create a person by email before they sign in" box
885
+ * ticked by your human. Refusals: `capability_required` (the box isn't
886
+ * ticked) · `invalid_email` (400 — must look like name@domain) ·
887
+ * `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
888
+ * where to write to raise it; `err.resetAt` says when the window ends).
889
+ */
890
+ async invitePerson(email) {
891
+ return this.gate("/server/people", {
892
+ method: "POST",
893
+ body: JSON.stringify({ email }),
894
+ });
895
+ }
896
+ /**
897
+ * What one of YOUR people holds, by person id — for the paths where no
898
+ * token is in hand (a webhook of your own, a nightly job, an admin
899
+ * screen you built). A person id, NEVER an email address: ids come
900
+ * from `verifySession` or the dashboard.
901
+ *
902
+ * A suspended person is RETURNED, with `suspended: true`, alongside
903
+ * their holdings — your function may need to say so. The gate itself
904
+ * (`verifySession`) refuses them.
905
+ *
906
+ * Refusals: `capability_required` — this key can't look up people by
907
+ * id; ask your human to mint a key with "Look up a person's access by
908
+ * id" ticked, or use `verifySession` with the person's own token ·
909
+ * `person_not_found` (404 — no person with this id in this app and
910
+ * environment; existence is never leaked).
911
+ */
912
+ async holdings(personId) {
913
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
914
+ }
915
+ /**
916
+ * Give a person access by hand — a trial, a promotion, an apology, a
917
+ * migration from your old system:
918
+ *
919
+ * await g.grantAccess(personId, {
920
+ * entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
921
+ * source: "trial",
922
+ * expiresAt: "2026-10-01T00:00:00.000Z",
923
+ * reason: "7-day trial from the onboarding flow",
924
+ * });
925
+ *
926
+ * MANUAL sources only. Purchases and subscriptions come only from
927
+ * Stripe — a key cannot mint paid access, by design. `reason` is up to
928
+ * 200 characters, is never edited afterwards, and is what the owner
929
+ * reads in their logs; write it for them.
930
+ *
931
+ * `sourceId` is minted per call, so two calls make TWO grants (each
932
+ * with its own one reason) — call it once, and keep your own retry
933
+ * key if the caller can retry.
934
+ *
935
+ * Returns the new grant and the holdings AFTER it; the owner's audit
936
+ * row carries before→after and the key's name.
937
+ *
938
+ * Refusals: `capability_required` — this key can't grant access; ask
939
+ * your human to mint a key with "Grant and revoke access" ticked ·
940
+ * `invalid_source` (purchase/subscription refused) ·
941
+ * `invalid_entitlement` / `unknown_plan` (no plan or product by that
942
+ * name — the owner adds it on the Payments page) · `person_not_found`.
943
+ */
944
+ async grantAccess(personId, input) {
945
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
946
+ method: "POST",
947
+ body: JSON.stringify({
948
+ entitlement: input.entitlement,
949
+ ...(input.source ? { source: input.source } : {}),
950
+ ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
951
+ ...(input.reason ? { reason: input.reason } : {}),
952
+ }),
953
+ });
954
+ }
955
+ /**
956
+ * End one grant — the reversibility law in one call:
957
+ *
958
+ * await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
959
+ *
960
+ * A grant ends ONCE: `revokedAt` is set and never edited, so a second
961
+ * call is `409 already_revoked`, not a silent no-op. A key MAY end a
962
+ * grant that a payment created (the same as the owner's "end this
963
+ * access" button) — the payment itself is untouched, and the audit row
964
+ * says so.
965
+ *
966
+ * Returns the revoked grant and the holdings after it.
967
+ *
968
+ * Refusals: `capability_required` (same ticked box as granting) ·
969
+ * `grant_not_found` (404 — not this person's grant, in this app and
970
+ * environment; existence is never leaked) · `already_revoked` (409).
971
+ */
972
+ async revokeAccess(personId, grantId, input = {}) {
973
+ return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
974
+ }
975
+ // One request path for the four gate calls, so every refusal reaches
976
+ // the caller through the SAME typed error the rest of the SDK throws —
977
+ // `err.code` carries the server's own code, `err.message` its sentence
978
+ // (which always names the next action).
979
+ async gate(path, init = {}) {
980
+ const headers = { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO };
981
+ if (init.body)
982
+ headers["content-type"] = "application/json";
983
+ const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
984
+ if (!response.ok) {
985
+ throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
986
+ }
987
+ return response.json();
988
+ }
819
989
  }
820
990
  exports.GemmeinServer = GemmeinServer;
821
991
  class ServerCollectionClient {
@@ -857,6 +1027,7 @@ class ServerCollectionClient {
857
1027
  async request(suffix, init = {}) {
858
1028
  const headers = {
859
1029
  "x-app-key": this.secretKey,
1030
+ "x-client-info": exports.CLIENT_INFO,
860
1031
  };
861
1032
  if (init.body) {
862
1033
  headers["content-type"] = "application/json";
@@ -932,6 +1103,7 @@ async function runtimeHeaders(config, headers) {
932
1103
  return {
933
1104
  ...headers,
934
1105
  "x-app-key": config.appKey,
1106
+ "x-client-info": exports.CLIENT_INFO,
935
1107
  ...(token ? { authorization: `Bearer ${token}` } : {})
936
1108
  };
937
1109
  }
package/dist/index.d.cts CHANGED
@@ -114,6 +114,19 @@ export type AuthSession = {
114
114
  email: string;
115
115
  };
116
116
  };
117
+ /** This build's version — the value `package.json` carries. Kept inline
118
+ * because the package is one source file and this file is imported
119
+ * straight from source by the security suite (no runtime file read, no
120
+ * second module). `scripts/sync-version.mjs` rewrites the literal from
121
+ * package.json before every build (`prebuild`), and a test pins the two
122
+ * equal, so a bump can never ship with a stale header. */
123
+ export declare const SDK_VERSION = "0.6.0";
124
+ /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
125
+ * `x-client-info: gemmein-sdk/<version>`. The server records it on the
126
+ * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
127
+ * misbehaving integration can be attributed to an SDK version from day
128
+ * one. It is a report, not a proof — any caller can set it. */
129
+ export declare const CLIENT_INFO = "gemmein-sdk/0.6.0";
117
130
  export declare class GemmeinError extends Error {
118
131
  readonly status: number;
119
132
  readonly code: string;
@@ -525,6 +538,56 @@ export type GemmeinServerOptions = {
525
538
  secretKey: string;
526
539
  apiUrl?: string;
527
540
  };
541
+ /**
542
+ * Where a grant came from — the KIND only. The gate never returns the
543
+ * source's id, an amount, or anything from Stripe.
544
+ */
545
+ export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay";
546
+ /**
547
+ * The sources a secret key may create by hand. `purchase` and
548
+ * `subscription` are deliberately absent: money-made access comes only
549
+ * from Stripe, and always will.
550
+ */
551
+ export type ManualGrantSource = "manual" | "trial" | "promotion" | "migration";
552
+ export type Grant = {
553
+ id: string;
554
+ /** `access:<slug>` — the plan's or product's own key. */
555
+ entitlement: string;
556
+ source: GrantSource;
557
+ startsAt: string;
558
+ expiresAt: string | null;
559
+ /** Present on the grant returned by `revokeAccess` — set once, never edited. */
560
+ revokedAt?: string | null;
561
+ };
562
+ /**
563
+ * What a person holds RIGHT NOW — never what they pay. `access` is the
564
+ * union of live grants' keys; `grants` lists those live grants (revoked
565
+ * and expired ones are gone, not flagged). `credits` is a reserved slot:
566
+ * it is `null` today because consumable credits are not shipped — don't
567
+ * design around them until this type says otherwise.
568
+ */
569
+ export type Holdings = {
570
+ access: string[];
571
+ grants: Grant[];
572
+ credits: {
573
+ balance: number;
574
+ } | null;
575
+ };
576
+ export type GatePerson = {
577
+ id: string;
578
+ email: string;
579
+ role: string;
580
+ };
581
+ /**
582
+ * W9.2 — the person `invitePerson` returns. `invited` is true until they
583
+ * sign in for the first time; `suspended` is the owner's switch (a
584
+ * suspended person is returned, never refused — `verifySession` is what
585
+ * refuses them).
586
+ */
587
+ export type InvitedPerson = GatePerson & {
588
+ invited: boolean;
589
+ suspended: boolean;
590
+ };
528
591
  export declare class GemmeinServer {
529
592
  private readonly apiUrl;
530
593
  private readonly secretKey;
@@ -582,6 +645,146 @@ export declare class GemmeinServer {
582
645
  replyRail?: boolean;
583
646
  recorded?: boolean;
584
647
  }>;
648
+ /**
649
+ * THE GATE — your compute, our answer. Gemmein runs no code of yours;
650
+ * your own function runs anywhere and asks the only three questions it
651
+ * has: who is this person, what do they hold, change what they hold.
652
+ *
653
+ * const { person, holdings } = await g.verifySession(token);
654
+ * if (!holdings.access.includes("access:pro")) return deny();
655
+ *
656
+ * ONE call per request answers identity AND holdings — don't call it
657
+ * twice, and don't cache the answer past the request. Verify needs no
658
+ * capability on the key (the caller already holds the person's token)
659
+ * and never touches the session: no extension, no last-seen.
660
+ *
661
+ * Refusals (`err.code`): `session_invalid` (no session matches this
662
+ * token — the person signs in again; never reuse tokens across
663
+ * people) · `session_expired` · `session_revoked` (a newer sign-in,
664
+ * a sign-out, or the owner) — all three send the person back to
665
+ * sign-in · `person_suspended` (the owner suspended them; access is
666
+ * off until the owner reactivates them in the dashboard) ·
667
+ * `invalid_body` (token missing, not a string, or over 512 chars).
668
+ */
669
+ verifySession(token: string): Promise<{
670
+ ok: true;
671
+ person: GatePerson;
672
+ holdings: Holdings;
673
+ }>;
674
+ /**
675
+ * W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
676
+ * The envelope, the invoice, the client portal, the booking-winner
677
+ * email — all address someone who has never signed in and so has no
678
+ * id yet. This is the one server call that takes an email:
679
+ *
680
+ * const { person, created } = await g.invitePerson("client@example.com");
681
+ * await g.notify(person.id, { subject: "Your contract", text: "…" });
682
+ *
683
+ * Create-or-fetch, idempotent, case-insensitive: the first call makes
684
+ * the person (`created: true`, HTTP 201), every later call finds them
685
+ * (`created: false`, 200) — one id either way, the address returned
686
+ * lowercased. Their first sign-in lands on this account: records and
687
+ * files you addressed to `person.id` are already theirs. `person.invited`
688
+ * stays true until that sign-in; a suspended person is returned with
689
+ * `suspended: true`, never refused.
690
+ *
691
+ * Needs the key's "Create a person by email before they sign in" box
692
+ * ticked by your human. Refusals: `capability_required` (the box isn't
693
+ * ticked) · `invalid_email` (400 — must look like name@domain) ·
694
+ * `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
695
+ * where to write to raise it; `err.resetAt` says when the window ends).
696
+ */
697
+ invitePerson(email: string): Promise<{
698
+ person: InvitedPerson;
699
+ created: boolean;
700
+ }>;
701
+ /**
702
+ * What one of YOUR people holds, by person id — for the paths where no
703
+ * token is in hand (a webhook of your own, a nightly job, an admin
704
+ * screen you built). A person id, NEVER an email address: ids come
705
+ * from `verifySession` or the dashboard.
706
+ *
707
+ * A suspended person is RETURNED, with `suspended: true`, alongside
708
+ * their holdings — your function may need to say so. The gate itself
709
+ * (`verifySession`) refuses them.
710
+ *
711
+ * Refusals: `capability_required` — this key can't look up people by
712
+ * id; ask your human to mint a key with "Look up a person's access by
713
+ * id" ticked, or use `verifySession` with the person's own token ·
714
+ * `person_not_found` (404 — no person with this id in this app and
715
+ * environment; existence is never leaked).
716
+ */
717
+ holdings(personId: string): Promise<{
718
+ ok: true;
719
+ person: GatePerson & {
720
+ suspended: boolean;
721
+ };
722
+ holdings: Holdings;
723
+ }>;
724
+ /**
725
+ * Give a person access by hand — a trial, a promotion, an apology, a
726
+ * migration from your old system:
727
+ *
728
+ * await g.grantAccess(personId, {
729
+ * entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
730
+ * source: "trial",
731
+ * expiresAt: "2026-10-01T00:00:00.000Z",
732
+ * reason: "7-day trial from the onboarding flow",
733
+ * });
734
+ *
735
+ * MANUAL sources only. Purchases and subscriptions come only from
736
+ * Stripe — a key cannot mint paid access, by design. `reason` is up to
737
+ * 200 characters, is never edited afterwards, and is what the owner
738
+ * reads in their logs; write it for them.
739
+ *
740
+ * `sourceId` is minted per call, so two calls make TWO grants (each
741
+ * with its own one reason) — call it once, and keep your own retry
742
+ * key if the caller can retry.
743
+ *
744
+ * Returns the new grant and the holdings AFTER it; the owner's audit
745
+ * row carries before→after and the key's name.
746
+ *
747
+ * Refusals: `capability_required` — this key can't grant access; ask
748
+ * your human to mint a key with "Grant and revoke access" ticked ·
749
+ * `invalid_source` (purchase/subscription refused) ·
750
+ * `invalid_entitlement` / `unknown_plan` (no plan or product by that
751
+ * name — the owner adds it on the Payments page) · `person_not_found`.
752
+ */
753
+ grantAccess(personId: string, input: {
754
+ entitlement: string;
755
+ source?: ManualGrantSource;
756
+ expiresAt?: string;
757
+ reason?: string;
758
+ }): Promise<{
759
+ ok: true;
760
+ grant: Grant;
761
+ holdings: Holdings;
762
+ }>;
763
+ /**
764
+ * End one grant — the reversibility law in one call:
765
+ *
766
+ * await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
767
+ *
768
+ * A grant ends ONCE: `revokedAt` is set and never edited, so a second
769
+ * call is `409 already_revoked`, not a silent no-op. A key MAY end a
770
+ * grant that a payment created (the same as the owner's "end this
771
+ * access" button) — the payment itself is untouched, and the audit row
772
+ * says so.
773
+ *
774
+ * Returns the revoked grant and the holdings after it.
775
+ *
776
+ * Refusals: `capability_required` (same ticked box as granting) ·
777
+ * `grant_not_found` (404 — not this person's grant, in this app and
778
+ * environment; existence is never leaked) · `already_revoked` (409).
779
+ */
780
+ revokeAccess(personId: string, grantId: string, input?: {
781
+ reason?: string;
782
+ }): Promise<{
783
+ ok: true;
784
+ grant: Grant;
785
+ holdings: Holdings;
786
+ }>;
787
+ private gate;
585
788
  }
586
789
  declare class ServerCollectionClient {
587
790
  private readonly apiUrl;
package/dist/index.d.ts CHANGED
@@ -114,6 +114,19 @@ export type AuthSession = {
114
114
  email: string;
115
115
  };
116
116
  };
117
+ /** This build's version — the value `package.json` carries. Kept inline
118
+ * because the package is one source file and this file is imported
119
+ * straight from source by the security suite (no runtime file read, no
120
+ * second module). `scripts/sync-version.mjs` rewrites the literal from
121
+ * package.json before every build (`prebuild`), and a test pins the two
122
+ * equal, so a bump can never ship with a stale header. */
123
+ export declare const SDK_VERSION = "0.6.0";
124
+ /** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
125
+ * `x-client-info: gemmein-sdk/<version>`. The server records it on the
126
+ * secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
127
+ * misbehaving integration can be attributed to an SDK version from day
128
+ * one. It is a report, not a proof — any caller can set it. */
129
+ export declare const CLIENT_INFO = "gemmein-sdk/0.6.0";
117
130
  export declare class GemmeinError extends Error {
118
131
  readonly status: number;
119
132
  readonly code: string;
@@ -525,6 +538,56 @@ export type GemmeinServerOptions = {
525
538
  secretKey: string;
526
539
  apiUrl?: string;
527
540
  };
541
+ /**
542
+ * Where a grant came from — the KIND only. The gate never returns the
543
+ * source's id, an amount, or anything from Stripe.
544
+ */
545
+ export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay";
546
+ /**
547
+ * The sources a secret key may create by hand. `purchase` and
548
+ * `subscription` are deliberately absent: money-made access comes only
549
+ * from Stripe, and always will.
550
+ */
551
+ export type ManualGrantSource = "manual" | "trial" | "promotion" | "migration";
552
+ export type Grant = {
553
+ id: string;
554
+ /** `access:<slug>` — the plan's or product's own key. */
555
+ entitlement: string;
556
+ source: GrantSource;
557
+ startsAt: string;
558
+ expiresAt: string | null;
559
+ /** Present on the grant returned by `revokeAccess` — set once, never edited. */
560
+ revokedAt?: string | null;
561
+ };
562
+ /**
563
+ * What a person holds RIGHT NOW — never what they pay. `access` is the
564
+ * union of live grants' keys; `grants` lists those live grants (revoked
565
+ * and expired ones are gone, not flagged). `credits` is a reserved slot:
566
+ * it is `null` today because consumable credits are not shipped — don't
567
+ * design around them until this type says otherwise.
568
+ */
569
+ export type Holdings = {
570
+ access: string[];
571
+ grants: Grant[];
572
+ credits: {
573
+ balance: number;
574
+ } | null;
575
+ };
576
+ export type GatePerson = {
577
+ id: string;
578
+ email: string;
579
+ role: string;
580
+ };
581
+ /**
582
+ * W9.2 — the person `invitePerson` returns. `invited` is true until they
583
+ * sign in for the first time; `suspended` is the owner's switch (a
584
+ * suspended person is returned, never refused — `verifySession` is what
585
+ * refuses them).
586
+ */
587
+ export type InvitedPerson = GatePerson & {
588
+ invited: boolean;
589
+ suspended: boolean;
590
+ };
528
591
  export declare class GemmeinServer {
529
592
  private readonly apiUrl;
530
593
  private readonly secretKey;
@@ -582,6 +645,146 @@ export declare class GemmeinServer {
582
645
  replyRail?: boolean;
583
646
  recorded?: boolean;
584
647
  }>;
648
+ /**
649
+ * THE GATE — your compute, our answer. Gemmein runs no code of yours;
650
+ * your own function runs anywhere and asks the only three questions it
651
+ * has: who is this person, what do they hold, change what they hold.
652
+ *
653
+ * const { person, holdings } = await g.verifySession(token);
654
+ * if (!holdings.access.includes("access:pro")) return deny();
655
+ *
656
+ * ONE call per request answers identity AND holdings — don't call it
657
+ * twice, and don't cache the answer past the request. Verify needs no
658
+ * capability on the key (the caller already holds the person's token)
659
+ * and never touches the session: no extension, no last-seen.
660
+ *
661
+ * Refusals (`err.code`): `session_invalid` (no session matches this
662
+ * token — the person signs in again; never reuse tokens across
663
+ * people) · `session_expired` · `session_revoked` (a newer sign-in,
664
+ * a sign-out, or the owner) — all three send the person back to
665
+ * sign-in · `person_suspended` (the owner suspended them; access is
666
+ * off until the owner reactivates them in the dashboard) ·
667
+ * `invalid_body` (token missing, not a string, or over 512 chars).
668
+ */
669
+ verifySession(token: string): Promise<{
670
+ ok: true;
671
+ person: GatePerson;
672
+ holdings: Holdings;
673
+ }>;
674
+ /**
675
+ * W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
676
+ * The envelope, the invoice, the client portal, the booking-winner
677
+ * email — all address someone who has never signed in and so has no
678
+ * id yet. This is the one server call that takes an email:
679
+ *
680
+ * const { person, created } = await g.invitePerson("client@example.com");
681
+ * await g.notify(person.id, { subject: "Your contract", text: "…" });
682
+ *
683
+ * Create-or-fetch, idempotent, case-insensitive: the first call makes
684
+ * the person (`created: true`, HTTP 201), every later call finds them
685
+ * (`created: false`, 200) — one id either way, the address returned
686
+ * lowercased. Their first sign-in lands on this account: records and
687
+ * files you addressed to `person.id` are already theirs. `person.invited`
688
+ * stays true until that sign-in; a suspended person is returned with
689
+ * `suspended: true`, never refused.
690
+ *
691
+ * Needs the key's "Create a person by email before they sign in" box
692
+ * ticked by your human. Refusals: `capability_required` (the box isn't
693
+ * ticked) · `invalid_email` (400 — must look like name@domain) ·
694
+ * `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
695
+ * where to write to raise it; `err.resetAt` says when the window ends).
696
+ */
697
+ invitePerson(email: string): Promise<{
698
+ person: InvitedPerson;
699
+ created: boolean;
700
+ }>;
701
+ /**
702
+ * What one of YOUR people holds, by person id — for the paths where no
703
+ * token is in hand (a webhook of your own, a nightly job, an admin
704
+ * screen you built). A person id, NEVER an email address: ids come
705
+ * from `verifySession` or the dashboard.
706
+ *
707
+ * A suspended person is RETURNED, with `suspended: true`, alongside
708
+ * their holdings — your function may need to say so. The gate itself
709
+ * (`verifySession`) refuses them.
710
+ *
711
+ * Refusals: `capability_required` — this key can't look up people by
712
+ * id; ask your human to mint a key with "Look up a person's access by
713
+ * id" ticked, or use `verifySession` with the person's own token ·
714
+ * `person_not_found` (404 — no person with this id in this app and
715
+ * environment; existence is never leaked).
716
+ */
717
+ holdings(personId: string): Promise<{
718
+ ok: true;
719
+ person: GatePerson & {
720
+ suspended: boolean;
721
+ };
722
+ holdings: Holdings;
723
+ }>;
724
+ /**
725
+ * Give a person access by hand — a trial, a promotion, an apology, a
726
+ * migration from your old system:
727
+ *
728
+ * await g.grantAccess(personId, {
729
+ * entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
730
+ * source: "trial",
731
+ * expiresAt: "2026-10-01T00:00:00.000Z",
732
+ * reason: "7-day trial from the onboarding flow",
733
+ * });
734
+ *
735
+ * MANUAL sources only. Purchases and subscriptions come only from
736
+ * Stripe — a key cannot mint paid access, by design. `reason` is up to
737
+ * 200 characters, is never edited afterwards, and is what the owner
738
+ * reads in their logs; write it for them.
739
+ *
740
+ * `sourceId` is minted per call, so two calls make TWO grants (each
741
+ * with its own one reason) — call it once, and keep your own retry
742
+ * key if the caller can retry.
743
+ *
744
+ * Returns the new grant and the holdings AFTER it; the owner's audit
745
+ * row carries before→after and the key's name.
746
+ *
747
+ * Refusals: `capability_required` — this key can't grant access; ask
748
+ * your human to mint a key with "Grant and revoke access" ticked ·
749
+ * `invalid_source` (purchase/subscription refused) ·
750
+ * `invalid_entitlement` / `unknown_plan` (no plan or product by that
751
+ * name — the owner adds it on the Payments page) · `person_not_found`.
752
+ */
753
+ grantAccess(personId: string, input: {
754
+ entitlement: string;
755
+ source?: ManualGrantSource;
756
+ expiresAt?: string;
757
+ reason?: string;
758
+ }): Promise<{
759
+ ok: true;
760
+ grant: Grant;
761
+ holdings: Holdings;
762
+ }>;
763
+ /**
764
+ * End one grant — the reversibility law in one call:
765
+ *
766
+ * await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
767
+ *
768
+ * A grant ends ONCE: `revokedAt` is set and never edited, so a second
769
+ * call is `409 already_revoked`, not a silent no-op. A key MAY end a
770
+ * grant that a payment created (the same as the owner's "end this
771
+ * access" button) — the payment itself is untouched, and the audit row
772
+ * says so.
773
+ *
774
+ * Returns the revoked grant and the holdings after it.
775
+ *
776
+ * Refusals: `capability_required` (same ticked box as granting) ·
777
+ * `grant_not_found` (404 — not this person's grant, in this app and
778
+ * environment; existence is never leaked) · `already_revoked` (409).
779
+ */
780
+ revokeAccess(personId: string, grantId: string, input?: {
781
+ reason?: string;
782
+ }): Promise<{
783
+ ok: true;
784
+ grant: Grant;
785
+ holdings: Holdings;
786
+ }>;
787
+ private gate;
585
788
  }
586
789
  declare class ServerCollectionClient {
587
790
  private readonly apiUrl;