@gemmein/sdk 0.9.0 → 0.10.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 +179 -0
- package/README.md +79 -18
- package/REFERENCE.md +282 -14
- package/dist/expo.cjs +423 -0
- package/dist/expo.d.cts +152 -0
- package/dist/expo.d.ts +152 -0
- package/dist/expo.js +387 -0
- package/dist/index.cjs +224 -34
- package/dist/index.d.cts +143 -9
- package/dist/index.d.ts +143 -9
- package/dist/index.js +223 -34
- package/llms.txt +320 -34
- package/migrations/README.md +1 -0
- package/migrations/secure-store-set-throws.md +75 -0
- package/package.json +23 -2
package/llms.txt
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
> nothing to configure. Build anywhere, with any AI, free — and come to
|
|
8
8
|
> Gemmein when you're done: the product is taking the app to real customers.
|
|
9
9
|
> Live apps pay one banded plan, $50-$150/mo, set by the previous month's
|
|
10
|
-
>
|
|
11
|
-
>
|
|
10
|
+
> active people — a person counts once a month, on their first signed-in
|
|
11
|
+
> request, on web and mobile alike (five pre-agreed steps up to 10,000
|
|
12
|
+
> people; beyond that, talk to Gemmein).
|
|
12
13
|
|
|
13
14
|
This file IS the product contract, not an overview: every line is
|
|
14
15
|
load-bearing, and building from a summary of it builds the wrong app. Work
|
|
@@ -47,7 +48,9 @@ creating no accounts is the correct behavior until the verdict is in.
|
|
|
47
48
|
access, race-safe bookings/stock/counters, account-deletion cascade.
|
|
48
49
|
Out of scope — each of these downgrades the verdict and must never be
|
|
49
50
|
silently approximated:
|
|
50
|
-
-
|
|
51
|
+
- Android or iOS written natively in Kotlin or Java (the mobile SDKs
|
|
52
|
+
are `@gemmein/sdk/expo` for Expo and React Native, and `GemmeinSwift`
|
|
53
|
+
for Apple platforms — see Mobile)
|
|
51
54
|
- password or social/OAuth sign-in (email codes are the only door)
|
|
52
55
|
- team/group/workspace visibility or per-user record permissions:
|
|
53
56
|
Gemmein is owner-run by design — one owner and their customers,
|
|
@@ -238,7 +241,9 @@ contents.
|
|
|
238
241
|
## What it is
|
|
239
242
|
|
|
240
243
|
- Audience: people who build web apps with AI tools (Cursor, Bolt, Lovable, Claude).
|
|
241
|
-
- Scope: web apps (TypeScript/JavaScript SDK)
|
|
244
|
+
- Scope: web apps (TypeScript/JavaScript SDK) and phone apps — Expo and
|
|
245
|
+
React Native through `@gemmein/sdk/expo`, Apple platforms through the
|
|
246
|
+
`GemmeinSwift` package (iOS 17+, macOS 14+). See Mobile.
|
|
242
247
|
- Shape: built for owner-run businesses — one owner and their customers,
|
|
243
248
|
nothing in between. No team seats, no staff accounts, no workspaces, on
|
|
244
249
|
purpose: one person holds the dashboard, and everyone else is a customer.
|
|
@@ -341,7 +346,8 @@ contents.
|
|
|
341
346
|
`.list()` returns `{ records, hasMore }` (an object, not an array) and
|
|
342
347
|
accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor,
|
|
343
348
|
since }`. limit is 25 by default and 100 at most; a larger ask is refused
|
|
344
|
-
with invalid_limit — page with cursor.
|
|
349
|
+
with invalid_limit — page with cursor. A record's data is at most 32 KB
|
|
350
|
+
(413 payload_too_large); files go through uploads, never inline.
|
|
345
351
|
- Live data (dashboards, feeds, inboxes): nothing is pushed — BY DESIGN
|
|
346
352
|
(pushed data is where other platforms leak; every Gemmein read passes the
|
|
347
353
|
permission check). Instead, polling is built in and cheap:
|
|
@@ -385,8 +391,15 @@ contents.
|
|
|
385
391
|
A person id, NEVER an email address — the recipient must be a verified
|
|
386
392
|
user of your app (404 not_a_customer otherwise; the address comes from
|
|
387
393
|
the server's own record). Plain text; a short "you have an account with
|
|
388
|
-
{your app}" footer is added for you.
|
|
389
|
-
|
|
394
|
+
{your app}" footer is added for you. notify sends only from YOUR verified
|
|
395
|
+
sender domain: until one is verified on the Domains page the send is
|
|
396
|
+
refused (409 sender_domain_required) — nothing leaves, nothing is
|
|
397
|
+
recorded, no cap is spent, and the same key sends once you verify. If
|
|
398
|
+
your provider refuses that address at send time the call fails
|
|
399
|
+
(502 send_failed) and is safe to retry; it is never quietly re-sent from
|
|
400
|
+
a Gemmein address. Sign-in codes are the one email Gemmein sends on your
|
|
401
|
+
behalf before a domain is verified, as "<App name> (via Gemmein)".
|
|
402
|
+
Every send appears in your
|
|
390
403
|
dashboard Inbox as a conversation; a customer's REPLY lands there too
|
|
391
404
|
once your domain's receiving is verified (the Domains page) — before
|
|
392
405
|
that the mail has no reply path, so if you expect answers, say where to
|
|
@@ -439,7 +452,7 @@ contents.
|
|
|
439
452
|
purchases and subscriptions come from the built-in Stripe path, and
|
|
440
453
|
another provider's payment grants through a relay (source `relay`). Every /server/*
|
|
441
454
|
call a resolved secret key makes, ok or refused, lands in that key's
|
|
442
|
-
usage ledger on the owner's
|
|
455
|
+
usage ledger on the owner's Secret keys page (a rejected or publishable key
|
|
443
456
|
can't be attributed, so it reaches only the request log); refusals also
|
|
444
457
|
write one activity row per key, route and code each hour. In `gemmein
|
|
445
458
|
dev` the local key already holds both capabilities, and the usage read
|
|
@@ -499,7 +512,7 @@ contents.
|
|
|
499
512
|
person or open access; something should run on a clock and write a
|
|
500
513
|
record or ping your URL.
|
|
501
514
|
Not when: the work computes or reshapes data (that is your function);
|
|
502
|
-
one event should email many people (a
|
|
515
|
+
one event should email many people (a relay addresses one person); a schedule
|
|
503
516
|
must address a person (not yet); you already use Stripe for the money —
|
|
504
517
|
the built-in Stripe path already grants access and writes receipts, and
|
|
505
518
|
a relay adds nothing there.
|
|
@@ -534,8 +547,18 @@ contents.
|
|
|
534
547
|
- receiver — a provider's signed webhook at `POST /hooks/<appId>/<name>`;
|
|
535
548
|
`verify` names the scheme (hmac_sha256_header | stripe | svix |
|
|
536
549
|
shared_token), `map` names dotted paths into the body (`event_id`
|
|
537
|
-
dedupes,
|
|
550
|
+
dedupes, ONE person key resolves the person, the rest ride as
|
|
538
551
|
`{{mapped.<name>}}`), `when` is an exact match on mapped fields.
|
|
552
|
+
A map names the person ONE way: person_email (their address, which
|
|
553
|
+
invites someone who has never signed in), person_id (a gemmein person
|
|
554
|
+
id) or person_token (the store account token their app filed with a
|
|
555
|
+
store). Two of them in one map is refused at save — the id and the
|
|
556
|
+
token could name different people, and the event would land on
|
|
557
|
+
whichever was tried first. Only `person_email` creates a person;
|
|
558
|
+
`person_id` and `person_token` are read-only lookups, because a value a
|
|
559
|
+
third party chose that matches nothing names no human at all. A key
|
|
560
|
+
that resolves to nobody records the event, answers 200 and reports
|
|
561
|
+
`person_not_found` naming the KEY that was tried, never its value.
|
|
539
562
|
- schedule — `every` 15m | 30m | 1h | 6h | 12h | 1d, `at: "HH:MM"` UTC
|
|
540
563
|
with 1d; one tick per period; no person, so person actions are refused.
|
|
541
564
|
- data_change — `collection`, `on` ⊆ created | updated | deleted, optional
|
|
@@ -548,7 +571,10 @@ contents.
|
|
|
548
571
|
- revoke_access { entitlement } — ends every live grant of it.
|
|
549
572
|
- grant_credits { amount (1..10,000), reason? } — adds to the person's
|
|
550
573
|
balance, once per event; the ledger line reads "relay: <reason>".
|
|
551
|
-
- email_person { subject, text, kind? } — rides notify's caps
|
|
574
|
+
- email_person { subject, text, kind? } — rides notify's caps, and notify's
|
|
575
|
+
sender law: it goes only from your verified sender domain. Until one is
|
|
576
|
+
verified the action alone is refused sender_domain_required and the event
|
|
577
|
+
records it; nothing is sent via Gemmein.
|
|
552
578
|
- call_url { url } — https, signed POST (`X-Gemmein-Signature`), 10 s; the
|
|
553
579
|
same `id` on every retry and replay — your URL deduplicates on it.
|
|
554
580
|
- fulfil_product { product, ref? } — product is a name on the Payments
|
|
@@ -594,6 +620,78 @@ contents.
|
|
|
594
620
|
A template that names nothing is recorded as a warning on the relay
|
|
595
621
|
event — read it on the Relays page before trusting a ref.
|
|
596
622
|
|
|
623
|
+
THE STORE ROAD (RevenueCat) — the three templates the Relays room offers,
|
|
624
|
+
and the way an App Store or Play Store purchase reaches Gemmein. It is
|
|
625
|
+
THREE relays, not one: `when` matches one exact value per field, so an
|
|
626
|
+
event-type SET cannot be written. Each relay has its own URL and its own
|
|
627
|
+
secret, which goes in RevenueCat's Authorization header field
|
|
628
|
+
(`shared_token` reads the `authorization` header).
|
|
629
|
+
|
|
630
|
+
The PURCHASE relay carries no `when` — a product names exactly one relay
|
|
631
|
+
as the road it is sold on, so the three purchase events cannot be split
|
|
632
|
+
across three relays — and its only guard is therefore the RevenueCat
|
|
633
|
+
integration itself, which you narrow to INITIAL_PURCHASE, RENEWAL and
|
|
634
|
+
NON_RENEWING_PURCHASE and nothing else.
|
|
635
|
+
|
|
636
|
+
The two REVOKING relays each carry their own `when` —
|
|
637
|
+
`{ "event_type": "EXPIRATION" }` and `{ "event_type": "CANCELLATION" }` —
|
|
638
|
+
because `revoke_access` is authorised by no road at all. Without the
|
|
639
|
+
filter, a purchase posted to a revoke relay by a mis-pasted URL or a
|
|
640
|
+
widened integration would take away the access it had just granted. The
|
|
641
|
+
guard is the RELAY'S OWN, in Gemmein, whatever a third party's console is
|
|
642
|
+
configured to send; an event a `when` does not match answers 200 and is
|
|
643
|
+
not even recorded.
|
|
644
|
+
|
|
645
|
+
The person is named by `person_token`: the app hands RevenueCat the
|
|
646
|
+
person's `storeAccountToken` as the app user id, so neither the gemmein
|
|
647
|
+
person id nor the address enters a third party's ledger. On the Payments
|
|
648
|
+
page, set the product's "How it's sold" to the purchase relay — that is
|
|
649
|
+
what lets it grant a purchase.
|
|
650
|
+
|
|
651
|
+
{
|
|
652
|
+
"name": "revenuecat-purchase",
|
|
653
|
+
"trigger": {
|
|
654
|
+
"kind": "receiver",
|
|
655
|
+
"verify": { "scheme": "shared_token", "header": "authorization" },
|
|
656
|
+
"map": {
|
|
657
|
+
"event_id": "event.id",
|
|
658
|
+
"event_type": "event.type",
|
|
659
|
+
"person_token": "event.app_user_id",
|
|
660
|
+
"product_id": "event.product_id",
|
|
661
|
+
"store": "event.store",
|
|
662
|
+
"transaction_id": "event.transaction_id"
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
"actions": [
|
|
666
|
+
{ "type": "fulfil_product", "product": "Pro", "ref": "{{mapped.transaction_id}}" }
|
|
667
|
+
]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
{
|
|
671
|
+
"name": "revenuecat-expiration",
|
|
672
|
+
"trigger": {
|
|
673
|
+
"kind": "receiver",
|
|
674
|
+
"verify": { "scheme": "shared_token", "header": "authorization" },
|
|
675
|
+
"map": {
|
|
676
|
+
"event_id": "event.id",
|
|
677
|
+
"event_type": "event.type",
|
|
678
|
+
"person_token": "event.app_user_id",
|
|
679
|
+
"product_id": "event.product_id",
|
|
680
|
+
"store": "event.store"
|
|
681
|
+
},
|
|
682
|
+
"when": { "event_type": "EXPIRATION" }
|
|
683
|
+
},
|
|
684
|
+
"actions": [
|
|
685
|
+
{ "type": "revoke_access", "entitlement": "access:pro" }
|
|
686
|
+
]
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
The cancellation twin is the same definition with the name
|
|
690
|
+
`revenuecat-cancellation` and `"when": { "event_type": "CANCELLATION" }`.
|
|
691
|
+
A subscription that is cancelled and then expires sends both, and the
|
|
692
|
+
second one finds nothing left to take — said out loud on the event row,
|
|
693
|
+
never a failure.
|
|
694
|
+
|
|
597
695
|
The refund twin swaps the trigger's `when` to
|
|
598
696
|
`{ "event_type": "refunded" }` and the action to `refund_product`.
|
|
599
697
|
`g.payments.buy` on a product sold this way answers 409
|
|
@@ -764,8 +862,14 @@ contents.
|
|
|
764
862
|
engine refuses customers without it, under all seven rules. (Server
|
|
765
863
|
secret keys and the owner's dashboard are exempt by design; link/expand
|
|
766
864
|
silently hide gated records rather than naming them.) Every plan and
|
|
767
|
-
product carries its own key,
|
|
768
|
-
`access
|
|
865
|
+
product carries its own key, DERIVED from its name the first time it is
|
|
866
|
+
saved: `access:` + the name lowercased, with every run of anything that
|
|
867
|
+
is not a letter or a digit turned into ONE dash, trimmed at both ends
|
|
868
|
+
(plan "pro" → `access:pro`; product "Pro Max" → `access:pro-max`; "Deep
|
|
869
|
+
Research!" → `access:deep-research`). It is minted ONCE and stored, so a
|
|
870
|
+
later rename keeps the key and nothing a customer holds moves. Never
|
|
871
|
+
build the key yourself from a name you have in hand — pass the NAME and
|
|
872
|
+
let the server resolve it. The paid webhook grants that key, and a FULL refund or a
|
|
769
873
|
cancellation revokes exactly what it granted — nothing else. A partial
|
|
770
874
|
refund leaves access in place.
|
|
771
875
|
Owners also grant and revoke by hand (trials, comps, support). Effective
|
|
@@ -958,13 +1062,15 @@ contents.
|
|
|
958
1062
|
— label, credits, requires, enabled, recordCalls — and the file's values
|
|
959
1063
|
for those apply ONCE, at creation; after that the dashboard's stand. A
|
|
960
1064
|
tool edited in the dashboard since the last sync is not silently
|
|
961
|
-
overwritten: sync asks, per tool.
|
|
962
|
-
|
|
963
|
-
tool on a
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1065
|
+
overwritten: sync asks, per tool. A tool can be defined before its
|
|
1066
|
+
provider key exists: the save takes on both rails, the console shows the
|
|
1067
|
+
tool as waiting on a key, and runs are refused `ai_not_configured` (409)
|
|
1068
|
+
until the key is pasted on the AI tools page — after which the same tool
|
|
1069
|
+
runs, with no further step. When the key is there, a pinned `model`
|
|
1070
|
+
outside that provider's allowed models on the AI tools page is refused
|
|
1071
|
+
`invalid_tool` (400 — the message lists what is allowed); with no key
|
|
1072
|
+
there is no allowed list, and the pin is taken as it stands. The tool's
|
|
1073
|
+
NAME is the file name and is fixed once created.
|
|
968
1074
|
Also true of the route itself: `sk_` is refused; it does not choose
|
|
969
1075
|
models, cache, summarise, moderate, or reshape the request or the
|
|
970
1076
|
answer; it does not refund a call that dies mid-stream, or one the
|
|
@@ -1012,6 +1118,14 @@ contents.
|
|
|
1012
1118
|
read that as the cloud's answer. A call that names no tool runs as the
|
|
1013
1119
|
default tool: one credit, your configured provider, any allowed model —
|
|
1014
1120
|
behind the same switch.
|
|
1121
|
+
THE ORDER THE ROUTE REFUSES IN, so you know which code a call actually
|
|
1122
|
+
meets: session → rate → body size → the named tool (unknown_tool,
|
|
1123
|
+
tool_disabled, entitlement_required) → the provider (provider_required,
|
|
1124
|
+
ai_not_configured) → raw_calls_off → the model → the spend
|
|
1125
|
+
(credits_exhausted). So a raw call to a provider with no key answers
|
|
1126
|
+
ai_not_configured, not raw_calls_off; a raw call naming a tool that does
|
|
1127
|
+
not exist answers unknown_tool; and no refusal above the spend has cost a
|
|
1128
|
+
credit.
|
|
1015
1129
|
|
|
1016
1130
|
const res = await g.ai.chat({ model: "gpt-4o-mini", stream: true,
|
|
1017
1131
|
messages: [{ role: "user", content: text }] }) // raw: needs the switch
|
|
@@ -1025,13 +1139,13 @@ contents.
|
|
|
1025
1139
|
this environment) · tool_disabled (403 — the owner switched it off)
|
|
1026
1140
|
· entitlement_required (403 — the message names the plan or product
|
|
1027
1141
|
it needs) · model_pinned (403 — the tool's model is fixed; leave
|
|
1028
|
-
`model` out of the body) ·
|
|
1029
|
-
tool: add that provider's key first) · too_many_tools (409 — 50
|
|
1142
|
+
`model` out of the body) · too_many_tools (409 — 50
|
|
1030
1143
|
tools per environment) · invalid_tool (400 — creating or updating a
|
|
1031
1144
|
tool with a bad field) · credits_exhausted (402 — the message names
|
|
1032
1145
|
the tool, its price and the balance, singular for 1; show the pack)
|
|
1033
|
-
· ai_not_configured (409 — no
|
|
1034
|
-
|
|
1146
|
+
· ai_not_configured (409 — no key
|
|
1147
|
+
set for the tool's provider; a tool may be defined before its key, and
|
|
1148
|
+
runs the moment the owner pastes one) · provider_required (400 — more than
|
|
1035
1149
|
one key set; name `provider`) · model_not_allowed (403 — the owner's
|
|
1036
1150
|
allowlist names what is allowed, for a tool with no pinned model) ·
|
|
1037
1151
|
ai_capped (429 — 20 per person per minute; wait for `resetAt`) ·
|
|
@@ -1061,11 +1175,140 @@ contents.
|
|
|
1061
1175
|
no pinned model (the AI tools page's test call uses the first); every
|
|
1062
1176
|
`/ai/chat` call counts toward the app's api_requests band like any
|
|
1063
1177
|
other request; a provider that echoes the key in a refusal reaches
|
|
1064
|
-
you as `***<hint>`. The owner's Usage
|
|
1178
|
+
you as `***<hint>`. The owner's Usage & billing page counts the calls; the
|
|
1065
1179
|
provider bills the tokens on the owner's own account. `gemmein dev`
|
|
1066
1180
|
answers a fake provider without a key (header `x-gemmein-ai: fake`),
|
|
1067
1181
|
so the loop runs locally; set GEMMEIN_AI_KEY_OPENAI / _ANTHROPIC /
|
|
1068
1182
|
_GOOGLE there for a real call.
|
|
1183
|
+
- Mobile — Expo / React Native and Swift.
|
|
1184
|
+
A phone app is the SAME contract as a browser app: the PUBLIC KEY
|
|
1185
|
+
identifies the app, the SESSION authorises the person, and VERIFIED
|
|
1186
|
+
DOMAINS are a browser control that is not checked for an app — an app
|
|
1187
|
+
sends no Origin, so nothing is being verified there. Ordinary API
|
|
1188
|
+
authentication does not establish app integrity: the app key ships inside
|
|
1189
|
+
the binary, so build as though any request could come from anywhere and
|
|
1190
|
+
let the session and the server's rules decide, exactly as on the web.
|
|
1191
|
+
See https://docs.gemmein.com/mobile
|
|
1192
|
+
What it is: the same Gemmein contract from an Expo app
|
|
1193
|
+
(`@gemmein/sdk/expo` — the same npm package, a second entry point) or a
|
|
1194
|
+
Swift app (`GemmeinSwift`, iOS 17+ / macOS 14+, the same method names).
|
|
1195
|
+
Does: sign in by email code; the app's own collections, private records
|
|
1196
|
+
included; sealed files and `files.link`; credits; AI tools; the STORE
|
|
1197
|
+
ACCOUNT TOKEN — `currentUser().storeAccountToken`, the name to hand
|
|
1198
|
+
RevenueCat as its app user id; sign out everywhere, the remedy for a
|
|
1199
|
+
stolen phone, run by the owner from the console on that person's page;
|
|
1200
|
+
secure persistence — the iOS Keychain / Android Keystore through
|
|
1201
|
+
`expo-secure-store`, the Keychain through `KeychainTokenStore` in Swift —
|
|
1202
|
+
so the app relaunches still signed in.
|
|
1203
|
+
Does not: Google or Apple sign-in (email codes are the only door, on
|
|
1204
|
+
every platform); store billing inside Gemmein (Gemmein never talks to the
|
|
1205
|
+
App Store or Play — a store purchase reaches it through RevenueCat's
|
|
1206
|
+
webhook into a relay); broadcast (one event acts on one person).
|
|
1207
|
+
Needs something else when: the person buys a STORE product → RevenueCat
|
|
1208
|
+
plus the three relay templates (THE STORE ROAD, under Relays); your
|
|
1209
|
+
sign-in codes should come from YOUR domain → verify a sender domain on
|
|
1210
|
+
the Domains page, the same setup as for a web app; until then the code
|
|
1211
|
+
email — the one email Gemmein sends on your behalf, and the only one —
|
|
1212
|
+
arrives from Gemmein's shared address as "<App name> (via Gemmein)"; they buy on
|
|
1213
|
+
the WEB, or the goods are not the store's → Stripe, the built-in path;
|
|
1214
|
+
your app needs more sign-in codes than the two bounds allow → they are
|
|
1215
|
+
abuse bounds, never a growth ceiling: 3 per address and 20 per IP per app,
|
|
1216
|
+
each per 15 minutes, and how many sign-in emails an app sends in a day is
|
|
1217
|
+
never capped; the store cannot keep the session (expo-secure-store not
|
|
1218
|
+
installed, an unsigned simulator build with no keychain access group, or
|
|
1219
|
+
a browser refusing the localStorage write)
|
|
1220
|
+
→ sign-in throws `secure_store_unavailable` rather than reporting a
|
|
1221
|
+
session the app will lose; catch it and rebuild the client with a
|
|
1222
|
+
`MemoryTokenStore`, which keeps the session for the life of the process.
|
|
1223
|
+
Example — Expo:
|
|
1224
|
+
|
|
1225
|
+
import { createExpoGemmein } from "@gemmein/sdk/expo";
|
|
1226
|
+
import { AppState, Platform } from "react-native";
|
|
1227
|
+
|
|
1228
|
+
export const g = createExpoGemmein(
|
|
1229
|
+
{ appKey: "pk_live_...", apiUrl: "http://127.0.0.1:4545" }, // apiUrl: local engine only
|
|
1230
|
+
{ AppState, Platform },
|
|
1231
|
+
);
|
|
1232
|
+
|
|
1233
|
+
await g.auth.sendEmailCode(email);
|
|
1234
|
+
await g.auth.verifyEmailCode({ email, code });
|
|
1235
|
+
const { records } = await g.collection("notes").list({ limit: 20 });
|
|
1236
|
+
|
|
1237
|
+
const me = await g.auth.currentUser();
|
|
1238
|
+
if (me.authenticated && me.storeAccountToken) {
|
|
1239
|
+
// RevenueCat must know the buyer by this name
|
|
1240
|
+
await Purchases.configure({ apiKey: RC_KEY, appUserID: me.storeAccountToken });
|
|
1241
|
+
// already configured? Purchases.logIn(me.storeAccountToken)
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
The SECOND argument is the modules, and every key in it is optional:
|
|
1245
|
+
`{ SecureStore, AppState, Platform, FileSystem, fetch }` — pass none and
|
|
1246
|
+
the entry resolves `expo-secure-store`, `react-native`, `expo-file-system`
|
|
1247
|
+
and `expo/fetch` lazily on first use; pass your own (an app's static
|
|
1248
|
+
imports, a test's fakes) and they win, with no dynamic resolution at all.
|
|
1249
|
+
Pass `Platform` and the client header reads `expo-ios` / `expo-android` /
|
|
1250
|
+
`expo-web`; without it it reads `expo` — a report either way, never a
|
|
1251
|
+
proof. Install the peers in the app, not the monorepo:
|
|
1252
|
+
`npx expo install expo-secure-store expo-file-system`.
|
|
1253
|
+
Example — Swift:
|
|
1254
|
+
|
|
1255
|
+
import GemmeinSwift
|
|
1256
|
+
|
|
1257
|
+
let g = try Gemmein(appKey: "pk_live_...") // KeychainTokenStore by default
|
|
1258
|
+
|
|
1259
|
+
try await g.auth.sendEmailCode(email)
|
|
1260
|
+
_ = try await g.auth.verifyEmailCode(email: email, code: code)
|
|
1261
|
+
let page = try await g.collection("notes").list(ListOptions(limit: 20))
|
|
1262
|
+
|
|
1263
|
+
if let token = try await g.auth.currentUser().storeAccountToken {
|
|
1264
|
+
Purchases.configure(withAPIKey: rcKey, appUserID: token)
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
Codes a phone meets most (the last one a browser meets too):
|
|
1268
|
+
`network_unreachable`
|
|
1269
|
+
(client-side, status 0 — the request never reached Gemmein; the message
|
|
1270
|
+
names the host, `err.cause` carries the transport's own error, and a call
|
|
1271
|
+
cancelled with `{ signal }` is not this) · `upload_input_unsupported`
|
|
1272
|
+
(client-side, status 0 — `@gemmein/sdk/expo` `upload()` was handed a
|
|
1273
|
+
picker's `{ uri }` and `expo-file-system` is not installed) ·
|
|
1274
|
+
`token_too_large` (client-side, status 0 — a value over 2,048 bytes was
|
|
1275
|
+
handed to the device secure store; a session token is far smaller, so
|
|
1276
|
+
store the token, not the session payload) · `secure_store_unavailable`
|
|
1277
|
+
(client-side, status 0 — the token store could not KEEP the session, on
|
|
1278
|
+
ALL THREE of the SDK's stores: `expo-secure-store` is not installed or the
|
|
1279
|
+
device refused the write, with the device's own error on `err.cause`; in
|
|
1280
|
+
Swift the Keychain refused and the OSStatus is in the message, `-34018`
|
|
1281
|
+
being an unsigned build with no keychain access group; and in a BROWSER
|
|
1282
|
+
`BrowserTokenStore` throws it when `localStorage.setItem` is refused —
|
|
1283
|
+
private mode past its quota, site data blocked, a sandboxed iframe. Reads
|
|
1284
|
+
and clears stay lenient everywhere — an unreadable store means signed out.
|
|
1285
|
+
The session is real, so an app can catch this one code and rebuild its
|
|
1286
|
+
client with a `MemoryTokenStore`). `denied` (401 on
|
|
1287
|
+
an anonymous read of a private collection) and `ai_not_configured` (409)
|
|
1288
|
+
read the same as on the web.
|
|
1289
|
+
Facts a phone build needs. A store purchase's credits arrive when
|
|
1290
|
+
RevenueCat's webhook reaches the relay, which is AFTER the store's own
|
|
1291
|
+
confirmation — show the pack as ARRIVING until the balance reads back,
|
|
1292
|
+
never as failed. Uploads are sniffed from the bytes at confirm against
|
|
1293
|
+
the file allow-list (images, PDF, ZIP, EPUB), so a `text/plain` blob is
|
|
1294
|
+
refused `415 unsupported_file_type`. Point the app at a local engine with
|
|
1295
|
+
`apiUrl` — it is an option on `createExpoGemmein`'s FIRST argument, beside
|
|
1296
|
+
`appKey`, and `apiURL:` on Swift's `Gemmein(appKey:apiURL:)`. An iOS
|
|
1297
|
+
simulator shares the Mac's network stack and reaches the engine at
|
|
1298
|
+
`http://127.0.0.1:<port>`; an Android emulator reaches it at
|
|
1299
|
+
`http://10.0.2.2:<port>` — `127.0.0.1`, inside the emulator, is
|
|
1300
|
+
the emulator. `gemmein dev` binds `127.0.0.1`, which a simulator and a
|
|
1301
|
+
current Android emulator both reach as it is. A REAL phone on the same
|
|
1302
|
+
wifi does not, and neither does a second computer — serve those with
|
|
1303
|
+
`npx gemmein dev --host 0.0.0.0`, an opt-in wider bind that prints its
|
|
1304
|
+
own warning: reachable from every device on your network, and the
|
|
1305
|
+
engine holds your app's data, its local server key and any AI provider
|
|
1306
|
+
key in your shell. Presigned upload URLs and file links are always
|
|
1307
|
+
re-addressed to the host the request arrived on, whichever bind is in
|
|
1308
|
+
use, so a file the device uploads is a file it can fetch back. The
|
|
1309
|
+
pricing unit does not change on a phone:
|
|
1310
|
+
a person counts once a month, on their first signed-in request, on web
|
|
1311
|
+
and mobile alike.
|
|
1069
1312
|
- Drafts on PUBLIC collections (public_read, community): create with the
|
|
1070
1313
|
OPTION `{ published: false }` → hidden from every reader except its
|
|
1071
1314
|
author and the owner, server-enforced; publish with
|
|
@@ -1074,8 +1317,10 @@ contents.
|
|
|
1074
1317
|
reaches every reader's network tab (silent-until-breach). `published` is
|
|
1075
1318
|
an option, not a data field: putting it inside `data` is refused as a
|
|
1076
1319
|
reserved server-managed field (403 forbidden), passing the OPTION on a
|
|
1077
|
-
non-public rule is 400 invalid_publish —
|
|
1078
|
-
public rules.
|
|
1320
|
+
non-public rule is 400 invalid_publish — the OPTION works only on the two
|
|
1321
|
+
public rules. The FIELD is on every record the engine returns, whatever
|
|
1322
|
+
the rule: a private record reads `published: true`, and it is not
|
|
1323
|
+
writable there — read it, never branch a private collection on it.
|
|
1079
1324
|
- Denials are 404-shaped: touching a record your session can't see returns
|
|
1080
1325
|
404 not_found, never a 403 that confirms it exists — existence is not
|
|
1081
1326
|
leaked. A real 403 comes back as code `forbidden` and names a rule problem
|
|
@@ -1115,7 +1360,10 @@ contents.
|
|
|
1115
1360
|
moderation and status flips on other users' records; scoping a secret
|
|
1116
1361
|
key to collections and actions; verifying the sending and receiving
|
|
1117
1362
|
domain; usage and logs; the Inbox, where every notify() send is a
|
|
1118
|
-
conversation
|
|
1363
|
+
conversation, customer replies land, and the owner writes to a customer
|
|
1364
|
+
themselves — that mail leaves their own verified sender domain too, and
|
|
1365
|
+
until one is verified the composer is refused sender_domain_required;
|
|
1366
|
+
holding the billing band;
|
|
1119
1367
|
running a relay: an inbound webhook from any provider that
|
|
1120
1368
|
signs its calls, a schedule, or a record change → write a record,
|
|
1121
1369
|
grant or revoke access, grant credits, fulfil or refund a product,
|
|
@@ -1127,7 +1375,7 @@ contents.
|
|
|
1127
1375
|
a page that already exists, without the enforcement.
|
|
1128
1376
|
- Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
|
|
1129
1377
|
are public and domain-locked. A secret key can be SCOPED in the
|
|
1130
|
-
dashboard's
|
|
1378
|
+
dashboard's Secret keys page to particular collections, and to read-only or
|
|
1131
1379
|
read-and-update; a call outside that scope answers 403 `scope_denied`.
|
|
1132
1380
|
Read that error as deliberate — the human narrowed the key on purpose.
|
|
1133
1381
|
Name the collection and access your code needs and ask them to mint a
|
|
@@ -1150,10 +1398,19 @@ rule. The specifics:
|
|
|
1150
1398
|
server-managed field is. A plain object inherits those names, so a
|
|
1151
1399
|
seal must never carry one.
|
|
1152
1400
|
- The signed-in user: `await g.auth.currentUser()` →
|
|
1153
|
-
`{ authenticated: true, userId, email }` or
|
|
1401
|
+
`{ authenticated: true, userId, email, storeAccountToken? }` or
|
|
1402
|
+
`{ authenticated: false }`. The
|
|
1154
1403
|
id field is **`userId`, not `id`** — `user.id` is `undefined`, and feeding
|
|
1155
1404
|
that into a keyed create (`key: "profile:" + user.userId`) is how you get one
|
|
1156
1405
|
record per user instead of every user colliding on `profile:undefined`.
|
|
1406
|
+
- `storeAccountToken` on that answer is the opaque per-person name to hand
|
|
1407
|
+
a store: RevenueCat's app user id, Apple's `appAccountToken`, Google's
|
|
1408
|
+
`obfuscatedExternalAccountId`. It is minted on the first `currentUser()`
|
|
1409
|
+
call, stable for the life of the person, correlated to nothing, and gone
|
|
1410
|
+
when they are erased. Hand a store THAT — never `userId`, never the
|
|
1411
|
+
address: a value inside a third party's ledger is outside the erasure
|
|
1412
|
+
cascade. An engine that does not carry one leaves the field absent; read
|
|
1413
|
+
that as "not carried", never as "no account".
|
|
1157
1414
|
- `await g.auth.verifyEmailCode({ email, code })` resolves the *session*:
|
|
1158
1415
|
`{ token, expiresAt, user: { id, email } }` (note: nested `user`, and here
|
|
1159
1416
|
the field is `id`). For the canonical signed-in identity in your UI, read
|
|
@@ -1162,6 +1419,35 @@ rule. The specifics:
|
|
|
1162
1419
|
is no `signOut`.
|
|
1163
1420
|
- Errors throw a `GemmeinError` with `.status`, `.code`, `.message`, and
|
|
1164
1421
|
`.resetAt` (on 429). Branch on **`err.code`**; render `err.message`.
|
|
1422
|
+
- A request that never reached Gemmein — no connection, an offline device, an
|
|
1423
|
+
`apiUrl` pointing at nothing — throws a `GemmeinError` too:
|
|
1424
|
+
`network_unreachable`, status 0, its message naming the host and
|
|
1425
|
+
`err.cause` carrying the transport's own error. "The server said no" and
|
|
1426
|
+
"the server was never reached" are different problems: branch on them
|
|
1427
|
+
separately, and keep the app usable for the second. A call cancelled with
|
|
1428
|
+
`{ signal }` is neither — it stays an `AbortError`.
|
|
1429
|
+
- `await g.auth.logout()` is idempotent: a session the owner already ended
|
|
1430
|
+
everywhere resolves rather than throwing, because the session being gone is
|
|
1431
|
+
what logout asked for. The stored token is cleared either way.
|
|
1432
|
+
- `gemmein(pk, options?)` takes `apiUrl`, `tokenStore`, and the three a
|
|
1433
|
+
non-browser runtime needs: `fetch` (used for EVERY request this client
|
|
1434
|
+
makes, in place of `globalThis.fetch`), `visibility`
|
|
1435
|
+
(`{ isHidden(): boolean; onChange(cb): () => void }`, where `watch()`
|
|
1436
|
+
learns the app went to the background, in place of
|
|
1437
|
+
`document.visibilityState`) and `platform` (a tag appended to the
|
|
1438
|
+
`x-client-info` header — `gemmein-sdk/<version> expo-ios`, cleaned and
|
|
1439
|
+
capped to 64 characters). `createExpoGemmein` from `@gemmein/sdk/expo`
|
|
1440
|
+
fills all four in for a phone, and every one of them is overridable.
|
|
1441
|
+
`GemmeinSwift` is the Swift package: `Gemmein(appKey:)`, the same method
|
|
1442
|
+
names, `KeychainTokenStore` by default. The DEFAULT store in a browser is
|
|
1443
|
+
`BrowserTokenStore` (localStorage, keyed per app key), and a store that
|
|
1444
|
+
cannot KEEP the session throws `secure_store_unavailable` out of
|
|
1445
|
+
`verifyEmailCode` on all three — reads and clears stay lenient.
|
|
1446
|
+
- On Expo, `createExpoGemmein` from `@gemmein/sdk/expo` accepts a picker's
|
|
1447
|
+
`{ uri, name, type, size }` in `upload()` and turns it into an
|
|
1448
|
+
`expo-file-system` `File` first — install `expo-file-system` beside
|
|
1449
|
+
`expo-secure-store`, or that one call throws
|
|
1450
|
+
`upload_input_unsupported`. A `Blob` or a `File` needs neither.
|
|
1165
1451
|
- `expand` works only on the collections that learn links — **community,
|
|
1166
1452
|
shared, direct**. Asking to expand a field on a `private`, `public_read`, or
|
|
1167
1453
|
`admin_write` collection throws (it has no link shape); join those in memory
|
|
@@ -1207,9 +1493,9 @@ enforces — never because these checks are the enforcement.
|
|
|
1207
1493
|
- Development is free indefinitely — no card at signup, unlimited
|
|
1208
1494
|
collections, the full security model included.
|
|
1209
1495
|
- Going live starts the one Live plan: a monthly price that moves within a
|
|
1210
|
-
pre-agreed band, set by the PREVIOUS calendar month's
|
|
1211
|
-
(a person
|
|
1212
|
-
|
|
1496
|
+
pre-agreed band, set by the PREVIOUS calendar month's active people
|
|
1497
|
+
(a person counts once a month, on their first signed-in request, on web
|
|
1498
|
+
and mobile alike; anonymous use never counts):
|
|
1213
1499
|
- up to 1,000 people → $50/mo · up to 2,500 → $75 · up to 5,000 → $100
|
|
1214
1500
|
· up to 7,500 → $125 · up to 10,000 → $150 (the cap — never exceeded
|
|
1215
1501
|
without a separate individual agreement).
|
|
@@ -1220,7 +1506,7 @@ enforces — never because these checks are the enforcement.
|
|
|
1220
1506
|
priced as a relationship, not a checkout.
|
|
1221
1507
|
- Pricing reflects responsibility, not complexity: almost nothing is
|
|
1222
1508
|
metered. Safe limits exist as safety rails against runaway scripts and
|
|
1223
|
-
are never billed. Ceilings follow the app's
|
|
1509
|
+
are never billed. Ceilings follow the app's active people — they grow
|
|
1224
1510
|
automatically as the business grows, with generous floors so a small app
|
|
1225
1511
|
never starts at a wall. Reaching a ceiling never stops your app: sign-ins,
|
|
1226
1512
|
writes and API requests keep working past it, the founder is told once
|
package/migrations/README.md
CHANGED
|
@@ -34,3 +34,4 @@ the same surface gets its own file.
|
|
|
34
34
|
|------|-------|--------------------|
|
|
35
35
|
| [`list-limit-refusal.md`](./list-limit-refusal.md) | 0.6.0 | 0.6.0 |
|
|
36
36
|
| [`raw-calls-off.md`](./raw-calls-off.md) | 0.9.0 | 0.9.0 |
|
|
37
|
+
| [`secure-store-set-throws.md`](./secure-store-set-throws.md) | 0.10.0 | 0.10.0 |
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# A token store that cannot keep the session says so
|
|
2
|
+
|
|
3
|
+
```
|
|
4
|
+
Since: 0.10.0
|
|
5
|
+
Action required by: 0.10.0
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
## What changed
|
|
9
|
+
|
|
10
|
+
Before 0.10.0 a token store that could not write swallowed the failure.
|
|
11
|
+
`auth.verifyEmailCode()` resolved, the app believed it was signed in, and the
|
|
12
|
+
session was gone on the next read — the app reported itself signed out with
|
|
13
|
+
nothing anywhere saying why.
|
|
14
|
+
|
|
15
|
+
From 0.10.0 `tokenStore.set()` THROWS a `GemmeinError` with code
|
|
16
|
+
`secure_store_unavailable` (status 0) when the store refuses the write, and
|
|
17
|
+
`auth.verifyEmailCode()` carries it to the caller. The law is all three
|
|
18
|
+
stores:
|
|
19
|
+
|
|
20
|
+
- `SecureStoreTokenStore` (`@gemmein/sdk/expo`) — `expo-secure-store` missing
|
|
21
|
+
or refusing (an unsigned simulator build has no keychain access group).
|
|
22
|
+
- `BrowserTokenStore` — a refused `localStorage` write: Safari private mode
|
|
23
|
+
past its quota, a browser set to block site data, a sandboxed iframe whose
|
|
24
|
+
access throws.
|
|
25
|
+
- `KeychainTokenStore` (`GemmeinSwift`) — the same, with the OSStatus and
|
|
26
|
+
`SecCopyErrorMessageString`'s words for it in the sentence.
|
|
27
|
+
|
|
28
|
+
The device's or browser's own error is on `err.cause`. `get()` and `clear()`
|
|
29
|
+
are unchanged and stay lenient: an unreadable store means signed out, which
|
|
30
|
+
every app already handles, and a crash at launch is worse than a sign-in
|
|
31
|
+
screen. `MemoryTokenStore.set()` never throws. The existing `token_too_large`
|
|
32
|
+
refusal is unchanged.
|
|
33
|
+
|
|
34
|
+
## Who is affected
|
|
35
|
+
|
|
36
|
+
Apps that call `auth.verifyEmailCode()` (or any call that stores a session)
|
|
37
|
+
in a place where the store may refuse the write, and that RELIED on the old
|
|
38
|
+
silence to keep running: a kiosk browser, an embedded or sandboxed iframe, a
|
|
39
|
+
private-mode visitor, an unsigned simulator or ad-hoc mobile build. An app in
|
|
40
|
+
an ordinary browser tab or a signed app sees no difference — nothing that
|
|
41
|
+
stored the token before throws now.
|
|
42
|
+
|
|
43
|
+
## What to do
|
|
44
|
+
|
|
45
|
+
Catch the one code and rebuild the client with a `MemoryTokenStore`. The
|
|
46
|
+
session is real either way — the server minted it — so an app that would
|
|
47
|
+
rather run than stop can carry it in memory: sign-in works, and ends with the
|
|
48
|
+
process.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { gemmein, MemoryTokenStore, GemmeinError } from "@gemmein/sdk";
|
|
52
|
+
|
|
53
|
+
let g = gemmein({ appKey });
|
|
54
|
+
try {
|
|
55
|
+
await g.auth.verifyEmailCode(email, code);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof GemmeinError && err.code === "secure_store_unavailable") {
|
|
58
|
+
// This session will not survive a reload. Say so, or carry on knowingly.
|
|
59
|
+
g = gemmein({ appKey, tokenStore: new MemoryTokenStore() });
|
|
60
|
+
await g.auth.verifyEmailCode(email, code);
|
|
61
|
+
} else {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
An app that knows it runs where nothing can be stored passes
|
|
68
|
+
`tokenStore: new MemoryTokenStore()` from the start and never sees the code.
|
|
69
|
+
|
|
70
|
+
## How to tell
|
|
71
|
+
|
|
72
|
+
`err.code === "secure_store_unavailable"` on the SDK (status 0, no HTTP
|
|
73
|
+
response — the request succeeded; the STORE refused), with the store's own
|
|
74
|
+
throw on `err.cause` and the fix in the message. In `GemmeinSwift` the same
|
|
75
|
+
case is a thrown `GemmeinError` whose message carries the keychain's OSStatus.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
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",
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
"import": "./dist/index.js",
|
|
14
14
|
"require": "./dist/index.cjs"
|
|
15
15
|
},
|
|
16
|
+
"./expo": {
|
|
17
|
+
"types": "./dist/expo.d.ts",
|
|
18
|
+
"import": "./dist/expo.js",
|
|
19
|
+
"require": "./dist/expo.cjs"
|
|
20
|
+
},
|
|
16
21
|
"./llms.txt": "./llms.txt",
|
|
17
22
|
"./REFERENCE.md": "./REFERENCE.md",
|
|
18
23
|
"./reaffirm.mjs": "./reaffirm.mjs",
|
|
@@ -27,6 +32,22 @@
|
|
|
27
32
|
"reaffirm.mjs"
|
|
28
33
|
],
|
|
29
34
|
"sideEffects": false,
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"expo": "*",
|
|
37
|
+
"expo-secure-store": "*",
|
|
38
|
+
"react-native": "*"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"expo": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"expo-secure-store": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"react-native": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
30
51
|
"engines": {
|
|
31
52
|
"node": ">=20"
|
|
32
53
|
},
|
|
@@ -49,7 +70,7 @@
|
|
|
49
70
|
"scripts": {
|
|
50
71
|
"version:sync": "node scripts/sync-version.mjs",
|
|
51
72
|
"prebuild": "node scripts/sync-version.mjs",
|
|
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",
|
|
73
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && tsc -p tsconfig.cjs.json && mv dist/cjs/index.js dist/index.cjs && mv dist/cjs/expo.js dist/expo.cjs && rm -rf dist/cjs && sed -i.bak 's#\"./index.js\"#\"./index.cjs\"#g' dist/expo.cjs && rm -f dist/expo.cjs.bak && cp dist/index.d.ts dist/index.d.cts && cp dist/expo.d.ts dist/expo.d.cts",
|
|
53
74
|
"prepack": "npm run build",
|
|
54
75
|
"prepublishOnly": "npm run build"
|
|
55
76
|
},
|