@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/CHANGELOG.md +147 -0
- package/REFERENCE.md +311 -5
- package/dist/index.cjs +176 -4
- package/dist/index.d.cts +203 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +175 -3
- package/llms.txt +221 -16
- package/migrations/README.md +35 -0
- package/migrations/list-limit-refusal.md +42 -0
- package/package.json +7 -3
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
|
-
|
|
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;
|