@gemmein/sdk 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/REFERENCE.md +98 -4
- package/dist/index.cjs +120 -0
- package/dist/index.d.cts +153 -0
- package/dist/index.d.ts +153 -0
- package/dist/index.js +120 -0
- package/llms.txt +102 -6
- package/package.json +1 -1
package/REFERENCE.md
CHANGED
|
@@ -21,10 +21,15 @@ The browser client. `appKey` is your public `pk_...` key. `options` (optional):
|
|
|
21
21
|
|
|
22
22
|
### `gemmeinServer(secretKey, options?) → GemmeinServer`
|
|
23
23
|
Server-only client for a `sk_...` secret key — **never ship this to the
|
|
24
|
-
browser.**
|
|
24
|
+
browser.** `options` (optional): `{ apiUrl?: string }` — **the option is
|
|
25
|
+
`apiUrl`**, not `baseUrl`. An unknown option is ignored silently, so a script
|
|
26
|
+
that passes `baseUrl` to reach a local or staging server quietly talks to
|
|
27
|
+
production instead. Exposes read/update on collections without a signed-in user,
|
|
25
28
|
`notify()` to email one of your app's own verified people (see **Notify**),
|
|
26
|
-
|
|
27
|
-
**
|
|
29
|
+
the gate — `verifySession()` / `holdings()` / `grantAccess()` /
|
|
30
|
+
`revokeAccess()`, for code of yours running on your own host (see **Server
|
|
31
|
+
gate**) — plus `testSession()` for CI self-tests (dev environments only —
|
|
32
|
+
see **Reaffirm**).
|
|
28
33
|
|
|
29
34
|
The client has two layers. **Your app's collections** — `g.collection(name)`
|
|
30
35
|
(the canonical spelling; `g.storage.collection(name)` is the same client). And
|
|
@@ -233,6 +238,10 @@ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
|
233
238
|
don't also redirect to the returned `url`, and never build a Stripe URL
|
|
234
239
|
yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
|
|
235
240
|
gate one-off fulfilment on the receipt record, never the redirect.
|
|
241
|
+
By-hand grants (trial, promotion, a support comp) are not listed to the
|
|
242
|
+
app — a gated read simply succeeds — so never rebuild the paywall from
|
|
243
|
+
`mine()`; let the server refuse with `entitlement_required`. The two grant
|
|
244
|
+
families and what ends each are in llms.txt, "Two FAMILIES of grant".
|
|
236
245
|
|
|
237
246
|
---
|
|
238
247
|
|
|
@@ -262,6 +271,88 @@ honestly, not hidden.)
|
|
|
262
271
|
|
|
263
272
|
---
|
|
264
273
|
|
|
274
|
+
## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess`
|
|
275
|
+
|
|
276
|
+
Gemmein hosts no compute. **Your own** function — Vercel, a VPS, a cron box,
|
|
277
|
+
anywhere — asks Gemmein the only three questions it has: *who is this person,
|
|
278
|
+
what do they hold, change what they hold.*
|
|
279
|
+
|
|
280
|
+
| Method | Signature | Returns |
|
|
281
|
+
|--------|-----------|---------|
|
|
282
|
+
| `verifySession` | `(token)` | `Promise<{ ok: true, person: { id, email, role }, holdings: Holdings }>` — the browser's session token in, identity **and** holdings out. Needs no capability on the key (the caller already holds the person's token) and never touches the session: no extension, no last-seen |
|
|
283
|
+
| `holdings` | `(personId)` | `Promise<{ ok: true, person: { id, email, role, suspended }, holdings: Holdings }>` — for the paths with no token in hand. A **suspended** person is returned, flagged `suspended: true`, with their holdings; `verifySession` refuses them |
|
|
284
|
+
| `grantAccess` | `(personId, { entitlement, source?, expiresAt?, reason? })` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` (201) — `holdings` is the state **after**; the owner's audit row carries before→after and the key's name |
|
|
285
|
+
| `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
type Holdings = {
|
|
289
|
+
access: string[] // the keys they hold NOW — ["access:pro"]
|
|
290
|
+
grants: Grant[] // the LIVE grants behind them (revoked/expired are gone)
|
|
291
|
+
credits: { balance: number } | null // reserved — null today; credits are NOT shipped
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
type Grant = {
|
|
295
|
+
id: string
|
|
296
|
+
entitlement: string // "access:<slug>" — the plan's or product's own key
|
|
297
|
+
source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration"
|
|
298
|
+
startsAt: string
|
|
299
|
+
expiresAt: string | null
|
|
300
|
+
revokedAt?: string | null // present on the grant revokeAccess returns
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
- **Pointing at a non-default server.** `gemmeinServer(sk, { apiUrl })` — the option
|
|
305
|
+
is **`apiUrl`**. `baseUrl` is not an option name and is ignored in silence, so a
|
|
306
|
+
test or E2E script that passes it runs against **production** without saying so.
|
|
307
|
+
- **One call per request.** `verifySession` answers identity *and* holdings
|
|
308
|
+
together — don't call it twice, and don't cache the answer past the request.
|
|
309
|
+
- **Holdings, not billing.** The gate never returns subscription status,
|
|
310
|
+
amounts, Stripe ids, or a grant's `sourceId` — the source **kind** only. Gate
|
|
311
|
+
on what a person *holds*, never on what they pay. Person id, never an email.
|
|
312
|
+
- **Two capabilities, ticked by the human.** `verifySession` needs neither.
|
|
313
|
+
`holdings` needs **"Look up a person's access by id"**; `grantAccess` and
|
|
314
|
+
`revokeAccess` need **"Grant and revoke access"** — plain-English checkboxes
|
|
315
|
+
the owner ticks when minting the key. Existing keys have both off, so nothing
|
|
316
|
+
in production changes.
|
|
317
|
+
- **Manual sources only.** `source` ∈ `manual | trial | promotion | migration`
|
|
318
|
+
(default `manual`). Purchases and subscriptions come only from Stripe — a key
|
|
319
|
+
cannot mint paid access. A key *may* end a payment-made grant (the same as the
|
|
320
|
+
dashboard's "end this access"); the payment itself is untouched.
|
|
321
|
+
- **One grant, one reason.** `reason` (≤ 200 chars) is what the owner reads in
|
|
322
|
+
their logs and is never edited. `sourceId` is minted per call, so two calls
|
|
323
|
+
make two grants — call it once and keep your own retry key.
|
|
324
|
+
- **Nothing silent, within a stated bound.** Every `/server/*` call a **resolved
|
|
325
|
+
secret key** makes, ok or refused, lands in that key's usage ledger — the owner
|
|
326
|
+
reads the summary on their Keys page and the full day × route × outcome table in
|
|
327
|
+
the key's own room. A rejected or publishable (`pk_`) key can't be attributed to a
|
|
328
|
+
key row, so its refusal reaches only the request log. Refusals also write one audit
|
|
329
|
+
row per key, route and code each hour (exact counts stay in the ledger); grants and
|
|
330
|
+
revokes write full before→after rows, attributed to the key by name. A refusal
|
|
331
|
+
counts as *use*: "last used" means last seen, not last worked. In `gemmein dev` the
|
|
332
|
+
local key already holds both capabilities, and the usage read is cloud-only — the
|
|
333
|
+
local runtime never mounts the console.
|
|
334
|
+
|
|
335
|
+
| code | status | meaning · do |
|
|
336
|
+
|------|--------|--------------|
|
|
337
|
+
| `session_invalid` | 401 | No session matches this token — send the person to sign in again; never store or reuse tokens across people |
|
|
338
|
+
| `session_expired` | 401 | The session ended (the message names when) — send them back to sign-in |
|
|
339
|
+
| `session_revoked` | 401 | A newer sign-in, a sign-out, or the owner ended it — send them back to sign-in |
|
|
340
|
+
| `person_suspended` | 403 | The owner suspended this person — access is off until the owner reactivates them in the dashboard |
|
|
341
|
+
| `person_not_found` | 404 | No person with this id in this app and environment — ids come from `verifySession` or the dashboard, never from an email. Existence is never leaked |
|
|
342
|
+
| `capability_required` | 403 | The key's box isn't ticked — mint a key with "Look up a person's access by id" / "Grant and revoke access" ticked (purchases still come only from Stripe) |
|
|
343
|
+
| `invalid_source` | 400 | `purchase` / `subscription` asked for by hand — refused; those come only from Stripe |
|
|
344
|
+
| `invalid_entitlement` | 400 | Not a valid `access:<slug>` key — or drop the key and pass the plan's or product's own NAME, which the gate resolves for you |
|
|
345
|
+
| `unknown_plan` | 400 | No plan or product by that name — the owner adds it on the Payments page. (Checkout's `unknown_plan` is a **404**; the gate's is a **400** — it is a bad argument to a write, not a missing resource) |
|
|
346
|
+
| `grant_not_found` | 404 | Not this person's grant, in this app and environment — re-read `holdings` |
|
|
347
|
+
| `already_revoked` | 409 | A grant ends once (`revokedAt` is set and never edited) — it is already ended |
|
|
348
|
+
| `invalid_body` | 400 | One malformed field, whichever it is — `token` (missing, not a string, over 512 chars), `expiresAt` (unparseable or in the past), `reason` (not text, over 200 chars). Branch on the code, read the **message**: it names the field |
|
|
349
|
+
| `scope_denied` | 403 | Not a secret key — the gate is server-only, never the browser |
|
|
350
|
+
| `invalid_id` | 400 | A prototype name (`__proto__`, `constructor`, `prototype`) was sent as a person id or a grant id. Ids come from `verifySession()` or the dashboard — never from a name |
|
|
351
|
+
| `unknown_route` | 404 | Not one of the gate's four routes — the message lists them all |
|
|
352
|
+
| `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess` and `revokeAccess` are POST, `holdings` is GET |
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
265
356
|
## Reaffirm — prove your app's boundaries in CI
|
|
266
357
|
|
|
267
358
|
Gemmein enforces the rules **server-side**, so your frontend is never the source
|
|
@@ -329,7 +420,10 @@ class GemmeinError extends Error {
|
|
|
329
420
|
}
|
|
330
421
|
```
|
|
331
422
|
|
|
332
|
-
Branch on `err.code`. The
|
|
423
|
+
Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
424
|
+
`session_expired`, `session_revoked`, `person_suspended`, `person_not_found`,
|
|
425
|
+
`capability_required`, `invalid_source`, `unknown_plan`, `grant_not_found`,
|
|
426
|
+
`already_revoked`) are in **Server gate** above, each with its action. The rest:
|
|
333
427
|
|
|
334
428
|
| code | meaning | do |
|
|
335
429
|
|------|---------|-----|
|
package/dist/index.cjs
CHANGED
|
@@ -816,6 +816,126 @@ class GemmeinServer {
|
|
|
816
816
|
}
|
|
817
817
|
return response.json();
|
|
818
818
|
}
|
|
819
|
+
/**
|
|
820
|
+
* THE GATE — your compute, our answer. Gemmein runs no code of yours;
|
|
821
|
+
* your own function runs anywhere and asks the only three questions it
|
|
822
|
+
* has: who is this person, what do they hold, change what they hold.
|
|
823
|
+
*
|
|
824
|
+
* const { person, holdings } = await g.verifySession(token);
|
|
825
|
+
* if (!holdings.access.includes("access:pro")) return deny();
|
|
826
|
+
*
|
|
827
|
+
* ONE call per request answers identity AND holdings — don't call it
|
|
828
|
+
* twice, and don't cache the answer past the request. Verify needs no
|
|
829
|
+
* capability on the key (the caller already holds the person's token)
|
|
830
|
+
* and never touches the session: no extension, no last-seen.
|
|
831
|
+
*
|
|
832
|
+
* Refusals (`err.code`): `session_invalid` (no session matches this
|
|
833
|
+
* token — the person signs in again; never reuse tokens across
|
|
834
|
+
* people) · `session_expired` · `session_revoked` (a newer sign-in,
|
|
835
|
+
* a sign-out, or the owner) — all three send the person back to
|
|
836
|
+
* sign-in · `person_suspended` (the owner suspended them; access is
|
|
837
|
+
* off until the owner reactivates them in the dashboard) ·
|
|
838
|
+
* `invalid_body` (token missing, not a string, or over 512 chars).
|
|
839
|
+
*/
|
|
840
|
+
async verifySession(token) {
|
|
841
|
+
return this.gate("/server/verify-session", {
|
|
842
|
+
method: "POST",
|
|
843
|
+
body: JSON.stringify({ token }),
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* What one of YOUR people holds, by person id — for the paths where no
|
|
848
|
+
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
849
|
+
* screen you built). A person id, NEVER an email address: ids come
|
|
850
|
+
* from `verifySession` or the dashboard.
|
|
851
|
+
*
|
|
852
|
+
* A suspended person is RETURNED, with `suspended: true`, alongside
|
|
853
|
+
* their holdings — your function may need to say so. The gate itself
|
|
854
|
+
* (`verifySession`) refuses them.
|
|
855
|
+
*
|
|
856
|
+
* Refusals: `capability_required` — this key can't look up people by
|
|
857
|
+
* id; ask your human to mint a key with "Look up a person's access by
|
|
858
|
+
* id" ticked, or use `verifySession` with the person's own token ·
|
|
859
|
+
* `person_not_found` (404 — no person with this id in this app and
|
|
860
|
+
* environment; existence is never leaked).
|
|
861
|
+
*/
|
|
862
|
+
async holdings(personId) {
|
|
863
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Give a person access by hand — a trial, a promotion, an apology, a
|
|
867
|
+
* migration from your old system:
|
|
868
|
+
*
|
|
869
|
+
* await g.grantAccess(personId, {
|
|
870
|
+
* entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
|
|
871
|
+
* source: "trial",
|
|
872
|
+
* expiresAt: "2026-10-01T00:00:00.000Z",
|
|
873
|
+
* reason: "7-day trial from the onboarding flow",
|
|
874
|
+
* });
|
|
875
|
+
*
|
|
876
|
+
* MANUAL sources only. Purchases and subscriptions come only from
|
|
877
|
+
* Stripe — a key cannot mint paid access, by design. `reason` is up to
|
|
878
|
+
* 200 characters, is never edited afterwards, and is what the owner
|
|
879
|
+
* reads in their logs; write it for them.
|
|
880
|
+
*
|
|
881
|
+
* `sourceId` is minted per call, so two calls make TWO grants (each
|
|
882
|
+
* with its own one reason) — call it once, and keep your own retry
|
|
883
|
+
* key if the caller can retry.
|
|
884
|
+
*
|
|
885
|
+
* Returns the new grant and the holdings AFTER it; the owner's audit
|
|
886
|
+
* row carries before→after and the key's name.
|
|
887
|
+
*
|
|
888
|
+
* Refusals: `capability_required` — this key can't grant access; ask
|
|
889
|
+
* your human to mint a key with "Grant and revoke access" ticked ·
|
|
890
|
+
* `invalid_source` (purchase/subscription refused) ·
|
|
891
|
+
* `invalid_entitlement` / `unknown_plan` (no plan or product by that
|
|
892
|
+
* name — the owner adds it on the Payments page) · `person_not_found`.
|
|
893
|
+
*/
|
|
894
|
+
async grantAccess(personId, input) {
|
|
895
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
|
|
896
|
+
method: "POST",
|
|
897
|
+
body: JSON.stringify({
|
|
898
|
+
entitlement: input.entitlement,
|
|
899
|
+
...(input.source ? { source: input.source } : {}),
|
|
900
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
901
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
902
|
+
}),
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* End one grant — the reversibility law in one call:
|
|
907
|
+
*
|
|
908
|
+
* await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
|
|
909
|
+
*
|
|
910
|
+
* A grant ends ONCE: `revokedAt` is set and never edited, so a second
|
|
911
|
+
* call is `409 already_revoked`, not a silent no-op. A key MAY end a
|
|
912
|
+
* grant that a payment created (the same as the owner's "end this
|
|
913
|
+
* access" button) — the payment itself is untouched, and the audit row
|
|
914
|
+
* says so.
|
|
915
|
+
*
|
|
916
|
+
* Returns the revoked grant and the holdings after it.
|
|
917
|
+
*
|
|
918
|
+
* Refusals: `capability_required` (same ticked box as granting) ·
|
|
919
|
+
* `grant_not_found` (404 — not this person's grant, in this app and
|
|
920
|
+
* environment; existence is never leaked) · `already_revoked` (409).
|
|
921
|
+
*/
|
|
922
|
+
async revokeAccess(personId, grantId, input = {}) {
|
|
923
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
|
|
924
|
+
}
|
|
925
|
+
// One request path for the four gate calls, so every refusal reaches
|
|
926
|
+
// the caller through the SAME typed error the rest of the SDK throws —
|
|
927
|
+
// `err.code` carries the server's own code, `err.message` its sentence
|
|
928
|
+
// (which always names the next action).
|
|
929
|
+
async gate(path, init = {}) {
|
|
930
|
+
const headers = { "x-app-key": this.secretKey };
|
|
931
|
+
if (init.body)
|
|
932
|
+
headers["content-type"] = "application/json";
|
|
933
|
+
const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
|
|
934
|
+
if (!response.ok) {
|
|
935
|
+
throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
|
|
936
|
+
}
|
|
937
|
+
return response.json();
|
|
938
|
+
}
|
|
819
939
|
}
|
|
820
940
|
exports.GemmeinServer = GemmeinServer;
|
|
821
941
|
class ServerCollectionClient {
|
package/dist/index.d.cts
CHANGED
|
@@ -525,6 +525,46 @@ export type GemmeinServerOptions = {
|
|
|
525
525
|
secretKey: string;
|
|
526
526
|
apiUrl?: string;
|
|
527
527
|
};
|
|
528
|
+
/**
|
|
529
|
+
* Where a grant came from — the KIND only. The gate never returns the
|
|
530
|
+
* source's id, an amount, or anything from Stripe.
|
|
531
|
+
*/
|
|
532
|
+
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration";
|
|
533
|
+
/**
|
|
534
|
+
* The sources a secret key may create by hand. `purchase` and
|
|
535
|
+
* `subscription` are deliberately absent: money-made access comes only
|
|
536
|
+
* from Stripe, and always will.
|
|
537
|
+
*/
|
|
538
|
+
export type ManualGrantSource = "manual" | "trial" | "promotion" | "migration";
|
|
539
|
+
export type Grant = {
|
|
540
|
+
id: string;
|
|
541
|
+
/** `access:<slug>` — the plan's or product's own key. */
|
|
542
|
+
entitlement: string;
|
|
543
|
+
source: GrantSource;
|
|
544
|
+
startsAt: string;
|
|
545
|
+
expiresAt: string | null;
|
|
546
|
+
/** Present on the grant returned by `revokeAccess` — set once, never edited. */
|
|
547
|
+
revokedAt?: string | null;
|
|
548
|
+
};
|
|
549
|
+
/**
|
|
550
|
+
* What a person holds RIGHT NOW — never what they pay. `access` is the
|
|
551
|
+
* union of live grants' keys; `grants` lists those live grants (revoked
|
|
552
|
+
* and expired ones are gone, not flagged). `credits` is a reserved slot:
|
|
553
|
+
* it is `null` today because consumable credits are not shipped — don't
|
|
554
|
+
* design around them until this type says otherwise.
|
|
555
|
+
*/
|
|
556
|
+
export type Holdings = {
|
|
557
|
+
access: string[];
|
|
558
|
+
grants: Grant[];
|
|
559
|
+
credits: {
|
|
560
|
+
balance: number;
|
|
561
|
+
} | null;
|
|
562
|
+
};
|
|
563
|
+
export type GatePerson = {
|
|
564
|
+
id: string;
|
|
565
|
+
email: string;
|
|
566
|
+
role: string;
|
|
567
|
+
};
|
|
528
568
|
export declare class GemmeinServer {
|
|
529
569
|
private readonly apiUrl;
|
|
530
570
|
private readonly secretKey;
|
|
@@ -582,6 +622,119 @@ export declare class GemmeinServer {
|
|
|
582
622
|
replyRail?: boolean;
|
|
583
623
|
recorded?: boolean;
|
|
584
624
|
}>;
|
|
625
|
+
/**
|
|
626
|
+
* THE GATE — your compute, our answer. Gemmein runs no code of yours;
|
|
627
|
+
* your own function runs anywhere and asks the only three questions it
|
|
628
|
+
* has: who is this person, what do they hold, change what they hold.
|
|
629
|
+
*
|
|
630
|
+
* const { person, holdings } = await g.verifySession(token);
|
|
631
|
+
* if (!holdings.access.includes("access:pro")) return deny();
|
|
632
|
+
*
|
|
633
|
+
* ONE call per request answers identity AND holdings — don't call it
|
|
634
|
+
* twice, and don't cache the answer past the request. Verify needs no
|
|
635
|
+
* capability on the key (the caller already holds the person's token)
|
|
636
|
+
* and never touches the session: no extension, no last-seen.
|
|
637
|
+
*
|
|
638
|
+
* Refusals (`err.code`): `session_invalid` (no session matches this
|
|
639
|
+
* token — the person signs in again; never reuse tokens across
|
|
640
|
+
* people) · `session_expired` · `session_revoked` (a newer sign-in,
|
|
641
|
+
* a sign-out, or the owner) — all three send the person back to
|
|
642
|
+
* sign-in · `person_suspended` (the owner suspended them; access is
|
|
643
|
+
* off until the owner reactivates them in the dashboard) ·
|
|
644
|
+
* `invalid_body` (token missing, not a string, or over 512 chars).
|
|
645
|
+
*/
|
|
646
|
+
verifySession(token: string): Promise<{
|
|
647
|
+
ok: true;
|
|
648
|
+
person: GatePerson;
|
|
649
|
+
holdings: Holdings;
|
|
650
|
+
}>;
|
|
651
|
+
/**
|
|
652
|
+
* What one of YOUR people holds, by person id — for the paths where no
|
|
653
|
+
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
654
|
+
* screen you built). A person id, NEVER an email address: ids come
|
|
655
|
+
* from `verifySession` or the dashboard.
|
|
656
|
+
*
|
|
657
|
+
* A suspended person is RETURNED, with `suspended: true`, alongside
|
|
658
|
+
* their holdings — your function may need to say so. The gate itself
|
|
659
|
+
* (`verifySession`) refuses them.
|
|
660
|
+
*
|
|
661
|
+
* Refusals: `capability_required` — this key can't look up people by
|
|
662
|
+
* id; ask your human to mint a key with "Look up a person's access by
|
|
663
|
+
* id" ticked, or use `verifySession` with the person's own token ·
|
|
664
|
+
* `person_not_found` (404 — no person with this id in this app and
|
|
665
|
+
* environment; existence is never leaked).
|
|
666
|
+
*/
|
|
667
|
+
holdings(personId: string): Promise<{
|
|
668
|
+
ok: true;
|
|
669
|
+
person: GatePerson & {
|
|
670
|
+
suspended: boolean;
|
|
671
|
+
};
|
|
672
|
+
holdings: Holdings;
|
|
673
|
+
}>;
|
|
674
|
+
/**
|
|
675
|
+
* Give a person access by hand — a trial, a promotion, an apology, a
|
|
676
|
+
* migration from your old system:
|
|
677
|
+
*
|
|
678
|
+
* await g.grantAccess(personId, {
|
|
679
|
+
* entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
|
|
680
|
+
* source: "trial",
|
|
681
|
+
* expiresAt: "2026-10-01T00:00:00.000Z",
|
|
682
|
+
* reason: "7-day trial from the onboarding flow",
|
|
683
|
+
* });
|
|
684
|
+
*
|
|
685
|
+
* MANUAL sources only. Purchases and subscriptions come only from
|
|
686
|
+
* Stripe — a key cannot mint paid access, by design. `reason` is up to
|
|
687
|
+
* 200 characters, is never edited afterwards, and is what the owner
|
|
688
|
+
* reads in their logs; write it for them.
|
|
689
|
+
*
|
|
690
|
+
* `sourceId` is minted per call, so two calls make TWO grants (each
|
|
691
|
+
* with its own one reason) — call it once, and keep your own retry
|
|
692
|
+
* key if the caller can retry.
|
|
693
|
+
*
|
|
694
|
+
* Returns the new grant and the holdings AFTER it; the owner's audit
|
|
695
|
+
* row carries before→after and the key's name.
|
|
696
|
+
*
|
|
697
|
+
* Refusals: `capability_required` — this key can't grant access; ask
|
|
698
|
+
* your human to mint a key with "Grant and revoke access" ticked ·
|
|
699
|
+
* `invalid_source` (purchase/subscription refused) ·
|
|
700
|
+
* `invalid_entitlement` / `unknown_plan` (no plan or product by that
|
|
701
|
+
* name — the owner adds it on the Payments page) · `person_not_found`.
|
|
702
|
+
*/
|
|
703
|
+
grantAccess(personId: string, input: {
|
|
704
|
+
entitlement: string;
|
|
705
|
+
source?: ManualGrantSource;
|
|
706
|
+
expiresAt?: string;
|
|
707
|
+
reason?: string;
|
|
708
|
+
}): Promise<{
|
|
709
|
+
ok: true;
|
|
710
|
+
grant: Grant;
|
|
711
|
+
holdings: Holdings;
|
|
712
|
+
}>;
|
|
713
|
+
/**
|
|
714
|
+
* End one grant — the reversibility law in one call:
|
|
715
|
+
*
|
|
716
|
+
* await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
|
|
717
|
+
*
|
|
718
|
+
* A grant ends ONCE: `revokedAt` is set and never edited, so a second
|
|
719
|
+
* call is `409 already_revoked`, not a silent no-op. A key MAY end a
|
|
720
|
+
* grant that a payment created (the same as the owner's "end this
|
|
721
|
+
* access" button) — the payment itself is untouched, and the audit row
|
|
722
|
+
* says so.
|
|
723
|
+
*
|
|
724
|
+
* Returns the revoked grant and the holdings after it.
|
|
725
|
+
*
|
|
726
|
+
* Refusals: `capability_required` (same ticked box as granting) ·
|
|
727
|
+
* `grant_not_found` (404 — not this person's grant, in this app and
|
|
728
|
+
* environment; existence is never leaked) · `already_revoked` (409).
|
|
729
|
+
*/
|
|
730
|
+
revokeAccess(personId: string, grantId: string, input?: {
|
|
731
|
+
reason?: string;
|
|
732
|
+
}): Promise<{
|
|
733
|
+
ok: true;
|
|
734
|
+
grant: Grant;
|
|
735
|
+
holdings: Holdings;
|
|
736
|
+
}>;
|
|
737
|
+
private gate;
|
|
585
738
|
}
|
|
586
739
|
declare class ServerCollectionClient {
|
|
587
740
|
private readonly apiUrl;
|
package/dist/index.d.ts
CHANGED
|
@@ -525,6 +525,46 @@ export type GemmeinServerOptions = {
|
|
|
525
525
|
secretKey: string;
|
|
526
526
|
apiUrl?: string;
|
|
527
527
|
};
|
|
528
|
+
/**
|
|
529
|
+
* Where a grant came from — the KIND only. The gate never returns the
|
|
530
|
+
* source's id, an amount, or anything from Stripe.
|
|
531
|
+
*/
|
|
532
|
+
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration";
|
|
533
|
+
/**
|
|
534
|
+
* The sources a secret key may create by hand. `purchase` and
|
|
535
|
+
* `subscription` are deliberately absent: money-made access comes only
|
|
536
|
+
* from Stripe, and always will.
|
|
537
|
+
*/
|
|
538
|
+
export type ManualGrantSource = "manual" | "trial" | "promotion" | "migration";
|
|
539
|
+
export type Grant = {
|
|
540
|
+
id: string;
|
|
541
|
+
/** `access:<slug>` — the plan's or product's own key. */
|
|
542
|
+
entitlement: string;
|
|
543
|
+
source: GrantSource;
|
|
544
|
+
startsAt: string;
|
|
545
|
+
expiresAt: string | null;
|
|
546
|
+
/** Present on the grant returned by `revokeAccess` — set once, never edited. */
|
|
547
|
+
revokedAt?: string | null;
|
|
548
|
+
};
|
|
549
|
+
/**
|
|
550
|
+
* What a person holds RIGHT NOW — never what they pay. `access` is the
|
|
551
|
+
* union of live grants' keys; `grants` lists those live grants (revoked
|
|
552
|
+
* and expired ones are gone, not flagged). `credits` is a reserved slot:
|
|
553
|
+
* it is `null` today because consumable credits are not shipped — don't
|
|
554
|
+
* design around them until this type says otherwise.
|
|
555
|
+
*/
|
|
556
|
+
export type Holdings = {
|
|
557
|
+
access: string[];
|
|
558
|
+
grants: Grant[];
|
|
559
|
+
credits: {
|
|
560
|
+
balance: number;
|
|
561
|
+
} | null;
|
|
562
|
+
};
|
|
563
|
+
export type GatePerson = {
|
|
564
|
+
id: string;
|
|
565
|
+
email: string;
|
|
566
|
+
role: string;
|
|
567
|
+
};
|
|
528
568
|
export declare class GemmeinServer {
|
|
529
569
|
private readonly apiUrl;
|
|
530
570
|
private readonly secretKey;
|
|
@@ -582,6 +622,119 @@ export declare class GemmeinServer {
|
|
|
582
622
|
replyRail?: boolean;
|
|
583
623
|
recorded?: boolean;
|
|
584
624
|
}>;
|
|
625
|
+
/**
|
|
626
|
+
* THE GATE — your compute, our answer. Gemmein runs no code of yours;
|
|
627
|
+
* your own function runs anywhere and asks the only three questions it
|
|
628
|
+
* has: who is this person, what do they hold, change what they hold.
|
|
629
|
+
*
|
|
630
|
+
* const { person, holdings } = await g.verifySession(token);
|
|
631
|
+
* if (!holdings.access.includes("access:pro")) return deny();
|
|
632
|
+
*
|
|
633
|
+
* ONE call per request answers identity AND holdings — don't call it
|
|
634
|
+
* twice, and don't cache the answer past the request. Verify needs no
|
|
635
|
+
* capability on the key (the caller already holds the person's token)
|
|
636
|
+
* and never touches the session: no extension, no last-seen.
|
|
637
|
+
*
|
|
638
|
+
* Refusals (`err.code`): `session_invalid` (no session matches this
|
|
639
|
+
* token — the person signs in again; never reuse tokens across
|
|
640
|
+
* people) · `session_expired` · `session_revoked` (a newer sign-in,
|
|
641
|
+
* a sign-out, or the owner) — all three send the person back to
|
|
642
|
+
* sign-in · `person_suspended` (the owner suspended them; access is
|
|
643
|
+
* off until the owner reactivates them in the dashboard) ·
|
|
644
|
+
* `invalid_body` (token missing, not a string, or over 512 chars).
|
|
645
|
+
*/
|
|
646
|
+
verifySession(token: string): Promise<{
|
|
647
|
+
ok: true;
|
|
648
|
+
person: GatePerson;
|
|
649
|
+
holdings: Holdings;
|
|
650
|
+
}>;
|
|
651
|
+
/**
|
|
652
|
+
* What one of YOUR people holds, by person id — for the paths where no
|
|
653
|
+
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
654
|
+
* screen you built). A person id, NEVER an email address: ids come
|
|
655
|
+
* from `verifySession` or the dashboard.
|
|
656
|
+
*
|
|
657
|
+
* A suspended person is RETURNED, with `suspended: true`, alongside
|
|
658
|
+
* their holdings — your function may need to say so. The gate itself
|
|
659
|
+
* (`verifySession`) refuses them.
|
|
660
|
+
*
|
|
661
|
+
* Refusals: `capability_required` — this key can't look up people by
|
|
662
|
+
* id; ask your human to mint a key with "Look up a person's access by
|
|
663
|
+
* id" ticked, or use `verifySession` with the person's own token ·
|
|
664
|
+
* `person_not_found` (404 — no person with this id in this app and
|
|
665
|
+
* environment; existence is never leaked).
|
|
666
|
+
*/
|
|
667
|
+
holdings(personId: string): Promise<{
|
|
668
|
+
ok: true;
|
|
669
|
+
person: GatePerson & {
|
|
670
|
+
suspended: boolean;
|
|
671
|
+
};
|
|
672
|
+
holdings: Holdings;
|
|
673
|
+
}>;
|
|
674
|
+
/**
|
|
675
|
+
* Give a person access by hand — a trial, a promotion, an apology, a
|
|
676
|
+
* migration from your old system:
|
|
677
|
+
*
|
|
678
|
+
* await g.grantAccess(personId, {
|
|
679
|
+
* entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
|
|
680
|
+
* source: "trial",
|
|
681
|
+
* expiresAt: "2026-10-01T00:00:00.000Z",
|
|
682
|
+
* reason: "7-day trial from the onboarding flow",
|
|
683
|
+
* });
|
|
684
|
+
*
|
|
685
|
+
* MANUAL sources only. Purchases and subscriptions come only from
|
|
686
|
+
* Stripe — a key cannot mint paid access, by design. `reason` is up to
|
|
687
|
+
* 200 characters, is never edited afterwards, and is what the owner
|
|
688
|
+
* reads in their logs; write it for them.
|
|
689
|
+
*
|
|
690
|
+
* `sourceId` is minted per call, so two calls make TWO grants (each
|
|
691
|
+
* with its own one reason) — call it once, and keep your own retry
|
|
692
|
+
* key if the caller can retry.
|
|
693
|
+
*
|
|
694
|
+
* Returns the new grant and the holdings AFTER it; the owner's audit
|
|
695
|
+
* row carries before→after and the key's name.
|
|
696
|
+
*
|
|
697
|
+
* Refusals: `capability_required` — this key can't grant access; ask
|
|
698
|
+
* your human to mint a key with "Grant and revoke access" ticked ·
|
|
699
|
+
* `invalid_source` (purchase/subscription refused) ·
|
|
700
|
+
* `invalid_entitlement` / `unknown_plan` (no plan or product by that
|
|
701
|
+
* name — the owner adds it on the Payments page) · `person_not_found`.
|
|
702
|
+
*/
|
|
703
|
+
grantAccess(personId: string, input: {
|
|
704
|
+
entitlement: string;
|
|
705
|
+
source?: ManualGrantSource;
|
|
706
|
+
expiresAt?: string;
|
|
707
|
+
reason?: string;
|
|
708
|
+
}): Promise<{
|
|
709
|
+
ok: true;
|
|
710
|
+
grant: Grant;
|
|
711
|
+
holdings: Holdings;
|
|
712
|
+
}>;
|
|
713
|
+
/**
|
|
714
|
+
* End one grant — the reversibility law in one call:
|
|
715
|
+
*
|
|
716
|
+
* await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
|
|
717
|
+
*
|
|
718
|
+
* A grant ends ONCE: `revokedAt` is set and never edited, so a second
|
|
719
|
+
* call is `409 already_revoked`, not a silent no-op. A key MAY end a
|
|
720
|
+
* grant that a payment created (the same as the owner's "end this
|
|
721
|
+
* access" button) — the payment itself is untouched, and the audit row
|
|
722
|
+
* says so.
|
|
723
|
+
*
|
|
724
|
+
* Returns the revoked grant and the holdings after it.
|
|
725
|
+
*
|
|
726
|
+
* Refusals: `capability_required` (same ticked box as granting) ·
|
|
727
|
+
* `grant_not_found` (404 — not this person's grant, in this app and
|
|
728
|
+
* environment; existence is never leaked) · `already_revoked` (409).
|
|
729
|
+
*/
|
|
730
|
+
revokeAccess(personId: string, grantId: string, input?: {
|
|
731
|
+
reason?: string;
|
|
732
|
+
}): Promise<{
|
|
733
|
+
ok: true;
|
|
734
|
+
grant: Grant;
|
|
735
|
+
holdings: Holdings;
|
|
736
|
+
}>;
|
|
737
|
+
private gate;
|
|
585
738
|
}
|
|
586
739
|
declare class ServerCollectionClient {
|
|
587
740
|
private readonly apiUrl;
|
package/dist/index.js
CHANGED
|
@@ -799,6 +799,126 @@ export class GemmeinServer {
|
|
|
799
799
|
}
|
|
800
800
|
return response.json();
|
|
801
801
|
}
|
|
802
|
+
/**
|
|
803
|
+
* THE GATE — your compute, our answer. Gemmein runs no code of yours;
|
|
804
|
+
* your own function runs anywhere and asks the only three questions it
|
|
805
|
+
* has: who is this person, what do they hold, change what they hold.
|
|
806
|
+
*
|
|
807
|
+
* const { person, holdings } = await g.verifySession(token);
|
|
808
|
+
* if (!holdings.access.includes("access:pro")) return deny();
|
|
809
|
+
*
|
|
810
|
+
* ONE call per request answers identity AND holdings — don't call it
|
|
811
|
+
* twice, and don't cache the answer past the request. Verify needs no
|
|
812
|
+
* capability on the key (the caller already holds the person's token)
|
|
813
|
+
* and never touches the session: no extension, no last-seen.
|
|
814
|
+
*
|
|
815
|
+
* Refusals (`err.code`): `session_invalid` (no session matches this
|
|
816
|
+
* token — the person signs in again; never reuse tokens across
|
|
817
|
+
* people) · `session_expired` · `session_revoked` (a newer sign-in,
|
|
818
|
+
* a sign-out, or the owner) — all three send the person back to
|
|
819
|
+
* sign-in · `person_suspended` (the owner suspended them; access is
|
|
820
|
+
* off until the owner reactivates them in the dashboard) ·
|
|
821
|
+
* `invalid_body` (token missing, not a string, or over 512 chars).
|
|
822
|
+
*/
|
|
823
|
+
async verifySession(token) {
|
|
824
|
+
return this.gate("/server/verify-session", {
|
|
825
|
+
method: "POST",
|
|
826
|
+
body: JSON.stringify({ token }),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* What one of YOUR people holds, by person id — for the paths where no
|
|
831
|
+
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
832
|
+
* screen you built). A person id, NEVER an email address: ids come
|
|
833
|
+
* from `verifySession` or the dashboard.
|
|
834
|
+
*
|
|
835
|
+
* A suspended person is RETURNED, with `suspended: true`, alongside
|
|
836
|
+
* their holdings — your function may need to say so. The gate itself
|
|
837
|
+
* (`verifySession`) refuses them.
|
|
838
|
+
*
|
|
839
|
+
* Refusals: `capability_required` — this key can't look up people by
|
|
840
|
+
* id; ask your human to mint a key with "Look up a person's access by
|
|
841
|
+
* id" ticked, or use `verifySession` with the person's own token ·
|
|
842
|
+
* `person_not_found` (404 — no person with this id in this app and
|
|
843
|
+
* environment; existence is never leaked).
|
|
844
|
+
*/
|
|
845
|
+
async holdings(personId) {
|
|
846
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/holdings`);
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Give a person access by hand — a trial, a promotion, an apology, a
|
|
850
|
+
* migration from your old system:
|
|
851
|
+
*
|
|
852
|
+
* await g.grantAccess(personId, {
|
|
853
|
+
* entitlement: "access:pro", // or the plan's NAME, e.g. "Pro"
|
|
854
|
+
* source: "trial",
|
|
855
|
+
* expiresAt: "2026-10-01T00:00:00.000Z",
|
|
856
|
+
* reason: "7-day trial from the onboarding flow",
|
|
857
|
+
* });
|
|
858
|
+
*
|
|
859
|
+
* MANUAL sources only. Purchases and subscriptions come only from
|
|
860
|
+
* Stripe — a key cannot mint paid access, by design. `reason` is up to
|
|
861
|
+
* 200 characters, is never edited afterwards, and is what the owner
|
|
862
|
+
* reads in their logs; write it for them.
|
|
863
|
+
*
|
|
864
|
+
* `sourceId` is minted per call, so two calls make TWO grants (each
|
|
865
|
+
* with its own one reason) — call it once, and keep your own retry
|
|
866
|
+
* key if the caller can retry.
|
|
867
|
+
*
|
|
868
|
+
* Returns the new grant and the holdings AFTER it; the owner's audit
|
|
869
|
+
* row carries before→after and the key's name.
|
|
870
|
+
*
|
|
871
|
+
* Refusals: `capability_required` — this key can't grant access; ask
|
|
872
|
+
* your human to mint a key with "Grant and revoke access" ticked ·
|
|
873
|
+
* `invalid_source` (purchase/subscription refused) ·
|
|
874
|
+
* `invalid_entitlement` / `unknown_plan` (no plan or product by that
|
|
875
|
+
* name — the owner adds it on the Payments page) · `person_not_found`.
|
|
876
|
+
*/
|
|
877
|
+
async grantAccess(personId, input) {
|
|
878
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/grants`, {
|
|
879
|
+
method: "POST",
|
|
880
|
+
body: JSON.stringify({
|
|
881
|
+
entitlement: input.entitlement,
|
|
882
|
+
...(input.source ? { source: input.source } : {}),
|
|
883
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
884
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
885
|
+
}),
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* End one grant — the reversibility law in one call:
|
|
890
|
+
*
|
|
891
|
+
* await g.revokeAccess(personId, grant.id, { reason: "trial ended" });
|
|
892
|
+
*
|
|
893
|
+
* A grant ends ONCE: `revokedAt` is set and never edited, so a second
|
|
894
|
+
* call is `409 already_revoked`, not a silent no-op. A key MAY end a
|
|
895
|
+
* grant that a payment created (the same as the owner's "end this
|
|
896
|
+
* access" button) — the payment itself is untouched, and the audit row
|
|
897
|
+
* says so.
|
|
898
|
+
*
|
|
899
|
+
* Returns the revoked grant and the holdings after it.
|
|
900
|
+
*
|
|
901
|
+
* Refusals: `capability_required` (same ticked box as granting) ·
|
|
902
|
+
* `grant_not_found` (404 — not this person's grant, in this app and
|
|
903
|
+
* environment; existence is never leaked) · `already_revoked` (409).
|
|
904
|
+
*/
|
|
905
|
+
async revokeAccess(personId, grantId, input = {}) {
|
|
906
|
+
return this.gate(`/server/people/${encodeURIComponent(personId)}/grants/${encodeURIComponent(grantId)}/revoke`, { method: "POST", body: JSON.stringify({ ...(input.reason ? { reason: input.reason } : {}) }) });
|
|
907
|
+
}
|
|
908
|
+
// One request path for the four gate calls, so every refusal reaches
|
|
909
|
+
// the caller through the SAME typed error the rest of the SDK throws —
|
|
910
|
+
// `err.code` carries the server's own code, `err.message` its sentence
|
|
911
|
+
// (which always names the next action).
|
|
912
|
+
async gate(path, init = {}) {
|
|
913
|
+
const headers = { "x-app-key": this.secretKey };
|
|
914
|
+
if (init.body)
|
|
915
|
+
headers["content-type"] = "application/json";
|
|
916
|
+
const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
|
|
917
|
+
if (!response.ok) {
|
|
918
|
+
throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
|
|
919
|
+
}
|
|
920
|
+
return response.json();
|
|
921
|
+
}
|
|
802
922
|
}
|
|
803
923
|
class ServerCollectionClient {
|
|
804
924
|
constructor(apiUrl, secretKey, name) {
|
package/llms.txt
CHANGED
|
@@ -49,9 +49,14 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
49
49
|
nothing in between — so an app whose CORE loop needs teams, roles
|
|
50
50
|
or seats DOESN'T FIT
|
|
51
51
|
- carts, quantities, physical goods, shipping, inventory
|
|
52
|
-
- credits
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
- credits or usage metering TODAY (paid access is yes-or-no; consumable
|
|
53
|
+
credits are planned, not shipped — don't design around them yet, and
|
|
54
|
+
never sell a "credit pack" until this file says the ledger exists)
|
|
55
|
+
- seats (owner-run: not coming)
|
|
56
|
+
- hosted functions or cron: Gemmein runs no code of yours — but your
|
|
57
|
+
own function anywhere asks Gemmein who a person is and what they
|
|
58
|
+
hold; read the server-side note closing this section before
|
|
59
|
+
counting that a gap
|
|
55
60
|
- custody of money (Stripe holds all funds; your human brings or
|
|
56
61
|
creates their own Stripe account when the app charges)
|
|
57
62
|
- multi-seller payouts or revenue splits: every payment lands whole in
|
|
@@ -74,8 +79,10 @@ code on your human's OWN host — Vercel, a VPS, anywhere — holding a
|
|
|
74
79
|
secret key already reads and updates collections and sends event emails
|
|
75
80
|
with `notify()`. (A secret key sees PAST the safety rules — every
|
|
76
81
|
customer's records — which is why it never touches a browser. It cannot
|
|
77
|
-
create or delete records
|
|
78
|
-
|
|
82
|
+
create or delete records: those are the signed-in customer's own calls,
|
|
83
|
+
or the dashboard's. It CAN verify a session token and read what that
|
|
84
|
+
person holds — the gate — and, when your human ticks the box on the key,
|
|
85
|
+
look a person up by id and grant or revoke access by hand.)
|
|
79
86
|
"This app needs a server-side piece" is therefore not a gap by itself:
|
|
80
87
|
the verdict downgrades when the server piece needs a call a secret key
|
|
81
88
|
cannot make, or when the app needs compute your human has nowhere to
|
|
@@ -345,6 +352,62 @@ contents.
|
|
|
345
352
|
cap (a security notice never loses to five order emails); misusing it
|
|
346
353
|
for campaigns shows in your own audit trail. In `gemmein dev` the send
|
|
347
354
|
prints in the terminal (NOTIFY · …) instead of mailing.
|
|
355
|
+
- Your compute, our gate: when code of yours must run elsewhere (a model
|
|
356
|
+
call, a PDF render, a nightly job), Gemmein hosts none of it — your own
|
|
357
|
+
function runs anywhere and asks Gemmein its only three questions: who is
|
|
358
|
+
this person, what do they hold, change what they hold.
|
|
359
|
+
|
|
360
|
+
import { gemmeinServer } from "@gemmein/sdk";
|
|
361
|
+
const g = gemmeinServer(process.env.GEMMEIN_SECRET_KEY);
|
|
362
|
+
const { person, holdings } = await g.verifySession(token);
|
|
363
|
+
if (!holdings.access.includes("access:pro")) return deny();
|
|
364
|
+
await g.holdings(personId); // no token in hand
|
|
365
|
+
await g.grantAccess(personId, { entitlement: "access:pro",
|
|
366
|
+
source: "trial", expiresAt, reason: "7-day trial" });
|
|
367
|
+
await g.revokeAccess(personId, grantId, { reason: "trial ended" });
|
|
368
|
+
|
|
369
|
+
ONE verifySession per request answers identity AND holdings — don't
|
|
370
|
+
call it twice, don't cache the answer past the request. `holdings` is
|
|
371
|
+
what the person holds NOW: `access` (keys like "access:pro"), `grants`
|
|
372
|
+
(each with its source KIND only — subscription | purchase | manual |
|
|
373
|
+
trial | promotion | migration — plus start and expiry), and `credits`,
|
|
374
|
+
a reserved slot that is always `null` today (consumable credits are NOT
|
|
375
|
+
shipped; don't design around them). Never subscription status, amounts
|
|
376
|
+
or Stripe ids: gate on what a person HOLDS, never on billing. Person
|
|
377
|
+
id, NEVER an email address. verifySession needs nothing extra; looking
|
|
378
|
+
someone up by id and granting are new power, so they sit behind per-key
|
|
379
|
+
checkboxes the HUMAN ticks when minting the key — ask your human to
|
|
380
|
+
tick "Look up a person's access by id" / "Grant and revoke access". A
|
|
381
|
+
key grants MANUAL access only (manual | trial | promotion | migration):
|
|
382
|
+
purchases and subscriptions still come only from Stripe. Every /server/*
|
|
383
|
+
call a resolved secret key makes, ok or refused, lands in that key's
|
|
384
|
+
usage ledger on the owner's Keys page (a rejected or publishable key
|
|
385
|
+
can't be attributed, so it reaches only the request log); refusals also
|
|
386
|
+
write one activity row per key, route and code each hour. In `gemmein
|
|
387
|
+
dev` the local key already holds both capabilities, and the usage read
|
|
388
|
+
is cloud-only — the local runtime never mounts the console. The
|
|
389
|
+
refusals, and what to do:
|
|
390
|
+
session_invalid no session matches this token — the person must
|
|
391
|
+
sign in again; never reuse tokens across people
|
|
392
|
+
session_expired the session ended — back to sign-in
|
|
393
|
+
session_revoked newer sign-in, sign-out, or the owner — sign-in
|
|
394
|
+
person_suspended the owner suspended them — access is off until
|
|
395
|
+
the owner reactivates them in the dashboard
|
|
396
|
+
person_not_found no person with this id in this app and env —
|
|
397
|
+
ids come from verifySession or the dashboard
|
|
398
|
+
capability_required the box isn't ticked on this key — ask your
|
|
399
|
+
human to mint one with it ticked
|
|
400
|
+
unknown_plan no plan or product by that name — the owner
|
|
401
|
+
adds it on the Payments page
|
|
402
|
+
grant_not_found not this person's grant here — re-read holdings
|
|
403
|
+
already_revoked a grant ends once — it is already ended
|
|
404
|
+
invalid_source purchase/subscription asked for by hand — use
|
|
405
|
+
manual | trial | promotion | migration instead
|
|
406
|
+
invalid_id a prototype name (__proto__, constructor) was
|
|
407
|
+
sent as a person or grant id — ids come from
|
|
408
|
+
verifySession or the dashboard, never a name
|
|
409
|
+
invalid_body one malformed field (token, expiresAt, reason)
|
|
410
|
+
— the message names which one and what it needs
|
|
348
411
|
- Linking records (author on a post, product on an order): store the other
|
|
349
412
|
record's id in a field (`authorProfileId: profile.id`) — in collections
|
|
350
413
|
users write (community, shared, direct) the server learns it's a link;
|
|
@@ -509,7 +572,40 @@ contents.
|
|
|
509
572
|
screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
|
|
510
573
|
(everything they paid for, refunds applied, with the `grants` each purchase
|
|
511
574
|
carries) and `await g.subscriptions.mine()`.
|
|
512
|
-
NO credits
|
|
575
|
+
NO credits or usage limits YET — access is yes-or-no today. Consumable
|
|
576
|
+
credits (buy a pack, spend atomically, a zero floor) are planned, not
|
|
577
|
+
shipped: until this file teaches them, a "100 credit pack" is NOT a
|
|
578
|
+
pattern (the webhook can only SET a number, so a second pack erases the
|
|
579
|
+
first). NO seats, by design — not coming.
|
|
580
|
+
- Two FAMILIES of grant, and the line between them is law. Every grant is
|
|
581
|
+
one key + one reason + a start + maybe an end, never edited (an extension
|
|
582
|
+
is a NEW grant). PURCHASE-TIED (`subscription`, `purchase`): written only
|
|
583
|
+
by Stripe's signed webhook when money arrives — never by the owner's
|
|
584
|
+
hand, never by your app; a subscription grant ends on cancellation or
|
|
585
|
+
lapse, a purchase grant on a FULL refund (partial leaves it); the only
|
|
586
|
+
grants that count as revenue. Revoking one by hand stops the access and
|
|
587
|
+
refunds nothing. BY-HAND (`manual`, `trial`, `promotion`, plus
|
|
588
|
+
`migration` for customers imported from elsewhere): the owner writes them
|
|
589
|
+
from a customer's page — plan or product BY NAME, optional end date, a
|
|
590
|
+
reason — and they end on that date or when revoked; never revenue; a
|
|
591
|
+
refund never touches them. Effective access is the union of both
|
|
592
|
+
families. WHEN A REFUND MEETS ACCESS, THE OWNER DECIDES: the platform
|
|
593
|
+
revokes only on events that plainly mean it (subscription cancelled or
|
|
594
|
+
lapsed; one-off purchase refunded IN FULL). A partial refund leaves
|
|
595
|
+
access; a refund on a subscription invoice touches no access at all
|
|
596
|
+
(grants are revoked by purchase reference, subscriptions by customer).
|
|
597
|
+
A cancel-at-period-end keeps access until the period ends. If the owner
|
|
598
|
+
wants access gone NOW, they cancel the subscription immediately in
|
|
599
|
+
Stripe or revoke by hand on the customer's page — both audited; every
|
|
600
|
+
refund the webhook chose not to act on is written to the activity view.
|
|
601
|
+
Tell your human this when they ask "I refunded them, why can they still
|
|
602
|
+
get in" — never write refund logic in the app. Your app sees purchases (`g.purchases.mine()`, each with its
|
|
603
|
+
`grants`) and the subscription (`g.subscriptions.mine()`) — by-hand
|
|
604
|
+
grants are NOT listed to the app; a gated read simply succeeds. So never
|
|
605
|
+
rebuild the paywall client-side from what you can list: let the server
|
|
606
|
+
refuse and show the upgrade prompt on `entitlement_required`. "Give X a
|
|
607
|
+
free month" is a trial grant on their customer page, never code — ask
|
|
608
|
+
your human.
|
|
513
609
|
- Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
|
|
514
610
|
access only — physical goods, shipping, inventory and carts are out of
|
|
515
611
|
scope, said out loud): plans are for subscriptions; products are for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Gemmein SDK — passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|