@gemmein/sdk 0.1.0 → 0.2.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/README.md CHANGED
@@ -26,10 +26,12 @@ await g.auth.sendEmailCode("user@example.com")
26
26
  await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
27
27
 
28
28
  // Store data — rules enforced server-side
29
- await g.storage.collection("tasks").create({ title: "Buy milk", done: false })
30
- const { records } = await g.storage.collection("tasks").list()
29
+ await g.collection("tasks").create({ title: "Buy milk", done: false })
30
+ const { records } = await g.collection("tasks").list()
31
31
  ```
32
32
 
33
+ The client has two layers: **your collections** (`g.collection("tasks")` — your app's own data model) and the **business primitives Gemmein runs for you** (`g.auth`, `g.subscriptions`, `g.payments`, `g.account` — self-service surfaces for the signed-in user; managing *other* people's users, subscriptions, and records happens in the owner's dashboard, on purpose).
34
+
33
35
  Sessions persist across page reloads automatically (localStorage in browsers, memory elsewhere — override with `tokenStore` if you need custom persistence). One session per user: verifying a new code revokes that email's older sessions — a stale token throws `auth_expired` once, the SDK clears it, and a retry (or re-auth) recovers.
34
36
 
35
37
  ## Auth
@@ -44,15 +46,22 @@ const user = await g.auth.currentUser()
44
46
  // { authenticated: true, userId: "usr_...", email: "user@example.com" }
45
47
 
46
48
  await g.auth.logout()
47
- await g.auth.deleteAccount() // GDPR erasure: revokes sessions, deletes the user's records
48
49
  ```
49
50
 
51
+ ### Deleting an account
52
+
53
+ ```js
54
+ await g.account.delete()
55
+ ```
56
+
57
+ Self-service erasure — the "delete my account" screen. Every app it **applies** to needs one (GDPR right to erasure; Apple 5.1.1(v) for any app with account creation). Server-side it's the full cascade: sessions revoked, the user's records and files deleted, their subscription row removed. Irreversible — put a real confirm in front of it.
58
+
50
59
  ## Storage
51
60
 
52
61
  Collections are created by the app owner in the dashboard (app.gemmein.com → data → "+ New collection") — the SDK can't create them. A 404 `unknown_collection` means it doesn't exist yet: ask the app owner to add it (one click) and pick its rule from the table below.
53
62
 
54
63
  ```js
55
- const tasks = g.storage.collection("tasks")
64
+ const tasks = g.collection("tasks")
56
65
 
57
66
  const task = await tasks.create({ title: "Buy milk", done: false })
58
67
 
@@ -152,20 +161,20 @@ Gemmein hosts your Stripe webhook and manages subscriptions for you: exactly one
152
161
  ```js
153
162
  // 1. Send the buyer to checkout — one call, Gemmein does the rest. The app
154
163
  // owner pasted each paid plan's Stripe Payment Link in their dashboard;
155
- // g.checkout() picks the right one and wires the signed-in buyer in.
164
+ // g.subscriptions.checkout() picks the right one and wires the signed-in buyer in.
156
165
  // Never build checkout URLs or sessions yourself: raw emails are
157
166
  // silently dropped by Stripe's URL rules, and sessions need a secret
158
167
  // key that must never ship client-side.
159
- await g.checkout("pro") // redirects; omit the arg to buy the paid plan
168
+ await g.subscriptions.checkout("pro") // redirects; omit the arg to buy the paid plan
160
169
  // GemmeinError codes worth handling: "authentication_required" (sign in
161
170
  // first), "plan_has_no_link" (owner hasn't pasted that plan's link yet)
162
171
 
163
172
  // 2. Gate paid features by reading the managed subscription:
164
- const sub = await g.subscription() // { plan, status } or null (never paid / payments off)
173
+ const sub = await g.subscriptions.mine() // { plan, status } or null (never paid / payments off)
165
174
  if (sub?.plan === "pro") { /* unlock */ }
166
175
  ```
167
176
 
168
- Do **not** write a webhook handler. Do **not** poll Stripe. Do **not** store plan/subscription state in your own collections — `g.subscription()` is the single source of truth, and there is deliberately no client write path to it.
177
+ Do **not** write a webhook handler. Do **not** poll Stripe. Do **not** store plan/subscription state in your own collections — `g.subscriptions.mine()` is the single source of truth, and there is deliberately no client write path to it.
169
178
 
170
179
  Plan **limits** (note counts, feature caps, seat numbers) are your app's logic — Gemmein only tells you who is on which plan; the dashboard's plan names carry no quotas.
171
180
 
@@ -177,13 +186,13 @@ Plans are for subscriptions. To sell a **thing** — a poster, a beat, an ebook,
177
186
  // One product covering many items (license tiers over a catalog)? Name the
178
187
  // item — it's display text on the receipt, the PRICE always comes from the
179
188
  // product's Payment Link:
180
- await g.pay("premium license", { item: "beat_37" }) // redirects
189
+ await g.payments.buy("premium license", { item: "beat_37" }) // redirects
181
190
 
182
191
  // Fulfilment: the completed payment writes a receipt record ADDRESSED to
183
192
  // the buyer — only they (and the owner) can read it. Gate the download on
184
193
  // the receipt, never on the redirect coming back (redirects can be faked;
185
194
  // receipts can't — they come from Stripe's signed webhook):
186
- const { records } = await g.storage.collection("receipts").list()
195
+ const { records } = await g.collection("receipts").list()
187
196
  const paid = records.find(r => r.data.product === "premium license" && r.data.status === "paid")
188
197
  if (paid) { /* unlock — paid.data.deliveryUrl holds the download when the owner set one */ }
189
198
  ```
@@ -228,7 +237,7 @@ Every method throws `GemmeinError`:
228
237
  import { GemmeinError } from "@gemmein/sdk"
229
238
 
230
239
  try {
231
- await g.storage.collection("tasks").create({ title: "Test" })
240
+ await g.collection("tasks").create({ title: "Test" })
232
241
  } catch (err) {
233
242
  if (err instanceof GemmeinError) {
234
243
  err.code // "auth_expired", "unknown_collection", "scope_denied",
@@ -253,7 +262,7 @@ try {
253
262
  | `unknown_product` | 404 | No product by that name — the message lists what the app sells |
254
263
  | `invalid_publish` | 400 | `published` is an option on public collections only — not a data field, not for scoped rules |
255
264
  | `forbidden` | 403 | The rules refused this — a permission your user doesn't have. **Never retry**: the same call will always be refused. Fix the approach (wrong collection rule, non-admin writing to `admin_write`, secret key out of scope) or show `err.message`. |
256
- | `denied` | 429 / 401 | The generic refusal for everything retriable or fixable: a rate limit (429 — carries `resetAt`, wait and retry) or a missing sign-in (401 — call `g.signIn`). Distinguish by HTTP status; show `err.message`, which reads correctly for each. |
265
+ | `denied` | 429 / 401 | The generic refusal for everything retriable or fixable: a rate limit (429 — carries `resetAt`, wait and retry) or a missing sign-in (401 — sign in first via `g.auth.sendEmailCode`). Distinguish by HTTP status; show `err.message`, which reads correctly for each. |
257
266
 
258
267
  **Branching on error codes:** switch on the *specific named* codes above. The one rule that matters: `forbidden` means stop — retrying can never succeed; `denied` means the request could work later (wait for `resetAt` on 429, sign in on 401). Only rate-limit `denied` carries `resetAt` — that's the reliable signal for a retry-after.
259
268
 
package/REFERENCE.md CHANGED
@@ -24,8 +24,12 @@ Server-only client for a `sk_...` secret key — **never ship this to the
24
24
  browser.** Exposes read/update on collections without a signed-in user, plus
25
25
  `testSession()` for CI self-tests (dev environments only — see **Reaffirm**).
26
26
 
27
- The client exposes two namespaces: **`g.auth`** and **`g.storage`**, plus the
28
- payment shortcuts `g.subscription()`, `g.checkout()`, `g.pay()`.
27
+ The client has two layers. **Your app's collections** — `g.collection(name)`
28
+ (the canonical spelling; `g.storage.collection(name)` is the same client). And
29
+ the **business primitives Gemmein runs for you** — `g.auth`,
30
+ `g.subscriptions`, `g.payments`, `g.account`: self-service surfaces for the
31
+ signed-in user. Managing *other* people's users, subscriptions, and records
32
+ happens in the owner's dashboard, deliberately not in this SDK.
29
33
 
30
34
  ---
31
35
 
@@ -37,7 +41,6 @@ payment shortcuts `g.subscription()`, `g.checkout()`, `g.pay()`.
37
41
  | `verifyEmailCode` | `({ email, code }: { email: string; code: string })` | `Promise<AuthSession>` |
38
42
  | `currentUser` | `()` | `Promise<CurrentUser>` — **never throws** for session state; safe on load |
39
43
  | `logout` | `()` | `Promise<void>` — revokes the server session **(the method is `logout`, not `signOut`)** |
40
- | `deleteAccount` | `()` | `Promise<unknown>` — erases the signed-in user |
41
44
 
42
45
  ```ts
43
46
  type AuthSession = { token: string; expiresAt: string; user: { id: string; email: string } };
@@ -49,9 +52,27 @@ type CurrentUser =
49
52
  resolves the **identity** (`userId`, flat). Use `currentUser()` for who's
50
53
  signed in. Sessions are one-per-user: verifying a new code revokes older ones.
51
54
 
55
+ `currentUser()` answering `{ authenticated: false }` is deliberately silent
56
+ about *why* — signed out, suspended by the owner, and erased all read the
57
+ same, so a moderated user's state never leaks to the client. One signed-out
58
+ screen covers all three.
59
+
60
+ ---
61
+
62
+ ## Account — `g.account`
63
+
64
+ | Method | Signature | Returns |
65
+ |--------|-----------|---------|
66
+ | `delete` | `()` | `Promise<unknown>` — erases the signed-in user, clears the stored token |
67
+
68
+ The "delete my account" screen. Every app it **applies** to needs one (GDPR
69
+ right to erasure; Apple 5.1.1(v) for any app with account creation).
70
+ Server-side it's the full cascade — sessions revoked, records and files
71
+ deleted, subscription row removed. Irreversible: put a real confirm in front.
72
+
52
73
  ---
53
74
 
54
- ## Data — `g.storage.collection<T>(name)`
75
+ ## Data — `g.collection<T>(name)`
55
76
 
56
77
  `name` must be **lowercase letters, numbers, and underscores** (`saved_games`,
57
78
  never `savedGames` — a bad name throws synchronously). Collections are created
@@ -105,18 +126,19 @@ on `private`/`public_read`/`admin_write` (no link shape) — join in memory ther
105
126
 
106
127
  ---
107
128
 
108
- ## Payments — `g.subscription()` / `g.checkout()` / `g.pay()`
129
+ ## Payments — `g.subscriptions` / `g.payments`
109
130
 
110
131
  | Method | Signature | Returns |
111
132
  |--------|-----------|---------|
112
- | `subscription` | `()` | `Promise<{ plan: string; status: "active" \| "cancelled" } \| null>` |
113
- | `checkout` | `(plan?: string)` | `Promise<{ url: string; plan: string }>` — **navigates the browser to Stripe** and resolves the url |
114
- | `pay` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
133
+ | `subscriptions.mine` | `()` | `Promise<{ plan: string; status: "active" \| "cancelled" } \| null>` |
134
+ | `subscriptions.checkout` | `(plan?: string)` | `Promise<{ url: string; plan: string }>` — **navigates the browser to Stripe** and resolves the url |
135
+ | `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
115
136
 
116
- `checkout` and `pay` self-navigate via `window.location` — just `await` them on
117
- the click; don't also redirect to the returned `url`, and never build a Stripe
118
- URL yourself. Gate features on `(await g.subscription())?.plan === "pro"`; gate
119
- one-off fulfilment on the receipt record, never the redirect.
137
+ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
138
+ `buy` self-navigate via `window.location` just `await` them on the click;
139
+ don't also redirect to the returned `url`, and never build a Stripe URL
140
+ yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
141
+ gate one-off fulfilment on the receipt record, never the redirect.
120
142
 
121
143
  ---
122
144
 
@@ -170,19 +192,19 @@ const asUser = (token) => gemmein(process.env.PUBLIC_KEY, {
170
192
  });
171
193
 
172
194
  // ── TIER A: functional + anonymous (no login) ──
173
- g.storage.collection("private_notes"); // misnamed → throws HERE, loudly, in CI
174
- await refuse("anon can't read private", "denied", () => g.storage.collection("private_notes").list());
175
- await refuse("anon can't write community", "denied", () => g.storage.collection("board").create({ text: "x" }));
195
+ g.collection("private_notes"); // misnamed → throws HERE, loudly, in CI
196
+ await refuse("anon can't read private", "denied", () => g.collection("private_notes").list());
197
+ await refuse("anon can't write community", "denied", () => g.collection("board").create({ text: "x" }));
176
198
 
177
199
  // ── TIER B: cross-user isolation (dev only) ──
178
200
  const alice = await srv.testSession("alice@test.dev");
179
201
  const bob = await srv.testSession("bob@test.dev");
180
202
  const A = asUser(alice.token), B = asUser(bob.token);
181
203
 
182
- const note = await A.storage.collection("private_notes").create({ text: "alice-secret" });
204
+ const note = await A.collection("private_notes").create({ text: "alice-secret" });
183
205
  await refuse("B can't read A's private note", "not_found", () =>
184
- B.storage.collection("private_notes").get(note.id));
185
- const bobSees = await B.storage.collection("private_notes").list();
206
+ B.collection("private_notes").get(note.id));
207
+ const bobSees = await B.collection("private_notes").list();
186
208
  if (bobSees.records.length !== 0) { console.error("✗ B sees A's private records"); fail++; }
187
209
  else console.log("✓ B's private list is isolated");
188
210
 
@@ -231,5 +253,12 @@ Branch on `err.code`. The stable codes:
231
253
  | `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
232
254
  | `invalid_file_content` | uploaded bytes aren't the claimed image type | send the real image |
233
255
  | `test_session_forbidden_live` | `testSession()` called on a live env / `sk_live` key | reaffirm's Tier B is dev-only — point it at your dev environment |
256
+ | `unknown_plan` | no plan by that name | use a name from the list in the message |
257
+ | `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
258
+ | `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
259
+ | `scope_denied` | secret key used outside its dashboard-configured scope (or on auth/management routes) | scope the key to that collection, or use the right surface |
260
+ | `unsupported_file_type` (415) | upload isn't one of the allowed image types | send JPEG/PNG/WebP/GIF/HEIC |
261
+ | `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
262
+ | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
234
263
 
235
264
  Keys: `pk_` (public, domain-locked, browser-safe) vs `sk_` (secret, server only).
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = 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.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = void 0;
4
4
  exports.gemmein = gemmein;
5
5
  exports.gemmeinServer = gemmeinServer;
6
6
  class GemmeinError extends Error {
@@ -64,9 +64,24 @@ function defaultTokenStore(appKey) {
64
64
  catch { /* SSR / Node / storage blocked */ }
65
65
  return new MemoryTokenStore();
66
66
  }
67
+ /**
68
+ * The client — two layers:
69
+ *
70
+ * - `g.collection("notes")` — YOUR app's collections. Records, files,
71
+ * safety rules. This is where your app's own data model lives.
72
+ * - Business primitives Gemmein runs for you: `g.auth` (sign-in),
73
+ * `g.subscriptions` (who's on which plan), `g.payments` (one-off
74
+ * purchases), `g.account` (the user's own account). These are
75
+ * SELF-SERVICE surfaces for the signed-in user — reads and Stripe
76
+ * hand-offs, never admin powers. Managing other people's users,
77
+ * subscriptions, or records happens in the owner's dashboard
78
+ * (app.gemmein.com), on purpose.
79
+ */
67
80
  class Gemmein {
68
81
  constructor(options) {
69
- if (options.appKey?.startsWith("sk_")) {
82
+ // A missing key must land on the signpost below, not a TypeError —
83
+ // gemmein(undefined) is exactly the "env var didn't load" case.
84
+ if (options?.appKey?.startsWith("sk_")) {
70
85
  throw new GemmeinError({
71
86
  status: 0,
72
87
  code: "invalid_app_key",
@@ -74,7 +89,7 @@ class Gemmein {
74
89
  });
75
90
  }
76
91
  // The signpost: an AI building without a key gets routed, not stuck.
77
- if (!options.appKey || !options.appKey.startsWith("pk_")) {
92
+ if (!options?.appKey || !options.appKey.startsWith("pk_")) {
78
93
  throw new GemmeinError({
79
94
  status: 0,
80
95
  code: "missing_app_key",
@@ -90,24 +105,16 @@ class Gemmein {
90
105
  };
91
106
  this.auth = new AuthClient(config);
92
107
  this.storage = new StorageClient(config);
93
- }
94
- /** The signed-in user's subscription — `(await g.subscription())?.plan === "pro"`. */
95
- subscription() {
96
- return this.auth.subscription();
97
- }
98
- /** Send the signed-in user to Stripe checkout for a plan — `g.checkout("pro")`. Redirects in browsers. */
99
- checkout(plan) {
100
- return this.auth.checkout(plan);
108
+ this.subscriptions = new SubscriptionsClient(config);
109
+ this.payments = new PaymentsClient(config);
110
+ this.account = new AccountClient(config);
101
111
  }
102
112
  /**
103
- * Buy a one-off product — `g.pay("poster")`. Redirects in browsers. NOT
104
- * for plans/subscriptions (that's `g.checkout`). The optional `item` note
105
- * names WHAT is being bought when one product covers many things (e.g. a
106
- * license tier across a catalog): `g.pay("premium license", { item: "beat_37" })`
107
- * — it lands on the buyer's receipt record for the owner to fulfil.
113
+ * Your app's data — `g.collection<{ title: string }>("notes")`. The
114
+ * canonical spelling; `g.storage.collection(name)` is the same client.
108
115
  */
109
- pay(product, options) {
110
- return this.auth.pay(product, options);
116
+ collection(name) {
117
+ return this.storage.collection(name);
111
118
  }
112
119
  }
113
120
  exports.Gemmein = Gemmein;
@@ -159,24 +166,52 @@ class AuthClient {
159
166
  await this.config.tokenStore.clear();
160
167
  }
161
168
  }
162
- async deleteAccount() {
163
- const result = await this.request("/auth/delete-account", { method: "POST" });
164
- await this.config.tokenStore.clear();
165
- return result;
169
+ async currentUser() {
170
+ // Contract: "who am I?" never throws for session state — the server
171
+ // answers 200 {authenticated:false} for a token it won't honour right
172
+ // now, same as no token. We deliberately do NOT clear the stored token
173
+ // here: authenticated:false also covers a SUSPENDED user, whose session
174
+ // is intact and restored on unsuspend — clearing it would make a
175
+ // reversible suspension permanently sign them out. A genuinely expired
176
+ // token is harmless to keep (the next data-plane call 401s and the app
177
+ // re-auths); logout() and g.account.delete() are the deliberate clears.
178
+ //
179
+ // Moderation note: authenticated:false is deliberately SILENT about the
180
+ // why — signed out, suspended, and erased all read the same here, so a
181
+ // moderated user's state is never leaked to the client. Build the
182
+ // signed-out screen for all three.
183
+ return this.request("/auth/current-user");
184
+ }
185
+ request(path, init = {}) {
186
+ return runtimeRequest(this.config, path, init);
166
187
  }
167
- async subscription() {
168
- // SUBS-1: Gemmein manages the subscription record — one per customer,
169
- // created by the payment itself. Read-only: gate features with
170
- // `sub?.plan === "pro"`. Null when payments are off or this user has
171
- // never paid; throws GemmeinError (401) when nobody is signed in a
172
- // data route, not the never-throw current-user contract. There is no
173
- // client write path, by design.
174
- const result = (await this.request("/auth/subscription"));
188
+ }
189
+ exports.AuthClient = AuthClient;
190
+ /**
191
+ * SUBS: the subscription primitive, self-service side. Gemmein keeps
192
+ * exactly one subscription per customer created by the payment itself,
193
+ * updated by Stripe's signed webhooks, overridable by the owner in their
194
+ * dashboard. The client surface is deliberately read-plus-checkout only:
195
+ * there is no client write path to plan or status, by design.
196
+ */
197
+ class SubscriptionsClient {
198
+ constructor(config) {
199
+ this.config = config;
200
+ }
201
+ /**
202
+ * The signed-in user's subscription — gate features with
203
+ * `(await g.subscriptions.mine())?.plan === "pro"`. Null when payments
204
+ * are off or this user has never paid; throws GemmeinError (401) when
205
+ * nobody is signed in — a data route, not the never-throw current-user
206
+ * contract.
207
+ */
208
+ async mine() {
209
+ const result = (await runtimeRequest(this.config, "/auth/subscription"));
175
210
  return result.subscription;
176
211
  }
177
212
  /**
178
- * SUBS-2: start a Stripe checkout for a plan — Gemmein mints the URL with
179
- * the signed-in buyer and the plan already wired in (never build checkout
213
+ * Start a Stripe checkout for a plan — Gemmein mints the URL with the
214
+ * signed-in buyer and the plan already wired in (never build checkout
180
215
  * URLs yourself; raw emails get silently dropped by Stripe's URL rules).
181
216
  * In a browser this redirects immediately; it also resolves with the URL
182
217
  * (for non-browser callers or custom handling). Requires a signed-in user
@@ -185,48 +220,61 @@ class AuthClient {
185
220
  */
186
221
  async checkout(plan) {
187
222
  const query = plan ? `?plan=${encodeURIComponent(plan)}` : "";
188
- const result = (await this.request(`/auth/checkout${query}`));
223
+ const result = (await runtimeRequest(this.config, `/auth/checkout${query}`));
189
224
  if (typeof window !== "undefined" && window.location) {
190
225
  window.location.assign(result.url);
191
226
  }
192
227
  return result;
193
228
  }
229
+ }
230
+ exports.SubscriptionsClient = SubscriptionsClient;
231
+ /** The one-off purchase primitive — things, not plans (plans are `g.subscriptions`). */
232
+ class PaymentsClient {
233
+ constructor(config) {
234
+ this.config = config;
235
+ }
194
236
  /**
195
- * PAY-1: buy a one-off product — same contract as checkout() but for
196
- * things, not plans. A completed payment writes a receipt record addressed
197
- * to the buyer in the owner's receipts collection; gate downloads/fulfilment
198
- * on that receipt, never on the redirect coming back.
237
+ * Buy a one-off product — `g.payments.buy("poster")`. Redirects in
238
+ * browsers, and resolves with the URL. The optional `item` note names
239
+ * WHAT is being bought when one product covers many things (e.g. a
240
+ * license tier across a catalog):
241
+ * `g.payments.buy("premium license", { item: "beat_37" })`.
242
+ * A completed payment writes a receipt record addressed to the buyer in
243
+ * the owner's receipts collection; gate downloads/fulfilment on that
244
+ * receipt, never on the redirect coming back.
199
245
  */
200
- async pay(product, options) {
246
+ async buy(product, options) {
201
247
  const params = new URLSearchParams({ product });
202
248
  if (options?.item)
203
249
  params.set("item", options.item);
204
- const result = (await this.request(`/auth/pay?${params.toString()}`));
250
+ const result = (await runtimeRequest(this.config, `/auth/pay?${params.toString()}`));
205
251
  if (typeof window !== "undefined" && window.location) {
206
252
  window.location.assign(result.url);
207
253
  }
208
254
  return result;
209
255
  }
210
- async currentUser() {
211
- // Contract: "who am I?" never throws for session state — the server
212
- // answers 200 {authenticated:false} for a token it won't honour right
213
- // now, same as no token. We deliberately do NOT clear the stored token
214
- // here: authenticated:false also covers a SUSPENDED user, whose session
215
- // is intact and restored on unsuspend — clearing it would make a
216
- // reversible suspension permanently sign them out. A genuinely expired
217
- // token is harmless to keep (the next data-plane call 401s and the app
218
- // re-auths); logout() and deleteAccount() are the deliberate clears.
219
- return this.request("/auth/current-user");
256
+ }
257
+ exports.PaymentsClient = PaymentsClient;
258
+ /** The signed-in user's own account self-service, one deliberate power. */
259
+ class AccountClient {
260
+ constructor(config) {
261
+ this.config = config;
220
262
  }
221
- async request(path, init = {}) {
222
- const response = await fetch(new URL(path, this.config.apiUrl), {
223
- ...init,
224
- headers: await runtimeHeaders(this.config, init.headers)
225
- });
226
- return handleResponse(response, this.config);
263
+ /**
264
+ * Self-service erasure the "delete my account" screen. Every app it
265
+ * APPLIES to needs one (GDPR right to erasure; Apple 5.1.1(v) requires it
266
+ * for any app with account creation). Server-side this is the full
267
+ * cascade: sessions revoked, the user's records and files deleted, their
268
+ * subscription row removed. Irreversible — put a real confirm in front
269
+ * of it.
270
+ */
271
+ async delete() {
272
+ const result = await runtimeRequest(this.config, "/auth/delete-account", { method: "POST" });
273
+ await this.config.tokenStore.clear();
274
+ return result;
227
275
  }
228
276
  }
229
- exports.AuthClient = AuthClient;
277
+ exports.AccountClient = AccountClient;
230
278
  function isSessionResponse(value) {
231
279
  return (typeof value === "object" &&
232
280
  value !== null &&
@@ -394,11 +442,15 @@ class CollectionClient {
394
442
  exports.CollectionClient = CollectionClient;
395
443
  class GemmeinServer {
396
444
  constructor(options) {
397
- if (options.secretKey.startsWith("pk_")) {
445
+ // Same signpost law as the client: a missing key (env var didn't load)
446
+ // must be a typed error, never a TypeError.
447
+ if (!options?.secretKey || !options.secretKey.startsWith("sk_")) {
398
448
  throw new GemmeinError({
399
449
  status: 0,
400
450
  code: "invalid_secret_key",
401
- message: "Public keys (pk_) must not be used with GemmeinServer — use the Gemmein client SDK instead"
451
+ message: options?.secretKey?.startsWith("pk_")
452
+ ? "Public keys (pk_) must not be used with GemmeinServer — use the Gemmein client SDK instead"
453
+ : 'GemmeinServer needs a secret key (it starts with "sk_") — create one in the dashboard at https://app.gemmein.com and pass it from a server env var'
402
454
  });
403
455
  }
404
456
  this.apiUrl = options.apiUrl ?? "https://api.gemmein.com";
@@ -546,6 +598,15 @@ async function readErrorBody(response) {
546
598
  message: `Gemmein request failed: ${response.status}`
547
599
  };
548
600
  }
601
+ // One request path for every runtime client (auth, subscriptions,
602
+ // payments, account) — same headers, same error handling, same signposts.
603
+ async function runtimeRequest(config, path, init = {}) {
604
+ const response = await fetch(new URL(path, config.apiUrl), {
605
+ ...init,
606
+ headers: await runtimeHeaders(config, init.headers)
607
+ });
608
+ return handleResponse(response, config);
609
+ }
549
610
  async function runtimeHeaders(config, headers) {
550
611
  const token = await config.tokenStore.get();
551
612
  return {
package/dist/index.d.cts CHANGED
@@ -125,34 +125,31 @@ export declare class BrowserTokenStore implements TokenStore {
125
125
  set(token: string): void;
126
126
  clear(): void;
127
127
  }
128
+ /**
129
+ * The client — two layers:
130
+ *
131
+ * - `g.collection("notes")` — YOUR app's collections. Records, files,
132
+ * safety rules. This is where your app's own data model lives.
133
+ * - Business primitives Gemmein runs for you: `g.auth` (sign-in),
134
+ * `g.subscriptions` (who's on which plan), `g.payments` (one-off
135
+ * purchases), `g.account` (the user's own account). These are
136
+ * SELF-SERVICE surfaces for the signed-in user — reads and Stripe
137
+ * hand-offs, never admin powers. Managing other people's users,
138
+ * subscriptions, or records happens in the owner's dashboard
139
+ * (app.gemmein.com), on purpose.
140
+ */
128
141
  export declare class Gemmein {
129
142
  readonly auth: AuthClient;
130
143
  readonly storage: StorageClient;
144
+ readonly subscriptions: SubscriptionsClient;
145
+ readonly payments: PaymentsClient;
146
+ readonly account: AccountClient;
131
147
  constructor(options: GemmeinOptions);
132
- /** The signed-in user's subscription — `(await g.subscription())?.plan === "pro"`. */
133
- subscription(): Promise<{
134
- plan: string;
135
- status: "active" | "cancelled";
136
- } | null>;
137
- /** Send the signed-in user to Stripe checkout for a plan — `g.checkout("pro")`. Redirects in browsers. */
138
- checkout(plan?: string): Promise<{
139
- url: string;
140
- plan: string;
141
- }>;
142
148
  /**
143
- * Buy a one-off product — `g.pay("poster")`. Redirects in browsers. NOT
144
- * for plans/subscriptions (that's `g.checkout`). The optional `item` note
145
- * names WHAT is being bought when one product covers many things (e.g. a
146
- * license tier across a catalog): `g.pay("premium license", { item: "beat_37" })`
147
- * — it lands on the buyer's receipt record for the owner to fulfil.
149
+ * Your app's data — `g.collection<{ title: string }>("notes")`. The
150
+ * canonical spelling; `g.storage.collection(name)` is the same client.
148
151
  */
149
- pay(product: string, options?: {
150
- item?: string;
151
- }): Promise<{
152
- url: string;
153
- product: string;
154
- item?: string;
155
- }>;
152
+ collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): CollectionClient<T>;
156
153
  }
157
154
  export declare function gemmein(appKeyOrOptions: string | GemmeinOptions, options?: Omit<GemmeinOptions, "appKey">): Gemmein;
158
155
  export declare function gemmeinServer(secretKeyOrOptions: string | GemmeinServerOptions, options?: Omit<GemmeinServerOptions, "secretKey">): GemmeinServer;
@@ -170,14 +167,33 @@ export declare class AuthClient {
170
167
  code: string;
171
168
  }): Promise<AuthSession>;
172
169
  logout(): Promise<void>;
173
- deleteAccount(): Promise<unknown>;
174
- subscription(): Promise<{
170
+ currentUser(): Promise<CurrentUser>;
171
+ private request;
172
+ }
173
+ /**
174
+ * SUBS: the subscription primitive, self-service side. Gemmein keeps
175
+ * exactly one subscription per customer — created by the payment itself,
176
+ * updated by Stripe's signed webhooks, overridable by the owner in their
177
+ * dashboard. The client surface is deliberately read-plus-checkout only:
178
+ * there is no client write path to plan or status, by design.
179
+ */
180
+ export declare class SubscriptionsClient {
181
+ private readonly config;
182
+ constructor(config: ClientConfig);
183
+ /**
184
+ * The signed-in user's subscription — gate features with
185
+ * `(await g.subscriptions.mine())?.plan === "pro"`. Null when payments
186
+ * are off or this user has never paid; throws GemmeinError (401) when
187
+ * nobody is signed in — a data route, not the never-throw current-user
188
+ * contract.
189
+ */
190
+ mine(): Promise<{
175
191
  plan: string;
176
192
  status: "active" | "cancelled";
177
193
  } | null>;
178
194
  /**
179
- * SUBS-2: start a Stripe checkout for a plan — Gemmein mints the URL with
180
- * the signed-in buyer and the plan already wired in (never build checkout
195
+ * Start a Stripe checkout for a plan — Gemmein mints the URL with the
196
+ * signed-in buyer and the plan already wired in (never build checkout
181
197
  * URLs yourself; raw emails get silently dropped by Stripe's URL rules).
182
198
  * In a browser this redirects immediately; it also resolves with the URL
183
199
  * (for non-browser callers or custom handling). Requires a signed-in user
@@ -188,21 +204,42 @@ export declare class AuthClient {
188
204
  url: string;
189
205
  plan: string;
190
206
  }>;
207
+ }
208
+ /** The one-off purchase primitive — things, not plans (plans are `g.subscriptions`). */
209
+ export declare class PaymentsClient {
210
+ private readonly config;
211
+ constructor(config: ClientConfig);
191
212
  /**
192
- * PAY-1: buy a one-off product — same contract as checkout() but for
193
- * things, not plans. A completed payment writes a receipt record addressed
194
- * to the buyer in the owner's receipts collection; gate downloads/fulfilment
195
- * on that receipt, never on the redirect coming back.
213
+ * Buy a one-off product — `g.payments.buy("poster")`. Redirects in
214
+ * browsers, and resolves with the URL. The optional `item` note names
215
+ * WHAT is being bought when one product covers many things (e.g. a
216
+ * license tier across a catalog):
217
+ * `g.payments.buy("premium license", { item: "beat_37" })`.
218
+ * A completed payment writes a receipt record addressed to the buyer in
219
+ * the owner's receipts collection; gate downloads/fulfilment on that
220
+ * receipt, never on the redirect coming back.
196
221
  */
197
- pay(product: string, options?: {
222
+ buy(product: string, options?: {
198
223
  item?: string;
199
224
  }): Promise<{
200
225
  url: string;
201
226
  product: string;
202
227
  item?: string;
203
228
  }>;
204
- currentUser(): Promise<CurrentUser>;
205
- private request;
229
+ }
230
+ /** The signed-in user's own account — self-service, one deliberate power. */
231
+ export declare class AccountClient {
232
+ private readonly config;
233
+ constructor(config: ClientConfig);
234
+ /**
235
+ * Self-service erasure — the "delete my account" screen. Every app it
236
+ * APPLIES to needs one (GDPR right to erasure; Apple 5.1.1(v) requires it
237
+ * for any app with account creation). Server-side this is the full
238
+ * cascade: sessions revoked, the user's records and files deleted, their
239
+ * subscription row removed. Irreversible — put a real confirm in front
240
+ * of it.
241
+ */
242
+ delete(): Promise<unknown>;
206
243
  }
207
244
  export declare class StorageClient {
208
245
  private readonly config;
package/dist/index.d.ts CHANGED
@@ -125,34 +125,31 @@ export declare class BrowserTokenStore implements TokenStore {
125
125
  set(token: string): void;
126
126
  clear(): void;
127
127
  }
128
+ /**
129
+ * The client — two layers:
130
+ *
131
+ * - `g.collection("notes")` — YOUR app's collections. Records, files,
132
+ * safety rules. This is where your app's own data model lives.
133
+ * - Business primitives Gemmein runs for you: `g.auth` (sign-in),
134
+ * `g.subscriptions` (who's on which plan), `g.payments` (one-off
135
+ * purchases), `g.account` (the user's own account). These are
136
+ * SELF-SERVICE surfaces for the signed-in user — reads and Stripe
137
+ * hand-offs, never admin powers. Managing other people's users,
138
+ * subscriptions, or records happens in the owner's dashboard
139
+ * (app.gemmein.com), on purpose.
140
+ */
128
141
  export declare class Gemmein {
129
142
  readonly auth: AuthClient;
130
143
  readonly storage: StorageClient;
144
+ readonly subscriptions: SubscriptionsClient;
145
+ readonly payments: PaymentsClient;
146
+ readonly account: AccountClient;
131
147
  constructor(options: GemmeinOptions);
132
- /** The signed-in user's subscription — `(await g.subscription())?.plan === "pro"`. */
133
- subscription(): Promise<{
134
- plan: string;
135
- status: "active" | "cancelled";
136
- } | null>;
137
- /** Send the signed-in user to Stripe checkout for a plan — `g.checkout("pro")`. Redirects in browsers. */
138
- checkout(plan?: string): Promise<{
139
- url: string;
140
- plan: string;
141
- }>;
142
148
  /**
143
- * Buy a one-off product — `g.pay("poster")`. Redirects in browsers. NOT
144
- * for plans/subscriptions (that's `g.checkout`). The optional `item` note
145
- * names WHAT is being bought when one product covers many things (e.g. a
146
- * license tier across a catalog): `g.pay("premium license", { item: "beat_37" })`
147
- * — it lands on the buyer's receipt record for the owner to fulfil.
149
+ * Your app's data — `g.collection<{ title: string }>("notes")`. The
150
+ * canonical spelling; `g.storage.collection(name)` is the same client.
148
151
  */
149
- pay(product: string, options?: {
150
- item?: string;
151
- }): Promise<{
152
- url: string;
153
- product: string;
154
- item?: string;
155
- }>;
152
+ collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): CollectionClient<T>;
156
153
  }
157
154
  export declare function gemmein(appKeyOrOptions: string | GemmeinOptions, options?: Omit<GemmeinOptions, "appKey">): Gemmein;
158
155
  export declare function gemmeinServer(secretKeyOrOptions: string | GemmeinServerOptions, options?: Omit<GemmeinServerOptions, "secretKey">): GemmeinServer;
@@ -170,14 +167,33 @@ export declare class AuthClient {
170
167
  code: string;
171
168
  }): Promise<AuthSession>;
172
169
  logout(): Promise<void>;
173
- deleteAccount(): Promise<unknown>;
174
- subscription(): Promise<{
170
+ currentUser(): Promise<CurrentUser>;
171
+ private request;
172
+ }
173
+ /**
174
+ * SUBS: the subscription primitive, self-service side. Gemmein keeps
175
+ * exactly one subscription per customer — created by the payment itself,
176
+ * updated by Stripe's signed webhooks, overridable by the owner in their
177
+ * dashboard. The client surface is deliberately read-plus-checkout only:
178
+ * there is no client write path to plan or status, by design.
179
+ */
180
+ export declare class SubscriptionsClient {
181
+ private readonly config;
182
+ constructor(config: ClientConfig);
183
+ /**
184
+ * The signed-in user's subscription — gate features with
185
+ * `(await g.subscriptions.mine())?.plan === "pro"`. Null when payments
186
+ * are off or this user has never paid; throws GemmeinError (401) when
187
+ * nobody is signed in — a data route, not the never-throw current-user
188
+ * contract.
189
+ */
190
+ mine(): Promise<{
175
191
  plan: string;
176
192
  status: "active" | "cancelled";
177
193
  } | null>;
178
194
  /**
179
- * SUBS-2: start a Stripe checkout for a plan — Gemmein mints the URL with
180
- * the signed-in buyer and the plan already wired in (never build checkout
195
+ * Start a Stripe checkout for a plan — Gemmein mints the URL with the
196
+ * signed-in buyer and the plan already wired in (never build checkout
181
197
  * URLs yourself; raw emails get silently dropped by Stripe's URL rules).
182
198
  * In a browser this redirects immediately; it also resolves with the URL
183
199
  * (for non-browser callers or custom handling). Requires a signed-in user
@@ -188,21 +204,42 @@ export declare class AuthClient {
188
204
  url: string;
189
205
  plan: string;
190
206
  }>;
207
+ }
208
+ /** The one-off purchase primitive — things, not plans (plans are `g.subscriptions`). */
209
+ export declare class PaymentsClient {
210
+ private readonly config;
211
+ constructor(config: ClientConfig);
191
212
  /**
192
- * PAY-1: buy a one-off product — same contract as checkout() but for
193
- * things, not plans. A completed payment writes a receipt record addressed
194
- * to the buyer in the owner's receipts collection; gate downloads/fulfilment
195
- * on that receipt, never on the redirect coming back.
213
+ * Buy a one-off product — `g.payments.buy("poster")`. Redirects in
214
+ * browsers, and resolves with the URL. The optional `item` note names
215
+ * WHAT is being bought when one product covers many things (e.g. a
216
+ * license tier across a catalog):
217
+ * `g.payments.buy("premium license", { item: "beat_37" })`.
218
+ * A completed payment writes a receipt record addressed to the buyer in
219
+ * the owner's receipts collection; gate downloads/fulfilment on that
220
+ * receipt, never on the redirect coming back.
196
221
  */
197
- pay(product: string, options?: {
222
+ buy(product: string, options?: {
198
223
  item?: string;
199
224
  }): Promise<{
200
225
  url: string;
201
226
  product: string;
202
227
  item?: string;
203
228
  }>;
204
- currentUser(): Promise<CurrentUser>;
205
- private request;
229
+ }
230
+ /** The signed-in user's own account — self-service, one deliberate power. */
231
+ export declare class AccountClient {
232
+ private readonly config;
233
+ constructor(config: ClientConfig);
234
+ /**
235
+ * Self-service erasure — the "delete my account" screen. Every app it
236
+ * APPLIES to needs one (GDPR right to erasure; Apple 5.1.1(v) requires it
237
+ * for any app with account creation). Server-side this is the full
238
+ * cascade: sessions revoked, the user's records and files deleted, their
239
+ * subscription row removed. Irreversible — put a real confirm in front
240
+ * of it.
241
+ */
242
+ delete(): Promise<unknown>;
206
243
  }
207
244
  export declare class StorageClient {
208
245
  private readonly config;
package/dist/index.js CHANGED
@@ -56,9 +56,24 @@ function defaultTokenStore(appKey) {
56
56
  catch { /* SSR / Node / storage blocked */ }
57
57
  return new MemoryTokenStore();
58
58
  }
59
+ /**
60
+ * The client — two layers:
61
+ *
62
+ * - `g.collection("notes")` — YOUR app's collections. Records, files,
63
+ * safety rules. This is where your app's own data model lives.
64
+ * - Business primitives Gemmein runs for you: `g.auth` (sign-in),
65
+ * `g.subscriptions` (who's on which plan), `g.payments` (one-off
66
+ * purchases), `g.account` (the user's own account). These are
67
+ * SELF-SERVICE surfaces for the signed-in user — reads and Stripe
68
+ * hand-offs, never admin powers. Managing other people's users,
69
+ * subscriptions, or records happens in the owner's dashboard
70
+ * (app.gemmein.com), on purpose.
71
+ */
59
72
  export class Gemmein {
60
73
  constructor(options) {
61
- if (options.appKey?.startsWith("sk_")) {
74
+ // A missing key must land on the signpost below, not a TypeError —
75
+ // gemmein(undefined) is exactly the "env var didn't load" case.
76
+ if (options?.appKey?.startsWith("sk_")) {
62
77
  throw new GemmeinError({
63
78
  status: 0,
64
79
  code: "invalid_app_key",
@@ -66,7 +81,7 @@ export class Gemmein {
66
81
  });
67
82
  }
68
83
  // The signpost: an AI building without a key gets routed, not stuck.
69
- if (!options.appKey || !options.appKey.startsWith("pk_")) {
84
+ if (!options?.appKey || !options.appKey.startsWith("pk_")) {
70
85
  throw new GemmeinError({
71
86
  status: 0,
72
87
  code: "missing_app_key",
@@ -82,24 +97,16 @@ export class Gemmein {
82
97
  };
83
98
  this.auth = new AuthClient(config);
84
99
  this.storage = new StorageClient(config);
85
- }
86
- /** The signed-in user's subscription — `(await g.subscription())?.plan === "pro"`. */
87
- subscription() {
88
- return this.auth.subscription();
89
- }
90
- /** Send the signed-in user to Stripe checkout for a plan — `g.checkout("pro")`. Redirects in browsers. */
91
- checkout(plan) {
92
- return this.auth.checkout(plan);
100
+ this.subscriptions = new SubscriptionsClient(config);
101
+ this.payments = new PaymentsClient(config);
102
+ this.account = new AccountClient(config);
93
103
  }
94
104
  /**
95
- * Buy a one-off product — `g.pay("poster")`. Redirects in browsers. NOT
96
- * for plans/subscriptions (that's `g.checkout`). The optional `item` note
97
- * names WHAT is being bought when one product covers many things (e.g. a
98
- * license tier across a catalog): `g.pay("premium license", { item: "beat_37" })`
99
- * — it lands on the buyer's receipt record for the owner to fulfil.
105
+ * Your app's data — `g.collection<{ title: string }>("notes")`. The
106
+ * canonical spelling; `g.storage.collection(name)` is the same client.
100
107
  */
101
- pay(product, options) {
102
- return this.auth.pay(product, options);
108
+ collection(name) {
109
+ return this.storage.collection(name);
103
110
  }
104
111
  }
105
112
  // Factory forms — what the copied prompts teach. `gemmein("pk_...")` reads
@@ -150,24 +157,51 @@ export class AuthClient {
150
157
  await this.config.tokenStore.clear();
151
158
  }
152
159
  }
153
- async deleteAccount() {
154
- const result = await this.request("/auth/delete-account", { method: "POST" });
155
- await this.config.tokenStore.clear();
156
- return result;
160
+ async currentUser() {
161
+ // Contract: "who am I?" never throws for session state — the server
162
+ // answers 200 {authenticated:false} for a token it won't honour right
163
+ // now, same as no token. We deliberately do NOT clear the stored token
164
+ // here: authenticated:false also covers a SUSPENDED user, whose session
165
+ // is intact and restored on unsuspend — clearing it would make a
166
+ // reversible suspension permanently sign them out. A genuinely expired
167
+ // token is harmless to keep (the next data-plane call 401s and the app
168
+ // re-auths); logout() and g.account.delete() are the deliberate clears.
169
+ //
170
+ // Moderation note: authenticated:false is deliberately SILENT about the
171
+ // why — signed out, suspended, and erased all read the same here, so a
172
+ // moderated user's state is never leaked to the client. Build the
173
+ // signed-out screen for all three.
174
+ return this.request("/auth/current-user");
175
+ }
176
+ request(path, init = {}) {
177
+ return runtimeRequest(this.config, path, init);
178
+ }
179
+ }
180
+ /**
181
+ * SUBS: the subscription primitive, self-service side. Gemmein keeps
182
+ * exactly one subscription per customer — created by the payment itself,
183
+ * updated by Stripe's signed webhooks, overridable by the owner in their
184
+ * dashboard. The client surface is deliberately read-plus-checkout only:
185
+ * there is no client write path to plan or status, by design.
186
+ */
187
+ export class SubscriptionsClient {
188
+ constructor(config) {
189
+ this.config = config;
157
190
  }
158
- async subscription() {
159
- // SUBS-1: Gemmein manages the subscription record one per customer,
160
- // created by the payment itself. Read-only: gate features with
161
- // `sub?.plan === "pro"`. Null when payments are off or this user has
162
- // never paid; throws GemmeinError (401) when nobody is signed in a
163
- // data route, not the never-throw current-user contract. There is no
164
- // client write path, by design.
165
- const result = (await this.request("/auth/subscription"));
191
+ /**
192
+ * The signed-in user's subscription — gate features with
193
+ * `(await g.subscriptions.mine())?.plan === "pro"`. Null when payments
194
+ * are off or this user has never paid; throws GemmeinError (401) when
195
+ * nobody is signed in a data route, not the never-throw current-user
196
+ * contract.
197
+ */
198
+ async mine() {
199
+ const result = (await runtimeRequest(this.config, "/auth/subscription"));
166
200
  return result.subscription;
167
201
  }
168
202
  /**
169
- * SUBS-2: start a Stripe checkout for a plan — Gemmein mints the URL with
170
- * the signed-in buyer and the plan already wired in (never build checkout
203
+ * Start a Stripe checkout for a plan — Gemmein mints the URL with the
204
+ * signed-in buyer and the plan already wired in (never build checkout
171
205
  * URLs yourself; raw emails get silently dropped by Stripe's URL rules).
172
206
  * In a browser this redirects immediately; it also resolves with the URL
173
207
  * (for non-browser callers or custom handling). Requires a signed-in user
@@ -176,45 +210,56 @@ export class AuthClient {
176
210
  */
177
211
  async checkout(plan) {
178
212
  const query = plan ? `?plan=${encodeURIComponent(plan)}` : "";
179
- const result = (await this.request(`/auth/checkout${query}`));
213
+ const result = (await runtimeRequest(this.config, `/auth/checkout${query}`));
180
214
  if (typeof window !== "undefined" && window.location) {
181
215
  window.location.assign(result.url);
182
216
  }
183
217
  return result;
184
218
  }
219
+ }
220
+ /** The one-off purchase primitive — things, not plans (plans are `g.subscriptions`). */
221
+ export class PaymentsClient {
222
+ constructor(config) {
223
+ this.config = config;
224
+ }
185
225
  /**
186
- * PAY-1: buy a one-off product — same contract as checkout() but for
187
- * things, not plans. A completed payment writes a receipt record addressed
188
- * to the buyer in the owner's receipts collection; gate downloads/fulfilment
189
- * on that receipt, never on the redirect coming back.
226
+ * Buy a one-off product — `g.payments.buy("poster")`. Redirects in
227
+ * browsers, and resolves with the URL. The optional `item` note names
228
+ * WHAT is being bought when one product covers many things (e.g. a
229
+ * license tier across a catalog):
230
+ * `g.payments.buy("premium license", { item: "beat_37" })`.
231
+ * A completed payment writes a receipt record addressed to the buyer in
232
+ * the owner's receipts collection; gate downloads/fulfilment on that
233
+ * receipt, never on the redirect coming back.
190
234
  */
191
- async pay(product, options) {
235
+ async buy(product, options) {
192
236
  const params = new URLSearchParams({ product });
193
237
  if (options?.item)
194
238
  params.set("item", options.item);
195
- const result = (await this.request(`/auth/pay?${params.toString()}`));
239
+ const result = (await runtimeRequest(this.config, `/auth/pay?${params.toString()}`));
196
240
  if (typeof window !== "undefined" && window.location) {
197
241
  window.location.assign(result.url);
198
242
  }
199
243
  return result;
200
244
  }
201
- async currentUser() {
202
- // Contract: "who am I?" never throws for session state the server
203
- // answers 200 {authenticated:false} for a token it won't honour right
204
- // now, same as no token. We deliberately do NOT clear the stored token
205
- // here: authenticated:false also covers a SUSPENDED user, whose session
206
- // is intact and restored on unsuspend — clearing it would make a
207
- // reversible suspension permanently sign them out. A genuinely expired
208
- // token is harmless to keep (the next data-plane call 401s and the app
209
- // re-auths); logout() and deleteAccount() are the deliberate clears.
210
- return this.request("/auth/current-user");
245
+ }
246
+ /** The signed-in user's own account self-service, one deliberate power. */
247
+ export class AccountClient {
248
+ constructor(config) {
249
+ this.config = config;
211
250
  }
212
- async request(path, init = {}) {
213
- const response = await fetch(new URL(path, this.config.apiUrl), {
214
- ...init,
215
- headers: await runtimeHeaders(this.config, init.headers)
216
- });
217
- return handleResponse(response, this.config);
251
+ /**
252
+ * Self-service erasure the "delete my account" screen. Every app it
253
+ * APPLIES to needs one (GDPR right to erasure; Apple 5.1.1(v) requires it
254
+ * for any app with account creation). Server-side this is the full
255
+ * cascade: sessions revoked, the user's records and files deleted, their
256
+ * subscription row removed. Irreversible — put a real confirm in front
257
+ * of it.
258
+ */
259
+ async delete() {
260
+ const result = await runtimeRequest(this.config, "/auth/delete-account", { method: "POST" });
261
+ await this.config.tokenStore.clear();
262
+ return result;
218
263
  }
219
264
  }
220
265
  function isSessionResponse(value) {
@@ -382,11 +427,15 @@ export class CollectionClient {
382
427
  }
383
428
  export class GemmeinServer {
384
429
  constructor(options) {
385
- if (options.secretKey.startsWith("pk_")) {
430
+ // Same signpost law as the client: a missing key (env var didn't load)
431
+ // must be a typed error, never a TypeError.
432
+ if (!options?.secretKey || !options.secretKey.startsWith("sk_")) {
386
433
  throw new GemmeinError({
387
434
  status: 0,
388
435
  code: "invalid_secret_key",
389
- message: "Public keys (pk_) must not be used with GemmeinServer — use the Gemmein client SDK instead"
436
+ message: options?.secretKey?.startsWith("pk_")
437
+ ? "Public keys (pk_) must not be used with GemmeinServer — use the Gemmein client SDK instead"
438
+ : 'GemmeinServer needs a secret key (it starts with "sk_") — create one in the dashboard at https://app.gemmein.com and pass it from a server env var'
390
439
  });
391
440
  }
392
441
  this.apiUrl = options.apiUrl ?? "https://api.gemmein.com";
@@ -533,6 +582,15 @@ async function readErrorBody(response) {
533
582
  message: `Gemmein request failed: ${response.status}`
534
583
  };
535
584
  }
585
+ // One request path for every runtime client (auth, subscriptions,
586
+ // payments, account) — same headers, same error handling, same signposts.
587
+ async function runtimeRequest(config, path, init = {}) {
588
+ const response = await fetch(new URL(path, config.apiUrl), {
589
+ ...init,
590
+ headers: await runtimeHeaders(config, init.headers)
591
+ });
592
+ return handleResponse(response, config);
593
+ }
536
594
  async function runtimeHeaders(config, headers) {
537
595
  const token = await config.tokenStore.get();
538
596
  return {
package/llms.txt CHANGED
@@ -36,8 +36,10 @@
36
36
  safety rule:
37
37
  - `private` — each signed-in user sees and edits only their own records
38
38
  (right for notes, tasks, anything personal).
39
- - `shared` — every signed-in user can read AND write every record (right
40
- for a team board everyone edits; WRONG for personal data it leaks).
39
+ - `shared` — every signed-in user reads every record and adds their own;
40
+ each user edits/deletes ONLY the records they created (a non-author
41
+ write comes back 404, same as any record you can't touch). Right for a
42
+ team feed everyone posts to; WRONG for personal data — it leaks.
41
43
  - `admin_write` — everyone signed in can read, only the owner can write
42
44
  (right for announcements and settings your human curates).
43
45
  - `public_read` — readable without signing in, ONLY the app owner writes
@@ -105,7 +107,7 @@
105
107
  construction, and your own retry returns it.
106
108
  - Images & files: NEVER base64 into record data and NEVER wire up your own
107
109
  storage bucket — uploads are built in:
108
- `const file = await g.storage.collection("posts").upload(blob, { name })`
110
+ `const file = await g.collection("posts").upload(blob, { name })`
109
111
  → `{ id, url, contentType, sizeBytes }`. Store `file.url` in a record
110
112
  field like any text (that's also how a record "has" an image — the
111
113
  reference pattern, same as links). Upload permission follows the
@@ -154,27 +156,27 @@
154
156
  to your human's Gemmein dashboard — they click the record there.
155
157
  - Payments: the builder names plans in the dashboard, pastes one Stripe
156
158
  signing secret, and pastes each paid plan's Stripe Payment Link there too.
157
- The app's ONLY checkout job is `await g.checkout("pro")` on the upgrade
159
+ The app's ONLY checkout job is `await g.subscriptions.checkout("pro")` on the upgrade
158
160
  button — Gemmein sends the signed-in user to the right Stripe checkout
159
161
  with the buyer and plan wired in. Never build checkout URLs, sessions, or
160
162
  Payment-Link redirects yourself (raw emails get silently dropped by
161
163
  Stripe's URL rules, and sessions need a secret key that must never ship
162
- client-side). If g.checkout errors with `plan_has_no_link`, ask your human
164
+ client-side). If g.subscriptions.checkout errors with `plan_has_no_link`, ask your human
163
165
  to paste that plan's Payment Link in their dashboard. Plan LIMITS (note
164
166
  counts, feature caps) are your app's logic — Gemmein only tells you who is
165
167
  on which plan. Gemmein keeps exactly one
166
168
  subscription per customer (enforced by the engine, case-insensitive on
167
169
  email); cancellations downgrade to the default plan automatically; events
168
170
  arriving out of order resolve to the newest. The app reads
169
- `await g.subscription()` → `{ plan, status }` or null, and gates features
171
+ `await g.subscriptions.mine()` → `{ plan, status }` or null, and gates features
170
172
  with `sub?.plan === "pro"`.
171
173
  - Selling THINGS (one-off purchases — a poster, a beat, an ebook): plans are
172
174
  for subscriptions; products are for things. The builder adds products
173
175
  (name + Stripe Payment Link) on the same Payments page and picks a
174
176
  receipts collection (rule `addressed`). The app calls
175
- `await g.pay("poster")` — or, when one product covers many items (license
177
+ `await g.payments.buy("poster")` — or, when one product covers many items (license
176
178
  tiers over a catalog), names the item:
177
- `await g.pay("premium license", { item: "beat_37" })` (display text on
179
+ `await g.payments.buy("premium license", { item: "beat_37" })` (display text on
178
180
  the receipt; the PRICE always comes from the product's Payment Link, so
179
181
  the item note can never change what's paid). The completed payment writes
180
182
  a receipt record ADDRESSED to the buyer: only they and the owner read it.
@@ -196,8 +198,10 @@
196
198
  `update(id, {}, { published: true })`. NEVER fake drafts with a status
197
199
  field + client-side filtering on a public collection — the data still
198
200
  reaches every reader's network tab (silent-until-breach). `published` is
199
- an option, not a data field: the server rejects it inside `data`
200
- (invalid_publish), and it only exists on the two public rules.
201
+ an option, not a data field: putting it inside `data` is refused as a
202
+ reserved server-managed field (403 forbidden), passing the OPTION on a
203
+ non-public rule is 400 invalid_publish — it only exists on the two
204
+ public rules.
201
205
  - Denials are 404-shaped: touching a record your session can't see returns
202
206
  404 not_found, never a 403 that confirms it exists — existence is not
203
207
  leaked. A real 403 comes back as code `forbidden` and names a rule problem
@@ -213,6 +217,17 @@
213
217
  id/updatedAt), not under `.data`. Non-authors only ever receive published
214
218
  records, so you see `false` only on your own drafts (or on everything, as
215
219
  the owner) — that's how you badge "unreleased" in an owner admin view.
220
+ - Account lifecycle — the bare-minimum screens a compliant app implements:
221
+ (1) sign-in (the email-code flow above); (2) a signed-out state — note that
222
+ `currentUser()` answering `authenticated: false` is deliberately silent
223
+ about WHY (signed out, suspended by the owner, and erased all read the
224
+ same; a moderated user's state is never leaked to the client, so one
225
+ signed-out screen covers all three); (3) a "delete my account" screen —
226
+ `await g.account.delete()` — for every app it APPLIES to (GDPR right to
227
+ erasure; Apple 5.1.1(v) for apps with account creation). It's the full
228
+ server-side cascade (sessions, records, files, subscription row) and
229
+ irreversible — put a real confirm in front of it. Suspensions, bans, and
230
+ owner-side erasure are dashboard actions, not SDK calls.
216
231
  - Secret keys (`sk_`) must never appear in browser code; app keys (`pk_`)
217
232
  are public and domain-locked.
218
233
 
@@ -223,7 +238,7 @@ rule. The specifics:
223
238
 
224
239
  - Collection names are **lowercase letters, numbers, and underscores only**
225
240
  (`saved_games`, `user_notes` — never `savedGames`). A bad name throws
226
- synchronously from `g.storage.collection(name)`; if you call that at module
241
+ synchronously from `g.collection(name)`; if you call that at module
227
242
  load, it can blank your whole app with no browser-console error. Name them
228
243
  right.
229
244
  - The signed-in user: `await g.auth.currentUser()` →
@@ -243,7 +258,7 @@ rule. The specifics:
243
258
  shared, direct**. Asking to expand a field on a `private`, `public_read`, or
244
259
  `admin_write` collection throws (it has no link shape); join those in memory
245
260
  instead.
246
- - `g.checkout(plan)` and `g.pay(product, { item? })` both **navigate the
261
+ - `g.subscriptions.checkout(plan)` and `g.payments.buy(product, { item? })` both **navigate the
247
262
  browser to Stripe themselves** (via `window.location`) *and* resolve with
248
263
  `{ url, ... }`. Just `await` them on the click — don't also redirect to the
249
264
  returned `url` (you'll double-navigate), and don't build the URL yourself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -12,7 +12,11 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "import": "./dist/index.js",
14
14
  "require": "./dist/index.cjs"
15
- }
15
+ },
16
+ "./llms.txt": "./llms.txt",
17
+ "./REFERENCE.md": "./REFERENCE.md",
18
+ "./reaffirm.mjs": "./reaffirm.mjs",
19
+ "./package.json": "./package.json"
16
20
  },
17
21
  "files": [
18
22
  "dist",
package/reaffirm.mjs CHANGED
@@ -39,13 +39,13 @@ const asUser = (token) => gemmein(PK, { ...opts, tokenStore: {
39
39
  get: async () => token, set: async () => {}, clear: async () => {} } });
40
40
 
41
41
  // ── TIER A — functional + anonymous. No login; safe against live. ───────────
42
- g.storage.collection(PRIVATE_COLLECTION); // a misnamed collection throws HERE, loudly
42
+ g.collection(PRIVATE_COLLECTION); // a misnamed collection throws HERE, loudly
43
43
  await refuse("anon can't read the private collection", "denied",
44
- () => g.storage.collection(PRIVATE_COLLECTION).list());
44
+ () => g.collection(PRIVATE_COLLECTION).list());
45
45
  await refuse("anon can't write the private collection", "denied",
46
- () => g.storage.collection(PRIVATE_COLLECTION).create({ probe: "x" }));
46
+ () => g.collection(PRIVATE_COLLECTION).create({ probe: "x" }));
47
47
  if (PUBLIC_COLLECTION) {
48
- const open = await g.storage.collection(PUBLIC_COLLECTION).list();
48
+ const open = await g.collection(PUBLIC_COLLECTION).list();
49
49
  console.log(`ℹ "${PUBLIC_COLLECTION}" is public by rule — ${open.records.length} records visible to ANYONE. Never put secrets in it.`);
50
50
  }
51
51
 
@@ -55,10 +55,10 @@ if (SK && !SK.startsWith("sk_live")) {
55
55
  const [a, b] = await Promise.all(TEST_USERS.map((e) => srv.testSession(e)));
56
56
  const A = asUser(a.token), B = asUser(b.token);
57
57
 
58
- const note = await A.storage.collection(PRIVATE_COLLECTION).create({ probe: "a-secret" });
58
+ const note = await A.collection(PRIVATE_COLLECTION).create({ probe: "a-secret" });
59
59
  await refuse("B can't read A's private record", "not_found",
60
- () => B.storage.collection(PRIVATE_COLLECTION).get(note.id));
61
- const bSees = await B.storage.collection(PRIVATE_COLLECTION).list();
60
+ () => B.collection(PRIVATE_COLLECTION).get(note.id));
61
+ const bSees = await B.collection(PRIVATE_COLLECTION).list();
62
62
  check("B's private list contains none of A's records",
63
63
  !bSees.records.some((r) => r.id === note.id));
64
64
 
@@ -66,7 +66,7 @@ if (SK && !SK.startsWith("sk_live")) {
66
66
  check("currentUser() exposes userId (not id)", !!who.userId);
67
67
  check("record fields live under .data", note.data?.probe === "a-secret");
68
68
 
69
- await A.storage.collection(PRIVATE_COLLECTION).delete(note.id); // leave dev tidy
69
+ await A.collection(PRIVATE_COLLECTION).delete(note.id); // leave dev tidy
70
70
  } else {
71
71
  console.log(SK ? "· Tier B skipped — sk_live can never mint test sessions (by design)"
72
72
  : "· Tier B skipped — set SECRET_KEY (sk_dev) to prove cross-user isolation");