@gemmein/sdk 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +224 -0
- package/REFERENCE.md +397 -20
- package/dist/index.cjs +259 -5
- package/dist/index.d.cts +179 -4
- package/dist/index.d.ts +179 -4
- package/dist/index.js +256 -4
- package/llms.txt +260 -25
- package/migrations/README.md +35 -0
- package/migrations/list-limit-refusal.md +42 -0
- package/package.json +7 -3
package/REFERENCE.md
CHANGED
|
@@ -27,9 +27,9 @@ that passes `baseUrl` to reach a local or staging server quietly talks to
|
|
|
27
27
|
production instead. Exposes read/update on collections without a signed-in user,
|
|
28
28
|
`notify()` to email one of your app's own verified people (see **Notify**),
|
|
29
29
|
the gate — `verifySession()` / `holdings()` / `grantAccess()` /
|
|
30
|
-
`revokeAccess()`, for code of yours running on your own
|
|
31
|
-
gate**) — plus `testSession()` for CI self-tests (dev
|
|
32
|
-
see **Reaffirm**).
|
|
30
|
+
`revokeAccess()` / `invitePerson()` / `spendCredits()`, for code of yours running on your own
|
|
31
|
+
host (see **Server gate**) — plus `testSession()` for CI self-tests (dev
|
|
32
|
+
environments only — see **Reaffirm**).
|
|
33
33
|
|
|
34
34
|
The client has two layers. **Your app's collections** — `g.collection(name)`
|
|
35
35
|
(the canonical spelling; `g.storage.collection(name)` is the same client). And
|
|
@@ -184,7 +184,7 @@ type ListResult<T> = {
|
|
|
184
184
|
};
|
|
185
185
|
|
|
186
186
|
type ListOptions = {
|
|
187
|
-
limit?: number;
|
|
187
|
+
limit?: number; // 25 by default, 100 at most — a larger ask is refused (400 invalid_limit); page with cursor
|
|
188
188
|
sort?: "newest" | "oldest" | "updated";
|
|
189
189
|
where?: Record<string, unknown>; // exact-match on data fields (and link fields)
|
|
190
190
|
cursor?: string; // from a previous ListResult
|
|
@@ -271,11 +271,160 @@ honestly, not hidden.)
|
|
|
271
271
|
|
|
272
272
|
---
|
|
273
273
|
|
|
274
|
-
##
|
|
274
|
+
## Credits — `g.credits` / `gemmeinServer(sk).spendCredits`
|
|
275
|
+
|
|
276
|
+
A balance your customers hold and your product spends: a pack they buy, a
|
|
277
|
+
comp the owner gives, a call that costs one. Credits are a **quantity beside
|
|
278
|
+
access**, never access itself — a locked collection still asks for the
|
|
279
|
+
entitlement, whatever the balance. The ledger adds at every purchase, grant
|
|
280
|
+
and refund, subtracts at every spend, and never goes below zero: a spend
|
|
281
|
+
past the balance is refused whole (`402 credits_exhausted`, the balance in
|
|
282
|
+
the message), never partly. No expiry. No per-token pricing.
|
|
283
|
+
|
|
284
|
+
| Surface | Signature | Returns |
|
|
285
|
+
|---------|-----------|---------|
|
|
286
|
+
| `g.credits.balance` | `()` | `Promise<{ balance: number }>` — the signed-in person's balance now (`401 session_required` without a session) |
|
|
287
|
+
| `gemmeinServer(sk).spendCredits` | `(personId, { amount?: number; reason: string; key?: string })` | `Promise<{ ok: true; spent: number; deduped: boolean; balance: { before: number; after: number }; event: { id, reason, actor } }>` — `amount` defaults to 1 (1..10,000); `reason` ≤ 200 chars is what the owner reads on the person's ledger; `key` makes the spend at-most-once per person (a repeat answers `deduped: true`, `spent: 0`, the same `event`, and moves nothing; one key reused for a second person charges that person). Needs **"Spend a person's credits"** ticked on the key (`403 capability_required` otherwise) |
|
|
288
|
+
| `Holdings.credits` | — | `{ balance: number }` on `verifySession()` and `holdings()` from engine 0.8.0 (`null` from an older local engine) — one verify answers who, what they hold and how many |
|
|
289
|
+
|
|
290
|
+
Where credits come from, and where they go:
|
|
291
|
+
|
|
292
|
+
- **A product with `grantsCredits`** (the "Grants credits" field on the
|
|
293
|
+
Payments page, 1..1,000,000). Each confirmed purchase **adds** that many to
|
|
294
|
+
the buyer's balance — two packs make double, never a reset — idempotent on
|
|
295
|
+
the payment. A purchase by an email that has never signed in creates the
|
|
296
|
+
person and credits them. A **full** refund claws back what that purchase's
|
|
297
|
+
own ledger row granted — at most what is still unspent, floor 0 — whatever
|
|
298
|
+
the product says today; a partial refund moves nothing. The purchase, the
|
|
299
|
+
grant and the clawback are each one ledger line.
|
|
300
|
+
- **The owner's dashboard** comps by hand from the person's page (≤100,000 per
|
|
301
|
+
action, a note required), and shows the balance, the newest 50 non-spend
|
|
302
|
+
ledger lines and the credits the person spent in the last 30 days.
|
|
303
|
+
- **A relay** adds with `grant_credits { amount (1..10,000), reason? }` — see
|
|
304
|
+
**Relays**.
|
|
305
|
+
- **Your server** spends with `spendCredits`; **the AI route** spends one per
|
|
306
|
+
call (see **AI**). Nothing spends from the browser.
|
|
307
|
+
- Spend lines are kept 90 days on the cloud rail; purchases, grants and
|
|
308
|
+
clawbacks are kept. A balance carries at most 1,000,000,000. Account
|
|
309
|
+
erasure removes the person's balance and ledger.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
// browser — show the balance
|
|
313
|
+
const { balance } = await g.credits.balance()
|
|
314
|
+
|
|
315
|
+
// server — one export costs one credit; the key makes a retry safe
|
|
316
|
+
const r = await gemmeinServer(process.env.GEMMEIN_SECRET_KEY).spendCredits(person.id, {
|
|
317
|
+
amount: 1, reason: "export", key: `export:${jobId}`
|
|
318
|
+
})
|
|
319
|
+
// r = { ok: true, spent: 1, deduped: false, balance: { before: 12, after: 11 },
|
|
320
|
+
// event: { id: "cev_…", reason: "export", actor: "<the key's name>" } }
|
|
321
|
+
// a retry with the same key: { …, spent: 0, deduped: true, event: the same one }
|
|
322
|
+
// GemmeinError 402 credits_exhausted — "this person has 0 credits — the spend needs 1"
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
| code | status | meaning · do |
|
|
326
|
+
|------|--------|--------------|
|
|
327
|
+
| `credits_exhausted` | 402 | The balance is below the spend — the message carries the balance ("this person has {balance} credits — the spend needs {amount}"). Show the pack; never retry the same spend |
|
|
328
|
+
| `session_required` | 401 | `g.credits.balance()` without a signed-in person — sign in first |
|
|
329
|
+
| `capability_required` | 403 | `spendCredits` on a key without "Spend a person's credits" — mint one with it ticked |
|
|
330
|
+
| `person_not_found` | 404 | No person with this id in this app and environment — ids come from `verifySession` or the dashboard |
|
|
331
|
+
| `invalid_amount` | 400 | `amount` outside 1..10,000 (or not a whole number) |
|
|
332
|
+
| `invalid_reason` | 400 | `reason` missing, not text, or over 200 chars |
|
|
333
|
+
| `invalid_key` | 400 | `key` not text, empty, or over 200 chars |
|
|
334
|
+
| `dedupe_conflict` | 409 | The `key` already names a different movement (another kind, or another person) — a key is one movement; reuse it only to retry that same one |
|
|
335
|
+
| `credits_ceiling` | 409 | A credit that would carry the balance past 1,000,000,000 — nothing was added |
|
|
336
|
+
|
|
337
|
+
`gemmein dev` keeps the same ledger in memory with snapshot and restore, and
|
|
338
|
+
its pay simulator honours the `grantsCredits` a product declares in
|
|
339
|
+
`gemmein/payments.json` (`gemmein payments setup` asks for it), so the
|
|
340
|
+
buy-then-spend loop runs on your machine before a payment provider is
|
|
341
|
+
connected.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## AI — `g.ai.chat` / `g.ai.text`
|
|
346
|
+
|
|
347
|
+
Your app talks to **OpenAI, Anthropic or Google** through Gemmein, on the
|
|
348
|
+
**owner's own provider key**, which never reaches the browser. The owner
|
|
349
|
+
pastes the key once in the dashboard's Keys room; it is write-only from then
|
|
350
|
+
on. A signed-in person's request costs **one credit**, spent before the
|
|
351
|
+
request is forwarded; the provider bills the tokens on the owner's own
|
|
352
|
+
account. The route forwards the provider's own request body as sent — minus
|
|
353
|
+
the `provider` field, and for Google minus `model` and `stream`, which ride
|
|
354
|
+
the URL — adds the provider's auth headers, `content-type` and `accept`, and
|
|
355
|
+
passes the status and the bytes straight back — a stream stays a stream. It does not choose models, cache, summarise,
|
|
356
|
+
moderate or reshape anything, and it is for the browser only: a server key is
|
|
357
|
+
refused (`403 scope_denied`) — a server calls the provider directly.
|
|
358
|
+
|
|
359
|
+
| Method | Signature | Returns |
|
|
360
|
+
|--------|-----------|---------|
|
|
361
|
+
| `ai.chat` | `(body: object, options?: { provider?: "openai" \| "anthropic" \| "google"; signal?: AbortSignal })` | `Promise<Response>` — the fetch `Response`, untouched: the provider's status, headers and body, streaming intact. A non-2xx from the provider is returned as-is (not thrown) — an answer carrying `x-gemmein-credits-remaining` passed the spend and is the provider's; a Gemmein refusal throws `GemmeinError` |
|
|
362
|
+
| `ai.text` | `(body: object, options?)` | `Promise<string>` — a non-stream call collected to one string, whichever provider answered (openai `choices[0].message.content`; anthropic `content[].text` joined; google `candidates[0].content.parts[].text` joined); a provider's non-2xx throws `provider_error` with the provider's status and message |
|
|
363
|
+
|
|
364
|
+
- `body` is exactly what the provider documents for its chat endpoint —
|
|
365
|
+
OpenAI chat completions, Anthropic messages, Google generateContent. Its
|
|
366
|
+
`model` field, when present, must match `^[A-Za-z0-9._:-]{1,80}$`; Google
|
|
367
|
+
needs it (it rides the URL); when the owner lists allowed models (up to
|
|
368
|
+
20 — the Keys room's test call uses the first), any other answers
|
|
369
|
+
`403 model_not_allowed`.
|
|
370
|
+
- `provider` is optional when one key is configured and required when more
|
|
371
|
+
than one is (`400 provider_required`). No key at all is
|
|
372
|
+
`409 ai_not_configured`. `?provider=` and `?stream=1` on the URL do what
|
|
373
|
+
the body fields do.
|
|
374
|
+
- Two response headers from Gemmein on every answer that passed the spend:
|
|
375
|
+
`x-gemmein-credits-remaining` (the balance after this call) and, on a
|
|
376
|
+
refund, `x-gemmein-credit: refunded`.
|
|
377
|
+
- **The refund rule.** A credit is refunded only when the provider fails
|
|
378
|
+
before its first byte (a non-2xx, or `502 provider_unreachable`). A stream
|
|
379
|
+
that dies after the first byte is not refunded; hanging up early does not
|
|
380
|
+
refund. A provider that echoes the key in a refusal reaches the app as
|
|
381
|
+
`***<hint>`.
|
|
382
|
+
- Limits: 20 calls per person per minute (`429 ai_capped`, `resetAt`), 256 KB
|
|
383
|
+
body (`413 payload_too_large`) nested at most 32 levels (`400 invalid_body`),
|
|
384
|
+
170 s in all and, on a stream, 10 s to the first response headers. Every
|
|
385
|
+
`/ai/chat` call counts toward the app's `api_requests` band like any other
|
|
386
|
+
request.
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
const res = await g.ai.chat({
|
|
390
|
+
model: "gpt-4o-mini", stream: true,
|
|
391
|
+
messages: [{ role: "user", content: text }]
|
|
392
|
+
}, { provider: "openai" })
|
|
393
|
+
for await (const chunk of res.body) render(chunk) // the provider's SSE, byte for byte
|
|
394
|
+
|
|
395
|
+
const answer = await g.ai.text({ model: "gpt-4o-mini", messages: [{ role: "user", content: text }] })
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
| code | status | meaning · do |
|
|
399
|
+
|------|--------|--------------|
|
|
400
|
+
| `credits_exhausted` | 402 | The person's balance is 0 — the message carries it. Show the pack |
|
|
401
|
+
| `ai_not_configured` | 409 | No provider key on this app and environment — the owner pastes one in the Keys room |
|
|
402
|
+
| `provider_required` | 400 | More than one provider key is set — pass `provider` |
|
|
403
|
+
| `model_not_allowed` | 403 | The owner's allowlist names the models this app may call; the message lists them |
|
|
404
|
+
| `ai_capped` | 429 | 20 calls per person per minute — wait for `resetAt` |
|
|
405
|
+
| `payload_too_large` | 413 | The body is over 256 KB — shorten the conversation you send |
|
|
406
|
+
| `invalid_body` | 400 | The body must be the provider's JSON request object, nested at most 32 levels |
|
|
407
|
+
| `session_required` | 401 | No signed-in person — sign in first |
|
|
408
|
+
| `scope_denied` | 403 | A secret key called the route — the route is for the browser; a server calls the provider directly |
|
|
409
|
+
| `provider_unreachable` | 502 | The provider did not answer before the first byte — nothing was charged (the credit is refunded); retry |
|
|
410
|
+
| `provider_error` | the provider's | `g.ai.text` only (client-side): the provider's own non-2xx, its message in `err.message` |
|
|
411
|
+
| `ai_test_capped` | 429 | The Keys room's test call — one a minute per app |
|
|
412
|
+
|
|
413
|
+
`gemmein dev` answers a fake provider without a key (header `x-gemmein-ai:
|
|
414
|
+
fake`, an echo stream), so the loop runs locally; set
|
|
415
|
+
`GEMMEIN_AI_KEY_OPENAI`, `GEMMEIN_AI_KEY_ANTHROPIC` or `GEMMEIN_AI_KEY_GOOGLE`
|
|
416
|
+
in the local rail's environment for a real call. The owner's Usage room
|
|
417
|
+
counts AI calls for the last 30 days: spent by the customers' credits, priced
|
|
418
|
+
by the provider — Gemmein meters the calls, the provider bills the tokens.
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess` / `invitePerson`
|
|
275
423
|
|
|
276
424
|
Gemmein hosts no compute. **Your own** function — Vercel, a VPS, a cron box,
|
|
277
425
|
anywhere — asks Gemmein the only three questions it has: *who is this person,
|
|
278
|
-
what do they hold, change what they hold
|
|
426
|
+
what do they hold, change what they hold* — and, when the person has never
|
|
427
|
+
signed in, it can create them by email first.
|
|
279
428
|
|
|
280
429
|
| Method | Signature | Returns |
|
|
281
430
|
|--------|-----------|---------|
|
|
@@ -283,18 +432,19 @@ what do they hold, change what they hold.*
|
|
|
283
432
|
| `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
433
|
| `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
434
|
| `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
|
|
435
|
+
| `invitePerson` | `(email)` | `Promise<{ person: InvitedPerson, created: boolean }>` — create a person by email **before they sign in** (201 `created: true`), or find them (200 `created: false`); idempotent, case-insensitive, one id. `InvitedPerson = { id, email, role, invited, suspended }` — `invited` stays true until their first sign-in; a suspended person is returned flagged. The one server call that takes an email |
|
|
286
436
|
|
|
287
437
|
```ts
|
|
288
438
|
type Holdings = {
|
|
289
439
|
access: string[] // the keys they hold NOW — ["access:pro"]
|
|
290
440
|
grants: Grant[] // the LIVE grants behind them (revoked/expired are gone)
|
|
291
|
-
credits: { balance: number } | null //
|
|
441
|
+
credits: { balance: number } | null // the balance now (engine 0.8.0+); null from an older local engine — see Credits
|
|
292
442
|
}
|
|
293
443
|
|
|
294
444
|
type Grant = {
|
|
295
445
|
id: string
|
|
296
446
|
entitlement: string // "access:<slug>" — the plan's or product's own key
|
|
297
|
-
source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration"
|
|
447
|
+
source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay"
|
|
298
448
|
startsAt: string
|
|
299
449
|
expiresAt: string | null
|
|
300
450
|
revokedAt?: string | null // present on the grant revokeAccess returns
|
|
@@ -307,16 +457,29 @@ type Grant = {
|
|
|
307
457
|
- **One call per request.** `verifySession` answers identity *and* holdings
|
|
308
458
|
together — don't call it twice, and don't cache the answer past the request.
|
|
309
459
|
- **Holdings, not billing.** The gate never returns subscription status,
|
|
310
|
-
amounts, Stripe ids, or a grant's `sourceId` — the source **kind** only
|
|
460
|
+
amounts, Stripe ids, or a grant's `sourceId` — the source **kind** only
|
|
461
|
+
(`relay` is the kind a relay's `grant_access` writes; see
|
|
462
|
+
**Relays**). Gate
|
|
311
463
|
on what a person *holds*, never on what they pay. Person id, never an email.
|
|
312
|
-
- **
|
|
464
|
+
- **Four capabilities, ticked by the human.** `verifySession` needs none.
|
|
313
465
|
`holdings` needs **"Look up a person's access by id"**; `grantAccess` and
|
|
314
|
-
`revokeAccess` need **"Grant and revoke access"
|
|
315
|
-
|
|
316
|
-
|
|
466
|
+
`revokeAccess` need **"Grant and revoke access"**; `invitePerson` needs
|
|
467
|
+
**"Create a person by email before they sign in"**; `spendCredits` needs
|
|
468
|
+
**"Spend a person's credits"** — plain-English checkboxes the owner ticks
|
|
469
|
+
when minting the key. Existing keys have all four off, so nothing in
|
|
470
|
+
production changes.
|
|
471
|
+
- **The invite door.** `invitePerson(email)` is how your server addresses
|
|
472
|
+
someone who has never signed in — the envelope, the invoice, the client
|
|
473
|
+
portal, the booking-winner email: create the person, hand them the file,
|
|
474
|
+
address the record, notify them; their first sign-in lands on a ready
|
|
475
|
+
account. The address is trimmed and lowercased; the person's row shows
|
|
476
|
+
"Invited · hasn't signed in yet" in the owner's dashboard until they sign
|
|
477
|
+
in, and is not counted in the people band before then. 500 invite calls per
|
|
478
|
+
app per day — a fetch of an existing person is a call too.
|
|
317
479
|
- **Manual sources only.** `source` ∈ `manual | trial | promotion | migration`
|
|
318
|
-
(default `manual`). Purchases and subscriptions come
|
|
319
|
-
|
|
480
|
+
(default `manual`). Purchases and subscriptions come from the built-in Stripe
|
|
481
|
+
path; a payment from any other provider that signs its webhooks drives access
|
|
482
|
+
through a relay (source `relay`). A key cannot mint paid access. A key *may* end a payment-made grant (the same as the
|
|
320
483
|
dashboard's "end this access"); the payment itself is untouched.
|
|
321
484
|
- **One grant, one reason.** `reason` (≤ 200 chars) is what the owner reads in
|
|
322
485
|
their logs and is never edited. `sourceId` is minted per call, so two calls
|
|
@@ -339,8 +502,10 @@ type Grant = {
|
|
|
339
502
|
| `session_revoked` | 401 | A newer sign-in, a sign-out, or the owner ended it — send them back to sign-in |
|
|
340
503
|
| `person_suspended` | 403 | The owner suspended this person — access is off until the owner reactivates them in the dashboard |
|
|
341
504
|
| `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 (
|
|
343
|
-
| `
|
|
505
|
+
| `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" / "Create a person by email before they sign in" / "Spend a person's credits" ticked (purchase and subscription grants come from the built-in Stripe path; another provider's payment grants through a relay) |
|
|
506
|
+
| `invalid_email` | 400 | `invitePerson`: the address must look like `name@domain` — trimmed, one `@`, a dotted domain, at most 254 characters, no whitespace |
|
|
507
|
+
| `invite_capped` | 429 | `invitePerson`: this app has made 500 invite calls today — the limit is temporary; write to hello@gemmein.com to raise it. `err.resetAt` says when the window ends |
|
|
508
|
+
| `invalid_source` | 400 | `purchase` / `subscription` asked for by hand — refused; those come from Stripe's signed webhook, and another provider's payment grants through a relay |
|
|
344
509
|
| `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
510
|
| `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
511
|
| `grant_not_found` | 404 | Not this person's grant, in this app and environment — re-read `holdings` |
|
|
@@ -348,8 +513,203 @@ type Grant = {
|
|
|
348
513
|
| `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
514
|
| `scope_denied` | 403 | Not a secret key — the gate is server-only, never the browser |
|
|
350
515
|
| `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
|
|
352
|
-
| `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess` and `
|
|
516
|
+
| `unknown_route` | 404 | Not one of the gate's six routes — the message lists them all |
|
|
517
|
+
| `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess`, `revokeAccess` and `invitePerson` are POST, `holdings` is GET |
|
|
518
|
+
|
|
519
|
+
---
|
|
520
|
+
|
|
521
|
+
## Relays — `gemmein/relays/<name>.json`
|
|
522
|
+
|
|
523
|
+
Route, map, authorise, never compute. A relay is one trigger — a
|
|
524
|
+
provider's webhook arriving, a clock, a record changing — and one to ten of
|
|
525
|
+
Gemmein's **own** verbs, run in order: write a record, grant or revoke access,
|
|
526
|
+
email the person, call your URL. Gemmein runs no code of yours inside one;
|
|
527
|
+
compute lives on your host, behind `call_url`. The definition is a JSON file
|
|
528
|
+
your AI writes; the owner's dashboard shows it read-only with its receiver URL,
|
|
529
|
+
its secrets (shown once), every event with each action's result, and a replay
|
|
530
|
+
button. There is no SDK method: the surface is the file and the dashboard.
|
|
531
|
+
Stripe stays built in; any provider that signs its webhooks — GoCardless, Paddle,
|
|
532
|
+
Lemon Squeezy among them — drives access the same way through a relay, and the
|
|
533
|
+
founder keeps their provider. This chapter is the full depth; `llms.txt` carries
|
|
534
|
+
the card, the worked example and one pointer here.
|
|
535
|
+
|
|
536
|
+
### The definition
|
|
537
|
+
|
|
538
|
+
```jsonc
|
|
539
|
+
{
|
|
540
|
+
"name": "gocardless-paid", // ^[a-z][a-z0-9-]{1,62}$ — the file name AND the receiver URL's last segment
|
|
541
|
+
"trigger": { ... }, // exactly one: receiver | schedule | data_change
|
|
542
|
+
"actions": [ ... ] // 1 to 10, run in order
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Unknown fields are refused **by name**, one sentence per problem
|
|
547
|
+
(`invalid_definition`). A trigger's `kind` cannot change after creation —
|
|
548
|
+
delete and create instead.
|
|
549
|
+
|
|
550
|
+
### Triggers
|
|
551
|
+
|
|
552
|
+
**`receiver`** — `POST /hooks/<appId>/<name>`, provider-agnostic.
|
|
553
|
+
|
|
554
|
+
```jsonc
|
|
555
|
+
{
|
|
556
|
+
"kind": "receiver",
|
|
557
|
+
"verify": { "scheme": "hmac_sha256_header", "header": "Webhook-Signature" },
|
|
558
|
+
"map": { "event_id": "events.0.id", "event_type": "events.0.action", "person_email": "events.0.details.customer_email" },
|
|
559
|
+
"when": { "event_type": "confirmed" }
|
|
560
|
+
}
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
| `verify.scheme` | fields | what is checked |
|
|
564
|
+
|---|---|---|
|
|
565
|
+
| `hmac_sha256_header` | `header`, `timestampHeader?`, `toleranceSeconds?` (default and maximum 300), `encoding?: "hex" \| "base64"` (default hex) | HMAC-SHA256 of the raw body with the receiver secret; when `timestampHeader` is named, of `<timestamp>.<body>` inside the window. GoCardless: header `Webhook-Signature`, hex, raw body |
|
|
566
|
+
| `stripe` | — | the `stripe-signature` header (`t=`/`v1=`), 5-minute window |
|
|
567
|
+
| `svix` | — | `svix-id` / `svix-timestamp` / `svix-signature`, 5-minute window |
|
|
568
|
+
| `shared_token` | `header?` (default `x-webhook-token`), `query?` (default `token`) | the token matches in either place — a query token travels in URLs, prefer the header |
|
|
569
|
+
|
|
570
|
+
- **Secrets.** Minted by Gemmein at create (`rcv_…`), shown once, never readable
|
|
571
|
+
again; rotate mints a new one. A provider that only shows its own secret
|
|
572
|
+
(Stripe, svix senders) is stored through rotate with a `value`.
|
|
573
|
+
- **`map`** — up to 20 names, each a dotted path into the body
|
|
574
|
+
(`events.0.details.customer_email`; prototype names are never followed).
|
|
575
|
+
`event_id` is the dedupe key: a repeat answers `{ received: true,
|
|
576
|
+
duplicate: true, eventId }` and runs nothing; without it the body's hash
|
|
577
|
+
deduplicates within the same UTC day. `event_type` is what `when` usually
|
|
578
|
+
reads. `person_email` names the person: created-or-fetched through the same
|
|
579
|
+
door as `invitePerson` (same daily cap, shown as invited until first sign-in,
|
|
580
|
+
audited with the relay as the actor). Every other name rides into
|
|
581
|
+
templates as `{{mapped.<name>}}`.
|
|
582
|
+
- **`when`** — exact string match on the **mapped** fields; its keys must be in
|
|
583
|
+
the map. A non-match answers 200 `{ received: true, ignored: "when" }` and
|
|
584
|
+
records no event, so a typo in `when` reads as the provider never calling.
|
|
585
|
+
Test with a real event first.
|
|
586
|
+
- **Answers.** 200 `{ received: true, eventId }` only once the event row is
|
|
587
|
+
stored — the row is the acknowledgement.
|
|
588
|
+
|
|
589
|
+
| code | status | meaning |
|
|
590
|
+
|---|---|---|
|
|
591
|
+
| `unknown_receiver` | 404 | no receiver relay with this name on this app — a deleted one no longer receives, a paused one still does |
|
|
592
|
+
| `bad_signature` | 401 | the signature did not verify against this receiver's secret — check the secret and the header the scheme expects |
|
|
593
|
+
| `body_too_large` | 413 | over 256 KB |
|
|
594
|
+
| `invalid_json` | 400 | the body is not JSON |
|
|
595
|
+
| `receiver_capped` | 429 | more than 120 verified events in a minute for this app; `resetAt` says when. Unverified traffic never spends this budget |
|
|
596
|
+
| `not_recorded` | 503 | the event could not be stored — nothing ran; send it again |
|
|
597
|
+
|
|
598
|
+
The URL names no environment: the secret decides which environment's
|
|
599
|
+
relay receives, so development and production may share a name, each
|
|
600
|
+
with its own secret. Renaming a relay renames its URL.
|
|
601
|
+
|
|
602
|
+
**`schedule`** — `{ "kind": "schedule", "every": "1d", "at": "09:00" }`.
|
|
603
|
+
`every` ∈ `15m | 30m | 1h | 6h | 12h | 1d`; `at` is `HH:MM` UTC and only with
|
|
604
|
+
`1d`. One tick per period; a relay created mid-period first fires next
|
|
605
|
+
period; after a gap the most recent missed period runs late and older missed
|
|
606
|
+
periods land as dead rows you can replay. A schedule has no person, so
|
|
607
|
+
`grant_access`, `revoke_access`, `email_person` and `to: "person"` are refused
|
|
608
|
+
on it at validation.
|
|
609
|
+
|
|
610
|
+
**`data_change`** — `{ "kind": "data_change", "collection": "bookings", "on": ["created"], "where": { "status": "won" } }`.
|
|
611
|
+
`on` ⊆ `created | updated | deleted`; `where` is an exact match on the record's
|
|
612
|
+
data fields (server-managed fields are refused). Fires for writes from your
|
|
613
|
+
app, the dashboard's editor and Stripe receipts, at most once per write per
|
|
614
|
+
relay; never for the runner's own writes (the loop guard) and never for
|
|
615
|
+
an account erasure. A `call_url` that writes back through the SDK does fire —
|
|
616
|
+
a loop through your host is yours to avoid. A definition change applies within
|
|
617
|
+
5 s. The person is the record's owner, else its recipient; an app-owned record
|
|
618
|
+
has none and person actions on it record `skipped`. **Authorisation law:** a
|
|
619
|
+
`where` on a field the signed-in user can write is a self-service grant —
|
|
620
|
+
authorise on fields they cannot set, or from a receiver.
|
|
621
|
+
|
|
622
|
+
### Actions
|
|
623
|
+
|
|
624
|
+
| type | fields | notes |
|
|
625
|
+
|---|---|---|
|
|
626
|
+
| `write_record` | `collection`, `data`, `to?: "person"` | keyed per event so a retry never duplicates; written through the same door as your app (plain-text and file laws apply); `to: "person"` addresses it to the event's person and is required on addressed and direct collections; the record is app-owned |
|
|
627
|
+
| `grant_access` | `entitlement` (plan or product **name**, or `access:<slug>`), `expiresAt?` (`"30d"`, `"12h"`, `"2w"` or an ISO date), `reason?` (≤ 200, templated) | source `relay`, the seventh grant source; skipped when this event already granted |
|
|
628
|
+
| `grant_credits` | `amount` (1..10,000), `reason?` (≤ 200, templated) | adds to the person's credit balance, once per event (a replay finds its own row); the ledger line reads "relay: <reason>"; a relay adds and never spends |
|
|
629
|
+
| `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
|
|
630
|
+
| `email_person` | `subject` (≤ 300), `text` (≤ 10,000), `kind?: "event" \| "account"` | rides `notify()`'s caps (200 per app per hour, 5 event sends per person per day) and the owner's sends switch; deduped per event |
|
|
631
|
+
| `call_url` | `url` | https only, no template in the URL, no IP literal, never a gemmein.com host, no credentials in the URL; `gemmein dev` allows http to localhost |
|
|
632
|
+
|
|
633
|
+
**The `call_url` contract.** Gemmein POSTs JSON:
|
|
634
|
+
|
|
635
|
+
```jsonc
|
|
636
|
+
{
|
|
637
|
+
"id": "<event id>", // the same id on every retry and replay
|
|
638
|
+
"relay": { "id": "...", "name": "gocardless-paid" },
|
|
639
|
+
"trigger": "receiver", // receiver | schedule | data_change
|
|
640
|
+
"event": { ... }, // the stored payload: { event, mapped } | { record, previous? } | { tick }
|
|
641
|
+
"person": { "id": "...", "email": "..." } | null,
|
|
642
|
+
"results": [ ... ] // the actions that ran before this one
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
Headers: `content-type: application/json`, `user-agent: Gemmein-Relays/1`,
|
|
647
|
+
`X-Gemmein-Signature: t=<unix seconds>,v1=<hex>` where `v1` is the HMAC-SHA256
|
|
648
|
+
of `<t>.<body>` with the relay's signing secret (`asig_…`, shown once at
|
|
649
|
+
create, rotatable). Verify over the raw body, in constant time. 2xx is done; a
|
|
650
|
+
redirect is a failure and is never followed; 10 s timeout; the status and the
|
|
651
|
+
first 4 KB of the answer are kept on the event. **Not idempotent on Gemmein's
|
|
652
|
+
side:** every retry and replay POSTs the same `id` — deduplicate on it. The
|
|
653
|
+
whole event rides in the body, so the URL receives private data.
|
|
654
|
+
|
|
655
|
+
```js
|
|
656
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
657
|
+
const [t, v1] = req.headers["x-gemmein-signature"].split(",").map((part) => part.slice(part.indexOf("=") + 1));
|
|
658
|
+
const expected = createHmac("sha256", process.env.GEMMEIN_SIGNING_SECRET).update(`${t}.${rawBody}`).digest("hex");
|
|
659
|
+
const ok = expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
Egress refusals arrive as the action's error, naming the rule: `https_only`,
|
|
663
|
+
`ip_literal`, `own_host`, `unresolvable`, `private_address`.
|
|
664
|
+
|
|
665
|
+
### Templates
|
|
666
|
+
|
|
667
|
+
In string values only — `write_record.data`, `email_person.subject` / `text`,
|
|
668
|
+
`grant_access.reason`; never in `call_url.url` or an `entitlement`:
|
|
669
|
+
`{{event.a.b}}` (the raw body), `{{mapped.x}}`, `{{record.data.x}}`,
|
|
670
|
+
`{{record.id}}`, `{{person.email}}`, `{{person.id}}`, `{{tick.periodStart}}`.
|
|
671
|
+
A path that names nothing renders empty and adds a warning to the event;
|
|
672
|
+
objects render as JSON. No expressions, no filters.
|
|
673
|
+
|
|
674
|
+
### Lifecycle
|
|
675
|
+
|
|
676
|
+
Every trigger lands as a durable event row **before** any action runs.
|
|
677
|
+
`queued → running → done`, or `failed` with retries at 1m, 5m, 30m, 2h, 8h,
|
|
678
|
+
16h (seven attempts in all), then `dead`: the owner gets the
|
|
679
|
+
`relay_failed` alert (on by default, switchable) and a replay button that
|
|
680
|
+
resets the count. A definition-level failure (a suspended person, a missing
|
|
681
|
+
collection, an unknown entitlement) is dead on the first attempt. Actions stop
|
|
682
|
+
at the first failure; a retry or replay runs every action again, and
|
|
683
|
+
`write_record`, `grant_access`, `revoke_access` and `email_person` each find
|
|
684
|
+
their own earlier work and record `skipped` — `call_url` POSTs again. Events
|
|
685
|
+
are kept 30 days. A paused relay still records receiver events and record
|
|
686
|
+
changes (they wait for resume); schedule ticks during a pause are not
|
|
687
|
+
recorded. Every run is one audit row with per-action before→after; every grant,
|
|
688
|
+
email and record a relay makes is attributed to the relay by name.
|
|
689
|
+
|
|
690
|
+
### Console codes
|
|
691
|
+
|
|
692
|
+
| code | status | meaning |
|
|
693
|
+
|---|---|---|
|
|
694
|
+
| `invalid_definition` | 400 | one sentence naming the field, why, and what to do |
|
|
695
|
+
| `relay_capped` | 400 | 20 relays in this environment — delete one, or fold two into one |
|
|
696
|
+
| `name_taken` | 409 | a relay with this name exists in this environment |
|
|
697
|
+
| `version_conflict` | 409 | the definition moved since it was read — re-read and reapply |
|
|
698
|
+
| `not_replayable` | 409 | replay applies to a dead, failed or done event; a queued or running one is already on its way |
|
|
699
|
+
| `unknown_environment` | 400 | the environment does not belong to this app |
|
|
700
|
+
| `not_found` | 404 | no relay or event with this id in this app and environment |
|
|
701
|
+
|
|
702
|
+
**Current limits:** 20 relays per environment, 10 actions each, 120
|
|
703
|
+
events per minute, 32 KB stored per event (a larger body is truncated with a
|
|
704
|
+
flag; `mapped` survives), events kept 30 days. Limits are raised on request:
|
|
705
|
+
hello@gemmein.com.
|
|
706
|
+
|
|
707
|
+
**Both rails.** `gemmein dev` runs receivers (the boot card prints each
|
|
708
|
+
receiver's local URL), schedules and data changes with the same runner and
|
|
709
|
+
prints `RELAY · <name> · <trigger> · <n actions> · ok|failed`;
|
|
710
|
+
`npx gemmein sync` carries the files to the cloud app's development
|
|
711
|
+
environment with the collections — contract, never data; the cloud mints its
|
|
712
|
+
own secrets.
|
|
353
713
|
|
|
354
714
|
---
|
|
355
715
|
|
|
@@ -439,6 +799,7 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
439
799
|
| `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 |
|
|
440
800
|
| `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 |
|
|
441
801
|
| `invalid_since` | `since` isn't a strict ISO 8601 timestamp, or came with `sort` | pass the previous answer's watermark; drop `sort` |
|
|
802
|
+
| `invalid_limit` | `limit` isn't a whole number from 1 to 100 (the message says so: "limit must be between 1 and 100") | ask for at most 100 and page with `cursor` — nothing is clamped for you |
|
|
442
803
|
| `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 |
|
|
443
804
|
| `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
|
|
444
805
|
| `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
|
|
@@ -451,10 +812,26 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
451
812
|
| `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
|
|
452
813
|
| `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes) | scope the key to that collection, or use the right surface |
|
|
453
814
|
| `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
|
|
454
|
-
| `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
|
|
815
|
+
| `invalid_key` | a keyed create's `key` breaks the charset/length law — or `spendCredits`' `key` is not text of up to 200 chars | 1-120 chars of letters, numbers, `: _ . @ / -` (a create); text ≤ 200 (a spend) |
|
|
816
|
+
| `invalid_amount` / `invalid_reason` (400) | `spendCredits`: `amount` outside 1..10,000, or `reason` missing / over 200 chars | fix the field the code names |
|
|
817
|
+
| `dedupe_conflict` (409) | `spendCredits`: the `key` already names a different movement (another kind or another person) | a key is one movement — reuse it only to retry that same one |
|
|
818
|
+
| `credits_ceiling` (409) | a credit would carry the balance past 1,000,000,000 (a comp, a pack, a relay grant) — nothing was added | the balance is at its most |
|
|
819
|
+
| `invalid_body` (400) | on `g.ai.chat`: the body is not the provider's JSON request object, or is nested deeper than 32 levels | send the provider's own request object |
|
|
820
|
+
| `provider_error` (client-side, the provider's status) | `g.ai.text`: the provider answered a non-2xx; `err.message` is the provider's own reason | read it — the credit was refunded when the provider failed before its first byte |
|
|
821
|
+
| `ai_test_capped` (429) | the Keys room's test call — one a minute per app | wait a minute |
|
|
455
822
|
| `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
|
|
823
|
+
| `invalid_response` (client-side, status 0) | the server answered 200 to `verifyEmailCode` without a session token — a proxy or mock in the path, not Gemmein | check `apiUrl` and anything rewriting responses; the call is safe to retry |
|
|
456
824
|
| `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
|
|
457
825
|
| `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
|
|
458
826
|
| `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
|
|
827
|
+
| `credits_exhausted` (402) | the person's balance is below the spend — the message carries the balance ("this person has {balance} credits — the spend needs {amount}") | show the pack; never retry the same spend |
|
|
828
|
+
| `ai_not_configured` (409) | no provider key on this app and environment | the owner pastes one in the Keys room |
|
|
829
|
+
| `provider_required` (400) | more than one provider key is set and the call named none | pass `provider` |
|
|
830
|
+
| `model_not_allowed` (403) | the owner's allowlist does not name this model | use one the message lists |
|
|
831
|
+
| `ai_capped` (429) | 20 AI calls per person per minute | wait for `resetAt` |
|
|
832
|
+
| `payload_too_large` (413) | on `g.ai.chat`: the body is over 256 KB | shorten the conversation you send |
|
|
833
|
+
| `session_required` (401) | `g.ai.chat` / `g.credits.balance` without a signed-in person | sign in first |
|
|
834
|
+
| `scope_denied` (403) | on `g.ai.chat`: a secret key called the route | the route is for the browser — a server calls the provider directly |
|
|
835
|
+
| `provider_unreachable` (502) | the provider did not answer before the first byte; the credit is refunded | retry |
|
|
459
836
|
|
|
460
837
|
Keys: `pk_` (public, domain-locked, browser-safe) vs `sk_` (secret, server only).
|