@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.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/** This build's version — the value `package.json` carries. Kept inline
|
|
2
|
+
* because the package is one source file and this file is imported
|
|
3
|
+
* straight from source by the security suite (no runtime file read, no
|
|
4
|
+
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
5
|
+
* package.json before every build (`prebuild`), and a test pins the two
|
|
6
|
+
* equal, so a bump can never ship with a stale header. */
|
|
7
|
+
export const SDK_VERSION = "0.6.0"; // synced from package.json — do not edit by hand
|
|
8
|
+
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
9
|
+
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
10
|
+
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
11
|
+
* misbehaving integration can be attributed to an SDK version from day
|
|
12
|
+
* one. It is a report, not a proof — any caller can set it. */
|
|
13
|
+
export const CLIENT_INFO = `gemmein-sdk/${SDK_VERSION}`;
|
|
1
14
|
export class GemmeinError extends Error {
|
|
2
15
|
constructor(input) {
|
|
3
16
|
super(input.message);
|
|
@@ -155,7 +168,15 @@ export class AuthClient {
|
|
|
155
168
|
await this.config.tokenStore.set(result.token);
|
|
156
169
|
return result;
|
|
157
170
|
}
|
|
158
|
-
|
|
171
|
+
// W9.1: the one place the SDK threw a bare Error. Every refusal the SDK
|
|
172
|
+
// raises is a GemmeinError so `err.code` is always there to branch on;
|
|
173
|
+
// status 0 is the SDK's own convention for "no HTTP status applies"
|
|
174
|
+
// (see invalid_collection_name, missing_app_key).
|
|
175
|
+
throw new GemmeinError({
|
|
176
|
+
status: 0,
|
|
177
|
+
code: "invalid_response",
|
|
178
|
+
message: "Gemmein auth response did not include a session token"
|
|
179
|
+
});
|
|
159
180
|
}
|
|
160
181
|
async logout() {
|
|
161
182
|
try {
|
|
@@ -747,7 +768,7 @@ export class GemmeinServer {
|
|
|
747
768
|
}
|
|
748
769
|
const response = await fetch(new URL("/server/test-session", this.apiUrl), {
|
|
749
770
|
method: "POST",
|
|
750
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
771
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO, "content-type": "application/json" },
|
|
751
772
|
body: JSON.stringify({ email }),
|
|
752
773
|
});
|
|
753
774
|
if (!response.ok) {
|
|
@@ -785,7 +806,7 @@ export class GemmeinServer {
|
|
|
785
806
|
async notify(personId, input) {
|
|
786
807
|
const response = await fetch(new URL("/server/notify", this.apiUrl), {
|
|
787
808
|
method: "POST",
|
|
788
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
809
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO, "content-type": "application/json" },
|
|
789
810
|
body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
|
|
790
811
|
});
|
|
791
812
|
if (!response.ok) {
|
|
@@ -799,6 +820,155 @@ export class GemmeinServer {
|
|
|
799
820
|
}
|
|
800
821
|
return response.json();
|
|
801
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* THE GATE — your compute, our answer. Gemmein runs no code of yours;
|
|
825
|
+
* your own function runs anywhere and asks the only three questions it
|
|
826
|
+
* has: who is this person, what do they hold, change what they hold.
|
|
827
|
+
*
|
|
828
|
+
* const { person, holdings } = await g.verifySession(token);
|
|
829
|
+
* if (!holdings.access.includes("access:pro")) return deny();
|
|
830
|
+
*
|
|
831
|
+
* ONE call per request answers identity AND holdings — don't call it
|
|
832
|
+
* twice, and don't cache the answer past the request. Verify needs no
|
|
833
|
+
* capability on the key (the caller already holds the person's token)
|
|
834
|
+
* and never touches the session: no extension, no last-seen.
|
|
835
|
+
*
|
|
836
|
+
* Refusals (`err.code`): `session_invalid` (no session matches this
|
|
837
|
+
* token — the person signs in again; never reuse tokens across
|
|
838
|
+
* people) · `session_expired` · `session_revoked` (a newer sign-in,
|
|
839
|
+
* a sign-out, or the owner) — all three send the person back to
|
|
840
|
+
* sign-in · `person_suspended` (the owner suspended them; access is
|
|
841
|
+
* off until the owner reactivates them in the dashboard) ·
|
|
842
|
+
* `invalid_body` (token missing, not a string, or over 512 chars).
|
|
843
|
+
*/
|
|
844
|
+
async verifySession(token) {
|
|
845
|
+
return this.gate("/server/verify-session", {
|
|
846
|
+
method: "POST",
|
|
847
|
+
body: JSON.stringify({ token }),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
|
|
852
|
+
* The envelope, the invoice, the client portal, the booking-winner
|
|
853
|
+
* email — all address someone who has never signed in and so has no
|
|
854
|
+
* id yet. This is the one server call that takes an email:
|
|
855
|
+
*
|
|
856
|
+
* const { person, created } = await g.invitePerson("client@example.com");
|
|
857
|
+
* await g.notify(person.id, { subject: "Your contract", text: "…" });
|
|
858
|
+
*
|
|
859
|
+
* Create-or-fetch, idempotent, case-insensitive: the first call makes
|
|
860
|
+
* the person (`created: true`, HTTP 201), every later call finds them
|
|
861
|
+
* (`created: false`, 200) — one id either way, the address returned
|
|
862
|
+
* lowercased. Their first sign-in lands on this account: records and
|
|
863
|
+
* files you addressed to `person.id` are already theirs. `person.invited`
|
|
864
|
+
* stays true until that sign-in; a suspended person is returned with
|
|
865
|
+
* `suspended: true`, never refused.
|
|
866
|
+
*
|
|
867
|
+
* Needs the key's "Create a person by email before they sign in" box
|
|
868
|
+
* ticked by your human. Refusals: `capability_required` (the box isn't
|
|
869
|
+
* ticked) · `invalid_email` (400 — must look like name@domain) ·
|
|
870
|
+
* `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
|
|
871
|
+
* where to write to raise it; `err.resetAt` says when the window ends).
|
|
872
|
+
*/
|
|
873
|
+
async invitePerson(email) {
|
|
874
|
+
return this.gate("/server/people", {
|
|
875
|
+
method: "POST",
|
|
876
|
+
body: JSON.stringify({ email }),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* What one of YOUR people holds, by person id — for the paths where no
|
|
881
|
+
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
882
|
+
* screen you built). A person id, NEVER an email address: ids come
|
|
883
|
+
* from `verifySession` or the dashboard.
|
|
884
|
+
*
|
|
885
|
+
* A suspended person is RETURNED, with `suspended: true`, alongside
|
|
886
|
+
* their holdings — your function may need to say so. The gate itself
|
|
887
|
+
* (`verifySession`) refuses them.
|
|
888
|
+
*
|
|
889
|
+
* Refusals: `capability_required` — this key can't look up people by
|
|
890
|
+
* id; ask your human to mint a key with "Look up a person's access by
|
|
891
|
+
* id" ticked, or use `verifySession` with the person's own token ·
|
|
892
|
+
* `person_not_found` (404 — no person with this id in this app and
|
|
893
|
+
* environment; existence is never leaked).
|
|
894
|
+
*/
|
|
895
|
+
async holdings(personId) {
|
|
896
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Give a person access by hand — a trial, a promotion, an apology, a
|
|
900
|
+
* migration from your old system:
|
|
901
|
+
*
|
|
902
|
+
* await g.grantAccess(personId, {
|
|
903
|
+
* entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
|
|
904
|
+
* source: "trial",
|
|
905
|
+
* expiresAt: "2026-10-01T00:00:00.000Z",
|
|
906
|
+
* reason: "7-day trial from the onboarding flow",
|
|
907
|
+
* });
|
|
908
|
+
*
|
|
909
|
+
* MANUAL sources only. Purchases and subscriptions come only from
|
|
910
|
+
* Stripe — a key cannot mint paid access, by design. `reason` is up to
|
|
911
|
+
* 200 characters, is never edited afterwards, and is what the owner
|
|
912
|
+
* reads in their logs; write it for them.
|
|
913
|
+
*
|
|
914
|
+
* `sourceId` is minted per call, so two calls make TWO grants (each
|
|
915
|
+
* with its own one reason) — call it once, and keep your own retry
|
|
916
|
+
* key if the caller can retry.
|
|
917
|
+
*
|
|
918
|
+
* Returns the new grant and the holdings AFTER it; the owner's audit
|
|
919
|
+
* row carries before→after and the key's name.
|
|
920
|
+
*
|
|
921
|
+
* Refusals: `capability_required` — this key can't grant access; ask
|
|
922
|
+
* your human to mint a key with "Grant and revoke access" ticked ·
|
|
923
|
+
* `invalid_source` (purchase/subscription refused) ·
|
|
924
|
+
* `invalid_entitlement` / `unknown_plan` (no plan or product by that
|
|
925
|
+
* name — the owner adds it on the Payments page) · `person_not_found`.
|
|
926
|
+
*/
|
|
927
|
+
async grantAccess(personId, input) {
|
|
928
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
|
|
929
|
+
method: "POST",
|
|
930
|
+
body: JSON.stringify({
|
|
931
|
+
entitlement: input.entitlement,
|
|
932
|
+
...(input.source ? { source: input.source } : {}),
|
|
933
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
934
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
935
|
+
}),
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* End one grant — the reversibility law in one call:
|
|
940
|
+
*
|
|
941
|
+
* await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
|
|
942
|
+
*
|
|
943
|
+
* A grant ends ONCE: `revokedAt` is set and never edited, so a second
|
|
944
|
+
* call is `409 already_revoked`, not a silent no-op. A key MAY end a
|
|
945
|
+
* grant that a payment created (the same as the owner's "end this
|
|
946
|
+
* access" button) — the payment itself is untouched, and the audit row
|
|
947
|
+
* says so.
|
|
948
|
+
*
|
|
949
|
+
* Returns the revoked grant and the holdings after it.
|
|
950
|
+
*
|
|
951
|
+
* Refusals: `capability_required` (same ticked box as granting) ·
|
|
952
|
+
* `grant_not_found` (404 — not this person's grant, in this app and
|
|
953
|
+
* environment; existence is never leaked) · `already_revoked` (409).
|
|
954
|
+
*/
|
|
955
|
+
async revokeAccess(personId, grantId, input = {}) {
|
|
956
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
|
|
957
|
+
}
|
|
958
|
+
// One request path for the four gate calls, so every refusal reaches
|
|
959
|
+
// the caller through the SAME typed error the rest of the SDK throws —
|
|
960
|
+
// `err.code` carries the server's own code, `err.message` its sentence
|
|
961
|
+
// (which always names the next action).
|
|
962
|
+
async gate(path, init = {}) {
|
|
963
|
+
const headers = { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO };
|
|
964
|
+
if (init.body)
|
|
965
|
+
headers["content-type"] = "application/json";
|
|
966
|
+
const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
|
|
967
|
+
if (!response.ok) {
|
|
968
|
+
throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
|
|
969
|
+
}
|
|
970
|
+
return response.json();
|
|
971
|
+
}
|
|
802
972
|
}
|
|
803
973
|
class ServerCollectionClient {
|
|
804
974
|
constructor(apiUrl, secretKey, name) {
|
|
@@ -839,6 +1009,7 @@ class ServerCollectionClient {
|
|
|
839
1009
|
async request(suffix, init = {}) {
|
|
840
1010
|
const headers = {
|
|
841
1011
|
"x-app-key": this.secretKey,
|
|
1012
|
+
"x-client-info": CLIENT_INFO,
|
|
842
1013
|
};
|
|
843
1014
|
if (init.body) {
|
|
844
1015
|
headers["content-type"] = "application/json";
|
|
@@ -914,6 +1085,7 @@ async function runtimeHeaders(config, headers) {
|
|
|
914
1085
|
return {
|
|
915
1086
|
...headers,
|
|
916
1087
|
"x-app-key": config.appKey,
|
|
1088
|
+
"x-client-info": CLIENT_INFO,
|
|
917
1089
|
...(token ? { authorization: `Bearer ${token}` } : {})
|
|
918
1090
|
};
|
|
919
1091
|
}
|
package/llms.txt
CHANGED
|
@@ -37,8 +37,9 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
37
37
|
2. Map every requirement against this file. In scope: web apps
|
|
38
38
|
(TypeScript/JavaScript), passwordless email sign-in, records under seven
|
|
39
39
|
safety rules (private, shared, admin_write, public_read, community,
|
|
40
|
-
addressed, direct),
|
|
41
|
-
|
|
40
|
+
addressed, direct), subscriptions and one-off digital products (Stripe
|
|
41
|
+
built in; any provider that signs its webhooks drives access through a
|
|
42
|
+
relay), image uploads and paid file delivery, entitlement-gated
|
|
42
43
|
access, race-safe bookings/stock/counters, account-deletion cascade.
|
|
43
44
|
Out of scope — each of these downgrades the verdict and must never be
|
|
44
45
|
silently approximated:
|
|
@@ -49,13 +50,23 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
49
50
|
nothing in between — so an app whose CORE loop needs teams, roles
|
|
50
51
|
or seats DOESN'T FIT
|
|
51
52
|
- carts, quantities, physical goods, shipping, inventory
|
|
52
|
-
- credits
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
53
|
+
- credits or usage metering TODAY (paid access is yes-or-no; consumable
|
|
54
|
+
credits are planned, not shipped — don't design around them yet, and
|
|
55
|
+
never sell a "credit pack" until this file says the ledger exists)
|
|
56
|
+
- seats (owner-run: not coming)
|
|
57
|
+
- hosted functions or cron: Gemmein runs no code of yours — but your
|
|
58
|
+
own function anywhere asks Gemmein who a person is and what they
|
|
59
|
+
hold, and a relay runs Gemmein's OWN verbs (write a record,
|
|
60
|
+
grant access, email the person, call your URL) on a webhook, a
|
|
61
|
+
schedule or a record change with no code; read the server-side
|
|
62
|
+
note closing this section before counting that a gap
|
|
63
|
+
- custody of money (Gemmein never holds funds; your human's own payment
|
|
64
|
+
provider does. Stripe is the built-in path because it is what Gemmein
|
|
65
|
+
itself uses; GoCardless, Paddle, Lemon Squeezy or any provider that
|
|
66
|
+
signs its webhooks drives access through a relay — the founder keeps
|
|
67
|
+
their provider)
|
|
57
68
|
- multi-seller payouts or revenue splits: every payment lands whole in
|
|
58
|
-
your human's ONE
|
|
69
|
+
your human's ONE provider account. A marketplace where sellers get
|
|
59
70
|
paid out means your human pays sellers themselves, outside Gemmein —
|
|
60
71
|
say that out loud in the verdict
|
|
61
72
|
3. Deliver one of three verdicts to your human BEFORE installing anything:
|
|
@@ -74,13 +85,17 @@ code on your human's OWN host — Vercel, a VPS, anywhere — holding a
|
|
|
74
85
|
secret key already reads and updates collections and sends event emails
|
|
75
86
|
with `notify()`. (A secret key sees PAST the safety rules — every
|
|
76
87
|
customer's records — which is why it never touches a browser. It cannot
|
|
77
|
-
create or delete records
|
|
78
|
-
|
|
88
|
+
create or delete records: those are the signed-in customer's own calls,
|
|
89
|
+
or the dashboard's. It CAN verify a session token and read what that
|
|
90
|
+
person holds — the gate — and, when your human ticks the box on the key,
|
|
91
|
+
look a person up by id and grant or revoke access by hand.)
|
|
79
92
|
"This app needs a server-side piece" is therefore not a gap by itself:
|
|
80
93
|
the verdict downgrades when the server piece needs a call a secret key
|
|
81
94
|
cannot make, or when the app needs compute your human has nowhere to
|
|
82
95
|
run — a cron schedule or an always-on worker with no host of its own —
|
|
83
|
-
because Gemmein never runs your code.
|
|
96
|
+
because Gemmein never runs your code. A schedule whose work is one of
|
|
97
|
+
the relay verbs (write a record, call your URL) is not that gap:
|
|
98
|
+
see Relays.
|
|
84
99
|
|
|
85
100
|
## Getting started (AI-run, human-answered)
|
|
86
101
|
|
|
@@ -193,8 +208,10 @@ contents.
|
|
|
193
208
|
nothing in between. No team seats, no staff accounts, no workspaces, on
|
|
194
209
|
purpose: one person holds the dashboard, and everyone else is a customer.
|
|
195
210
|
- The platform holds identity, data, and subscription state. It never holds,
|
|
196
|
-
moves, or processes money — Stripe
|
|
197
|
-
|
|
211
|
+
moves, or processes money — Stripe is built in end-to-end because it is
|
|
212
|
+
what Gemmein itself uses, and any provider that signs its webhooks drives
|
|
213
|
+
access the same way through a relay; Gemmein only receives the webhooks
|
|
214
|
+
and records who holds what. App
|
|
198
215
|
compute/hosting
|
|
199
216
|
belongs anywhere (Vercel, Cloudflare, any static host). There are no hosted
|
|
200
217
|
functions — server-side needs use a secret key from any server.
|
|
@@ -288,7 +305,8 @@ contents.
|
|
|
288
305
|
read-only: never store your own userId/role/owner fields inside `data`.
|
|
289
306
|
`.list()` returns `{ records, hasMore }` (an object, not an array) and
|
|
290
307
|
accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor,
|
|
291
|
-
since }`.
|
|
308
|
+
since }`. limit is 25 by default and 100 at most; a larger ask is refused
|
|
309
|
+
with invalid_limit — page with cursor.
|
|
292
310
|
- Live data (dashboards, feeds, inboxes): nothing is pushed — BY DESIGN
|
|
293
311
|
(pushed data is where other platforms leak; every Gemmein read passes the
|
|
294
312
|
permission check). Instead, polling is built in and cheap:
|
|
@@ -345,6 +363,154 @@ contents.
|
|
|
345
363
|
cap (a security notice never loses to five order emails); misusing it
|
|
346
364
|
for campaigns shows in your own audit trail. In `gemmein dev` the send
|
|
347
365
|
prints in the terminal (NOTIFY · …) instead of mailing.
|
|
366
|
+
- Your compute, our gate: when code of yours must run elsewhere (a model
|
|
367
|
+
call, a PDF render, a nightly job), Gemmein hosts none of it — your own
|
|
368
|
+
function runs anywhere and asks Gemmein its only three questions: who is
|
|
369
|
+
this person, what do they hold, change what they hold.
|
|
370
|
+
|
|
371
|
+
import { gemmeinServer } from "@gemmein/sdk";
|
|
372
|
+
const g = gemmeinServer(process.env.GEMMEIN_SECRET_KEY);
|
|
373
|
+
const { person, holdings } = await g.verifySession(token);
|
|
374
|
+
if (!holdings.access.includes("access:pro")) return deny();
|
|
375
|
+
await g.holdings(personId); // no token in hand
|
|
376
|
+
await g.grantAccess(personId, { entitlement: "access:pro",
|
|
377
|
+
source: "trial", expiresAt, reason: "7-day trial" });
|
|
378
|
+
await g.revokeAccess(personId, grantId, { reason: "trial ended" });
|
|
379
|
+
const { person, created } = await g.invitePerson("client@example.com");
|
|
380
|
+
|
|
381
|
+
A secret key with the capability may create a person by email BEFORE
|
|
382
|
+
they sign in — the envelope, the invoice, the client portal, the
|
|
383
|
+
booking-winner email: create the person, hand them the file, address
|
|
384
|
+
the record, notify them; their first sign-in lands on a ready account.
|
|
385
|
+
`invitePerson(email)` is create-or-fetch (created: true the first time,
|
|
386
|
+
false after; one id, case-insensitive) and is the ONE server call that
|
|
387
|
+
takes an email; everything else takes the person id it returns. The
|
|
388
|
+
owner sees them as "invited · hasn't signed in yet" until they sign in.
|
|
389
|
+
ONE verifySession per request answers identity AND holdings — don't
|
|
390
|
+
call it twice, don't cache the answer past the request. `holdings` is
|
|
391
|
+
what the person holds NOW: `access` (keys like "access:pro"), `grants`
|
|
392
|
+
(each with its source KIND only — subscription | purchase | manual |
|
|
393
|
+
trial | promotion | migration | relay — plus start and expiry), and `credits`,
|
|
394
|
+
a reserved slot that is always `null` today (consumable credits are NOT
|
|
395
|
+
shipped; don't design around them). Never subscription status, amounts
|
|
396
|
+
or Stripe ids: gate on what a person HOLDS, never on billing. Person
|
|
397
|
+
id, NEVER an email address. verifySession needs nothing extra; looking
|
|
398
|
+
someone up by id and granting are new power, so they sit behind per-key
|
|
399
|
+
checkboxes the HUMAN ticks when minting the key — ask your human to
|
|
400
|
+
tick "Look up a person's access by id" / "Grant and revoke access" /
|
|
401
|
+
"Create a person by email before they sign in" (invitePerson). A
|
|
402
|
+
key grants MANUAL access only (manual | trial | promotion | migration):
|
|
403
|
+
purchases and subscriptions come from the built-in Stripe path, and
|
|
404
|
+
another provider's payment grants through a relay (source `relay`). Every /server/*
|
|
405
|
+
call a resolved secret key makes, ok or refused, lands in that key's
|
|
406
|
+
usage ledger on the owner's Keys page (a rejected or publishable key
|
|
407
|
+
can't be attributed, so it reaches only the request log); refusals also
|
|
408
|
+
write one activity row per key, route and code each hour. In `gemmein
|
|
409
|
+
dev` the local key already holds both capabilities, and the usage read
|
|
410
|
+
is cloud-only — the local runtime never mounts the console. The
|
|
411
|
+
refusals, and what to do:
|
|
412
|
+
session_invalid no session matches this token — the person must
|
|
413
|
+
sign in again; never reuse tokens across people
|
|
414
|
+
session_expired the session ended — back to sign-in
|
|
415
|
+
session_revoked newer sign-in, sign-out, or the owner — sign-in
|
|
416
|
+
person_suspended the owner suspended them — access is off until
|
|
417
|
+
the owner reactivates them in the dashboard
|
|
418
|
+
person_not_found no person with this id in this app and env —
|
|
419
|
+
ids come from verifySession or the dashboard
|
|
420
|
+
capability_required the box isn't ticked on this key — ask your
|
|
421
|
+
human to mint one with it ticked
|
|
422
|
+
unknown_plan no plan or product by that name — the owner
|
|
423
|
+
adds it on the Payments page
|
|
424
|
+
grant_not_found not this person's grant here — re-read holdings
|
|
425
|
+
already_revoked a grant ends once — it is already ended
|
|
426
|
+
invalid_source purchase/subscription asked for by hand — use
|
|
427
|
+
manual | trial | promotion | migration instead
|
|
428
|
+
invalid_id a prototype name (__proto__, constructor) was
|
|
429
|
+
sent as a person or grant id — ids come from
|
|
430
|
+
verifySession or the dashboard, never a name
|
|
431
|
+
invalid_body one malformed field (token, expiresAt, reason)
|
|
432
|
+
— the message names which one and what it needs
|
|
433
|
+
invalid_email invitePerson: must look like name@domain
|
|
434
|
+
invite_capped invitePerson: 500 invite calls per app per day (a
|
|
435
|
+
fetch of an existing person counts) — the message
|
|
436
|
+
says where to write; resetAt says when
|
|
437
|
+
- Relays — route, map, authorise, never compute.
|
|
438
|
+
What it is: a rule in one JSON file — when this happens, Gemmein does
|
|
439
|
+
these things, keeps a row per event, retries, and shows every run in the
|
|
440
|
+
owner's dashboard with a replay button. A relay takes a verified signal
|
|
441
|
+
and closes a circuit: GoCardless says paid, the relay opens access,
|
|
442
|
+
writes the receipt, tells the person, passes the signal to your URL.
|
|
443
|
+
Stripe stays built in; any provider that signs its webhooks drives
|
|
444
|
+
access the same way.
|
|
445
|
+
Does: write a record · grant access · revoke access · email the person
|
|
446
|
+
the event is about · call your URL (a signed notice to a server YOU run).
|
|
447
|
+
Does not: run your code; call OpenAI or any third API for you (call_url
|
|
448
|
+
carries Gemmein's body and signature, never your key or a request you
|
|
449
|
+
shape); attach a person to a schedule (no "email everyone due tomorrow"
|
|
450
|
+
yet); transform or compute data; act on more than one person per event
|
|
451
|
+
(a list is a broadcast); grant credits (planned, not shipped).
|
|
452
|
+
Needs something else when: the work THINKS — a model call, a score, a
|
|
453
|
+
transform, a third API — put that in your own function behind call_url;
|
|
454
|
+
it receives a verified event with the person already resolved. The AI
|
|
455
|
+
route (planned) is where Gemmein will hold a provider key and call for you.
|
|
456
|
+
Use a relay when: a provider other than Stripe signs a webhook that
|
|
457
|
+
should change access or write a record (GoCardless, Paddle, Lemon
|
|
458
|
+
Squeezy, a form or signature tool); a record change should tell the
|
|
459
|
+
person or open access; something should run on a clock and write a
|
|
460
|
+
record or ping your URL.
|
|
461
|
+
Not when: the work computes or reshapes data (that is your function);
|
|
462
|
+
one event should email many people (a broadcast, planned); a schedule
|
|
463
|
+
must address a person (not yet); you already use Stripe for the money —
|
|
464
|
+
the built-in Stripe path already grants access and writes receipts, and
|
|
465
|
+
a relay adds nothing there.
|
|
466
|
+
The GoCardless case end to end — a payment confirms, the payer gets Pro,
|
|
467
|
+
an email, and your function hears about it. The file is
|
|
468
|
+
`gemmein/relays/<name>.json`; the owner's dashboard shows it read-only
|
|
469
|
+
(Setup → Relays) with its receiver URL, its secrets (shown once), every
|
|
470
|
+
event with each action's result, and a replay button:
|
|
471
|
+
|
|
472
|
+
{
|
|
473
|
+
"name": "gocardless-paid",
|
|
474
|
+
"trigger": {
|
|
475
|
+
"kind": "receiver",
|
|
476
|
+
"verify": { "scheme": "hmac_sha256_header", "header": "Webhook-Signature" },
|
|
477
|
+
"map": {
|
|
478
|
+
"event_id": "events.0.id",
|
|
479
|
+
"event_type": "events.0.action",
|
|
480
|
+
"person_email": "events.0.details.customer_email"
|
|
481
|
+
},
|
|
482
|
+
"when": { "event_type": "confirmed" }
|
|
483
|
+
},
|
|
484
|
+
"actions": [
|
|
485
|
+
{ "type": "grant_access", "entitlement": "Pro", "reason": "GoCardless payment {{mapped.event_id}}" },
|
|
486
|
+
{ "type": "email_person", "subject": "Your payment is confirmed", "text": "Thanks. Pro is open on your account." },
|
|
487
|
+
{ "type": "call_url", "url": "https://hooks.example.com/paid" }
|
|
488
|
+
]
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
Triggers (exactly one per relay):
|
|
492
|
+
- receiver — a provider's signed webhook at `POST /hooks/<appId>/<name>`;
|
|
493
|
+
`verify` names the scheme (hmac_sha256_header | stripe | svix |
|
|
494
|
+
shared_token), `map` names dotted paths into the body (`event_id`
|
|
495
|
+
dedupes, `person_email` resolves the person, the rest ride as
|
|
496
|
+
`{{mapped.<name>}}`), `when` is an exact match on mapped fields.
|
|
497
|
+
- schedule — `every` 15m | 30m | 1h | 6h | 12h | 1d, `at: "HH:MM"` UTC
|
|
498
|
+
with 1d; one tick per period; no person, so person actions are refused.
|
|
499
|
+
- data_change — `collection`, `on` ⊆ created | updated | deleted, optional
|
|
500
|
+
`where` on the record's data; never for the runner's own writes.
|
|
501
|
+
Authorise on fields the signed-in user cannot set, or from a receiver.
|
|
502
|
+
Actions (1 to 10, run in order, each idempotent per event):
|
|
503
|
+
- write_record { collection, data, to?: "person" } — keyed per event.
|
|
504
|
+
- grant_access { entitlement, expiresAt?, reason? } — source `relay`, the
|
|
505
|
+
seventh grant source; "granted by relay <name>" on the person's page.
|
|
506
|
+
- revoke_access { entitlement } — ends every live grant of it.
|
|
507
|
+
- email_person { subject, text, kind? } — rides notify's caps.
|
|
508
|
+
- call_url { url } — https, signed POST (`X-Gemmein-Signature`), 10 s; the
|
|
509
|
+
same `id` on every retry and replay — your URL deduplicates on it.
|
|
510
|
+
Full depth — every verify scheme, the map and when grammar, the call_url
|
|
511
|
+
contract and signature, every refusal code, the limits and both rails:
|
|
512
|
+
https://docs.gemmein.com/relays (the same chapter is REFERENCE.md
|
|
513
|
+
"## Relays", which the MCP `reference` tool serves).
|
|
348
514
|
- Linking records (author on a post, product on an order): store the other
|
|
349
515
|
record's id in a field (`authorProfileId: profile.id`) — in collections
|
|
350
516
|
users write (community, shared, direct) the server learns it's a link;
|
|
@@ -509,7 +675,40 @@ contents.
|
|
|
509
675
|
screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
|
|
510
676
|
(everything they paid for, refunds applied, with the `grants` each purchase
|
|
511
677
|
carries) and `await g.subscriptions.mine()`.
|
|
512
|
-
NO credits
|
|
678
|
+
NO credits or usage limits YET — access is yes-or-no today. Consumable
|
|
679
|
+
credits (buy a pack, spend atomically, a zero floor) are planned, not
|
|
680
|
+
shipped: until this file teaches them, a "100 credit pack" is NOT a
|
|
681
|
+
pattern (the webhook can only SET a number, so a second pack erases the
|
|
682
|
+
first). NO seats, by design — not coming.
|
|
683
|
+
- Two FAMILIES of grant, and the line between them is law. Every grant is
|
|
684
|
+
one key + one reason + a start + maybe an end, never edited (an extension
|
|
685
|
+
is a NEW grant). PURCHASE-TIED (`subscription`, `purchase`): written only
|
|
686
|
+
by Stripe's signed webhook when money arrives — never by the owner's
|
|
687
|
+
hand, never by your app; a subscription grant ends on cancellation or
|
|
688
|
+
lapse, a purchase grant on a FULL refund (partial leaves it); the only
|
|
689
|
+
grants that count as revenue. Revoking one by hand stops the access and
|
|
690
|
+
refunds nothing. BY-HAND (`manual`, `trial`, `promotion`, plus
|
|
691
|
+
`migration` for customers imported from elsewhere): the owner writes them
|
|
692
|
+
from a customer's page — plan or product BY NAME, optional end date, a
|
|
693
|
+
reason — and they end on that date or when revoked; never revenue; a
|
|
694
|
+
refund never touches them. Effective access is the union of both
|
|
695
|
+
families. WHEN A REFUND MEETS ACCESS, THE OWNER DECIDES: the platform
|
|
696
|
+
revokes only on events that plainly mean it (subscription cancelled or
|
|
697
|
+
lapsed; one-off purchase refunded IN FULL). A partial refund leaves
|
|
698
|
+
access; a refund on a subscription invoice touches no access at all
|
|
699
|
+
(grants are revoked by purchase reference, subscriptions by customer).
|
|
700
|
+
A cancel-at-period-end keeps access until the period ends. If the owner
|
|
701
|
+
wants access gone NOW, they cancel the subscription immediately in
|
|
702
|
+
Stripe or revoke by hand on the customer's page — both audited; every
|
|
703
|
+
refund the webhook chose not to act on is written to the activity view.
|
|
704
|
+
Tell your human this when they ask "I refunded them, why can they still
|
|
705
|
+
get in" — never write refund logic in the app. Your app sees purchases (`g.purchases.mine()`, each with its
|
|
706
|
+
`grants`) and the subscription (`g.subscriptions.mine()`) — by-hand
|
|
707
|
+
grants are NOT listed to the app; a gated read simply succeeds. So never
|
|
708
|
+
rebuild the paywall client-side from what you can list: let the server
|
|
709
|
+
refuse and show the upgrade prompt on `entitlement_required`. "Give X a
|
|
710
|
+
free month" is a trial grant on their customer page, never code — ask
|
|
711
|
+
your human.
|
|
513
712
|
- Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
|
|
514
713
|
access only — physical goods, shipping, inventory and carts are out of
|
|
515
714
|
scope, said out loud): plans are for subscriptions; products are for
|
|
@@ -587,7 +786,13 @@ contents.
|
|
|
587
786
|
moderation and status flips on other users' records; scoping a secret
|
|
588
787
|
key to collections and actions; verifying the sending and receiving
|
|
589
788
|
domain; usage and logs; the Inbox, where every notify() send is a
|
|
590
|
-
conversation and customer replies land; holding the billing band
|
|
789
|
+
conversation and customer replies land; holding the billing band;
|
|
790
|
+
running a relay: an inbound webhook from any provider that
|
|
791
|
+
signs its calls, a schedule, or a record change → write a record,
|
|
792
|
+
grant or revoke access, email the person, or call your URL, with
|
|
793
|
+
retries and a replay button (the definition is a file you write —
|
|
794
|
+
see Relays — and the dashboard is where it is read, paused,
|
|
795
|
+
replayed and its secrets rotated). None
|
|
591
796
|
of them has an SDK equivalent, so an owner screen you build for one is
|
|
592
797
|
a page that already exists, without the enforcement.
|
|
593
798
|
- Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Migration notes — @gemmein/sdk
|
|
2
|
+
|
|
3
|
+
One file per behaviour change that may ask something of an app already
|
|
4
|
+
built on the SDK. `CHANGELOG.md` says what changed in each version; a note
|
|
5
|
+
here says what, if anything, your app has to do about it, and by when.
|
|
6
|
+
|
|
7
|
+
## Format
|
|
8
|
+
|
|
9
|
+
Each note is a Markdown file named for the change (`list-limit-refusal.md`),
|
|
10
|
+
and opens with two header lines:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Since: <version> — the SDK version the change arrived in
|
|
14
|
+
Action required by: <version> — the version by which an app must have
|
|
15
|
+
acted, or "none" when nothing is required
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then, in this order:
|
|
19
|
+
|
|
20
|
+
1. **What changed** — the old behaviour and the new one, as facts.
|
|
21
|
+
2. **Who is affected** — the call shapes that see a difference. An app that
|
|
22
|
+
never made those calls can stop reading.
|
|
23
|
+
3. **What to do** — the smallest change that keeps the app correct, with
|
|
24
|
+
the code.
|
|
25
|
+
4. **How to tell** — how the change shows up at runtime (the error code,
|
|
26
|
+
the message) so it can be recognised in logs.
|
|
27
|
+
|
|
28
|
+
A note is never edited after the version it names ships; a later change to
|
|
29
|
+
the same surface gets its own file.
|
|
30
|
+
|
|
31
|
+
## Notes
|
|
32
|
+
|
|
33
|
+
| File | Since | Action required by |
|
|
34
|
+
|------|-------|--------------------|
|
|
35
|
+
| [`list-limit-refusal.md`](./list-limit-refusal.md) | 0.6.0 | 0.6.0 |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# `list({ limit })` over 100 is refused
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Since: 0.6.0
|
|
5
|
+
Action required by: 0.6.0
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
## What changed
|
|
9
|
+
|
|
10
|
+
Before 0.6.0 the server clamped a list `limit` above 100 to 100 and
|
|
11
|
+
answered a normal page (`hasMore: true`). From 0.6.0 a `limit` outside 1..100
|
|
12
|
+
is refused: the call throws `GemmeinError` with code `invalid_limit` and the
|
|
13
|
+
message "limit must be between 1 and 100". The default (no `limit`) is 25 and
|
|
14
|
+
the maximum is 100, as before. The same applies on the local engine
|
|
15
|
+
(`gemmein dev`) from 0.7.0 — it runs the same server code.
|
|
16
|
+
|
|
17
|
+
## Who is affected
|
|
18
|
+
|
|
19
|
+
Apps that call `collection.list({ limit: N })` with `N > 100`, `N < 1`, or a
|
|
20
|
+
non-integer `N`, on either the browser client or `gemmeinServer(sk)`.
|
|
21
|
+
`watch()` is unaffected (it asks for 100). An app that passes no `limit`, or
|
|
22
|
+
1..100, sees no difference.
|
|
23
|
+
|
|
24
|
+
## What to do
|
|
25
|
+
|
|
26
|
+
Ask for at most 100 and page with `cursor`:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const all = [];
|
|
30
|
+
let cursor: string | undefined;
|
|
31
|
+
do {
|
|
32
|
+
const page = await g.collection("orders").list({ limit: 100, cursor });
|
|
33
|
+
all.push(...page.records);
|
|
34
|
+
cursor = page.hasMore ? page.cursor : undefined;
|
|
35
|
+
} while (cursor);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How to tell
|
|
39
|
+
|
|
40
|
+
A 400 with `code: "invalid_limit"` in the response, `err.code ===
|
|
41
|
+
"invalid_limit"` in the SDK, and the row `invalid_limit` under the key's
|
|
42
|
+
refusals on the Keys page when a secret key made the call.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Gemmein SDK
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Gemmein SDK \u2014 passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.cjs",
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
"dist",
|
|
23
23
|
"llms.txt",
|
|
24
24
|
"REFERENCE.md",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"migrations",
|
|
25
27
|
"reaffirm.mjs"
|
|
26
28
|
],
|
|
27
29
|
"sideEffects": false,
|
|
28
30
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
31
|
+
"node": ">=20"
|
|
30
32
|
},
|
|
31
33
|
"keywords": [
|
|
32
34
|
"auth",
|
|
@@ -45,6 +47,8 @@
|
|
|
45
47
|
"access": "public"
|
|
46
48
|
},
|
|
47
49
|
"scripts": {
|
|
50
|
+
"version:sync": "node scripts/sync-version.mjs",
|
|
51
|
+
"prebuild": "node scripts/sync-version.mjs",
|
|
48
52
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && mv dist/cjs/index.js dist/index.cjs && rm -rf dist/cjs && cp dist/index.d.ts dist/index.d.cts",
|
|
49
53
|
"prepack": "npm run build",
|
|
50
54
|
"prepublishOnly": "npm run build"
|