@gemmein/sdk 0.1.0 → 0.2.1

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,14 +52,38 @@ 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
+
52
60
  ---
53
61
 
54
- ## Data — `g.storage.collection<T>(name)`
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
+
73
+ ---
74
+
75
+ ## Data — `g.collection<T>(name, options?)`
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
58
79
  by the app owner in the dashboard, never by the SDK.
59
80
 
81
+ `options.intent` — one sentence: what the collection is for and who should
82
+ access it. It rides every call as a hint; against a **local `gemmein dev`
83
+ runtime** an undeclared collection then reaches the human with your
84
+ suggestion attached. The cloud ignores it. Pass it whenever you aren't
85
+ certain the collection exists yet.
86
+
60
87
  | Method | Signature | Returns |
61
88
  |--------|-----------|---------|
62
89
  | `create` | `(data: T, options?: { key?: string; for?: string; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
@@ -105,18 +132,19 @@ on `private`/`public_read`/`admin_write` (no link shape) — join in memory ther
105
132
 
106
133
  ---
107
134
 
108
- ## Payments — `g.subscription()` / `g.checkout()` / `g.pay()`
135
+ ## Payments — `g.subscriptions` / `g.payments`
109
136
 
110
137
  | Method | Signature | Returns |
111
138
  |--------|-----------|---------|
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 |
139
+ | `subscriptions.mine` | `()` | `Promise<{ plan: string; status: "active" \| "cancelled" } \| null>` |
140
+ | `subscriptions.checkout` | `(plan?: string)` | `Promise<{ url: string; plan: string }>` — **navigates the browser to Stripe** and resolves the url |
141
+ | `payments.buy` | `(product: string, options?: { item?: string })` | `Promise<{ url: string; product: string; item?: string }>` — **navigates the browser to Stripe** and resolves the url |
115
142
 
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.
143
+ Plans are `g.subscriptions`; one-off things are `g.payments`. `checkout` and
144
+ `buy` self-navigate via `window.location` just `await` them on the click;
145
+ don't also redirect to the returned `url`, and never build a Stripe URL
146
+ yourself. Gate features on `(await g.subscriptions.mine())?.plan === "pro"`;
147
+ gate one-off fulfilment on the receipt record, never the redirect.
120
148
 
121
149
  ---
122
150
 
@@ -170,19 +198,19 @@ const asUser = (token) => gemmein(process.env.PUBLIC_KEY, {
170
198
  });
171
199
 
172
200
  // ── 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" }));
201
+ g.collection("private_notes"); // misnamed → throws HERE, loudly, in CI
202
+ await refuse("anon can't read private", "denied", () => g.collection("private_notes").list());
203
+ await refuse("anon can't write community", "denied", () => g.collection("board").create({ text: "x" }));
176
204
 
177
205
  // ── TIER B: cross-user isolation (dev only) ──
178
206
  const alice = await srv.testSession("alice@test.dev");
179
207
  const bob = await srv.testSession("bob@test.dev");
180
208
  const A = asUser(alice.token), B = asUser(bob.token);
181
209
 
182
- const note = await A.storage.collection("private_notes").create({ text: "alice-secret" });
210
+ const note = await A.collection("private_notes").create({ text: "alice-secret" });
183
211
  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();
212
+ B.collection("private_notes").get(note.id));
213
+ const bobSees = await B.collection("private_notes").list();
186
214
  if (bobSees.records.length !== 0) { console.error("✗ B sees A's private records"); fail++; }
187
215
  else console.log("✓ B's private list is isolated");
188
216
 
@@ -231,5 +259,15 @@ Branch on `err.code`. The stable codes:
231
259
  | `payload_too_large` / `file_too_large` | over the size cap (message states it) | shrink it |
232
260
  | `invalid_file_content` | uploaded bytes aren't the claimed image type | send the real image |
233
261
  | `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 |
262
+ | `unknown_plan` | no plan by that name | use a name from the list in the message |
263
+ | `plan_not_purchasable` | tried to check out the free default plan | nothing to buy — gate on the paid plan's name |
264
+ | `invalid_expand` | `expand` on a field/rule with no link shape | join in memory instead (private/public_read/admin_write have no links) |
265
+ | `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 |
266
+ | `unsupported_file_type` (415) | upload isn't one of the allowed image types | send JPEG/PNG/WebP/GIF/HEIC |
267
+ | `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
268
+ | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
269
+ | `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
270
+ | `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
271
+ | `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
234
272
 
235
273
  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,22 @@ 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.
115
+ *
116
+ * `intent` (one sentence: what this collection is for and who should
117
+ * access it) travels with every call. Against a LOCAL gemmein dev
118
+ * runtime, an undeclared collection then reaches the human with your
119
+ * suggestion attached — always pass it when you aren't certain the
120
+ * collection exists yet. The cloud ignores it.
108
121
  */
109
- pay(product, options) {
110
- return this.auth.pay(product, options);
122
+ collection(name, options = {}) {
123
+ return this.storage.collection(name, options);
111
124
  }
112
125
  }
113
126
  exports.Gemmein = Gemmein;
@@ -159,24 +172,52 @@ class AuthClient {
159
172
  await this.config.tokenStore.clear();
160
173
  }
161
174
  }
162
- async deleteAccount() {
163
- const result = await this.request("/auth/delete-account", { method: "POST" });
164
- await this.config.tokenStore.clear();
165
- return result;
175
+ async currentUser() {
176
+ // Contract: "who am I?" never throws for session state — the server
177
+ // answers 200 {authenticated:false} for a token it won't honour right
178
+ // now, same as no token. We deliberately do NOT clear the stored token
179
+ // here: authenticated:false also covers a SUSPENDED user, whose session
180
+ // is intact and restored on unsuspend — clearing it would make a
181
+ // reversible suspension permanently sign them out. A genuinely expired
182
+ // token is harmless to keep (the next data-plane call 401s and the app
183
+ // re-auths); logout() and g.account.delete() are the deliberate clears.
184
+ //
185
+ // Moderation note: authenticated:false is deliberately SILENT about the
186
+ // why — signed out, suspended, and erased all read the same here, so a
187
+ // moderated user's state is never leaked to the client. Build the
188
+ // signed-out screen for all three.
189
+ return this.request("/auth/current-user");
190
+ }
191
+ request(path, init = {}) {
192
+ return runtimeRequest(this.config, path, init);
166
193
  }
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"));
194
+ }
195
+ exports.AuthClient = AuthClient;
196
+ /**
197
+ * SUBS: the subscription primitive, self-service side. Gemmein keeps
198
+ * exactly one subscription per customer created by the payment itself,
199
+ * updated by Stripe's signed webhooks, overridable by the owner in their
200
+ * dashboard. The client surface is deliberately read-plus-checkout only:
201
+ * there is no client write path to plan or status, by design.
202
+ */
203
+ class SubscriptionsClient {
204
+ constructor(config) {
205
+ this.config = config;
206
+ }
207
+ /**
208
+ * The signed-in user's subscription — gate features with
209
+ * `(await g.subscriptions.mine())?.plan === "pro"`. Null when payments
210
+ * are off or this user has never paid; throws GemmeinError (401) when
211
+ * nobody is signed in — a data route, not the never-throw current-user
212
+ * contract.
213
+ */
214
+ async mine() {
215
+ const result = (await runtimeRequest(this.config, "/auth/subscription"));
175
216
  return result.subscription;
176
217
  }
177
218
  /**
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
219
+ * Start a Stripe checkout for a plan — Gemmein mints the URL with the
220
+ * signed-in buyer and the plan already wired in (never build checkout
180
221
  * URLs yourself; raw emails get silently dropped by Stripe's URL rules).
181
222
  * In a browser this redirects immediately; it also resolves with the URL
182
223
  * (for non-browser callers or custom handling). Requires a signed-in user
@@ -185,48 +226,61 @@ class AuthClient {
185
226
  */
186
227
  async checkout(plan) {
187
228
  const query = plan ? `?plan=${encodeURIComponent(plan)}` : "";
188
- const result = (await this.request(`/auth/checkout${query}`));
229
+ const result = (await runtimeRequest(this.config, `/auth/checkout${query}`));
189
230
  if (typeof window !== "undefined" && window.location) {
190
231
  window.location.assign(result.url);
191
232
  }
192
233
  return result;
193
234
  }
235
+ }
236
+ exports.SubscriptionsClient = SubscriptionsClient;
237
+ /** The one-off purchase primitive — things, not plans (plans are `g.subscriptions`). */
238
+ class PaymentsClient {
239
+ constructor(config) {
240
+ this.config = config;
241
+ }
194
242
  /**
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.
243
+ * Buy a one-off product — `g.payments.buy("poster")`. Redirects in
244
+ * browsers, and resolves with the URL. The optional `item` note names
245
+ * WHAT is being bought when one product covers many things (e.g. a
246
+ * license tier across a catalog):
247
+ * `g.payments.buy("premium license", { item: "beat_37" })`.
248
+ * A completed payment writes a receipt record addressed to the buyer in
249
+ * the owner's receipts collection; gate downloads/fulfilment on that
250
+ * receipt, never on the redirect coming back.
199
251
  */
200
- async pay(product, options) {
252
+ async buy(product, options) {
201
253
  const params = new URLSearchParams({ product });
202
254
  if (options?.item)
203
255
  params.set("item", options.item);
204
- const result = (await this.request(`/auth/pay?${params.toString()}`));
256
+ const result = (await runtimeRequest(this.config, `/auth/pay?${params.toString()}`));
205
257
  if (typeof window !== "undefined" && window.location) {
206
258
  window.location.assign(result.url);
207
259
  }
208
260
  return result;
209
261
  }
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");
262
+ }
263
+ exports.PaymentsClient = PaymentsClient;
264
+ /** The signed-in user's own account self-service, one deliberate power. */
265
+ class AccountClient {
266
+ constructor(config) {
267
+ this.config = config;
220
268
  }
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);
269
+ /**
270
+ * Self-service erasure the "delete my account" screen. Every app it
271
+ * APPLIES to needs one (GDPR right to erasure; Apple 5.1.1(v) requires it
272
+ * for any app with account creation). Server-side this is the full
273
+ * cascade: sessions revoked, the user's records and files deleted, their
274
+ * subscription row removed. Irreversible — put a real confirm in front
275
+ * of it.
276
+ */
277
+ async delete() {
278
+ const result = await runtimeRequest(this.config, "/auth/delete-account", { method: "POST" });
279
+ await this.config.tokenStore.clear();
280
+ return result;
227
281
  }
228
282
  }
229
- exports.AuthClient = AuthClient;
283
+ exports.AccountClient = AccountClient;
230
284
  function isSessionResponse(value) {
231
285
  return (typeof value === "object" &&
232
286
  value !== null &&
@@ -238,9 +292,9 @@ class StorageClient {
238
292
  this.config = config;
239
293
  }
240
294
  /** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
241
- collection(name) {
295
+ collection(name, options = {}) {
242
296
  assertCollectionName(name);
243
- return new CollectionClient(this.config, name);
297
+ return new CollectionClient(this.config, name, options);
244
298
  }
245
299
  }
246
300
  exports.StorageClient = StorageClient;
@@ -251,9 +305,10 @@ exports.StorageClient = StorageClient;
251
305
  * it there, don't retry.
252
306
  */
253
307
  class CollectionClient {
254
- constructor(config, name) {
308
+ constructor(config, name, options = {}) {
255
309
  this.config = config;
256
310
  this.name = name;
311
+ this.intent = options.intent;
257
312
  }
258
313
  /**
259
314
  * Create a record from your fields. The signed-in user becomes its owner.
@@ -385,6 +440,10 @@ class CollectionClient {
385
440
  ...init,
386
441
  headers: await runtimeHeaders(this.config, {
387
442
  "content-type": "application/json",
443
+ // The intent rides every call so an undeclared collection reaches
444
+ // the human WITH the AI's suggestion attached (local runtime only;
445
+ // the cloud ignores it).
446
+ ...(this.intent ? { "x-collection-intent": this.intent.slice(0, 200) } : {}),
388
447
  ...init.headers
389
448
  })
390
449
  });
@@ -394,11 +453,15 @@ class CollectionClient {
394
453
  exports.CollectionClient = CollectionClient;
395
454
  class GemmeinServer {
396
455
  constructor(options) {
397
- if (options.secretKey.startsWith("pk_")) {
456
+ // Same signpost law as the client: a missing key (env var didn't load)
457
+ // must be a typed error, never a TypeError.
458
+ if (!options?.secretKey || !options.secretKey.startsWith("sk_")) {
398
459
  throw new GemmeinError({
399
460
  status: 0,
400
461
  code: "invalid_secret_key",
401
- message: "Public keys (pk_) must not be used with GemmeinServer — use the Gemmein client SDK instead"
462
+ message: options?.secretKey?.startsWith("pk_")
463
+ ? "Public keys (pk_) must not be used with GemmeinServer — use the Gemmein client SDK instead"
464
+ : '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
465
  });
403
466
  }
404
467
  this.apiUrl = options.apiUrl ?? "https://api.gemmein.com";
@@ -546,6 +609,15 @@ async function readErrorBody(response) {
546
609
  message: `Gemmein request failed: ${response.status}`
547
610
  };
548
611
  }
612
+ // One request path for every runtime client (auth, subscriptions,
613
+ // payments, account) — same headers, same error handling, same signposts.
614
+ async function runtimeRequest(config, path, init = {}) {
615
+ const response = await fetch(new URL(path, config.apiUrl), {
616
+ ...init,
617
+ headers: await runtimeHeaders(config, init.headers)
618
+ });
619
+ return handleResponse(response, config);
620
+ }
549
621
  async function runtimeHeaders(config, headers) {
550
622
  const token = await config.tokenStore.get();
551
623
  return {