@gemmein/sdk 0.2.0 → 0.3.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
@@ -80,11 +80,28 @@ const one = await tasks.get("rec_abc123")
80
80
  await tasks.update("rec_abc123", { done: true })
81
81
  await tasks.delete("rec_abc123")
82
82
 
83
- // Images/files: presigned upload, returns a CDN URL to store in a record
83
+ // Images/files: presigned upload, returns a REFERENCE to store in a record
84
84
  const file = await tasks.upload(imageBlob, { name: "avatar.png" })
85
- // { id, url, contentType, sizeBytes }
85
+ // { id, ref, contentType, sizeBytes } ref looks like "file:01K…"
86
+ await tasks.create({ title: "Profile", avatar: file.ref })
87
+
88
+ // To show or download it — one call, whatever the collection:
89
+ const { url } = await g.files.link(record.avatar)
90
+ // public collection → a permanent, cacheable URL
91
+ // anything else → a signed URL valid for a couple of minutes, re-checked
92
+ // against who you are and what you still hold
86
93
  ```
87
94
 
95
+ Store the reference, never the URL. A reference doesn't expire and grants
96
+ nothing on its own; `link()` is where authorization happens, every time. That
97
+ also means the same app code keeps working if a collection later changes from
98
+ public to private.
99
+
100
+ **The bound, honestly:** revoking access stops Gemmein issuing new links
101
+ immediately. A link already in someone's hands works until it expires. Nothing
102
+ can take back a file they already downloaded — this is controlled delivery, not
103
+ DRM.
104
+
88
105
  ### Collection rules
89
106
 
90
107
  Each collection has one rule, set in the dashboard. The server enforces it — your app never implements authorization:
package/REFERENCE.md CHANGED
@@ -72,12 +72,18 @@ deleted, subscription row removed. Irreversible: put a real confirm in front.
72
72
 
73
73
  ---
74
74
 
75
- ## Data — `g.collection<T>(name)`
75
+ ## Data — `g.collection<T>(name, options?)`
76
76
 
77
77
  `name` must be **lowercase letters, numbers, and underscores** (`saved_games`,
78
78
  never `savedGames` — a bad name throws synchronously). Collections are created
79
79
  by the app owner in the dashboard, never by the SDK.
80
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
+
81
87
  | Method | Signature | Returns |
82
88
  |--------|-----------|---------|
83
89
  | `create` | `(data: T, options?: { key?: string; for?: string; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
@@ -85,7 +91,40 @@ by the app owner in the dashboard, never by the SDK.
85
91
  | `get` | `(id: string, options?: { expand?: string[] })` | `Promise<GemmeinRecord<T>>` |
86
92
  | `update` | `(id: string, data: Partial<T> \| { field: { increment\|decrement, floor?, ceiling? } }, options?: { ifVersion?: number; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
87
93
  | `delete` | `(id: string)` | `Promise<void>` |
88
- | `upload` | `(file: Blob \| File, options?: { name?: string })` | `Promise<{ id: string; url: string; contentType: string; sizeBytes: number }>` |
94
+ | `upload` | `(file: Blob \| File, options?: { name?: string })` | `Promise<{ id: string; ref: FileRef; contentType: string; sizeBytes: number }>` |
95
+
96
+ ### Files
97
+
98
+ `upload()` returns a **reference** (`file:01K…`), not a URL. Store the reference
99
+ in your record — it never expires and grants nothing on its own.
100
+
101
+ | Method | Signature | Returns |
102
+ |--------|-----------|---------|
103
+ | `g.files.link` | `(ref: FileRef \| string, options?: { intent?: "inline" \| "download" })` | `Promise<{ ref, url, expiresAt?, contentType, sizeBytes?, name? }>` |
104
+
105
+ ```ts
106
+ const { ref } = await g.collection("films").upload(file)
107
+ await g.collection("films").create({ title, poster: ref })
108
+
109
+ // later, to render or download:
110
+ const { url } = await g.files.link(record.poster)
111
+ ```
112
+
113
+ One call for every file. A collection anyone can read gives a permanent,
114
+ cacheable URL; anything else gives one that expires in a couple of minutes and
115
+ is re-checked against who you are, what the collection's rule says, whether the
116
+ file is yours, and any entitlement the collection requires. That is why the
117
+ same code keeps working when a collection changes from public to private.
118
+
119
+ **Don't store what `link()` returns.** Store the reference and call it again.
120
+
121
+ `403 entitlement_required` from `link()` names the key the customer is
122
+ missing. `404` means the file isn't theirs, or isn't there.
123
+
124
+ **The bound, honestly:** revoking access stops Gemmein issuing *new* links
125
+ immediately; a link already issued works until it expires, and a download that
126
+ started before expiry may finish after it. Controlled delivery, not DRM —
127
+ nothing takes back a file someone already downloaded.
89
128
 
90
129
  ```ts
91
130
  type GemmeinRecord<T> = {
@@ -260,5 +299,8 @@ Branch on `err.code`. The stable codes:
260
299
  | `unsupported_file_type` (415) | upload isn't one of the allowed image types | send JPEG/PNG/WebP/GIF/HEIC |
261
300
  | `invalid_key` | a keyed create's `key` breaks the charset/length law | 1-120 chars of letters, numbers, `: _ . @ / -` |
262
301
  | `invalid_secret_key` (client-side) | `gemmeinServer()` got a missing/`pk_` key | pass the `sk_` key from a server env var |
302
+ | `authentication_required` (401) | checkout/subscription/pay without a signed-in user | sign the user in first |
303
+ | `plan_has_no_link` (409) | the paid plan has no Payment Link pasted yet | ask the owner to paste it in their dashboard |
304
+ | `account_suspended` (403) | the app owner's account is suspended (billing) | the owner fixes payment at app.gemmein.com |
263
305
 
264
306
  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.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = void 0;
3
+ exports.GemmeinServer = exports.CollectionClient = exports.StorageClient = exports.AccountClient = exports.PaymentsClient = exports.SubscriptionsClient = exports.FilesClient = exports.PurchasesClient = exports.AuthClient = exports.Gemmein = exports.BrowserTokenStore = exports.MemoryTokenStore = exports.GemmeinError = void 0;
4
4
  exports.gemmein = gemmein;
5
5
  exports.gemmeinServer = gemmeinServer;
6
6
  class GemmeinError extends Error {
@@ -107,14 +107,22 @@ class Gemmein {
107
107
  this.storage = new StorageClient(config);
108
108
  this.subscriptions = new SubscriptionsClient(config);
109
109
  this.payments = new PaymentsClient(config);
110
+ this.purchases = new PurchasesClient(config);
110
111
  this.account = new AccountClient(config);
112
+ this.files = new FilesClient(config);
111
113
  }
112
114
  /**
113
115
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
114
116
  * canonical spelling; `g.storage.collection(name)` is the same client.
117
+ *
118
+ * `intent` (one sentence: what this collection is for and who should
119
+ * access it) travels with every call. Against a LOCAL gemmein dev
120
+ * runtime, an undeclared collection then reaches the human with your
121
+ * suggestion attached — always pass it when you aren't certain the
122
+ * collection exists yet. The cloud ignores it.
115
123
  */
116
- collection(name) {
117
- return this.storage.collection(name);
124
+ collection(name, options = {}) {
125
+ return this.storage.collection(name, options);
118
126
  }
119
127
  }
120
128
  exports.Gemmein = Gemmein;
@@ -194,6 +202,61 @@ exports.AuthClient = AuthClient;
194
202
  * dashboard. The client surface is deliberately read-plus-checkout only:
195
203
  * there is no client write path to plan or status, by design.
196
204
  */
205
+ /**
206
+ * The signed-in customer's own purchase history, read from Gemmein's
207
+ * immutable commercial record rather than from your data.
208
+ *
209
+ * This exists so a buyer keeps proof of what they paid for even when you keep
210
+ * no receipts collection at all — and it stays true if you later rename or
211
+ * delete one, because financial truth does not live in application data.
212
+ *
213
+ * Amounts are MINOR UNITS (pence, cents) with the currency alongside, exactly
214
+ * as the payment provider reported them. Formatting is yours; rounding here
215
+ * would quietly lose money.
216
+ */
217
+ class PurchasesClient {
218
+ constructor(config) {
219
+ this.config = config;
220
+ }
221
+ /**
222
+ * Everything this customer has paid for, newest first, with refunds already
223
+ * applied — `status` is "paid", "part_refunded" or "refunded", and
224
+ * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
225
+ * nobody is signed in.
226
+ */
227
+ async mine() {
228
+ const result = (await runtimeRequest(this.config, "/auth/purchases"));
229
+ return result.purchases;
230
+ }
231
+ }
232
+ exports.PurchasesClient = PurchasesClient;
233
+ /**
234
+ * Turn a stored file reference into a URL you can actually use.
235
+ *
236
+ * const { url } = await g.files.link(record.poster)
237
+ * img.src = url
238
+ *
239
+ * One call for every file, whichever collection it lives in. If the collection
240
+ * is one anyone can read, you get a permanent, cacheable URL. If it isn't, you
241
+ * get one that works for a couple of minutes and is re-checked against who you
242
+ * are, what the collection's rule says, whether the file is yours, and whether
243
+ * you still hold whatever the collection requires.
244
+ *
245
+ * That is why the same app code keeps working when a collection later changes
246
+ * from public to private — and why a refund actually takes a download away.
247
+ *
248
+ * Don't store what this returns. Store the reference and call this again.
249
+ */
250
+ class FilesClient {
251
+ constructor(config) {
252
+ this.config = config;
253
+ }
254
+ async link(ref, options = {}) {
255
+ const query = options.intent === "download" ? "?intent=download" : "";
256
+ return (await runtimeRequest(this.config, `/files/${encodeURIComponent(String(ref))}/link${query}`));
257
+ }
258
+ }
259
+ exports.FilesClient = FilesClient;
197
260
  class SubscriptionsClient {
198
261
  constructor(config) {
199
262
  this.config = config;
@@ -286,9 +349,9 @@ class StorageClient {
286
349
  this.config = config;
287
350
  }
288
351
  /** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
289
- collection(name) {
352
+ collection(name, options = {}) {
290
353
  assertCollectionName(name);
291
- return new CollectionClient(this.config, name);
354
+ return new CollectionClient(this.config, name, options);
292
355
  }
293
356
  }
294
357
  exports.StorageClient = StorageClient;
@@ -299,9 +362,10 @@ exports.StorageClient = StorageClient;
299
362
  * it there, don't retry.
300
363
  */
301
364
  class CollectionClient {
302
- constructor(config, name) {
365
+ constructor(config, name, options = {}) {
303
366
  this.config = config;
304
367
  this.name = name;
368
+ this.intent = options.intent;
305
369
  }
306
370
  /**
307
371
  * Create a record from your fields. The signed-in user becomes its owner.
@@ -401,6 +465,22 @@ class CollectionClient {
401
465
  async delete(id) {
402
466
  await this.request(`/${encodeURIComponent(id)}`, { method: "DELETE" });
403
467
  }
468
+ /**
469
+ * Upload a file and get back a REFERENCE — `file:01K…` — not a URL.
470
+ *
471
+ * Store the reference. It never expires, it is safe to log and export, and
472
+ * it grants nothing on its own. To show or download the file, call
473
+ * `g.files.link(ref)`; Gemmein re-checks who is asking every time, which is
474
+ * what makes revoking access actually take a download away.
475
+ *
476
+ * const { ref } = await g.collection("films").upload(file)
477
+ * await g.collection("films").create({ title, poster: ref })
478
+ * // later, to render:
479
+ * const { url } = await g.files.link(record.poster)
480
+ *
481
+ * There is deliberately no `url` here. A URL that outlives a refund is the
482
+ * bug this replaced.
483
+ */
404
484
  async upload(file, options) {
405
485
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
406
486
  // Step 1: Get presigned upload URL
@@ -433,6 +513,10 @@ class CollectionClient {
433
513
  ...init,
434
514
  headers: await runtimeHeaders(this.config, {
435
515
  "content-type": "application/json",
516
+ // The intent rides every call so an undeclared collection reaches
517
+ // the human WITH the AI's suggestion attached (local runtime only;
518
+ // the cloud ignores it).
519
+ ...(this.intent ? { "x-collection-intent": this.intent.slice(0, 200) } : {}),
436
520
  ...init.headers
437
521
  })
438
522
  });
package/dist/index.d.cts CHANGED
@@ -143,13 +143,23 @@ export declare class Gemmein {
143
143
  readonly storage: StorageClient;
144
144
  readonly subscriptions: SubscriptionsClient;
145
145
  readonly payments: PaymentsClient;
146
+ readonly purchases: PurchasesClient;
146
147
  readonly account: AccountClient;
148
+ readonly files: FilesClient;
147
149
  constructor(options: GemmeinOptions);
148
150
  /**
149
151
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
150
152
  * canonical spelling; `g.storage.collection(name)` is the same client.
153
+ *
154
+ * `intent` (one sentence: what this collection is for and who should
155
+ * access it) travels with every call. Against a LOCAL gemmein dev
156
+ * runtime, an undeclared collection then reaches the human with your
157
+ * suggestion attached — always pass it when you aren't certain the
158
+ * collection exists yet. The cloud ignores it.
151
159
  */
152
- collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): CollectionClient<T>;
160
+ collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string, options?: {
161
+ intent?: string;
162
+ }): CollectionClient<T>;
153
163
  }
154
164
  export declare function gemmein(appKeyOrOptions: string | GemmeinOptions, options?: Omit<GemmeinOptions, "appKey">): Gemmein;
155
165
  export declare function gemmeinServer(secretKeyOrOptions: string | GemmeinServerOptions, options?: Omit<GemmeinServerOptions, "secretKey">): GemmeinServer;
@@ -177,6 +187,81 @@ export declare class AuthClient {
177
187
  * dashboard. The client surface is deliberately read-plus-checkout only:
178
188
  * there is no client write path to plan or status, by design.
179
189
  */
190
+ /**
191
+ * The signed-in customer's own purchase history, read from Gemmein's
192
+ * immutable commercial record rather than from your data.
193
+ *
194
+ * This exists so a buyer keeps proof of what they paid for even when you keep
195
+ * no receipts collection at all — and it stays true if you later rename or
196
+ * delete one, because financial truth does not live in application data.
197
+ *
198
+ * Amounts are MINOR UNITS (pence, cents) with the currency alongside, exactly
199
+ * as the payment provider reported them. Formatting is yours; rounding here
200
+ * would quietly lose money.
201
+ */
202
+ export declare class PurchasesClient {
203
+ private readonly config;
204
+ constructor(config: ClientConfig);
205
+ /**
206
+ * Everything this customer has paid for, newest first, with refunds already
207
+ * applied — `status` is "paid", "part_refunded" or "refunded", and
208
+ * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
209
+ * nobody is signed in.
210
+ */
211
+ mine(): Promise<Array<{
212
+ item: string;
213
+ kind: "purchase" | "subscription";
214
+ amountMinor: number | null;
215
+ currency: string | null;
216
+ refundedMinor: number;
217
+ status: "paid" | "part_refunded" | "refunded";
218
+ grants: string[];
219
+ paidAt: string;
220
+ }>>;
221
+ }
222
+ /**
223
+ * A file reference — `file:01K…`. What `upload()` gives you and what your
224
+ * record should store.
225
+ *
226
+ * Branded so it cannot be mistaken for a URL: `<img src={record.poster}>` is a
227
+ * type error, which is the cheapest possible moment to learn that a reference
228
+ * is a name and not a location.
229
+ */
230
+ export type FileRef = string & {
231
+ readonly __gemmeinFileRef: unique symbol;
232
+ };
233
+ /**
234
+ * Turn a stored file reference into a URL you can actually use.
235
+ *
236
+ * const { url } = await g.files.link(record.poster)
237
+ * img.src = url
238
+ *
239
+ * One call for every file, whichever collection it lives in. If the collection
240
+ * is one anyone can read, you get a permanent, cacheable URL. If it isn't, you
241
+ * get one that works for a couple of minutes and is re-checked against who you
242
+ * are, what the collection's rule says, whether the file is yours, and whether
243
+ * you still hold whatever the collection requires.
244
+ *
245
+ * That is why the same app code keeps working when a collection later changes
246
+ * from public to private — and why a refund actually takes a download away.
247
+ *
248
+ * Don't store what this returns. Store the reference and call this again.
249
+ */
250
+ export declare class FilesClient {
251
+ private readonly config;
252
+ constructor(config: ClientConfig);
253
+ link(ref: FileRef | string, options?: {
254
+ intent?: "inline" | "download";
255
+ }): Promise<{
256
+ ref: FileRef;
257
+ url: string;
258
+ /** Absent when the collection is public — those links don't expire. */
259
+ expiresAt?: string;
260
+ contentType: string;
261
+ sizeBytes?: number;
262
+ name?: string;
263
+ }>;
264
+ }
180
265
  export declare class SubscriptionsClient {
181
266
  private readonly config;
182
267
  constructor(config: ClientConfig);
@@ -245,7 +330,9 @@ export declare class StorageClient {
245
330
  private readonly config;
246
331
  constructor(config: ClientConfig);
247
332
  /** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
248
- collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): CollectionClient<T>;
333
+ collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string, options?: {
334
+ intent?: string;
335
+ }): CollectionClient<T>;
249
336
  }
250
337
  /**
251
338
  * Talks to one collection. Collections themselves are created by the app
@@ -256,7 +343,10 @@ export declare class StorageClient {
256
343
  export declare class CollectionClient<T extends Record<string, unknown> = Record<string, unknown>> {
257
344
  private readonly config;
258
345
  private readonly name;
259
- constructor(config: ClientConfig, name: string);
346
+ private readonly intent?;
347
+ constructor(config: ClientConfig, name: string, options?: {
348
+ intent?: string;
349
+ });
260
350
  /**
261
351
  * Create a record from your fields. The signed-in user becomes its owner.
262
352
  *
@@ -317,11 +407,27 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
317
407
  published?: boolean;
318
408
  }): Promise<GemmeinRecord<T>>;
319
409
  delete(id: string): Promise<void>;
410
+ /**
411
+ * Upload a file and get back a REFERENCE — `file:01K…` — not a URL.
412
+ *
413
+ * Store the reference. It never expires, it is safe to log and export, and
414
+ * it grants nothing on its own. To show or download the file, call
415
+ * `g.files.link(ref)`; Gemmein re-checks who is asking every time, which is
416
+ * what makes revoking access actually take a download away.
417
+ *
418
+ * const { ref } = await g.collection("films").upload(file)
419
+ * await g.collection("films").create({ title, poster: ref })
420
+ * // later, to render:
421
+ * const { url } = await g.files.link(record.poster)
422
+ *
423
+ * There is deliberately no `url` here. A URL that outlives a refund is the
424
+ * bug this replaced.
425
+ */
320
426
  upload(file: Blob | File, options?: {
321
427
  name?: string;
322
428
  }): Promise<{
323
429
  id: string;
324
- url: string;
430
+ ref: FileRef;
325
431
  contentType: string;
326
432
  sizeBytes: number;
327
433
  }>;
package/dist/index.d.ts CHANGED
@@ -143,13 +143,23 @@ export declare class Gemmein {
143
143
  readonly storage: StorageClient;
144
144
  readonly subscriptions: SubscriptionsClient;
145
145
  readonly payments: PaymentsClient;
146
+ readonly purchases: PurchasesClient;
146
147
  readonly account: AccountClient;
148
+ readonly files: FilesClient;
147
149
  constructor(options: GemmeinOptions);
148
150
  /**
149
151
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
150
152
  * canonical spelling; `g.storage.collection(name)` is the same client.
153
+ *
154
+ * `intent` (one sentence: what this collection is for and who should
155
+ * access it) travels with every call. Against a LOCAL gemmein dev
156
+ * runtime, an undeclared collection then reaches the human with your
157
+ * suggestion attached — always pass it when you aren't certain the
158
+ * collection exists yet. The cloud ignores it.
151
159
  */
152
- collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): CollectionClient<T>;
160
+ collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string, options?: {
161
+ intent?: string;
162
+ }): CollectionClient<T>;
153
163
  }
154
164
  export declare function gemmein(appKeyOrOptions: string | GemmeinOptions, options?: Omit<GemmeinOptions, "appKey">): Gemmein;
155
165
  export declare function gemmeinServer(secretKeyOrOptions: string | GemmeinServerOptions, options?: Omit<GemmeinServerOptions, "secretKey">): GemmeinServer;
@@ -177,6 +187,81 @@ export declare class AuthClient {
177
187
  * dashboard. The client surface is deliberately read-plus-checkout only:
178
188
  * there is no client write path to plan or status, by design.
179
189
  */
190
+ /**
191
+ * The signed-in customer's own purchase history, read from Gemmein's
192
+ * immutable commercial record rather than from your data.
193
+ *
194
+ * This exists so a buyer keeps proof of what they paid for even when you keep
195
+ * no receipts collection at all — and it stays true if you later rename or
196
+ * delete one, because financial truth does not live in application data.
197
+ *
198
+ * Amounts are MINOR UNITS (pence, cents) with the currency alongside, exactly
199
+ * as the payment provider reported them. Formatting is yours; rounding here
200
+ * would quietly lose money.
201
+ */
202
+ export declare class PurchasesClient {
203
+ private readonly config;
204
+ constructor(config: ClientConfig);
205
+ /**
206
+ * Everything this customer has paid for, newest first, with refunds already
207
+ * applied — `status` is "paid", "part_refunded" or "refunded", and
208
+ * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
209
+ * nobody is signed in.
210
+ */
211
+ mine(): Promise<Array<{
212
+ item: string;
213
+ kind: "purchase" | "subscription";
214
+ amountMinor: number | null;
215
+ currency: string | null;
216
+ refundedMinor: number;
217
+ status: "paid" | "part_refunded" | "refunded";
218
+ grants: string[];
219
+ paidAt: string;
220
+ }>>;
221
+ }
222
+ /**
223
+ * A file reference — `file:01K…`. What `upload()` gives you and what your
224
+ * record should store.
225
+ *
226
+ * Branded so it cannot be mistaken for a URL: `<img src={record.poster}>` is a
227
+ * type error, which is the cheapest possible moment to learn that a reference
228
+ * is a name and not a location.
229
+ */
230
+ export type FileRef = string & {
231
+ readonly __gemmeinFileRef: unique symbol;
232
+ };
233
+ /**
234
+ * Turn a stored file reference into a URL you can actually use.
235
+ *
236
+ * const { url } = await g.files.link(record.poster)
237
+ * img.src = url
238
+ *
239
+ * One call for every file, whichever collection it lives in. If the collection
240
+ * is one anyone can read, you get a permanent, cacheable URL. If it isn't, you
241
+ * get one that works for a couple of minutes and is re-checked against who you
242
+ * are, what the collection's rule says, whether the file is yours, and whether
243
+ * you still hold whatever the collection requires.
244
+ *
245
+ * That is why the same app code keeps working when a collection later changes
246
+ * from public to private — and why a refund actually takes a download away.
247
+ *
248
+ * Don't store what this returns. Store the reference and call this again.
249
+ */
250
+ export declare class FilesClient {
251
+ private readonly config;
252
+ constructor(config: ClientConfig);
253
+ link(ref: FileRef | string, options?: {
254
+ intent?: "inline" | "download";
255
+ }): Promise<{
256
+ ref: FileRef;
257
+ url: string;
258
+ /** Absent when the collection is public — those links don't expire. */
259
+ expiresAt?: string;
260
+ contentType: string;
261
+ sizeBytes?: number;
262
+ name?: string;
263
+ }>;
264
+ }
180
265
  export declare class SubscriptionsClient {
181
266
  private readonly config;
182
267
  constructor(config: ClientConfig);
@@ -245,7 +330,9 @@ export declare class StorageClient {
245
330
  private readonly config;
246
331
  constructor(config: ClientConfig);
247
332
  /** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
248
- collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string): CollectionClient<T>;
333
+ collection<T extends Record<string, unknown> = Record<string, unknown>>(name: string, options?: {
334
+ intent?: string;
335
+ }): CollectionClient<T>;
249
336
  }
250
337
  /**
251
338
  * Talks to one collection. Collections themselves are created by the app
@@ -256,7 +343,10 @@ export declare class StorageClient {
256
343
  export declare class CollectionClient<T extends Record<string, unknown> = Record<string, unknown>> {
257
344
  private readonly config;
258
345
  private readonly name;
259
- constructor(config: ClientConfig, name: string);
346
+ private readonly intent?;
347
+ constructor(config: ClientConfig, name: string, options?: {
348
+ intent?: string;
349
+ });
260
350
  /**
261
351
  * Create a record from your fields. The signed-in user becomes its owner.
262
352
  *
@@ -317,11 +407,27 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
317
407
  published?: boolean;
318
408
  }): Promise<GemmeinRecord<T>>;
319
409
  delete(id: string): Promise<void>;
410
+ /**
411
+ * Upload a file and get back a REFERENCE — `file:01K…` — not a URL.
412
+ *
413
+ * Store the reference. It never expires, it is safe to log and export, and
414
+ * it grants nothing on its own. To show or download the file, call
415
+ * `g.files.link(ref)`; Gemmein re-checks who is asking every time, which is
416
+ * what makes revoking access actually take a download away.
417
+ *
418
+ * const { ref } = await g.collection("films").upload(file)
419
+ * await g.collection("films").create({ title, poster: ref })
420
+ * // later, to render:
421
+ * const { url } = await g.files.link(record.poster)
422
+ *
423
+ * There is deliberately no `url` here. A URL that outlives a refund is the
424
+ * bug this replaced.
425
+ */
320
426
  upload(file: Blob | File, options?: {
321
427
  name?: string;
322
428
  }): Promise<{
323
429
  id: string;
324
- url: string;
430
+ ref: FileRef;
325
431
  contentType: string;
326
432
  sizeBytes: number;
327
433
  }>;
package/dist/index.js CHANGED
@@ -99,14 +99,22 @@ export class Gemmein {
99
99
  this.storage = new StorageClient(config);
100
100
  this.subscriptions = new SubscriptionsClient(config);
101
101
  this.payments = new PaymentsClient(config);
102
+ this.purchases = new PurchasesClient(config);
102
103
  this.account = new AccountClient(config);
104
+ this.files = new FilesClient(config);
103
105
  }
104
106
  /**
105
107
  * Your app's data — `g.collection<{ title: string }>("notes")`. The
106
108
  * canonical spelling; `g.storage.collection(name)` is the same client.
109
+ *
110
+ * `intent` (one sentence: what this collection is for and who should
111
+ * access it) travels with every call. Against a LOCAL gemmein dev
112
+ * runtime, an undeclared collection then reaches the human with your
113
+ * suggestion attached — always pass it when you aren't certain the
114
+ * collection exists yet. The cloud ignores it.
107
115
  */
108
- collection(name) {
109
- return this.storage.collection(name);
116
+ collection(name, options = {}) {
117
+ return this.storage.collection(name, options);
110
118
  }
111
119
  }
112
120
  // Factory forms — what the copied prompts teach. `gemmein("pk_...")` reads
@@ -184,6 +192,59 @@ export class AuthClient {
184
192
  * dashboard. The client surface is deliberately read-plus-checkout only:
185
193
  * there is no client write path to plan or status, by design.
186
194
  */
195
+ /**
196
+ * The signed-in customer's own purchase history, read from Gemmein's
197
+ * immutable commercial record rather than from your data.
198
+ *
199
+ * This exists so a buyer keeps proof of what they paid for even when you keep
200
+ * no receipts collection at all — and it stays true if you later rename or
201
+ * delete one, because financial truth does not live in application data.
202
+ *
203
+ * Amounts are MINOR UNITS (pence, cents) with the currency alongside, exactly
204
+ * as the payment provider reported them. Formatting is yours; rounding here
205
+ * would quietly lose money.
206
+ */
207
+ export class PurchasesClient {
208
+ constructor(config) {
209
+ this.config = config;
210
+ }
211
+ /**
212
+ * Everything this customer has paid for, newest first, with refunds already
213
+ * applied — `status` is "paid", "part_refunded" or "refunded", and
214
+ * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
215
+ * nobody is signed in.
216
+ */
217
+ async mine() {
218
+ const result = (await runtimeRequest(this.config, "/auth/purchases"));
219
+ return result.purchases;
220
+ }
221
+ }
222
+ /**
223
+ * Turn a stored file reference into a URL you can actually use.
224
+ *
225
+ * const { url } = await g.files.link(record.poster)
226
+ * img.src = url
227
+ *
228
+ * One call for every file, whichever collection it lives in. If the collection
229
+ * is one anyone can read, you get a permanent, cacheable URL. If it isn't, you
230
+ * get one that works for a couple of minutes and is re-checked against who you
231
+ * are, what the collection's rule says, whether the file is yours, and whether
232
+ * you still hold whatever the collection requires.
233
+ *
234
+ * That is why the same app code keeps working when a collection later changes
235
+ * from public to private — and why a refund actually takes a download away.
236
+ *
237
+ * Don't store what this returns. Store the reference and call this again.
238
+ */
239
+ export class FilesClient {
240
+ constructor(config) {
241
+ this.config = config;
242
+ }
243
+ async link(ref, options = {}) {
244
+ const query = options.intent === "download" ? "?intent=download" : "";
245
+ return (await runtimeRequest(this.config, `/files/${encodeURIComponent(String(ref))}/link${query}`));
246
+ }
247
+ }
187
248
  export class SubscriptionsClient {
188
249
  constructor(config) {
189
250
  this.config = config;
@@ -273,9 +334,9 @@ export class StorageClient {
273
334
  this.config = config;
274
335
  }
275
336
  /** Optionally type your fields: `g.storage.collection<{ title: string }>("notes")`. */
276
- collection(name) {
337
+ collection(name, options = {}) {
277
338
  assertCollectionName(name);
278
- return new CollectionClient(this.config, name);
339
+ return new CollectionClient(this.config, name, options);
279
340
  }
280
341
  }
281
342
  /**
@@ -285,9 +346,10 @@ export class StorageClient {
285
346
  * it there, don't retry.
286
347
  */
287
348
  export class CollectionClient {
288
- constructor(config, name) {
349
+ constructor(config, name, options = {}) {
289
350
  this.config = config;
290
351
  this.name = name;
352
+ this.intent = options.intent;
291
353
  }
292
354
  /**
293
355
  * Create a record from your fields. The signed-in user becomes its owner.
@@ -387,6 +449,22 @@ export class CollectionClient {
387
449
  async delete(id) {
388
450
  await this.request(`/${encodeURIComponent(id)}`, { method: "DELETE" });
389
451
  }
452
+ /**
453
+ * Upload a file and get back a REFERENCE — `file:01K…` — not a URL.
454
+ *
455
+ * Store the reference. It never expires, it is safe to log and export, and
456
+ * it grants nothing on its own. To show or download the file, call
457
+ * `g.files.link(ref)`; Gemmein re-checks who is asking every time, which is
458
+ * what makes revoking access actually take a download away.
459
+ *
460
+ * const { ref } = await g.collection("films").upload(file)
461
+ * await g.collection("films").create({ title, poster: ref })
462
+ * // later, to render:
463
+ * const { url } = await g.files.link(record.poster)
464
+ *
465
+ * There is deliberately no `url` here. A URL that outlives a refund is the
466
+ * bug this replaced.
467
+ */
390
468
  async upload(file, options) {
391
469
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
392
470
  // Step 1: Get presigned upload URL
@@ -419,6 +497,10 @@ export class CollectionClient {
419
497
  ...init,
420
498
  headers: await runtimeHeaders(this.config, {
421
499
  "content-type": "application/json",
500
+ // The intent rides every call so an undeclared collection reaches
501
+ // the human WITH the AI's suggestion attached (local runtime only;
502
+ // the cloud ignores it).
503
+ ...(this.intent ? { "x-collection-intent": this.intent.slice(0, 200) } : {}),
422
504
  ...init.headers
423
505
  })
424
506
  });
package/llms.txt CHANGED
@@ -4,8 +4,8 @@
4
4
  > structured data storage with plain-English safety rules, and built-in Stripe
5
5
  > subscription handling. Security is on by default for every app — tenant
6
6
  > isolation, rate limiting, audit logs, domain-locked keys — with nothing to
7
- > configure. Free while building; $14.99/mo when an app goes live (first
8
- > 1,000 monthly signed-in people included; +$5 per additional 1,000).
7
+ > configure. Free while building; going live starts the Live state at
8
+ > $50/mo (up to 1,000 people); Growing unlocks 10,000 at $150/mo.
9
9
 
10
10
  ## What it is
11
11
 
@@ -30,9 +30,13 @@
30
30
  - Data: records live in collections. Collections are created by YOUR HUMAN in
31
31
  their dashboard (app.gemmein.com → data → "+ New collection"), never by you
32
32
  or the SDK. Best practice: at planning time, list the collections your app
33
- will need and ask your human up front. If one is missing at runtime
34
- (404 `unknown_collection`), stop and ask them to create it, telling them
35
- the name and which safety rule to pick. Each collection has exactly one
33
+ will need and ask your human up front, and pass your intent whenever a
34
+ collection might not exist yet
35
+ `g.collection("bookings", { intent: "students reserve slots; each sees only their own" })`
36
+ — so the missing-collection conversation reaches your human with your
37
+ suggestion attached (local dev runtimes show it; the cloud ignores it).
38
+ If one is missing at runtime (404 `unknown_collection`), stop and ask
39
+ them to create it, telling them the name and which safety rule to pick. Each collection has exactly one
36
40
  safety rule:
37
41
  - `private` — each signed-in user sees and edits only their own records
38
42
  (right for notes, tasks, anything personal).
@@ -108,22 +112,33 @@
108
112
  - Images & files: NEVER base64 into record data and NEVER wire up your own
109
113
  storage bucket — uploads are built in:
110
114
  `const file = await g.collection("posts").upload(blob, { name })`
111
- → `{ id, url, contentType, sizeBytes }`. Store `file.url` in a record
112
- field like any text (that's also how a record "has" an image — the
113
- reference pattern, same as links). Upload permission follows the
114
- collection's WRITE rule; images only (JPEG/PNG/WebP/GIF/HEIC). Oversized
115
- files are refused loudly (413 file_too_large the message says the cap).
116
- The server checks the actual bytes at confirm a 400
117
- invalid_file_content means the file isn't really the image type it
118
- claimed (usually a renamed file); send the real image, don't retry.
119
- File URLs are unguessable but not revocable per-reader never put
120
- secrets in files.
115
+ → `{ id, ref, contentType, sizeBytes }`. Store `file.ref` (`"file:01K…"`)
116
+ in a record field like any text that's how a record "has" an image.
117
+ Store the REFERENCE, never a URL: a reference never expires and grants
118
+ nothing on its own.
119
+ To show or download it: `const { url } = await g.files.link(record.photo)`
120
+ one call for every file. Pass `{ intent: "download" }` for a download
121
+ rather than a preview. Files in a collection anyone can read get a
122
+ permanent link; every other file gets one that expires in a couple of
123
+ minutes, so call `link()` when you render, don't store what it returns.
124
+ Upload permission follows the collection's WRITE rule; images only
125
+ (JPEG/PNG/WebP/GIF/HEIC). Oversized files are refused loudly (413
126
+ file_too_large — the message says the cap). The server checks the actual
127
+ bytes at confirm — a 400 invalid_file_content means the file isn't really
128
+ the image type it claimed (usually a renamed file); send the real image,
129
+ don't retry. A 403 from `link()` means the customer isn't allowed this
130
+ file right now — signed out, not theirs, or an entitlement they no longer
131
+ hold (`entitlement_required` names the key).
132
+ Honest bound: revoking access stops NEW links immediately; a link already
133
+ issued works until it expires. Gemmein controls delivery, it can't take
134
+ back a file someone already downloaded.
121
135
  - Shapes: collections your users write (community, shared, direct) have a
122
136
  SHAPE — the set of fields allowed. While your human builds, the server
123
137
  learns it from your writes automatically (nothing to do); when they go
124
138
  live it locks. A 400 invalid_shape means the field isn't in the locked
125
- shape stop and tell your human to add it in their dashboard (one
126
- click), exactly like unknown_collection. Never rename fields to dodge it.
139
+ shape. A live shape is sealed and cannot take new fields stop, tell
140
+ your human which field you needed, and send only the fields the shape
141
+ already has. Never rename fields to dodge it.
127
142
  - Contention (bookings, slugs, stock, shared edits): when two users can race
128
143
  for the same thing, a permission model can't save you — preconditions do,
129
144
  and they're just arguments on calls you already make. A 409 `conflict` from
@@ -184,7 +199,7 @@
184
199
  back — redirects can be faked, receipts come from Stripe's signed
185
200
  webhook. Receipts carry
186
201
  { product, item?, status: "paid"|"refunded", amountTotal (minor units,
187
- as Stripe said), currency, paidAt, deliveryUrl? } — deliveryUrl appears
202
+ as Stripe said), currency, paidAt, deliveryUrl?, paymentRef } — deliveryUrl appears
188
203
  when the builder attached a delivery link to the product (that's how
189
204
  digital goods deliver themselves; never put a secret download URL in a
190
205
  public collection). Fulfilment status changes ("shipped") are the owner
@@ -287,14 +302,22 @@ enforces — never because these checks are the enforcement. A ready-to-edit
287
302
  `reaffirm.mjs` ships inside this npm package (next to this file and
288
303
  REFERENCE.md) — copy it out, name your collections, run it in CI.
289
304
 
290
- ## Pricing (current, v3)
305
+ ## Pricing (current, Pricing Model v1 — states, not plans)
291
306
 
292
- - Free while building — no card at signup, unlimited collections.
293
- - $14.99/mo per live app — first 1,000 people/month included (a person =
294
- someone who signed in that calendar month).
295
- - One dial: +$5 per additional 1,000 people. Turns down as easily as up.
296
- - Safe limits exist per person purely for abuse prevention and are never
297
- billed; real users never notice them.
307
+ - Development is free indefinitely — no card at signup, unlimited
308
+ collections, the full security model included.
309
+ - Going live starts the Live state: $50/mo, up to 1,000 people and 10 GB
310
+ of file storage (a person = a unique enabled end-user identity on the
311
+ live app).
312
+ - Growing: $150/mo, up to 10,000 people and 50 GB — unlocked from the
313
+ dashboard when the product outgrows Live; down as easily as up.
314
+ - Beyond 10,000 people: talk to Gemmein (hello@gemmein.com) — scale is
315
+ priced as a relationship, not a checkout.
316
+ - Pricing reflects responsibility, not complexity: almost nothing is
317
+ metered. Safe limits exist purely as safety rails against runaway
318
+ scripts, are never billed, and real users never notice them. Hitting a
319
+ limit never breaks the app outright — there is grace, and the owner is
320
+ told.
298
321
 
299
322
  ## Facts for citation
300
323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",