@gemmein/sdk 0.5.0 → 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 +230 -18
- package/dist/index.cjs +57 -5
- package/dist/index.d.cts +51 -1
- package/dist/index.d.ts +51 -1
- package/dist/index.js +56 -4
- package/llms.txt +125 -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
|
@@ -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()`, 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,12 @@ honestly, not hidden.)
|
|
|
271
271
|
|
|
272
272
|
---
|
|
273
273
|
|
|
274
|
-
## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess`
|
|
274
|
+
## Server gate — `gemmeinServer(sk).verifySession` / `holdings` / `grantAccess` / `revokeAccess` / `invitePerson`
|
|
275
275
|
|
|
276
276
|
Gemmein hosts no compute. **Your own** function — Vercel, a VPS, a cron box,
|
|
277
277
|
anywhere — asks Gemmein the only three questions it has: *who is this person,
|
|
278
|
-
what do they hold, change what they hold
|
|
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.
|
|
279
280
|
|
|
280
281
|
| Method | Signature | Returns |
|
|
281
282
|
|--------|-----------|---------|
|
|
@@ -283,6 +284,7 @@ what do they hold, change what they hold.*
|
|
|
283
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 |
|
|
284
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 |
|
|
285
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 |
|
|
286
288
|
|
|
287
289
|
```ts
|
|
288
290
|
type Holdings = {
|
|
@@ -294,7 +296,7 @@ type Holdings = {
|
|
|
294
296
|
type Grant = {
|
|
295
297
|
id: string
|
|
296
298
|
entitlement: string // "access:<slug>" — the plan's or product's own key
|
|
297
|
-
source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration"
|
|
299
|
+
source: "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay"
|
|
298
300
|
startsAt: string
|
|
299
301
|
expiresAt: string | null
|
|
300
302
|
revokedAt?: string | null // present on the grant revokeAccess returns
|
|
@@ -307,16 +309,28 @@ type Grant = {
|
|
|
307
309
|
- **One call per request.** `verifySession` answers identity *and* holdings
|
|
308
310
|
together — don't call it twice, and don't cache the answer past the request.
|
|
309
311
|
- **Holdings, not billing.** The gate never returns subscription status,
|
|
310
|
-
amounts, Stripe ids, or a grant's `sourceId` — the source **kind** only
|
|
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
|
|
311
315
|
on what a person *holds*, never on what they pay. Person id, never an email.
|
|
312
|
-
- **
|
|
316
|
+
- **Three capabilities, ticked by the human.** `verifySession` needs none.
|
|
313
317
|
`holdings` needs **"Look up a person's access by id"**; `grantAccess` and
|
|
314
|
-
`revokeAccess` need **"Grant and revoke access"
|
|
315
|
-
|
|
316
|
-
|
|
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.
|
|
317
330
|
- **Manual sources only.** `source` ∈ `manual | trial | promotion | migration`
|
|
318
|
-
(default `manual`). Purchases and subscriptions come
|
|
319
|
-
|
|
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
|
|
320
334
|
dashboard's "end this access"); the payment itself is untouched.
|
|
321
335
|
- **One grant, one reason.** `reason` (≤ 200 chars) is what the owner reads in
|
|
322
336
|
their logs and is never edited. `sourceId` is minted per call, so two calls
|
|
@@ -339,8 +353,10 @@ type Grant = {
|
|
|
339
353
|
| `session_revoked` | 401 | A newer sign-in, a sign-out, or the owner ended it — send them back to sign-in |
|
|
340
354
|
| `person_suspended` | 403 | The owner suspended this person — access is off until the owner reactivates them in the dashboard |
|
|
341
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 |
|
|
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
|
-
| `
|
|
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 |
|
|
344
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 |
|
|
345
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) |
|
|
346
362
|
| `grant_not_found` | 404 | Not this person's grant, in this app and environment — re-read `holdings` |
|
|
@@ -348,8 +364,202 @@ type Grant = {
|
|
|
348
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 |
|
|
349
365
|
| `scope_denied` | 403 | Not a secret key — the gate is server-only, never the browser |
|
|
350
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 |
|
|
351
|
-
| `unknown_route` | 404 | Not one of the gate's
|
|
352
|
-
| `method_not_allowed` | 405 | The right route, the wrong verb: `verifySession`, `grantAccess` and `
|
|
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.
|
|
353
563
|
|
|
354
564
|
---
|
|
355
565
|
|
|
@@ -439,6 +649,7 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
439
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 |
|
|
440
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 |
|
|
441
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 |
|
|
442
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 |
|
|
443
654
|
| `in_flight` (409) | a `notify()` with the same `key` is sending right now | retry in a moment — a delivered send answers idempotently |
|
|
444
655
|
| `invalid_audience` | `for` isn't a user of this app | fix the recipient id |
|
|
@@ -453,6 +664,7 @@ Branch on `err.code`. The gate's own codes (`session_invalid`,
|
|
|
453
664
|
| `unsupported_file_type` (415) | upload isn't an allowed type | images (JPEG/PNG/WebP/GIF/HEIC) or documents (PDF/ZIP/EPUB) |
|
|
454
665
|
| `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
|
|
455
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 |
|
|
456
668
|
| `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
|
|
457
669
|
| `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
|
|
458
670
|
| `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
|
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = void 0;
|
|
3
|
+
exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = exports.CLIENT_INFO = exports.SDK_VERSION = void 0;
|
|
4
4
|
exports.gemmein = gemmein;
|
|
5
5
|
exports.gemmeinServer = gemmeinServer;
|
|
6
|
+
/** This build's version — the value `package.json` carries. Kept inline
|
|
7
|
+
* because the package is one source file and this file is imported
|
|
8
|
+
* straight from source by the security suite (no runtime file read, no
|
|
9
|
+
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
10
|
+
* package.json before every build (`prebuild`), and a test pins the two
|
|
11
|
+
* equal, so a bump can never ship with a stale header. */
|
|
12
|
+
exports.SDK_VERSION = "0.6.0"; // synced from package.json — do not edit by hand
|
|
13
|
+
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
14
|
+
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
15
|
+
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
16
|
+
* misbehaving integration can be attributed to an SDK version from day
|
|
17
|
+
* one. It is a report, not a proof — any caller can set it. */
|
|
18
|
+
exports.CLIENT_INFO = `gemmein-sdk/${exports.SDK_VERSION}`;
|
|
6
19
|
class GemmeinError extends Error {
|
|
7
20
|
constructor(input) {
|
|
8
21
|
super(input.message);
|
|
@@ -164,7 +177,15 @@ class AuthClient {
|
|
|
164
177
|
await this.config.tokenStore.set(result.token);
|
|
165
178
|
return result;
|
|
166
179
|
}
|
|
167
|
-
|
|
180
|
+
// W9.1: the one place the SDK threw a bare Error. Every refusal the SDK
|
|
181
|
+
// raises is a GemmeinError so `err.code` is always there to branch on;
|
|
182
|
+
// status 0 is the SDK's own convention for "no HTTP status applies"
|
|
183
|
+
// (see invalid_collection_name, missing_app_key).
|
|
184
|
+
throw new GemmeinError({
|
|
185
|
+
status: 0,
|
|
186
|
+
code: "invalid_response",
|
|
187
|
+
message: "Gemmein auth response did not include a session token"
|
|
188
|
+
});
|
|
168
189
|
}
|
|
169
190
|
async logout() {
|
|
170
191
|
try {
|
|
@@ -764,7 +785,7 @@ class GemmeinServer {
|
|
|
764
785
|
}
|
|
765
786
|
const response = await fetch(new URL("/server/test-session", this.apiUrl), {
|
|
766
787
|
method: "POST",
|
|
767
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
788
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
|
|
768
789
|
body: JSON.stringify({ email }),
|
|
769
790
|
});
|
|
770
791
|
if (!response.ok) {
|
|
@@ -802,7 +823,7 @@ class GemmeinServer {
|
|
|
802
823
|
async notify(personId, input) {
|
|
803
824
|
const response = await fetch(new URL("/server/notify", this.apiUrl), {
|
|
804
825
|
method: "POST",
|
|
805
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
826
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO, "content-type": "application/json" },
|
|
806
827
|
body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
|
|
807
828
|
});
|
|
808
829
|
if (!response.ok) {
|
|
@@ -843,6 +864,35 @@ class GemmeinServer {
|
|
|
843
864
|
body: JSON.stringify({ token }),
|
|
844
865
|
});
|
|
845
866
|
}
|
|
867
|
+
/**
|
|
868
|
+
* W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
|
|
869
|
+
* The envelope, the invoice, the client portal, the booking-winner
|
|
870
|
+
* email — all address someone who has never signed in and so has no
|
|
871
|
+
* id yet. This is the one server call that takes an email:
|
|
872
|
+
*
|
|
873
|
+
* const { person, created } = await g.invitePerson("client@example.com");
|
|
874
|
+
* await g.notify(person.id, { subject: "Your contract", text: "…" });
|
|
875
|
+
*
|
|
876
|
+
* Create-or-fetch, idempotent, case-insensitive: the first call makes
|
|
877
|
+
* the person (`created: true`, HTTP 201), every later call finds them
|
|
878
|
+
* (`created: false`, 200) — one id either way, the address returned
|
|
879
|
+
* lowercased. Their first sign-in lands on this account: records and
|
|
880
|
+
* files you addressed to `person.id` are already theirs. `person.invited`
|
|
881
|
+
* stays true until that sign-in; a suspended person is returned with
|
|
882
|
+
* `suspended: true`, never refused.
|
|
883
|
+
*
|
|
884
|
+
* Needs the key's "Create a person by email before they sign in" box
|
|
885
|
+
* ticked by your human. Refusals: `capability_required` (the box isn't
|
|
886
|
+
* ticked) · `invalid_email` (400 — must look like name@domain) ·
|
|
887
|
+
* `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
|
|
888
|
+
* where to write to raise it; `err.resetAt` says when the window ends).
|
|
889
|
+
*/
|
|
890
|
+
async invitePerson(email) {
|
|
891
|
+
return this.gate("/server/people", {
|
|
892
|
+
method: "POST",
|
|
893
|
+
body: JSON.stringify({ email }),
|
|
894
|
+
});
|
|
895
|
+
}
|
|
846
896
|
/**
|
|
847
897
|
* What one of YOUR people holds, by person id — for the paths where no
|
|
848
898
|
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
@@ -927,7 +977,7 @@ class GemmeinServer {
|
|
|
927
977
|
// `err.code` carries the server's own code, `err.message` its sentence
|
|
928
978
|
// (which always names the next action).
|
|
929
979
|
async gate(path, init = {}) {
|
|
930
|
-
const headers = { "x-app-key": this.secretKey };
|
|
980
|
+
const headers = { "x-app-key": this.secretKey, "x-client-info": exports.CLIENT_INFO };
|
|
931
981
|
if (init.body)
|
|
932
982
|
headers["content-type"] = "application/json";
|
|
933
983
|
const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
|
|
@@ -977,6 +1027,7 @@ class ServerCollectionClient {
|
|
|
977
1027
|
async request(suffix, init = {}) {
|
|
978
1028
|
const headers = {
|
|
979
1029
|
"x-app-key": this.secretKey,
|
|
1030
|
+
"x-client-info": exports.CLIENT_INFO,
|
|
980
1031
|
};
|
|
981
1032
|
if (init.body) {
|
|
982
1033
|
headers["content-type"] = "application/json";
|
|
@@ -1052,6 +1103,7 @@ async function runtimeHeaders(config, headers) {
|
|
|
1052
1103
|
return {
|
|
1053
1104
|
...headers,
|
|
1054
1105
|
"x-app-key": config.appKey,
|
|
1106
|
+
"x-client-info": exports.CLIENT_INFO,
|
|
1055
1107
|
...(token ? { authorization: `Bearer ${token}` } : {})
|
|
1056
1108
|
};
|
|
1057
1109
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -114,6 +114,19 @@ export type AuthSession = {
|
|
|
114
114
|
email: string;
|
|
115
115
|
};
|
|
116
116
|
};
|
|
117
|
+
/** This build's version — the value `package.json` carries. Kept inline
|
|
118
|
+
* because the package is one source file and this file is imported
|
|
119
|
+
* straight from source by the security suite (no runtime file read, no
|
|
120
|
+
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
121
|
+
* package.json before every build (`prebuild`), and a test pins the two
|
|
122
|
+
* equal, so a bump can never ship with a stale header. */
|
|
123
|
+
export declare const SDK_VERSION = "0.6.0";
|
|
124
|
+
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
125
|
+
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
126
|
+
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
127
|
+
* misbehaving integration can be attributed to an SDK version from day
|
|
128
|
+
* one. It is a report, not a proof — any caller can set it. */
|
|
129
|
+
export declare const CLIENT_INFO = "gemmein-sdk/0.6.0";
|
|
117
130
|
export declare class GemmeinError extends Error {
|
|
118
131
|
readonly status: number;
|
|
119
132
|
readonly code: string;
|
|
@@ -529,7 +542,7 @@ export type GemmeinServerOptions = {
|
|
|
529
542
|
* Where a grant came from — the KIND only. The gate never returns the
|
|
530
543
|
* source's id, an amount, or anything from Stripe.
|
|
531
544
|
*/
|
|
532
|
-
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration";
|
|
545
|
+
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay";
|
|
533
546
|
/**
|
|
534
547
|
* The sources a secret key may create by hand. `purchase` and
|
|
535
548
|
* `subscription` are deliberately absent: money-made access comes only
|
|
@@ -565,6 +578,16 @@ export type GatePerson = {
|
|
|
565
578
|
email: string;
|
|
566
579
|
role: string;
|
|
567
580
|
};
|
|
581
|
+
/**
|
|
582
|
+
* W9.2 — the person `invitePerson` returns. `invited` is true until they
|
|
583
|
+
* sign in for the first time; `suspended` is the owner's switch (a
|
|
584
|
+
* suspended person is returned, never refused — `verifySession` is what
|
|
585
|
+
* refuses them).
|
|
586
|
+
*/
|
|
587
|
+
export type InvitedPerson = GatePerson & {
|
|
588
|
+
invited: boolean;
|
|
589
|
+
suspended: boolean;
|
|
590
|
+
};
|
|
568
591
|
export declare class GemmeinServer {
|
|
569
592
|
private readonly apiUrl;
|
|
570
593
|
private readonly secretKey;
|
|
@@ -648,6 +671,33 @@ export declare class GemmeinServer {
|
|
|
648
671
|
person: GatePerson;
|
|
649
672
|
holdings: Holdings;
|
|
650
673
|
}>;
|
|
674
|
+
/**
|
|
675
|
+
* W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
|
|
676
|
+
* The envelope, the invoice, the client portal, the booking-winner
|
|
677
|
+
* email — all address someone who has never signed in and so has no
|
|
678
|
+
* id yet. This is the one server call that takes an email:
|
|
679
|
+
*
|
|
680
|
+
* const { person, created } = await g.invitePerson("client@example.com");
|
|
681
|
+
* await g.notify(person.id, { subject: "Your contract", text: "…" });
|
|
682
|
+
*
|
|
683
|
+
* Create-or-fetch, idempotent, case-insensitive: the first call makes
|
|
684
|
+
* the person (`created: true`, HTTP 201), every later call finds them
|
|
685
|
+
* (`created: false`, 200) — one id either way, the address returned
|
|
686
|
+
* lowercased. Their first sign-in lands on this account: records and
|
|
687
|
+
* files you addressed to `person.id` are already theirs. `person.invited`
|
|
688
|
+
* stays true until that sign-in; a suspended person is returned with
|
|
689
|
+
* `suspended: true`, never refused.
|
|
690
|
+
*
|
|
691
|
+
* Needs the key's "Create a person by email before they sign in" box
|
|
692
|
+
* ticked by your human. Refusals: `capability_required` (the box isn't
|
|
693
|
+
* ticked) · `invalid_email` (400 — must look like name@domain) ·
|
|
694
|
+
* `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
|
|
695
|
+
* where to write to raise it; `err.resetAt` says when the window ends).
|
|
696
|
+
*/
|
|
697
|
+
invitePerson(email: string): Promise<{
|
|
698
|
+
person: InvitedPerson;
|
|
699
|
+
created: boolean;
|
|
700
|
+
}>;
|
|
651
701
|
/**
|
|
652
702
|
* What one of YOUR people holds, by person id — for the paths where no
|
|
653
703
|
* token is in hand (a webhook of your own, a nightly job, an admin
|
package/dist/index.d.ts
CHANGED
|
@@ -114,6 +114,19 @@ export type AuthSession = {
|
|
|
114
114
|
email: string;
|
|
115
115
|
};
|
|
116
116
|
};
|
|
117
|
+
/** This build's version — the value `package.json` carries. Kept inline
|
|
118
|
+
* because the package is one source file and this file is imported
|
|
119
|
+
* straight from source by the security suite (no runtime file read, no
|
|
120
|
+
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
121
|
+
* package.json before every build (`prebuild`), and a test pins the two
|
|
122
|
+
* equal, so a bump can never ship with a stale header. */
|
|
123
|
+
export declare const SDK_VERSION = "0.6.0";
|
|
124
|
+
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
125
|
+
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
126
|
+
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
127
|
+
* misbehaving integration can be attributed to an SDK version from day
|
|
128
|
+
* one. It is a report, not a proof — any caller can set it. */
|
|
129
|
+
export declare const CLIENT_INFO = "gemmein-sdk/0.6.0";
|
|
117
130
|
export declare class GemmeinError extends Error {
|
|
118
131
|
readonly status: number;
|
|
119
132
|
readonly code: string;
|
|
@@ -529,7 +542,7 @@ export type GemmeinServerOptions = {
|
|
|
529
542
|
* Where a grant came from — the KIND only. The gate never returns the
|
|
530
543
|
* source's id, an amount, or anything from Stripe.
|
|
531
544
|
*/
|
|
532
|
-
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration";
|
|
545
|
+
export type GrantSource = "subscription" | "purchase" | "manual" | "trial" | "promotion" | "migration" | "relay";
|
|
533
546
|
/**
|
|
534
547
|
* The sources a secret key may create by hand. `purchase` and
|
|
535
548
|
* `subscription` are deliberately absent: money-made access comes only
|
|
@@ -565,6 +578,16 @@ export type GatePerson = {
|
|
|
565
578
|
email: string;
|
|
566
579
|
role: string;
|
|
567
580
|
};
|
|
581
|
+
/**
|
|
582
|
+
* W9.2 — the person `invitePerson` returns. `invited` is true until they
|
|
583
|
+
* sign in for the first time; `suspended` is the owner's switch (a
|
|
584
|
+
* suspended person is returned, never refused — `verifySession` is what
|
|
585
|
+
* refuses them).
|
|
586
|
+
*/
|
|
587
|
+
export type InvitedPerson = GatePerson & {
|
|
588
|
+
invited: boolean;
|
|
589
|
+
suspended: boolean;
|
|
590
|
+
};
|
|
568
591
|
export declare class GemmeinServer {
|
|
569
592
|
private readonly apiUrl;
|
|
570
593
|
private readonly secretKey;
|
|
@@ -648,6 +671,33 @@ export declare class GemmeinServer {
|
|
|
648
671
|
person: GatePerson;
|
|
649
672
|
holdings: Holdings;
|
|
650
673
|
}>;
|
|
674
|
+
/**
|
|
675
|
+
* W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
|
|
676
|
+
* The envelope, the invoice, the client portal, the booking-winner
|
|
677
|
+
* email — all address someone who has never signed in and so has no
|
|
678
|
+
* id yet. This is the one server call that takes an email:
|
|
679
|
+
*
|
|
680
|
+
* const { person, created } = await g.invitePerson("client@example.com");
|
|
681
|
+
* await g.notify(person.id, { subject: "Your contract", text: "…" });
|
|
682
|
+
*
|
|
683
|
+
* Create-or-fetch, idempotent, case-insensitive: the first call makes
|
|
684
|
+
* the person (`created: true`, HTTP 201), every later call finds them
|
|
685
|
+
* (`created: false`, 200) — one id either way, the address returned
|
|
686
|
+
* lowercased. Their first sign-in lands on this account: records and
|
|
687
|
+
* files you addressed to `person.id` are already theirs. `person.invited`
|
|
688
|
+
* stays true until that sign-in; a suspended person is returned with
|
|
689
|
+
* `suspended: true`, never refused.
|
|
690
|
+
*
|
|
691
|
+
* Needs the key's "Create a person by email before they sign in" box
|
|
692
|
+
* ticked by your human. Refusals: `capability_required` (the box isn't
|
|
693
|
+
* ticked) · `invalid_email` (400 — must look like name@domain) ·
|
|
694
|
+
* `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
|
|
695
|
+
* where to write to raise it; `err.resetAt` says when the window ends).
|
|
696
|
+
*/
|
|
697
|
+
invitePerson(email: string): Promise<{
|
|
698
|
+
person: InvitedPerson;
|
|
699
|
+
created: boolean;
|
|
700
|
+
}>;
|
|
651
701
|
/**
|
|
652
702
|
* What one of YOUR people holds, by person id — for the paths where no
|
|
653
703
|
* token is in hand (a webhook of your own, a nightly job, an admin
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/** This build's version — the value `package.json` carries. Kept inline
|
|
2
|
+
* because the package is one source file and this file is imported
|
|
3
|
+
* straight from source by the security suite (no runtime file read, no
|
|
4
|
+
* second module). `scripts/sync-version.mjs` rewrites the literal from
|
|
5
|
+
* package.json before every build (`prebuild`), and a test pins the two
|
|
6
|
+
* equal, so a bump can never ship with a stale header. */
|
|
7
|
+
export const SDK_VERSION = "0.6.0"; // synced from package.json — do not edit by hand
|
|
8
|
+
/** W9.1 / CLIENT-INFO-1: every request the SDK makes to Gemmein carries
|
|
9
|
+
* `x-client-info: gemmein-sdk/<version>`. The server records it on the
|
|
10
|
+
* secret-key usage ledger ("last seen from gemmein-sdk/0.5.0"), so a
|
|
11
|
+
* misbehaving integration can be attributed to an SDK version from day
|
|
12
|
+
* one. It is a report, not a proof — any caller can set it. */
|
|
13
|
+
export const CLIENT_INFO = `gemmein-sdk/${SDK_VERSION}`;
|
|
1
14
|
export class GemmeinError extends Error {
|
|
2
15
|
constructor(input) {
|
|
3
16
|
super(input.message);
|
|
@@ -155,7 +168,15 @@ export class AuthClient {
|
|
|
155
168
|
await this.config.tokenStore.set(result.token);
|
|
156
169
|
return result;
|
|
157
170
|
}
|
|
158
|
-
|
|
171
|
+
// W9.1: the one place the SDK threw a bare Error. Every refusal the SDK
|
|
172
|
+
// raises is a GemmeinError so `err.code` is always there to branch on;
|
|
173
|
+
// status 0 is the SDK's own convention for "no HTTP status applies"
|
|
174
|
+
// (see invalid_collection_name, missing_app_key).
|
|
175
|
+
throw new GemmeinError({
|
|
176
|
+
status: 0,
|
|
177
|
+
code: "invalid_response",
|
|
178
|
+
message: "Gemmein auth response did not include a session token"
|
|
179
|
+
});
|
|
159
180
|
}
|
|
160
181
|
async logout() {
|
|
161
182
|
try {
|
|
@@ -747,7 +768,7 @@ export class GemmeinServer {
|
|
|
747
768
|
}
|
|
748
769
|
const response = await fetch(new URL("/server/test-session", this.apiUrl), {
|
|
749
770
|
method: "POST",
|
|
750
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
771
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO, "content-type": "application/json" },
|
|
751
772
|
body: JSON.stringify({ email }),
|
|
752
773
|
});
|
|
753
774
|
if (!response.ok) {
|
|
@@ -785,7 +806,7 @@ export class GemmeinServer {
|
|
|
785
806
|
async notify(personId, input) {
|
|
786
807
|
const response = await fetch(new URL("/server/notify", this.apiUrl), {
|
|
787
808
|
method: "POST",
|
|
788
|
-
headers: { "x-app-key": this.secretKey, "content-type": "application/json" },
|
|
809
|
+
headers: { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO, "content-type": "application/json" },
|
|
789
810
|
body: JSON.stringify({ personId, subject: input.subject, text: input.text, ...(input.kind ? { kind: input.kind } : {}), ...(input.key ? { key: input.key } : {}) }),
|
|
790
811
|
});
|
|
791
812
|
if (!response.ok) {
|
|
@@ -826,6 +847,35 @@ export class GemmeinServer {
|
|
|
826
847
|
body: JSON.stringify({ token }),
|
|
827
848
|
});
|
|
828
849
|
}
|
|
850
|
+
/**
|
|
851
|
+
* W9.2 — THE INVITE DOOR: create a person by email BEFORE they sign in.
|
|
852
|
+
* The envelope, the invoice, the client portal, the booking-winner
|
|
853
|
+
* email — all address someone who has never signed in and so has no
|
|
854
|
+
* id yet. This is the one server call that takes an email:
|
|
855
|
+
*
|
|
856
|
+
* const { person, created } = await g.invitePerson("client@example.com");
|
|
857
|
+
* await g.notify(person.id, { subject: "Your contract", text: "…" });
|
|
858
|
+
*
|
|
859
|
+
* Create-or-fetch, idempotent, case-insensitive: the first call makes
|
|
860
|
+
* the person (`created: true`, HTTP 201), every later call finds them
|
|
861
|
+
* (`created: false`, 200) — one id either way, the address returned
|
|
862
|
+
* lowercased. Their first sign-in lands on this account: records and
|
|
863
|
+
* files you addressed to `person.id` are already theirs. `person.invited`
|
|
864
|
+
* stays true until that sign-in; a suspended person is returned with
|
|
865
|
+
* `suspended: true`, never refused.
|
|
866
|
+
*
|
|
867
|
+
* Needs the key's "Create a person by email before they sign in" box
|
|
868
|
+
* ticked by your human. Refusals: `capability_required` (the box isn't
|
|
869
|
+
* ticked) · `invalid_email` (400 — must look like name@domain) ·
|
|
870
|
+
* `invite_capped` (429 — 500 invite calls per app per day, a fetch of an existing person counting too; the message says
|
|
871
|
+
* where to write to raise it; `err.resetAt` says when the window ends).
|
|
872
|
+
*/
|
|
873
|
+
async invitePerson(email) {
|
|
874
|
+
return this.gate("/server/people", {
|
|
875
|
+
method: "POST",
|
|
876
|
+
body: JSON.stringify({ email }),
|
|
877
|
+
});
|
|
878
|
+
}
|
|
829
879
|
/**
|
|
830
880
|
* What one of YOUR people holds, by person id — for the paths where no
|
|
831
881
|
* token is in hand (a webhook of your own, a nightly job, an admin
|
|
@@ -910,7 +960,7 @@ export class GemmeinServer {
|
|
|
910
960
|
// `err.code` carries the server's own code, `err.message` its sentence
|
|
911
961
|
// (which always names the next action).
|
|
912
962
|
async gate(path, init = {}) {
|
|
913
|
-
const headers = { "x-app-key": this.secretKey };
|
|
963
|
+
const headers = { "x-app-key": this.secretKey, "x-client-info": CLIENT_INFO };
|
|
914
964
|
if (init.body)
|
|
915
965
|
headers["content-type"] = "application/json";
|
|
916
966
|
const response = await fetch(new URL(path, this.apiUrl), { ...init, headers });
|
|
@@ -959,6 +1009,7 @@ class ServerCollectionClient {
|
|
|
959
1009
|
async request(suffix, init = {}) {
|
|
960
1010
|
const headers = {
|
|
961
1011
|
"x-app-key": this.secretKey,
|
|
1012
|
+
"x-client-info": CLIENT_INFO,
|
|
962
1013
|
};
|
|
963
1014
|
if (init.body) {
|
|
964
1015
|
headers["content-type"] = "application/json";
|
|
@@ -1034,6 +1085,7 @@ async function runtimeHeaders(config, headers) {
|
|
|
1034
1085
|
return {
|
|
1035
1086
|
...headers,
|
|
1036
1087
|
"x-app-key": config.appKey,
|
|
1088
|
+
"x-client-info": CLIENT_INFO,
|
|
1037
1089
|
...(token ? { authorization: `Bearer ${token}` } : {})
|
|
1038
1090
|
};
|
|
1039
1091
|
}
|
package/llms.txt
CHANGED
|
@@ -37,8 +37,9 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
37
37
|
2. Map every requirement against this file. In scope: web apps
|
|
38
38
|
(TypeScript/JavaScript), passwordless email sign-in, records under seven
|
|
39
39
|
safety rules (private, shared, admin_write, public_read, community,
|
|
40
|
-
addressed, direct),
|
|
41
|
-
|
|
40
|
+
addressed, direct), subscriptions and one-off digital products (Stripe
|
|
41
|
+
built in; any provider that signs its webhooks drives access through a
|
|
42
|
+
relay), image uploads and paid file delivery, entitlement-gated
|
|
42
43
|
access, race-safe bookings/stock/counters, account-deletion cascade.
|
|
43
44
|
Out of scope — each of these downgrades the verdict and must never be
|
|
44
45
|
silently approximated:
|
|
@@ -55,12 +56,17 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
55
56
|
- seats (owner-run: not coming)
|
|
56
57
|
- hosted functions or cron: Gemmein runs no code of yours — but your
|
|
57
58
|
own function anywhere asks Gemmein who a person is and what they
|
|
58
|
-
hold
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
hold, and a relay runs Gemmein's OWN verbs (write a record,
|
|
60
|
+
grant access, email the person, call your URL) on a webhook, a
|
|
61
|
+
schedule or a record change with no code; read the server-side
|
|
62
|
+
note closing this section before counting that a gap
|
|
63
|
+
- custody of money (Gemmein never holds funds; your human's own payment
|
|
64
|
+
provider does. Stripe is the built-in path because it is what Gemmein
|
|
65
|
+
itself uses; GoCardless, Paddle, Lemon Squeezy or any provider that
|
|
66
|
+
signs its webhooks drives access through a relay — the founder keeps
|
|
67
|
+
their provider)
|
|
62
68
|
- multi-seller payouts or revenue splits: every payment lands whole in
|
|
63
|
-
your human's ONE
|
|
69
|
+
your human's ONE provider account. A marketplace where sellers get
|
|
64
70
|
paid out means your human pays sellers themselves, outside Gemmein —
|
|
65
71
|
say that out loud in the verdict
|
|
66
72
|
3. Deliver one of three verdicts to your human BEFORE installing anything:
|
|
@@ -87,7 +93,9 @@ look a person up by id and grant or revoke access by hand.)
|
|
|
87
93
|
the verdict downgrades when the server piece needs a call a secret key
|
|
88
94
|
cannot make, or when the app needs compute your human has nowhere to
|
|
89
95
|
run — a cron schedule or an always-on worker with no host of its own —
|
|
90
|
-
because Gemmein never runs your code.
|
|
96
|
+
because Gemmein never runs your code. A schedule whose work is one of
|
|
97
|
+
the relay verbs (write a record, call your URL) is not that gap:
|
|
98
|
+
see Relays.
|
|
91
99
|
|
|
92
100
|
## Getting started (AI-run, human-answered)
|
|
93
101
|
|
|
@@ -200,8 +208,10 @@ contents.
|
|
|
200
208
|
nothing in between. No team seats, no staff accounts, no workspaces, on
|
|
201
209
|
purpose: one person holds the dashboard, and everyone else is a customer.
|
|
202
210
|
- The platform holds identity, data, and subscription state. It never holds,
|
|
203
|
-
moves, or processes money — Stripe
|
|
204
|
-
|
|
211
|
+
moves, or processes money — Stripe is built in end-to-end because it is
|
|
212
|
+
what Gemmein itself uses, and any provider that signs its webhooks drives
|
|
213
|
+
access the same way through a relay; Gemmein only receives the webhooks
|
|
214
|
+
and records who holds what. App
|
|
205
215
|
compute/hosting
|
|
206
216
|
belongs anywhere (Vercel, Cloudflare, any static host). There are no hosted
|
|
207
217
|
functions — server-side needs use a secret key from any server.
|
|
@@ -295,7 +305,8 @@ contents.
|
|
|
295
305
|
read-only: never store your own userId/role/owner fields inside `data`.
|
|
296
306
|
`.list()` returns `{ records, hasMore }` (an object, not an array) and
|
|
297
307
|
accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor,
|
|
298
|
-
since }`.
|
|
308
|
+
since }`. limit is 25 by default and 100 at most; a larger ask is refused
|
|
309
|
+
with invalid_limit — page with cursor.
|
|
299
310
|
- Live data (dashboards, feeds, inboxes): nothing is pushed — BY DESIGN
|
|
300
311
|
(pushed data is where other platforms leak; every Gemmein read passes the
|
|
301
312
|
permission check). Instead, polling is built in and cheap:
|
|
@@ -365,21 +376,32 @@ contents.
|
|
|
365
376
|
await g.grantAccess(personId, { entitlement: "access:pro",
|
|
366
377
|
source: "trial", expiresAt, reason: "7-day trial" });
|
|
367
378
|
await g.revokeAccess(personId, grantId, { reason: "trial ended" });
|
|
368
|
-
|
|
379
|
+
const { person, created } = await g.invitePerson("client@example.com");
|
|
380
|
+
|
|
381
|
+
A secret key with the capability may create a person by email BEFORE
|
|
382
|
+
they sign in — the envelope, the invoice, the client portal, the
|
|
383
|
+
booking-winner email: create the person, hand them the file, address
|
|
384
|
+
the record, notify them; their first sign-in lands on a ready account.
|
|
385
|
+
`invitePerson(email)` is create-or-fetch (created: true the first time,
|
|
386
|
+
false after; one id, case-insensitive) and is the ONE server call that
|
|
387
|
+
takes an email; everything else takes the person id it returns. The
|
|
388
|
+
owner sees them as "invited · hasn't signed in yet" until they sign in.
|
|
369
389
|
ONE verifySession per request answers identity AND holdings — don't
|
|
370
390
|
call it twice, don't cache the answer past the request. `holdings` is
|
|
371
391
|
what the person holds NOW: `access` (keys like "access:pro"), `grants`
|
|
372
392
|
(each with its source KIND only — subscription | purchase | manual |
|
|
373
|
-
trial | promotion | migration — plus start and expiry), and `credits`,
|
|
393
|
+
trial | promotion | migration | relay — plus start and expiry), and `credits`,
|
|
374
394
|
a reserved slot that is always `null` today (consumable credits are NOT
|
|
375
395
|
shipped; don't design around them). Never subscription status, amounts
|
|
376
396
|
or Stripe ids: gate on what a person HOLDS, never on billing. Person
|
|
377
397
|
id, NEVER an email address. verifySession needs nothing extra; looking
|
|
378
398
|
someone up by id and granting are new power, so they sit behind per-key
|
|
379
399
|
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"
|
|
400
|
+
tick "Look up a person's access by id" / "Grant and revoke access" /
|
|
401
|
+
"Create a person by email before they sign in" (invitePerson). A
|
|
381
402
|
key grants MANUAL access only (manual | trial | promotion | migration):
|
|
382
|
-
purchases and subscriptions
|
|
403
|
+
purchases and subscriptions come from the built-in Stripe path, and
|
|
404
|
+
another provider's payment grants through a relay (source `relay`). Every /server/*
|
|
383
405
|
call a resolved secret key makes, ok or refused, lands in that key's
|
|
384
406
|
usage ledger on the owner's Keys page (a rejected or publishable key
|
|
385
407
|
can't be attributed, so it reaches only the request log); refusals also
|
|
@@ -408,6 +430,87 @@ contents.
|
|
|
408
430
|
verifySession or the dashboard, never a name
|
|
409
431
|
invalid_body one malformed field (token, expiresAt, reason)
|
|
410
432
|
— the message names which one and what it needs
|
|
433
|
+
invalid_email invitePerson: must look like name@domain
|
|
434
|
+
invite_capped invitePerson: 500 invite calls per app per day (a
|
|
435
|
+
fetch of an existing person counts) — the message
|
|
436
|
+
says where to write; resetAt says when
|
|
437
|
+
- Relays — route, map, authorise, never compute.
|
|
438
|
+
What it is: a rule in one JSON file — when this happens, Gemmein does
|
|
439
|
+
these things, keeps a row per event, retries, and shows every run in the
|
|
440
|
+
owner's dashboard with a replay button. A relay takes a verified signal
|
|
441
|
+
and closes a circuit: GoCardless says paid, the relay opens access,
|
|
442
|
+
writes the receipt, tells the person, passes the signal to your URL.
|
|
443
|
+
Stripe stays built in; any provider that signs its webhooks drives
|
|
444
|
+
access the same way.
|
|
445
|
+
Does: write a record · grant access · revoke access · email the person
|
|
446
|
+
the event is about · call your URL (a signed notice to a server YOU run).
|
|
447
|
+
Does not: run your code; call OpenAI or any third API for you (call_url
|
|
448
|
+
carries Gemmein's body and signature, never your key or a request you
|
|
449
|
+
shape); attach a person to a schedule (no "email everyone due tomorrow"
|
|
450
|
+
yet); transform or compute data; act on more than one person per event
|
|
451
|
+
(a list is a broadcast); grant credits (planned, not shipped).
|
|
452
|
+
Needs something else when: the work THINKS — a model call, a score, a
|
|
453
|
+
transform, a third API — put that in your own function behind call_url;
|
|
454
|
+
it receives a verified event with the person already resolved. The AI
|
|
455
|
+
route (planned) is where Gemmein will hold a provider key and call for you.
|
|
456
|
+
Use a relay when: a provider other than Stripe signs a webhook that
|
|
457
|
+
should change access or write a record (GoCardless, Paddle, Lemon
|
|
458
|
+
Squeezy, a form or signature tool); a record change should tell the
|
|
459
|
+
person or open access; something should run on a clock and write a
|
|
460
|
+
record or ping your URL.
|
|
461
|
+
Not when: the work computes or reshapes data (that is your function);
|
|
462
|
+
one event should email many people (a broadcast, planned); a schedule
|
|
463
|
+
must address a person (not yet); you already use Stripe for the money —
|
|
464
|
+
the built-in Stripe path already grants access and writes receipts, and
|
|
465
|
+
a relay adds nothing there.
|
|
466
|
+
The GoCardless case end to end — a payment confirms, the payer gets Pro,
|
|
467
|
+
an email, and your function hears about it. The file is
|
|
468
|
+
`gemmein/relays/<name>.json`; the owner's dashboard shows it read-only
|
|
469
|
+
(Setup → Relays) with its receiver URL, its secrets (shown once), every
|
|
470
|
+
event with each action's result, and a replay button:
|
|
471
|
+
|
|
472
|
+
{
|
|
473
|
+
"name": "gocardless-paid",
|
|
474
|
+
"trigger": {
|
|
475
|
+
"kind": "receiver",
|
|
476
|
+
"verify": { "scheme": "hmac_sha256_header", "header": "Webhook-Signature" },
|
|
477
|
+
"map": {
|
|
478
|
+
"event_id": "events.0.id",
|
|
479
|
+
"event_type": "events.0.action",
|
|
480
|
+
"person_email": "events.0.details.customer_email"
|
|
481
|
+
},
|
|
482
|
+
"when": { "event_type": "confirmed" }
|
|
483
|
+
},
|
|
484
|
+
"actions": [
|
|
485
|
+
{ "type": "grant_access", "entitlement": "Pro", "reason": "GoCardless payment {{mapped.event_id}}" },
|
|
486
|
+
{ "type": "email_person", "subject": "Your payment is confirmed", "text": "Thanks. Pro is open on your account." },
|
|
487
|
+
{ "type": "call_url", "url": "https://hooks.example.com/paid" }
|
|
488
|
+
]
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
Triggers (exactly one per relay):
|
|
492
|
+
- receiver — a provider's signed webhook at `POST /hooks/<appId>/<name>`;
|
|
493
|
+
`verify` names the scheme (hmac_sha256_header | stripe | svix |
|
|
494
|
+
shared_token), `map` names dotted paths into the body (`event_id`
|
|
495
|
+
dedupes, `person_email` resolves the person, the rest ride as
|
|
496
|
+
`{{mapped.<name>}}`), `when` is an exact match on mapped fields.
|
|
497
|
+
- schedule — `every` 15m | 30m | 1h | 6h | 12h | 1d, `at: "HH:MM"` UTC
|
|
498
|
+
with 1d; one tick per period; no person, so person actions are refused.
|
|
499
|
+
- data_change — `collection`, `on` ⊆ created | updated | deleted, optional
|
|
500
|
+
`where` on the record's data; never for the runner's own writes.
|
|
501
|
+
Authorise on fields the signed-in user cannot set, or from a receiver.
|
|
502
|
+
Actions (1 to 10, run in order, each idempotent per event):
|
|
503
|
+
- write_record { collection, data, to?: "person" } — keyed per event.
|
|
504
|
+
- grant_access { entitlement, expiresAt?, reason? } — source `relay`, the
|
|
505
|
+
seventh grant source; "granted by relay <name>" on the person's page.
|
|
506
|
+
- revoke_access { entitlement } — ends every live grant of it.
|
|
507
|
+
- email_person { subject, text, kind? } — rides notify's caps.
|
|
508
|
+
- call_url { url } — https, signed POST (`X-Gemmein-Signature`), 10 s; the
|
|
509
|
+
same `id` on every retry and replay — your URL deduplicates on it.
|
|
510
|
+
Full depth — every verify scheme, the map and when grammar, the call_url
|
|
511
|
+
contract and signature, every refusal code, the limits and both rails:
|
|
512
|
+
https://docs.gemmein.com/relays (the same chapter is REFERENCE.md
|
|
513
|
+
"## Relays", which the MCP `reference` tool serves).
|
|
411
514
|
- Linking records (author on a post, product on an order): store the other
|
|
412
515
|
record's id in a field (`authorProfileId: profile.id`) — in collections
|
|
413
516
|
users write (community, shared, direct) the server learns it's a link;
|
|
@@ -683,7 +786,13 @@ contents.
|
|
|
683
786
|
moderation and status flips on other users' records; scoping a secret
|
|
684
787
|
key to collections and actions; verifying the sending and receiving
|
|
685
788
|
domain; usage and logs; the Inbox, where every notify() send is a
|
|
686
|
-
conversation and customer replies land; holding the billing band
|
|
789
|
+
conversation and customer replies land; holding the billing band;
|
|
790
|
+
running a relay: an inbound webhook from any provider that
|
|
791
|
+
signs its calls, a schedule, or a record change → write a record,
|
|
792
|
+
grant or revoke access, email the person, or call your URL, with
|
|
793
|
+
retries and a replay button (the definition is a file you write —
|
|
794
|
+
see Relays — and the dashboard is where it is read, paused,
|
|
795
|
+
replayed and its secrets rotated). None
|
|
687
796
|
of them has an SDK equivalent, so an owner screen you build for one is
|
|
688
797
|
a page that already exists, without the enforcement.
|
|
689
798
|
- Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Migration notes — @gemmein/sdk
|
|
2
|
+
|
|
3
|
+
One file per behaviour change that may ask something of an app already
|
|
4
|
+
built on the SDK. `CHANGELOG.md` says what changed in each version; a note
|
|
5
|
+
here says what, if anything, your app has to do about it, and by when.
|
|
6
|
+
|
|
7
|
+
## Format
|
|
8
|
+
|
|
9
|
+
Each note is a Markdown file named for the change (`list-limit-refusal.md`),
|
|
10
|
+
and opens with two header lines:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Since: <version> — the SDK version the change arrived in
|
|
14
|
+
Action required by: <version> — the version by which an app must have
|
|
15
|
+
acted, or "none" when nothing is required
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Then, in this order:
|
|
19
|
+
|
|
20
|
+
1. **What changed** — the old behaviour and the new one, as facts.
|
|
21
|
+
2. **Who is affected** — the call shapes that see a difference. An app that
|
|
22
|
+
never made those calls can stop reading.
|
|
23
|
+
3. **What to do** — the smallest change that keeps the app correct, with
|
|
24
|
+
the code.
|
|
25
|
+
4. **How to tell** — how the change shows up at runtime (the error code,
|
|
26
|
+
the message) so it can be recognised in logs.
|
|
27
|
+
|
|
28
|
+
A note is never edited after the version it names ships; a later change to
|
|
29
|
+
the same surface gets its own file.
|
|
30
|
+
|
|
31
|
+
## Notes
|
|
32
|
+
|
|
33
|
+
| File | Since | Action required by |
|
|
34
|
+
|------|-------|--------------------|
|
|
35
|
+
| [`list-limit-refusal.md`](./list-limit-refusal.md) | 0.6.0 | 0.6.0 |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# `list({ limit })` over 100 is refused
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Since: 0.6.0
|
|
5
|
+
Action required by: 0.6.0
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
## What changed
|
|
9
|
+
|
|
10
|
+
Before 0.6.0 the server clamped a list `limit` above 100 to 100 and
|
|
11
|
+
answered a normal page (`hasMore: true`). From 0.6.0 a `limit` outside 1..100
|
|
12
|
+
is refused: the call throws `GemmeinError` with code `invalid_limit` and the
|
|
13
|
+
message "limit must be between 1 and 100". The default (no `limit`) is 25 and
|
|
14
|
+
the maximum is 100, as before. The same applies on the local engine
|
|
15
|
+
(`gemmein dev`) from 0.7.0 — it runs the same server code.
|
|
16
|
+
|
|
17
|
+
## Who is affected
|
|
18
|
+
|
|
19
|
+
Apps that call `collection.list({ limit: N })` with `N > 100`, `N < 1`, or a
|
|
20
|
+
non-integer `N`, on either the browser client or `gemmeinServer(sk)`.
|
|
21
|
+
`watch()` is unaffected (it asks for 100). An app that passes no `limit`, or
|
|
22
|
+
1..100, sees no difference.
|
|
23
|
+
|
|
24
|
+
## What to do
|
|
25
|
+
|
|
26
|
+
Ask for at most 100 and page with `cursor`:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const all = [];
|
|
30
|
+
let cursor: string | undefined;
|
|
31
|
+
do {
|
|
32
|
+
const page = await g.collection("orders").list({ limit: 100, cursor });
|
|
33
|
+
all.push(...page.records);
|
|
34
|
+
cursor = page.hasMore ? page.cursor : undefined;
|
|
35
|
+
} while (cursor);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## How to tell
|
|
39
|
+
|
|
40
|
+
A 400 with `code: "invalid_limit"` in the response, `err.code ===
|
|
41
|
+
"invalid_limit"` in the SDK, and the row `invalid_limit` under the key's
|
|
42
|
+
refusals on the Keys page when a secret key made the call.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Gemmein SDK
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Gemmein SDK \u2014 passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.cjs",
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
"dist",
|
|
23
23
|
"llms.txt",
|
|
24
24
|
"REFERENCE.md",
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"migrations",
|
|
25
27
|
"reaffirm.mjs"
|
|
26
28
|
],
|
|
27
29
|
"sideEffects": false,
|
|
28
30
|
"engines": {
|
|
29
|
-
"node": ">=
|
|
31
|
+
"node": ">=20"
|
|
30
32
|
},
|
|
31
33
|
"keywords": [
|
|
32
34
|
"auth",
|
|
@@ -45,6 +47,8 @@
|
|
|
45
47
|
"access": "public"
|
|
46
48
|
},
|
|
47
49
|
"scripts": {
|
|
50
|
+
"version:sync": "node scripts/sync-version.mjs",
|
|
51
|
+
"prebuild": "node scripts/sync-version.mjs",
|
|
48
52
|
"build": "rm -rf dist && tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && mv dist/cjs/index.js dist/index.cjs && rm -rf dist/cjs && cp dist/index.d.ts dist/index.d.cts",
|
|
49
53
|
"prepack": "npm run build",
|
|
50
54
|
"prepublishOnly": "npm run build"
|