@gemmein/sdk 0.4.7 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +147 -0
- package/REFERENCE.md +311 -5
- package/dist/index.cjs +176 -4
- package/dist/index.d.cts +203 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.js +175 -3
- package/llms.txt +221 -16
- package/migrations/README.md +35 -0
- package/migrations/list-limit-refusal.md +42 -0
- package/package.json +7 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Changelog — @gemmein/sdk
|
|
2
|
+
|
|
3
|
+
All notable changes to the SDK are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
|
+
semver. A change that asks something of your app has a note in
|
|
6
|
+
[`migrations/`](./migrations/README.md) naming the version it arrived in
|
|
7
|
+
and the version by which the action is required.
|
|
8
|
+
|
|
9
|
+
The SDK and the local engine (`@gemmein/cli`) share a minor version from
|
|
10
|
+
0.6.0 / 0.7.0 onward: a feature that lands in one lands in the other in
|
|
11
|
+
the same release.
|
|
12
|
+
|
|
13
|
+
## [Unreleased]
|
|
14
|
+
|
|
15
|
+
## [0.6.0] — 2026-09-04
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- `list({ limit })` is honoured or refused, never clamped: a `limit`
|
|
19
|
+
outside 1..100 answers 400 `invalid_limit` ("limit must be between 1 and
|
|
20
|
+
100"). The default stays 25 and the maximum 100. Apps that asked for more
|
|
21
|
+
than 100 and read the first page as the whole set now get the refusal —
|
|
22
|
+
page with `cursor`. See `migrations/list-limit-refusal.md`.
|
|
23
|
+
- `verifyEmailCode` throws `GemmeinError` with code `invalid_response`
|
|
24
|
+
(status 0) when a 200 answer carries no session token, where it threw a
|
|
25
|
+
bare `Error` before. Every error the SDK raises is now a `GemmeinError`.
|
|
26
|
+
- `engines.node` is `>=20`. Node 18 reached end of life in April 2025; the
|
|
27
|
+
SDK is built and tested on 20 and 22.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Relays. One JSON file, `gemmein/relays/<name>.json`: a trigger (a
|
|
31
|
+
provider's signed webhook at `POST /hooks/<appId>/<name>`, a schedule, or a
|
|
32
|
+
record change) and one to ten of Gemmein's own verbs (write a record, grant
|
|
33
|
+
or revoke access, email the person, call your URL), run in order with
|
|
34
|
+
retries and a replay button in the dashboard. No SDK method: the surface is
|
|
35
|
+
the file and the dashboard. `REFERENCE.md` carries the definition, the
|
|
36
|
+
triggers, the actions, the `call_url` contract and its signature header,
|
|
37
|
+
the templates, the lifecycle and the codes; `llms.txt` carries the
|
|
38
|
+
GoCardless worked example. A `call_url` that writes back through the SDK
|
|
39
|
+
fires `data_change` like any write. Stripe stays built in; any provider that
|
|
40
|
+
signs its webhooks drives access the same way.
|
|
41
|
+
- `GrantSource` gains `relay`: a grant a relay's `grant_access`
|
|
42
|
+
wrote. `holdings` and `verifySession` list it by kind like the other six;
|
|
43
|
+
`ManualGrantSource` is unchanged — no key can mint it.
|
|
44
|
+
- `gemmeinServer(...).invitePerson(email)` creates a person by email before
|
|
45
|
+
they sign in, or returns the one that exists (`{ person, created }`, type
|
|
46
|
+
`InvitedPerson`). Behind the third key capability, "Create a person by
|
|
47
|
+
email before they sign in". Refusals: `invalid_email`, `invite_capped`
|
|
48
|
+
(500 accepted calls per app per day, raised on request). An invited person
|
|
49
|
+
shows as invited on the person page until their first sign-in and is not
|
|
50
|
+
counted in the people band (the billable unit) until then.
|
|
51
|
+
- Every request carries `x-client-info: gemmein-sdk/<version>`. The Keys
|
|
52
|
+
page shows the SDK build behind a key's most recent call ("Last seen
|
|
53
|
+
from gemmein-sdk/0.6.0, as reported"). `SDK_VERSION` and `CLIENT_INFO`
|
|
54
|
+
are exported.
|
|
55
|
+
- `CHANGELOG.md` and `migrations/` ship in the package.
|
|
56
|
+
|
|
57
|
+
## [0.5.0] — 2026-09-03
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
- The server gate on `gemmeinServer(sk)`: `verifySession(token)`,
|
|
61
|
+
`holdings(personId)`, `grantAccess(personId, { entitlement, reason? })`,
|
|
62
|
+
`revokeAccess(personId, grantId, { reason? })`. Grant and revoke require
|
|
63
|
+
the key's `grantAccess` capability (minted on the Keys page).
|
|
64
|
+
- Exported types `Holdings`, `Grant`, `GatePerson`, `GrantSource`,
|
|
65
|
+
`ManualGrantSource`.
|
|
66
|
+
|
|
67
|
+
## [0.4.7] — 2026-09-02
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
- Documentation-only release. The packaged `llms.txt` and `REFERENCE.md`
|
|
71
|
+
carry the W5 teaching: a promote run carries new fields as well as new
|
|
72
|
+
collections.
|
|
73
|
+
|
|
74
|
+
## [0.4.6] — 2026-08-26
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
- `reaffirm.mjs` runs the 16-probe harness; its teachings are rewritten.
|
|
78
|
+
|
|
79
|
+
## [0.4.5] — 2026-08-26
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
- Documentation-only release. `REFERENCE.md` catches up with documents,
|
|
83
|
+
`since`, `watch()`, `notify()`, the file laws and `purchases.mine()`.
|
|
84
|
+
|
|
85
|
+
## [0.4.4] — 2026-08-26
|
|
86
|
+
|
|
87
|
+
### Added
|
|
88
|
+
- `upload(file, { for })` addresses a file to one person; the file-kind
|
|
89
|
+
(image or document) and file-class teaching.
|
|
90
|
+
|
|
91
|
+
## [0.4.3] — 2026-08-25
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
- `gemmeinServer(sk).notify(personId, { subject, text, kind?, key? })`.
|
|
95
|
+
|
|
96
|
+
### Changed
|
|
97
|
+
- `watch()` stops on an auth-terminal answer instead of retrying it.
|
|
98
|
+
|
|
99
|
+
## [0.4.2] — 2026-08-25
|
|
100
|
+
|
|
101
|
+
### Added
|
|
102
|
+
- `list({ since })` with `ListResult.deleted` and `ListResult.watermark`;
|
|
103
|
+
`watch()` on a collection.
|
|
104
|
+
|
|
105
|
+
## [0.4.1] — 2026-08-22
|
|
106
|
+
|
|
107
|
+
### Added
|
|
108
|
+
- `GemmeinError.requires` (and `requiresAny`) on 403 `entitlement_required`,
|
|
109
|
+
on both the collection path and the runtime clients.
|
|
110
|
+
|
|
111
|
+
## [0.4.0] — 2026-07-30
|
|
112
|
+
|
|
113
|
+
### Changed
|
|
114
|
+
- A product's file lives on the product card and the purchase is the
|
|
115
|
+
authorisation to read it.
|
|
116
|
+
|
|
117
|
+
## [0.3.2] — 2026-07-29
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
- The wave-audit fixes for the 0.3 line.
|
|
121
|
+
|
|
122
|
+
## [0.3.1] — 2026-07-29
|
|
123
|
+
|
|
124
|
+
### Changed
|
|
125
|
+
- Documentation: the guide teaches paid access.
|
|
126
|
+
|
|
127
|
+
## [0.3.0] — 2026-07-29
|
|
128
|
+
|
|
129
|
+
### Added
|
|
130
|
+
- The file reference contract: records store references, files resolve
|
|
131
|
+
through the SDK.
|
|
132
|
+
|
|
133
|
+
## [0.2.1] — 2026-07-22
|
|
134
|
+
|
|
135
|
+
### Changed
|
|
136
|
+
- Documentation-only refresh.
|
|
137
|
+
|
|
138
|
+
## [0.2.0] — 2026-07-22
|
|
139
|
+
|
|
140
|
+
### Changed
|
|
141
|
+
- Two-layer shape: `g.collection(name)` plus the self-service primitives.
|
|
142
|
+
|
|
143
|
+
## [0.1.0] — 2026-07-11
|
|
144
|
+
|
|
145
|
+
### Added
|
|
146
|
+
- First published release: built `dist`, persistent sessions, journey
|
|
147
|
+
signposts.
|
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()` / `invitePerson()`, 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**).
|
|
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
|
|
@@ -179,7 +184,7 @@ type ListResult<T> = {
|
|
|
179
184
|
};
|
|
180
185
|
|
|
181
186
|
type ListOptions = {
|
|
182
|
-
limit?: number;
|
|
187
|
+
limit?: number; // 25 by default, 100 at most — a larger ask is refused (400 invalid_limit); page with cursor
|
|
183
188
|
sort?: "newest" | "oldest" | "updated";
|
|
184
189
|
where?: Record<string, unknown>; // exact-match on data fields (and link fields)
|
|
185
190
|
cursor?: string; // from a previous ListResult
|
|
@@ -233,6 +238,10 @@ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
|
|
|
233
238
|
don't also redirect to the returned `url`, and never build a Stripe URL
|
|
234
239
|
yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
|
|
235
240
|
gate one-off fulfilment on the receipt record, never the redirect.
|
|
241
|
+
By-hand grants (trial, promotion, a support comp) are not listed to the
|
|
242
|
+
app — a gated read simply succeeds — so never rebuild the paywall from
|
|
243
|
+
`mine()`; let the server refuse with `entitlement_required`. The two grant
|
|
244
|
+
families and what ends each are in llms.txt, "Two FAMILIES of grant".
|
|
236
245
|
|
|
237
246
|
---
|
|
238
247
|
|
|
@@ -262,6 +271,298 @@ honestly, not hidden.)
|
|
|
262
271
|
|
|
263
272
|
---
|
|
264
273
|
|
|
274
|
+
## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess` / `invitePerson`
|
|
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* — and, when the person has never
|
|
279
|
+
signed in, it can create them by email first.
|
|
280
|
+
|
|
281
|
+
| Method | Signature | Returns |
|
|
282
|
+
|--------|-----------|---------|
|
|
283
|
+
| `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 |
|
|
284
|
+
| `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 |
|
|
285
|
+
| `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 |
|
|
286
|
+
| `revokeAccess` | `(personId, grantId, { reason? }?)` | `Promise<{ ok: true, grant: Grant, holdings: Holdings }>` — the returned grant carries `revokedAt` |
|
|
287
|
+
| `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 |
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
type Holdings = {
|
|
291
|
+
access: string[] // the keys they hold NOW — ["access:pro"]
|
|
292
|
+
grants: Grant[] // the LIVE grants behind them (revoked/expired are gone)
|
|
293
|
+
credits: { balance: number } | null // reserved — null today; credits are NOT shipped
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type Grant = {
|
|
297
|
+
id: string
|
|
298
|
+
entitlement: string // "access:<slug>" — the plan's or product's own key
|
|
299
|
+
source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay"
|
|
300
|
+
startsAt: string
|
|
301
|
+
expiresAt: string | null
|
|
302
|
+
revokedAt?: string | null // present on the grant revokeAccess returns
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
- **Pointing at a non-default server.** `gemmeinServer(sk, { apiUrl })` — the option
|
|
307
|
+
is **`apiUrl`**. `baseUrl` is not an option name and is ignored in silence, so a
|
|
308
|
+
test or E2E script that passes it runs against **production** without saying so.
|
|
309
|
+
- **One call per request.** `verifySession` answers identity *and* holdings
|
|
310
|
+
together — don't call it twice, and don't cache the answer past the request.
|
|
311
|
+
- **Holdings, not billing.** The gate never returns subscription status,
|
|
312
|
+
amounts, Stripe ids, or a grant's `sourceId` — the source **kind** only
|
|
313
|
+
(`relay` is the kind a relay's `grant_access` writes; see
|
|
314
|
+
**Relays**). Gate
|
|
315
|
+
on what a person *holds*, never on what they pay. Person id, never an email.
|
|
316
|
+
- **Three capabilities, ticked by the human.** `verifySession` needs none.
|
|
317
|
+
`holdings` needs **"Look up a person's access by id"**; `grantAccess` and
|
|
318
|
+
`revokeAccess` need **"Grant and revoke access"**; `invitePerson` needs
|
|
319
|
+
**"Create a person by email before they sign in"** — plain-English checkboxes
|
|
320
|
+
the owner ticks when minting the key. Existing keys have all three off, so
|
|
321
|
+
nothing in production changes.
|
|
322
|
+
- **The invite door.** `invitePerson(email)` is how your server addresses
|
|
323
|
+
someone who has never signed in — the envelope, the invoice, the client
|
|
324
|
+
portal, the booking-winner email: create the person, hand them the file,
|
|
325
|
+
address the record, notify them; their first sign-in lands on a ready
|
|
326
|
+
account. The address is trimmed and lowercased; the person's row shows
|
|
327
|
+
"Invited · hasn't signed in yet" in the owner's dashboard until they sign
|
|
328
|
+
in, and is not counted in the people band before then. 500 invite calls per
|
|
329
|
+
app per day — a fetch of an existing person is a call too.
|
|
330
|
+
- **Manual sources only.** `source` ∈ `manual | trial | promotion | migration`
|
|
331
|
+
(default `manual`). Purchases and subscriptions come from the built-in Stripe
|
|
332
|
+
path; a payment from any other provider that signs its webhooks drives access
|
|
333
|
+
through a relay (source `relay`). A key cannot mint paid access. A key *may* end a payment-made grant (the same as the
|
|
334
|
+
dashboard's "end this access"); the payment itself is untouched.
|
|
335
|
+
- **One grant, one reason.** `reason` (≤ 200 chars) is what the owner reads in
|
|
336
|
+
their logs and is never edited. `sourceId` is minted per call, so two calls
|
|
337
|
+
make two grants — call it once and keep your own retry key.
|
|
338
|
+
- **Nothing silent, within a stated bound.** Every `/server/*` call a **resolved
|
|
339
|
+
secret key** makes, ok or refused, lands in that key's usage ledger — the owner
|
|
340
|
+
reads the summary on their Keys page and the full day × route × outcome table in
|
|
341
|
+
the key's own room. A rejected or publishable (`pk_`) key can't be attributed to a
|
|
342
|
+
key row, so its refusal reaches only the request log. Refusals also write one audit
|
|
343
|
+
row per key, route and code each hour (exact counts stay in the ledger); grants and
|
|
344
|
+
revokes write full before→after rows, attributed to the key by name. A refusal
|
|
345
|
+
counts as *use*: "last used" means last seen, not last worked. In `gemmein dev` the
|
|
346
|
+
local key already holds both capabilities, and the usage read is cloud-only — the
|
|
347
|
+
local runtime never mounts the console.
|
|
348
|
+
|
|
349
|
+
| code | status | meaning · do |
|
|
350
|
+
|------|--------|--------------|
|
|
351
|
+
| `session_invalid` | 401 | No session matches this token — send the person to sign in again; never store or reuse tokens across people |
|
|
352
|
+
| `session_expired` | 401 | The session ended (the message names when) — send them back to sign-in |
|
|
353
|
+
| `session_revoked` | 401 | A newer sign-in, a sign-out, or the owner ended it — send them back to sign-in |
|
|
354
|
+
| `person_suspended` | 403 | The owner suspended this person — access is off until the owner reactivates them in the dashboard |
|
|
355
|
+
| `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 |
|
|
356
|
+
| `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" ticked (purchase and subscription grants come from the built-in Stripe path; another provider's payment grants through a relay) |
|
|
357
|
+
| `invalid_email` | 400 | `invitePerson`: the address must look like `name@domain` — trimmed, one `@`, a dotted domain, at most 254 characters, no whitespace |
|
|
358
|
+
| `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 |
|
|
359
|
+
| `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 |
|
|
360
|
+
| `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 |
|
|
361
|
+
| `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) |
|
|
362
|
+
| `grant_not_found` | 404 | Not this person's grant, in this app and environment — re-read `holdings` |
|
|
363
|
+
| `already_revoked` | 409 | A grant ends once (`revokedAt` is set and never edited) — it is already ended |
|
|
364
|
+
| `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 |
|
|
365
|
+
| `scope_denied` | 403 | Not a secret key — the gate is server-only, never the browser |
|
|
366
|
+
| `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 |
|
|
367
|
+
| `unknown_route` | 404 | Not one of the gate's five routes — the message lists them all |
|
|
368
|
+
| `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess`, `revokeAccess` and `invitePerson` are POST, `holdings` is GET |
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Relays — `gemmein/relays/<name>.json`
|
|
373
|
+
|
|
374
|
+
Route, map, authorise, never compute. A relay is one trigger — a
|
|
375
|
+
provider's webhook arriving, a clock, a record changing — and one to ten of
|
|
376
|
+
Gemmein's **own** verbs, run in order: write a record, grant or revoke access,
|
|
377
|
+
email the person, call your URL. Gemmein runs no code of yours inside one;
|
|
378
|
+
compute lives on your host, behind `call_url`. The definition is a JSON file
|
|
379
|
+
your AI writes; the owner's dashboard shows it read-only with its receiver URL,
|
|
380
|
+
its secrets (shown once), every event with each action's result, and a replay
|
|
381
|
+
button. There is no SDK method: the surface is the file and the dashboard.
|
|
382
|
+
Stripe stays built in; any provider that signs its webhooks — GoCardless, Paddle,
|
|
383
|
+
Lemon Squeezy among them — drives access the same way through a relay, and the
|
|
384
|
+
founder keeps their provider. This chapter is the full depth; `llms.txt` carries
|
|
385
|
+
the card, the worked example and one pointer here.
|
|
386
|
+
|
|
387
|
+
### The definition
|
|
388
|
+
|
|
389
|
+
```jsonc
|
|
390
|
+
{
|
|
391
|
+
"name": "gocardless-paid", // ^[a-z][a-z0-9-]{1,62}$ — the file name AND the receiver URL's last segment
|
|
392
|
+
"trigger": { ... }, // exactly one: receiver | schedule | data_change
|
|
393
|
+
"actions": [ ... ] // 1 to 10, run in order
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Unknown fields are refused **by name**, one sentence per problem
|
|
398
|
+
(`invalid_definition`). A trigger's `kind` cannot change after creation —
|
|
399
|
+
delete and create instead.
|
|
400
|
+
|
|
401
|
+
### Triggers
|
|
402
|
+
|
|
403
|
+
**`receiver`** — `POST /hooks/<appId>/<name>`, provider-agnostic.
|
|
404
|
+
|
|
405
|
+
```jsonc
|
|
406
|
+
{
|
|
407
|
+
"kind": "receiver",
|
|
408
|
+
"verify": { "scheme": "hmac_sha256_header", "header": "Webhook-Signature" },
|
|
409
|
+
"map": { "event_id": "events.0.id", "event_type": "events.0.action", "person_email": "events.0.details.customer_email" },
|
|
410
|
+
"when": { "event_type": "confirmed" }
|
|
411
|
+
}
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
| `verify.scheme` | fields | what is checked |
|
|
415
|
+
|---|---|---|
|
|
416
|
+
| `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 |
|
|
417
|
+
| `stripe` | — | the `stripe-signature` header (`t=`/`v1=`), 5-minute window |
|
|
418
|
+
| `svix` | — | `svix-id` / `svix-timestamp` / `svix-signature`, 5-minute window |
|
|
419
|
+
| `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 |
|
|
420
|
+
|
|
421
|
+
- **Secrets.** Minted by Gemmein at create (`rcv_…`), shown once, never readable
|
|
422
|
+
again; rotate mints a new one. A provider that only shows its own secret
|
|
423
|
+
(Stripe, svix senders) is stored through rotate with a `value`.
|
|
424
|
+
- **`map`** — up to 20 names, each a dotted path into the body
|
|
425
|
+
(`events.0.details.customer_email`; prototype names are never followed).
|
|
426
|
+
`event_id` is the dedupe key: a repeat answers `{ received: true,
|
|
427
|
+
duplicate: true, eventId }` and runs nothing; without it the body's hash
|
|
428
|
+
deduplicates within the same UTC day. `event_type` is what `when` usually
|
|
429
|
+
reads. `person_email` names the person: created-or-fetched through the same
|
|
430
|
+
door as `invitePerson` (same daily cap, shown as invited until first sign-in,
|
|
431
|
+
audited with the relay as the actor). Every other name rides into
|
|
432
|
+
templates as `{{mapped.<name>}}`.
|
|
433
|
+
- **`when`** — exact string match on the **mapped** fields; its keys must be in
|
|
434
|
+
the map. A non-match answers 200 `{ received: true, ignored: "when" }` and
|
|
435
|
+
records no event, so a typo in `when` reads as the provider never calling.
|
|
436
|
+
Test with a real event first.
|
|
437
|
+
- **Answers.** 200 `{ received: true, eventId }` only once the event row is
|
|
438
|
+
stored — the row is the acknowledgement.
|
|
439
|
+
|
|
440
|
+
| code | status | meaning |
|
|
441
|
+
|---|---|---|
|
|
442
|
+
| `unknown_receiver` | 404 | no receiver relay with this name on this app — a deleted one no longer receives, a paused one still does |
|
|
443
|
+
| `bad_signature` | 401 | the signature did not verify against this receiver's secret — check the secret and the header the scheme expects |
|
|
444
|
+
| `body_too_large` | 413 | over 256 KB |
|
|
445
|
+
| `invalid_json` | 400 | the body is not JSON |
|
|
446
|
+
| `receiver_capped` | 429 | more than 120 verified events in a minute for this app; `resetAt` says when. Unverified traffic never spends this budget |
|
|
447
|
+
| `not_recorded` | 503 | the event could not be stored — nothing ran; send it again |
|
|
448
|
+
|
|
449
|
+
The URL names no environment: the secret decides which environment's
|
|
450
|
+
relay receives, so development and production may share a name, each
|
|
451
|
+
with its own secret. Renaming a relay renames its URL.
|
|
452
|
+
|
|
453
|
+
**`schedule`** — `{ "kind": "schedule", "every": "1d", "at": "09:00" }`.
|
|
454
|
+
`every` ∈ `15m | 30m | 1h | 6h | 12h | 1d`; `at` is `HH:MM` UTC and only with
|
|
455
|
+
`1d`. One tick per period; a relay created mid-period first fires next
|
|
456
|
+
period; after a gap the most recent missed period runs late and older missed
|
|
457
|
+
periods land as dead rows you can replay. A schedule has no person, so
|
|
458
|
+
`grant_access`, `revoke_access`, `email_person` and `to: "person"` are refused
|
|
459
|
+
on it at validation.
|
|
460
|
+
|
|
461
|
+
**`data_change`** — `{ "kind": "data_change", "collection": "bookings", "on": ["created"], "where": { "status": "won" } }`.
|
|
462
|
+
`on` ⊆ `created | updated | deleted`; `where` is an exact match on the record's
|
|
463
|
+
data fields (server-managed fields are refused). Fires for writes from your
|
|
464
|
+
app, the dashboard's editor and Stripe receipts, at most once per write per
|
|
465
|
+
relay; never for the runner's own writes (the loop guard) and never for
|
|
466
|
+
an account erasure. A `call_url` that writes back through the SDK does fire —
|
|
467
|
+
a loop through your host is yours to avoid. A definition change applies within
|
|
468
|
+
5 s. The person is the record's owner, else its recipient; an app-owned record
|
|
469
|
+
has none and person actions on it record `skipped`. **Authorisation law:** a
|
|
470
|
+
`where` on a field the signed-in user can write is a self-service grant —
|
|
471
|
+
authorise on fields they cannot set, or from a receiver.
|
|
472
|
+
|
|
473
|
+
### Actions
|
|
474
|
+
|
|
475
|
+
| type | fields | notes |
|
|
476
|
+
|---|---|---|
|
|
477
|
+
| `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 |
|
|
478
|
+
| `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 |
|
|
479
|
+
| `revoke_access` | `entitlement` | ends every live grant of that entitlement the person holds; skipped when none |
|
|
480
|
+
| `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 |
|
|
481
|
+
| `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 |
|
|
482
|
+
|
|
483
|
+
**The `call_url` contract.** Gemmein POSTs JSON:
|
|
484
|
+
|
|
485
|
+
```jsonc
|
|
486
|
+
{
|
|
487
|
+
"id": "<event id>", // the same id on every retry and replay
|
|
488
|
+
"relay": { "id": "...", "name": "gocardless-paid" },
|
|
489
|
+
"trigger": "receiver", // receiver | schedule | data_change
|
|
490
|
+
"event": { ... }, // the stored payload: { event, mapped } | { record, previous? } | { tick }
|
|
491
|
+
"person": { "id": "...", "email": "..." } | null,
|
|
492
|
+
"results": [ ... ] // the actions that ran before this one
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Headers: `content-type: application/json`, `user-agent: Gemmein-Relays/1`,
|
|
497
|
+
`X-Gemmein-Signature: t=<unix seconds>,v1=<hex>` where `v1` is the HMAC-SHA256
|
|
498
|
+
of `<t>.<body>` with the relay's signing secret (`asig_…`, shown once at
|
|
499
|
+
create, rotatable). Verify over the raw body, in constant time. 2xx is done; a
|
|
500
|
+
redirect is a failure and is never followed; 10 s timeout; the status and the
|
|
501
|
+
first 4 KB of the answer are kept on the event. **Not idempotent on Gemmein's
|
|
502
|
+
side:** every retry and replay POSTs the same `id` — deduplicate on it. The
|
|
503
|
+
whole event rides in the body, so the URL receives private data.
|
|
504
|
+
|
|
505
|
+
```js
|
|
506
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
507
|
+
const [t, v1] = req.headers["x-gemmein-signature"].split(",").map((part) => part.slice(part.indexOf("=") + 1));
|
|
508
|
+
const expected = createHmac("sha256", process.env.GEMMEIN_SIGNING_SECRET).update(`${t}.${rawBody}`).digest("hex");
|
|
509
|
+
const ok = expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
Egress refusals arrive as the action's error, naming the rule: `https_only`,
|
|
513
|
+
`ip_literal`, `own_host`, `unresolvable`, `private_address`.
|
|
514
|
+
|
|
515
|
+
### Templates
|
|
516
|
+
|
|
517
|
+
In string values only — `write_record.data`, `email_person.subject` / `text`,
|
|
518
|
+
`grant_access.reason`; never in `call_url.url` or an `entitlement`:
|
|
519
|
+
`{{event.a.b}}` (the raw body), `{{mapped.x}}`, `{{record.data.x}}`,
|
|
520
|
+
`{{record.id}}`, `{{person.email}}`, `{{person.id}}`, `{{tick.periodStart}}`.
|
|
521
|
+
A path that names nothing renders empty and adds a warning to the event;
|
|
522
|
+
objects render as JSON. No expressions, no filters.
|
|
523
|
+
|
|
524
|
+
### Lifecycle
|
|
525
|
+
|
|
526
|
+
Every trigger lands as a durable event row **before** any action runs.
|
|
527
|
+
`queued → running → done`, or `failed` with retries at 1m, 5m, 30m, 2h, 8h,
|
|
528
|
+
16h (seven attempts in all), then `dead`: the owner gets the
|
|
529
|
+
`relay_failed` alert (on by default, switchable) and a replay button that
|
|
530
|
+
resets the count. A definition-level failure (a suspended person, a missing
|
|
531
|
+
collection, an unknown entitlement) is dead on the first attempt. Actions stop
|
|
532
|
+
at the first failure; a retry or replay runs every action again, and
|
|
533
|
+
`write_record`, `grant_access`, `revoke_access` and `email_person` each find
|
|
534
|
+
their own earlier work and record `skipped` — `call_url` POSTs again. Events
|
|
535
|
+
are kept 30 days. A paused relay still records receiver events and record
|
|
536
|
+
changes (they wait for resume); schedule ticks during a pause are not
|
|
537
|
+
recorded. Every run is one audit row with per-action before→after; every grant,
|
|
538
|
+
email and record a relay makes is attributed to the relay by name.
|
|
539
|
+
|
|
540
|
+
### Console codes
|
|
541
|
+
|
|
542
|
+
| code | status | meaning |
|
|
543
|
+
|---|---|---|
|
|
544
|
+
| `invalid_definition` | 400 | one sentence naming the field, why, and what to do |
|
|
545
|
+
| `relay_capped` | 400 | 20 relays in this environment — delete one, or fold two into one |
|
|
546
|
+
| `name_taken` | 409 | a relay with this name exists in this environment |
|
|
547
|
+
| `version_conflict` | 409 | the definition moved since it was read — re-read and reapply |
|
|
548
|
+
| `not_replayable` | 409 | replay applies to a dead, failed or done event; a queued or running one is already on its way |
|
|
549
|
+
| `unknown_environment` | 400 | the environment does not belong to this app |
|
|
550
|
+
| `not_found` | 404 | no relay or event with this id in this app and environment |
|
|
551
|
+
|
|
552
|
+
**Current limits:** 20 relays per environment, 10 actions each, 120
|
|
553
|
+
events per minute, 32 KB stored per event (a larger body is truncated with a
|
|
554
|
+
flag; `mapped` survives), events kept 30 days. Limits are raised on request:
|
|
555
|
+
hello@gemmein.com.
|
|
556
|
+
|
|
557
|
+
**Both rails.** `gemmein dev` runs receivers (the boot card prints each
|
|
558
|
+
receiver's local URL), schedules and data changes with the same runner and
|
|
559
|
+
prints `RELAY · <name> · <trigger> · <n actions> · ok|failed`;
|
|
560
|
+
`npx gemmein sync` carries the files to the cloud app's development
|
|
561
|
+
environment with the collections — contract, never data; the cloud mints its
|
|
562
|
+
own secrets.
|
|
563
|
+
|
|
564
|
+
---
|
|
565
|
+
|
|
265
566
|
## Reaffirm — prove your app's boundaries in CI
|
|
266
567
|
|
|
267
568
|
Gemmein enforces the rules **server-side**, so your frontend is never the source
|
|
@@ -329,7 +630,10 @@ class GemmeinError extends Error {
|
|
|
329
630
|
}
|
|
330
631
|
```
|
|
331
632
|
|
|
332
|
-
Branch on `err.code`. The
|
|
633
|
+
Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
634
|
+
`session_expired`, `session_revoked`, `person_suspended`, `person_not_found`,
|
|
635
|
+
`capability_required`, `invalid_source`, `unknown_plan`, `grant_not_found`,
|
|
636
|
+
`already_revoked`) are in **Server gate** above, each with its action. The rest:
|
|
333
637
|
|
|
334
638
|
| code | meaning | do |
|
|
335
639
|
|------|---------|-----|
|
|
@@ -345,6 +649,7 @@ Branch on `err.code`. The stable codes:
|
|
|
345
649
|
| `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 |
|
|
346
650
|
| `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 |
|
|
347
651
|
| `invalid_since` | `since` isn't a strict ISO 8601 timestamp, or came with `sort` | pass the previous answer's watermark; drop `sort` |
|
|
652
|
+
| `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 |
|
|
348
653
|
| `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 |
|
|
349
654
|
| `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
|
|
350
655
|
| `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
|
|
@@ -359,6 +664,7 @@ Branch on `err.code`. The stable codes:
|
|
|
359
664
|
| `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
|
|
360
665
|
| `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
|
|
361
666
|
| `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
|
|
667
|
+
| `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 |
|
|
362
668
|
| `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
|
|
363
669
|
| `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
|
|
364
670
|
| `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
|