@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/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
- > verified people (five pre-agreed steps up to 10,000 people; beyond that,
11
- > talk to Gemmein).
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
- - native mobile apps (no mobile SDKs today; web only)
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). Mobile SDKs are not offered today.
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. The email is branded as your app
389
- (your verified domain when you have one). Every send appears in your
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 Keys page (a rejected or publishable key
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 broadcast, planned); a schedule
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, `person_email` resolves the person, the rest ride as
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, `access:<slug of its name>` (plan "pro"
768
- `access:pro`); the paid webhook grants that key, and a FULL refund or a
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. On the local rail a tool saves without
962
- a provider key or a model allowlist; the cloud checks both at save: a
963
- tool on a provider with no key on the AI tools page is refused
964
- `provider_not_configured` (409), and a pinned `model` outside that
965
- provider's allowed models on the AI tools page is refused `invalid_tool`
966
- (400 the message lists what is allowed). The tool's NAME is the file
967
- name and is fixed once created.
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) · provider_not_configured (409 — creating a
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
- key set; the owner pastes one) · provider_required (400 more than
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 room counts the calls; the
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 — it only exists on the two
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 and customer replies land; holding the billing band;
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 Keys room to particular collections, and to read-only or
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 `{ authenticated: false }`. The
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 verified people
1211
- (a person = a unique end-user identity that verified a sign-in to the
1212
- live app that month; failed or refused attempts never count):
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 verified people — they grow
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
@@ -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.9.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
  },