@gemmein/sdk 0.2.1 → 0.3.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
@@ -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
@@ -91,7 +91,40 @@ certain the collection exists yet.
91
91
  | `get` | `(id: string, options?: { expand?: string[] })` | `Promise<GemmeinRecord<T>>` |
92
92
  | `update` | `(id: string, data: Partial<T> \| { field: { increment\|decrement, floor?, ceiling? } }, options?: { ifVersion?: number; published?: boolean })` | `Promise<GemmeinRecord<T>>` |
93
93
  | `delete` | `(id: string)` | `Promise<void>` |
94
- | `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.
95
128
 
96
129
  ```ts
97
130
  type GemmeinRecord<T> = {
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,7 +107,9 @@ 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
@@ -200,6 +202,61 @@ exports.AuthClient = AuthClient;
200
202
  * dashboard. The client surface is deliberately read-plus-checkout only:
201
203
  * there is no client write path to plan or status, by design.
202
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;
203
260
  class SubscriptionsClient {
204
261
  constructor(config) {
205
262
  this.config = config;
@@ -408,6 +465,22 @@ class CollectionClient {
408
465
  async delete(id) {
409
466
  await this.request(`/${encodeURIComponent(id)}`, { method: "DELETE" });
410
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
+ */
411
484
  async upload(file, options) {
412
485
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
413
486
  // Step 1: Get presigned upload URL
package/dist/index.d.cts CHANGED
@@ -143,7 +143,9 @@ 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
@@ -185,6 +187,81 @@ export declare class AuthClient {
185
187
  * dashboard. The client surface is deliberately read-plus-checkout only:
186
188
  * there is no client write path to plan or status, by design.
187
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
+ }
188
265
  export declare class SubscriptionsClient {
189
266
  private readonly config;
190
267
  constructor(config: ClientConfig);
@@ -330,11 +407,27 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
330
407
  published?: boolean;
331
408
  }): Promise<GemmeinRecord<T>>;
332
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
+ */
333
426
  upload(file: Blob | File, options?: {
334
427
  name?: string;
335
428
  }): Promise<{
336
429
  id: string;
337
- url: string;
430
+ ref: FileRef;
338
431
  contentType: string;
339
432
  sizeBytes: number;
340
433
  }>;
package/dist/index.d.ts CHANGED
@@ -143,7 +143,9 @@ 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
@@ -185,6 +187,81 @@ export declare class AuthClient {
185
187
  * dashboard. The client surface is deliberately read-plus-checkout only:
186
188
  * there is no client write path to plan or status, by design.
187
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
+ }
188
265
  export declare class SubscriptionsClient {
189
266
  private readonly config;
190
267
  constructor(config: ClientConfig);
@@ -330,11 +407,27 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
330
407
  published?: boolean;
331
408
  }): Promise<GemmeinRecord<T>>;
332
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
+ */
333
426
  upload(file: Blob | File, options?: {
334
427
  name?: string;
335
428
  }): Promise<{
336
429
  id: string;
337
- url: string;
430
+ ref: FileRef;
338
431
  contentType: string;
339
432
  sizeBytes: number;
340
433
  }>;
package/dist/index.js CHANGED
@@ -99,7 +99,9 @@ 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
@@ -190,6 +192,59 @@ export class AuthClient {
190
192
  * dashboard. The client surface is deliberately read-plus-checkout only:
191
193
  * there is no client write path to plan or status, by design.
192
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
+ }
193
248
  export class SubscriptionsClient {
194
249
  constructor(config) {
195
250
  this.config = config;
@@ -394,6 +449,22 @@ export class CollectionClient {
394
449
  async delete(id) {
395
450
  await this.request(`/${encodeURIComponent(id)}`, { method: "DELETE" });
396
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
+ */
397
468
  async upload(file, options) {
398
469
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
399
470
  // Step 1: Get presigned upload URL
package/llms.txt CHANGED
@@ -112,22 +112,33 @@
112
112
  - Images & files: NEVER base64 into record data and NEVER wire up your own
113
113
  storage bucket — uploads are built in:
114
114
  `const file = await g.collection("posts").upload(blob, { name })`
115
- → `{ id, url, contentType, sizeBytes }`. Store `file.url` in a record
116
- field like any text (that's also how a record "has" an image — the
117
- reference pattern, same as links). Upload permission follows the
118
- collection's WRITE rule; images only (JPEG/PNG/WebP/GIF/HEIC). Oversized
119
- files are refused loudly (413 file_too_large the message says the cap).
120
- The server checks the actual bytes at confirm a 400
121
- invalid_file_content means the file isn't really the image type it
122
- claimed (usually a renamed file); send the real image, don't retry.
123
- File URLs are unguessable but not revocable per-reader never put
124
- 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.
125
135
  - Shapes: collections your users write (community, shared, direct) have a
126
136
  SHAPE — the set of fields allowed. While your human builds, the server
127
137
  learns it from your writes automatically (nothing to do); when they go
128
138
  live it locks. A 400 invalid_shape means the field isn't in the locked
129
- shape stop and tell your human to add it in their dashboard (one
130
- 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.
131
142
  - Contention (bookings, slugs, stock, shared edits): when two users can race
132
143
  for the same thing, a permission model can't save you — preconditions do,
133
144
  and they're just arguments on calls you already make. A 409 `conflict` from
@@ -174,28 +185,43 @@
174
185
  arriving out of order resolve to the newest. The app reads
175
186
  `await g.subscriptions.mine()` → `{ plan, status }` or null, and gates features
176
187
  with `sub?.plan === "pro"`.
177
- - Selling THINGS (one-off purchases a poster, a beat, an ebook): plans are
178
- for subscriptions; products are for things. The builder adds products
179
- (name + Stripe Payment Link) on the same Payments page and picks a
180
- receipts collection (rule `addressed`). The app calls
181
- `await g.payments.buy("poster")` or, when one product covers many items (license
188
+ - Paid ACCESS (entitlements): a collection can require a key your human
189
+ sets `requires: "access:pro"` in its settings and the engine refuses
190
+ anyone without it, on every operation, under all seven rules. Keys are
191
+ granted by money: a plan or product lists what it unlocks (e.g.
192
+ `access:pro, access:exports`), the paid webhook grants those keys, and a
193
+ refund or cancellation revokes exactly what it granted — nothing else.
194
+ Owners also grant and revoke by hand (trials, comps, support). Effective
195
+ access is the UNION of a customer's live grants. A signed-in customer
196
+ without the key gets `403 entitlement_required` naming it — show your
197
+ upgrade screen and send them to checkout; never retry. Proof surfaces:
198
+ `await g.purchases.mine()` (everything they paid for, refunds applied,
199
+ with the `grants` each purchase carries) and `await g.subscriptions.mine()`.
200
+ NO credits, NO usage limits, NO seats — access is yes-or-no by design.
201
+ - Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
202
+ access only — physical goods, shipping, inventory and carts are out of
203
+ scope, said out loud): plans are for subscriptions; products are for
204
+ things. The builder adds products (name + Stripe Payment Link) on the
205
+ same Payments page. The app calls
206
+ `await g.payments.buy("beat")` — or, when one product covers many items (license
182
207
  tiers over a catalog), names the item:
183
- `await g.payments.buy("premium license", { item: "beat_37" })` (display text on
184
- the receipt; the PRICE always comes from the product's Payment Link, so
185
- the item note can never change what's paid). The completed payment writes
186
- a receipt record ADDRESSED to the buyer: only they and the owner read it.
187
- Gate downloads/fulfilment on the receipt, never on the redirect coming
188
- back redirects can be faked, receipts come from Stripe's signed
189
- webhook. Receipts carry
190
- { product, item?, status: "paid"|"refunded", amountTotal (minor units,
191
- as Stripe said), currency, paidAt, deliveryUrl?, paymentRef } deliveryUrl appears
192
- when the builder attached a delivery link to the product (that's how
193
- digital goods deliver themselves; never put a secret download URL in a
194
- public collection). Fulfilment status changes ("shipped") are the owner
195
- editing the receipt in their dashboard; your app just re-reads it.
196
- NO carts, NO quantities one product per checkout by design; a cart is
197
- N checkouts or one bundled product. 404 unknown_product lists what the
198
- app actually sells use those names.
208
+ `await g.payments.buy("premium license", { item: "beat_37" })` (display text
209
+ only; the PRICE always comes from the product's Payment Link, so the
210
+ item note can never change what's paid). Gemmein records every completed
211
+ payment itself `await g.purchases.mine()` is the buyer's proof:
212
+ { item, kind, status: "paid"|"part_refunded"|"refunded", amountMinor,
213
+ currency, refundedMinor, grants, paidAt }. A receipts collection (rule
214
+ `addressed`) is OPTIONAL — add one when you want receipts as records the
215
+ app renders; only the buyer and the owner read them. Delivery: attach
216
+ the thing to the product a sealed Gemmein file (the receipt carries a
217
+ `file:` ref; resolve it per reader with `g.files.link`, which re-checks
218
+ access on every mint) or an external `deliveryUrl` (a plain handover:
219
+ Gemmein controls who is TOLD, not who can use it). Gate fulfilment on
220
+ the purchase or the entitlement it granted, never on the redirect coming
221
+ back redirects can be faked; the record comes from Stripe's signed
222
+ webhook. NO carts, NO quantities — one product per checkout by design; a
223
+ cart is N checkouts or one bundled product. 404 unknown_product lists
224
+ what the app actually sells — use those names.
199
225
  - Drafts on PUBLIC collections (public_read, community): create with the
200
226
  OPTION `{ published: false }` → hidden from every reader except its
201
227
  author and the owner, server-enforced; publish with
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.2.1",
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.",
3
+ "version": "0.3.1",
4
+ "description": "Gemmein SDK 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",
7
7
  "main": "./dist/index.cjs",