@gemmein/sdk 0.4.6 → 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 +110 -7
- 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 +234 -24
- package/package.json +2 -2
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
|
|
@@ -79,6 +84,8 @@ deleted, subscription row removed. Irreversible: put a real confirm in front.
|
|
|
79
84
|
`name` must be **lowercase letters, numbers, and underscores** (`saved_games`,
|
|
80
85
|
never `savedGames` — a bad name throws synchronously). Collections are created
|
|
81
86
|
by the app owner in the dashboard, never by the SDK.
|
|
87
|
+
Names JavaScript owns (`constructor`, `toString`, `__proto__`…) are refused as
|
|
88
|
+
collection and field names — a plain object inherits them.
|
|
82
89
|
|
|
83
90
|
`options.intent` — one sentence: what the collection is for and who should
|
|
84
91
|
access it. It rides every call as a hint; against a **local `gemmein dev`
|
|
@@ -189,13 +196,20 @@ type ListOptions = {
|
|
|
189
196
|
```
|
|
190
197
|
|
|
191
198
|
**Options notes.** `key` = create-if-absent (a second writer gets 409
|
|
192
|
-
`conflict`; your own retry returns the record with `existing: true`).
|
|
199
|
+
`conflict`; your own retry returns the record with `existing: true`). The
|
|
200
|
+
retry doubles as the lookup: there is no separate get-by-key call — re-issue
|
|
201
|
+
the same `create()` with the same key to fetch your own record (it counts
|
|
202
|
+
as a write; a re-fetch idiom, not a read path). `for` =
|
|
193
203
|
recipient id on `addressed`/`direct`. `published: false` = draft on a public
|
|
194
204
|
rule. `ifVersion` = optimistic concurrency. Atomic counters go in *value*
|
|
195
205
|
position: `update(id, { stock: { decrement: 1, floor: 0 } })`. `expand` throws
|
|
196
206
|
on `private`/`public_read`/`admin_write` (no link shape) — join in memory there.
|
|
197
207
|
`since` fixes the order (oldest change first) — pairing it with `sort` is a 400
|
|
198
|
-
`invalid_since`. Deltas are at-least-once: apply by id.
|
|
208
|
+
`invalid_since`. Deltas are at-least-once: apply by id. A field added to a
|
|
209
|
+
sealed shape by a promote run may carry a **default**: records that never wrote
|
|
210
|
+
it read that value, `expand` targets and `where` see it too, and a written value
|
|
211
|
+
wins (writing `null` clears it back to the default) — `search` still matches
|
|
212
|
+
stored text only.
|
|
199
213
|
|
|
200
214
|
### Live data — `watch()`
|
|
201
215
|
|
|
@@ -224,6 +238,10 @@ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
|
224
238
|
don't also redirect to the returned `url`, and never build a Stripe URL
|
|
225
239
|
yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
|
|
226
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".
|
|
227
245
|
|
|
228
246
|
---
|
|
229
247
|
|
|
@@ -253,6 +271,88 @@ honestly, not hidden.)
|
|
|
253
271
|
|
|
254
272
|
---
|
|
255
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
|
+
|
|
256
356
|
## Reaffirm — prove your app's boundaries in CI
|
|
257
357
|
|
|
258
358
|
Gemmein enforces the rules **server-side**, so your frontend is never the source
|
|
@@ -320,7 +420,10 @@ class GemmeinError extends Error {
|
|
|
320
420
|
}
|
|
321
421
|
```
|
|
322
422
|
|
|
323
|
-
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:
|
|
324
427
|
|
|
325
428
|
| code | meaning | do |
|
|
326
429
|
|------|---------|-----|
|
|
@@ -333,7 +436,7 @@ Branch on `err.code`. The stable codes:
|
|
|
333
436
|
| `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
|
|
334
437
|
| `html_not_allowed` | HTML in a community/addressed/direct field | store plain text |
|
|
335
438
|
| `invalid_publish` | `{ published }` on a non-public rule | drop it |
|
|
336
|
-
| `invalid_shape` | field not in a locked (live) collection's shape — or a file field given the wrong kind (the message names which) |
|
|
439
|
+
| `invalid_shape` | field not in a locked (live) collection's shape — or a file field given the wrong kind (the message names which) | after the field exists in development, one promote run adds it (the owner answers blank or a default) — until then send only the fields the shape has / send the kind the field takes |
|
|
337
440
|
| `unknown_file` | a ref-shaped value points at a file that doesn't exist or isn't yours to hand — every rule, every write | fix the ref — never invent one |
|
|
338
441
|
| `invalid_since` | `since` isn't a strict ISO 8601 timestamp, or came with `sort` | pass the previous answer's watermark; drop `sort` |
|
|
339
442
|
| `not_a_customer` (404) | `notify()`'s recipient isn't a verified person of this app and environment | fix the person id — one code on purpose |
|
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
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
> verified people (five pre-agreed steps up to 10,000 people; beyond that,
|
|
11
11
|
> talk to Gemmein).
|
|
12
12
|
|
|
13
|
+
This file IS the product contract, not an overview: every line is
|
|
14
|
+
load-bearing, and building from a summary of it builds the wrong app. Work
|
|
15
|
+
from the full raw file — if what you hold is a summary or an excerpt,
|
|
16
|
+
re-fetch https://docs.gemmein.com/llms.txt raw and read all of it before
|
|
17
|
+
you answer or build.
|
|
18
|
+
|
|
13
19
|
## First: assess fit (before any install, account, or code)
|
|
14
20
|
|
|
15
21
|
You are likely an AI assistant whose human asked you to build an app — or to
|
|
@@ -38,11 +44,19 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
38
44
|
silently approximated:
|
|
39
45
|
- native mobile apps (no mobile SDKs today; web only)
|
|
40
46
|
- password or social/OAuth sign-in (email codes are the only door)
|
|
41
|
-
- team/group/workspace visibility or per-user record permissions
|
|
47
|
+
- team/group/workspace visibility or per-user record permissions:
|
|
48
|
+
Gemmein is owner-run by design — one owner and their customers,
|
|
49
|
+
nothing in between — so an app whose CORE loop needs teams, roles
|
|
50
|
+
or seats DOESN'T FIT
|
|
42
51
|
- carts, quantities, physical goods, shipping, inventory
|
|
43
|
-
- credits
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
46
60
|
- custody of money (Stripe holds all funds; your human brings or
|
|
47
61
|
creates their own Stripe account when the app charges)
|
|
48
62
|
- multi-seller payouts or revenue splits: every payment lands whole in
|
|
@@ -60,6 +74,21 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
60
74
|
- DOESN'T FIT — say so, name why, and stop. Recommending nothing is
|
|
61
75
|
the correct outcome here.
|
|
62
76
|
|
|
77
|
+
Server-side work is NOT itself a downgrade. Gemmein hosts no compute, but
|
|
78
|
+
code on your human's OWN host — Vercel, a VPS, anywhere — holding a
|
|
79
|
+
secret key already reads and updates collections and sends event emails
|
|
80
|
+
with `notify()`. (A secret key sees PAST the safety rules — every
|
|
81
|
+
customer's records — which is why it never touches a browser. It cannot
|
|
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.)
|
|
86
|
+
"This app needs a server-side piece" is therefore not a gap by itself:
|
|
87
|
+
the verdict downgrades when the server piece needs a call a secret key
|
|
88
|
+
cannot make, or when the app needs compute your human has nowhere to
|
|
89
|
+
run — a cron schedule or an always-on worker with no host of its own —
|
|
90
|
+
because Gemmein never runs your code.
|
|
91
|
+
|
|
63
92
|
## Getting started (AI-run, human-answered)
|
|
64
93
|
|
|
65
94
|
No signup, no account, no keys — building is local and free. Your human
|
|
@@ -108,17 +137,68 @@ go-live. Everything else is yours.
|
|
|
108
137
|
lists the rest.
|
|
109
138
|
4. Hand over for testing — the human moment mid-build: they click through
|
|
110
139
|
the app, and their sign-in codes print in the dev terminal, so tell
|
|
111
|
-
them where to look.
|
|
140
|
+
them where to look. Running dev in the background with no terminal in
|
|
141
|
+
view? The same codes append to gemmein/.data/signin-codes.log (fresh
|
|
142
|
+
each dev boot) — read the latest line instead of scraping stdout.
|
|
112
143
|
5. Done building? `npx gemmein check` reads the project and says what's
|
|
113
144
|
ready and what go-live still needs. Then `npx gemmein sync` — THIS is
|
|
114
145
|
the moment your human signs up (free) at app.gemmein.com and pastes two
|
|
115
146
|
dev keys — and `npx gemmein go-live` walks the rest: plans, Payment
|
|
116
147
|
Links, the live flip. A card enters at go-live, never before.
|
|
148
|
+
6. After go-live, the DATA MODEL reaches live only by PROMOTION from
|
|
149
|
+
development: new collections built in dev promote (`npx gemmein
|
|
150
|
+
go-live` again, or the dashboard's Go-live page), and so do new FIELDS
|
|
151
|
+
for a live collection whose shape sealed at go-live (see Shapes). Ship
|
|
152
|
+
in that order: promote first, then deploy the frontend that depends on
|
|
153
|
+
the new collection or field — a frontend shipped ahead of its
|
|
154
|
+
promotion reads blanks or refusals from live. Each added field is one
|
|
155
|
+
question for YOUR HUMAN, asked by the promote run: existing records
|
|
156
|
+
don't have it, so what should they show? Blank is a fine answer, or
|
|
157
|
+
they give a default those records will show. What is already
|
|
158
|
+
sealed never moves — a live field never changes type or name and never
|
|
159
|
+
leaves — and rules still never change through promotion. Plans,
|
|
160
|
+
Payment Links and domains stay live-editable in the dashboard, and a
|
|
161
|
+
field's default joins them there (changeable or cleared later, with no
|
|
162
|
+
promotion needed).
|
|
163
|
+
|
|
164
|
+
Two runtimes, one dashboard — keep your human oriented on where things
|
|
165
|
+
live, or the dashboard will look broken to them. The local runtime
|
|
166
|
+
(`gemmein dev`, `pk_local_` keys) is a private rehearsal room: every
|
|
167
|
+
record, person and simulated payment stays in gemmein/.data on this
|
|
168
|
+
machine and never appears in any dashboard. The dashboard at
|
|
169
|
+
app.gemmein.com is the back office of the CLOUD app only — there is no
|
|
170
|
+
local dashboard and nothing to log into locally. `npx gemmein sync`
|
|
171
|
+
carries STRUCTURE to the cloud app's development environment —
|
|
172
|
+
collections, rules, plan locks — never records or people; after a sync
|
|
173
|
+
the dashboard shows the shape of the app with no data in it, and that is
|
|
174
|
+
correct, not a bug. Data reaches the dashboard only when the app runs on
|
|
175
|
+
cloud keys: the dev pair from app.gemmein.com (`pk_test_` in the
|
|
176
|
+
browser, `sk_dev_` on a server) drives the real API's development
|
|
177
|
+
environment, and `pk_live_` drives production after go-live. The key
|
|
178
|
+
prefix always names the rail — local, test, live. The dev rail is where
|
|
179
|
+
a REAL rehearsal happens before any card enters: real emailed sign-in
|
|
180
|
+
codes, real data in the dashboard — and it can face the public. Put the
|
|
181
|
+
app on a real https domain and list that domain on the dashboard's
|
|
182
|
+
Domains page: before go-live a listed domain works WITHOUT verification
|
|
183
|
+
(http://localhost and http://127.0.0.1 also work unlisted until go-live
|
|
184
|
+
drops them; any other raw IP never works — the Domains page accepts
|
|
185
|
+
names only). Go-live is when the keys lock to proven domains. The
|
|
186
|
+
development environment carries its own free monthly allowance (10,000
|
|
187
|
+
API requests, 500 writes, 500 sign-in events, 1 GiB of files); crossing
|
|
188
|
+
a counter never stops the app and never turns into a bill — the
|
|
189
|
+
dashboard's usage page shows what crossed, and storage is the one thing
|
|
190
|
+
that can fill (see Pricing). Tell your human before they first open the
|
|
191
|
+
dashboard: local building is invisible there on purpose — nothing
|
|
192
|
+
leaves this machine until sync, and sync moves the blueprint, not the
|
|
193
|
+
contents.
|
|
117
194
|
|
|
118
195
|
## What it is
|
|
119
196
|
|
|
120
197
|
- Audience: people who build web apps with AI tools (Cursor, Bolt, Lovable, Claude).
|
|
121
198
|
- Scope: web apps (TypeScript/JavaScript SDK). Mobile SDKs are not offered today.
|
|
199
|
+
- Shape: built for owner-run businesses — one owner and their customers,
|
|
200
|
+
nothing in between. No team seats, no staff accounts, no workspaces, on
|
|
201
|
+
purpose: one person holds the dashboard, and everyone else is a customer.
|
|
122
202
|
- The platform holds identity, data, and subscription state. It never holds,
|
|
123
203
|
moves, or processes money — Stripe handles all payments end-to-end; Gemmein
|
|
124
204
|
only receives Stripe's webhooks and records who is on which plan. App
|
|
@@ -143,7 +223,7 @@ go-live. Everything else is yours.
|
|
|
143
223
|
those local declarations. A declaration is `gemmein/collections/<name>.json`:
|
|
144
224
|
`{ "rule": "shared", "means": "<why, in your human's words>" }`, plus an
|
|
145
225
|
optional `"unlockedBy": ["pro"]` — plan or product NAMES from
|
|
146
|
-
gemmein/payments.json (never access keys) — which is the
|
|
226
|
+
gemmein/payments.json (never access keys) — which is the dashboard's
|
|
147
227
|
"Unlocked by": the local engine refuses members without one of them
|
|
148
228
|
(403 entitlement_required) the moment the file lands, and sync carries
|
|
149
229
|
it to the cloud app. An unknown name is refused out loud in the dev
|
|
@@ -272,6 +352,62 @@ go-live. Everything else is yours.
|
|
|
272
352
|
cap (a security notice never loses to five order emails); misusing it
|
|
273
353
|
for campaigns shows in your own audit trail. In `gemmein dev` the send
|
|
274
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
|
|
275
411
|
- Linking records (author on a post, product on an order): store the other
|
|
276
412
|
record's id in a field (`authorProfileId: profile.id`) — in collections
|
|
277
413
|
users write (community, shared, direct) the server learns it's a link;
|
|
@@ -315,7 +451,7 @@ go-live. Everything else is yours.
|
|
|
315
451
|
retry. A 403 from `link()` means the customer isn't allowed this
|
|
316
452
|
file right now — signed out, not theirs, or an entitlement they no longer
|
|
317
453
|
hold (`entitlement_required` — `err.requires` is the plan's key, the one
|
|
318
|
-
the
|
|
454
|
+
the dashboard shows by name).
|
|
319
455
|
Honest bound: revoking access stops NEW links immediately; a link already
|
|
320
456
|
issued works until it expires. Gemmein controls delivery, it can't take
|
|
321
457
|
back a file someone already downloaded. A file can name ONE other reader:
|
|
@@ -339,11 +475,22 @@ go-live. Everything else is yours.
|
|
|
339
475
|
If a stored ref's file was later deleted, re-sending it refuses the same
|
|
340
476
|
way: clear the field (null) or upload a fresh file. Once sealed, class
|
|
341
477
|
is law too: a ZIP into a profile photo field is refused with the teach;
|
|
342
|
-
before the seal, what you upload is what the field learns. A 400
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
478
|
+
before the seal, what you upload is what the field learns. A 400
|
|
479
|
+
invalid_shape means the field isn't in the locked shape or the value
|
|
480
|
+
doesn't fit its kind. A sealed shape CAN still gain a new field, but
|
|
481
|
+
only through a promote run: build it in development, tell your human
|
|
482
|
+
which field you needed, and until that promotion runs send only the
|
|
483
|
+
fields the shape already has. Never rename fields to dodge it. What is
|
|
484
|
+
sealed never moves — a field never changes type or name and never
|
|
485
|
+
leaves. A field promoted with a DEFAULT is served to the records that
|
|
486
|
+
never wrote it: get, list, expand targets and `where` filters all see
|
|
487
|
+
the default (an atomic increment counts from it too), and a written
|
|
488
|
+
value wins; writing `null` clears it back to the default. Only text,
|
|
489
|
+
number and yes/no fields can carry a default; list, link, json and
|
|
490
|
+
file fields join blank. Honest bound: full-text `search` scans stored
|
|
491
|
+
text only, so a record showing a default is not found by searching for
|
|
492
|
+
it. New collections built in dev reach live through the same PROMOTION,
|
|
493
|
+
sealing on the way.
|
|
347
494
|
- Contention (bookings, slugs, stock, shared edits): when two users can race
|
|
348
495
|
for the same thing, a permission model can't save you — preconditions do,
|
|
349
496
|
and they're just arguments on calls you already make. A 409 `conflict` from
|
|
@@ -352,14 +499,19 @@ go-live. Everything else is yours.
|
|
|
352
499
|
- Uniqueness: `create(data, { key: "slot:2026-07-15T15:00" })` — derive the
|
|
353
500
|
key from the thing that must be unique; the second writer gets 409, your
|
|
354
501
|
own retry gets your existing record back (`existing: true`), deleting
|
|
355
|
-
frees the key.
|
|
356
|
-
|
|
357
|
-
|
|
502
|
+
frees the key. That retry IS the lookup — there is no separate
|
|
503
|
+
get-by-key call: to fetch your own record by key later, issue the
|
|
504
|
+
same `create()` with the same key and read `existing: true` off the
|
|
505
|
+
result. It counts as a write (rate and allowance), so use it to
|
|
506
|
+
re-fetch a claim — never on a render path. Keys are unique across
|
|
507
|
+
the WHOLE collection — every writer, every recipient, under every
|
|
508
|
+
safety rule (live records only) — so a claim is race-proof even in
|
|
509
|
+
`direct`/`addressed`. Never
|
|
358
510
|
find-then-create — that races. Keys are 1-120 chars of letters,
|
|
359
511
|
numbers, and `: _ . @ / -` only. A claimed key holds until its record
|
|
360
512
|
is deleted: if a claim must be PAID to stick (book then pay), expiring
|
|
361
|
-
unpaid claims is
|
|
362
|
-
dashboard
|
|
513
|
+
unpaid claims is the owner's job — they delete them from their
|
|
514
|
+
dashboard (a secret key cannot delete records); there is no cron.
|
|
363
515
|
- Limited stock (N units anyone can buy): claim units with keyed creates —
|
|
364
516
|
try `create({...}, { key: "unit:item42:1" })`, on conflict try `:2` … `:N`;
|
|
365
517
|
all taken = sold out. Race-proof under every safety rule.
|
|
@@ -404,7 +556,7 @@ go-live. Everything else is yours.
|
|
|
404
556
|
checks that the lock holds and names collections left open while the app
|
|
405
557
|
sells something) — and the
|
|
406
558
|
engine refuses customers without it, under all seven rules. (Server
|
|
407
|
-
secret keys and the owner's
|
|
559
|
+
secret keys and the owner's dashboard are exempt by design; link/expand
|
|
408
560
|
silently hide gated records rather than naming them.) Every plan and
|
|
409
561
|
product carries its own key, `access:<slug of its name>` (plan "pro" →
|
|
410
562
|
`access:pro`); the paid webhook grants that key, and a FULL refund or a
|
|
@@ -413,13 +565,47 @@ go-live. Everything else is yours.
|
|
|
413
565
|
Owners also grant and revoke by hand (trials, comps, support). Effective
|
|
414
566
|
access is the UNION of a customer's live grants. A signed-in customer
|
|
415
567
|
without access gets `403 entitlement_required`: `err.requires` is the
|
|
416
|
-
plan's key (the
|
|
568
|
+
plan's key (the dashboard shows the plan by name; the key is
|
|
569
|
+
`access:<slug>`).
|
|
417
570
|
A collection unlocked by several plans (OR) still names ONE key — offer
|
|
418
571
|
your plans by name through checkout, never a key list. Show your upgrade
|
|
419
572
|
screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
|
|
420
573
|
(everything they paid for, refunds applied, with the `grants` each purchase
|
|
421
574
|
carries) and `await g.subscriptions.mine()`.
|
|
422
|
-
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.
|
|
423
609
|
- Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
|
|
424
610
|
access only — physical goods, shipping, inventory and carts are out of
|
|
425
611
|
scope, said out loud): plans are for subscriptions; products are for
|
|
@@ -471,7 +657,9 @@ go-live. Everything else is yours.
|
|
|
471
657
|
sign in first). Render `err.message`; it reads correctly in every case.
|
|
472
658
|
Branch only on the specifically-named codes (unknown_collection,
|
|
473
659
|
unknown_product, invalid_shape, html_not_allowed, invalid_publish,
|
|
474
|
-
conflict, …) plus the forbidden-means-stop rule.
|
|
660
|
+
conflict, …) plus the forbidden-means-stop rule. If something is stuck
|
|
661
|
+
in a way this file doesn't explain, `npx gemmein feedback` reaches the
|
|
662
|
+
builders — it works from anywhere, with no project and no account.
|
|
475
663
|
- Draft state reads back as a TOP-LEVEL boolean `record.published` (next to
|
|
476
664
|
id/updatedAt), not under `.data`. Non-authors only ever receive published
|
|
477
665
|
records, so you see `false` only on your own drafts (or on everything, as
|
|
@@ -485,10 +673,27 @@ go-live. Everything else is yours.
|
|
|
485
673
|
`await g.account.delete()` — for every app it APPLIES to (GDPR right to
|
|
486
674
|
erasure; Apple 5.1.1(v) for apps with account creation). It's the full
|
|
487
675
|
server-side cascade (sessions, records, files, subscription row) and
|
|
488
|
-
irreversible — put a real confirm in front of it. Suspensions,
|
|
489
|
-
owner-side erasure are dashboard actions, not
|
|
676
|
+
irreversible — put a real confirm in front of it. Suspensions, forced
|
|
677
|
+
sign-out everywhere, and owner-side erasure are dashboard actions, not
|
|
678
|
+
SDK calls.
|
|
679
|
+
- The owner's dashboard already handles these — do not build them into the
|
|
680
|
+
app: creating collections and choosing their safety rule; naming plans
|
|
681
|
+
and products and pasting their Stripe Payment Links; the "Unlocked by"
|
|
682
|
+
lock on a collection; suspending, signing out and erasing customers;
|
|
683
|
+
moderation and status flips on other users' records; scoping a secret
|
|
684
|
+
key to collections and actions; verifying the sending and receiving
|
|
685
|
+
domain; usage and logs; the Inbox, where every notify() send is a
|
|
686
|
+
conversation and customer replies land; holding the billing band. None
|
|
687
|
+
of them has an SDK equivalent, so an owner screen you build for one is
|
|
688
|
+
a page that already exists, without the enforcement.
|
|
490
689
|
- Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
|
|
491
|
-
are public and domain-locked.
|
|
690
|
+
are public and domain-locked. A secret key can be SCOPED in the
|
|
691
|
+
dashboard's Keys room to particular collections, and to read-only or
|
|
692
|
+
read-and-update; a call outside that scope answers 403 `scope_denied`.
|
|
693
|
+
Read that error as deliberate — the human narrowed the key on purpose.
|
|
694
|
+
Name the collection and access your code needs and ask them to mint a
|
|
695
|
+
replacement key that includes it — scopes are fixed when a key is
|
|
696
|
+
created; never reach for a broader key than the job needs.
|
|
492
697
|
|
|
493
698
|
## Return values & shapes (get these exactly right)
|
|
494
699
|
|
|
@@ -500,6 +705,11 @@ rule. The specifics:
|
|
|
500
705
|
synchronously from `g.collection(name)`; if you call that at module
|
|
501
706
|
load, it can blank your whole app with no browser-console error. Name them
|
|
502
707
|
right.
|
|
708
|
+
One more refusal, for collection AND field names alike: anything
|
|
709
|
+
JavaScript itself owns — `constructor`, `toString`, `__proto__`,
|
|
710
|
+
`hasOwnProperty` and their kin — is refused the same way a reserved
|
|
711
|
+
server-managed field is. A plain object inherits those names, so a
|
|
712
|
+
seal must never carry one.
|
|
503
713
|
- The signed-in user: `await g.auth.currentUser()` →
|
|
504
714
|
`{ authenticated: true, userId, email }` or `{ authenticated: false }`. The
|
|
505
715
|
id field is **`userId`, not `id`** — `user.id` is `undefined`, and feeding
|
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.5.0",
|
|
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",
|
|
7
7
|
"main": "./dist/index.cjs",
|