@gemmein/sdk 0.3.2 → 0.4.2

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
@@ -263,6 +263,8 @@ try {
263
263
  err.status // HTTP status
264
264
  err.message // human-readable, includes what to do next
265
265
  err.resetAt // rate limits: when to retry
266
+ err.requires // 403 entitlement_required: the plan/product key this
267
+ // collection asks for — show your upgrade screen
266
268
  }
267
269
  }
268
270
  ```
@@ -279,6 +281,7 @@ try {
279
281
  | `unknown_product` | 404 | No product by that name — the message lists what the app sells |
280
282
  | `invalid_publish` | 400 | `published` is an option on public collections only — not a data field, not for scoped rules |
281
283
  | `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`. |
284
+ | `entitlement_required` | 403 | Signed in, but not on a plan (or holding a product) this collection is unlocked by. `err.requires` carries that plan's key (`access:pro` for a plan named pro). The one 403 that succeeds later: show your upgrade screen, send them to checkout, retry after they hold it. |
282
285
  | `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. |
283
286
 
284
287
  **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.
package/REFERENCE.md CHANGED
@@ -271,6 +271,7 @@ class GemmeinError extends Error {
271
271
  code: string; // branch on this
272
272
  message: string; // render this — reads correctly for users
273
273
  resetAt?: string; // present on 429 — wait until then, retry
274
+ requires?: string; // present on 403 entitlement_required — the plan/product key it asks for
274
275
  }
275
276
  ```
276
277
 
@@ -281,6 +282,7 @@ Branch on `err.code`. The stable codes:
281
282
  | `unknown_collection` | collection doesn't exist | ask the owner to create it — don't retry |
282
283
  | `unknown_product` | product not sold | use a name from the list in the message |
283
284
  | `forbidden` | the rules refused you (e.g. only the owner writes) | **stop** — the same call always fails |
285
+ | `entitlement_required` | 403 — signed in, but not on a plan (or holding a product) this collection is unlocked by; `err.requires` is that plan's key (`access:<slug of its name>`) | show your upgrade screen and send them to checkout — the one 403 that succeeds later |
284
286
  | `not_found` | record you can't see (existence not leaked) | treat as absent |
285
287
  | `denied` | 401 (sign in first) or 429 (rate limit — see `resetAt`) | re-auth or wait+retry |
286
288
  | `conflict` | a keyed create / floor / stale `ifVersion` | it's the mechanism — tell the user it's taken |
package/dist/index.cjs CHANGED
@@ -10,6 +10,7 @@ class GemmeinError extends Error {
10
10
  this.status = input.status;
11
11
  this.code = input.code;
12
12
  this.resetAt = input.resetAt;
13
+ this.requires = input.requires;
13
14
  }
14
15
  }
15
16
  exports.GemmeinError = GemmeinError;
@@ -223,6 +224,13 @@ class PurchasesClient {
223
224
  * applied — `status` is "paid", "part_refunded" or "refunded", and
224
225
  * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
225
226
  * nobody is signed in.
227
+ *
228
+ * W4.1b: a purchase whose product delivers something carries `delivery` —
229
+ * `{ type: "gemmein_file", file }` (resolve the ref with
230
+ * `g.files.link(file, { intent: "download" })`; the purchase itself is the
231
+ * authorization, re-checked on every mint, and a full refund cuts it off)
232
+ * or `{ type: "external_url", url }` (a plain handover). A refunded
233
+ * purchase never carries delivery.
226
234
  */
227
235
  async mine() {
228
236
  const result = (await runtimeRequest(this.config, "/auth/purchases"));
@@ -431,11 +439,167 @@ class CollectionClient {
431
439
  query.set("search", options.search);
432
440
  if (options.expand && options.expand.length > 0)
433
441
  query.set("expand", options.expand.join(","));
442
+ if (options.since !== undefined)
443
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
434
444
  // NOT query.size — absent on Node <19.8 / older browsers, where
435
445
  // `undefined > 0` would silently drop every filter.
436
446
  const qs = query.toString();
437
447
  return this.request(qs ? `?${qs}` : "");
438
448
  }
449
+ /**
450
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
451
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
452
+ * exactly the same permission gate as list(). Nothing is pushed.
453
+ *
454
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
455
+ * // initial=true: REPLACE your state — records IS the full current
456
+ * // set, and replacing is what clears anything removed while you
457
+ * // weren't looking. initial=false: upsert records by id (a change
458
+ * // can arrive twice, never be missed); remove ids in `deleted`.
459
+ * });
460
+ * // later: w.stop()
461
+ *
462
+ * In a browser it sleeps while the tab is hidden and does a full resync on
463
+ * return (that resync is also how a record erased outright — not deleted
464
+ * by the app, erased — leaves the screen). On errors it backs off,
465
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
466
+ * page is the intended shape — share its result, don't stack watchers.
467
+ */
468
+ watch(onChange, options = {}) {
469
+ const asked = options.every;
470
+ const every = Math.max(5000, Math.min(300000, Number.isFinite(asked) ? asked : 10000));
471
+ const base = { where: options.where, search: options.search, limit: options.limit ?? 100 };
472
+ let stopped = false;
473
+ let timer;
474
+ let delayMs = every;
475
+ let watermark;
476
+ // One tick at a time. A visibility flip mid-tick sets resyncPending
477
+ // instead of racing a second loop into existence (each raced loop would
478
+ // have doubled the poll rate forever).
479
+ let inFlight = false;
480
+ let resyncPending = false;
481
+ const page = async (since) => {
482
+ const records = [];
483
+ const deleted = [];
484
+ let cursor;
485
+ let mark;
486
+ do {
487
+ const result = await this.list({ ...base, cursor, ...(since !== undefined ? { since } : {}) });
488
+ records.push(...result.records);
489
+ if (result.deleted)
490
+ deleted.push(...result.deleted);
491
+ cursor = result.hasMore ? result.cursor : undefined;
492
+ if (since === undefined) {
493
+ // Full sync: keep the FIRST page's watermark — anything written
494
+ // while later pages stream must redeliver on the next poll, not
495
+ // fall below an end-of-paging mark and vanish.
496
+ mark = mark ?? result.watermark;
497
+ }
498
+ else if (!result.hasMore) {
499
+ mark = result.watermark;
500
+ }
501
+ } while (cursor && !stopped);
502
+ return { records, deleted, mark };
503
+ };
504
+ const tick = async (resync) => {
505
+ if (stopped || inFlight)
506
+ return;
507
+ inFlight = true;
508
+ let delivery;
509
+ try {
510
+ const doResync = resync || resyncPending;
511
+ resyncPending = false;
512
+ const { records, deleted, mark } = await page(doResync ? undefined : watermark);
513
+ if (stopped) {
514
+ inFlight = false;
515
+ return;
516
+ }
517
+ if (mark !== undefined)
518
+ watermark = mark;
519
+ delayMs = every;
520
+ if (doResync || records.length > 0 || deleted.length > 0) {
521
+ delivery = { records, deleted, initial: doResync };
522
+ }
523
+ }
524
+ catch (err) {
525
+ if (stopped) {
526
+ inFlight = false;
527
+ return;
528
+ }
529
+ // Back off; a rate limit says exactly when to come back.
530
+ delayMs = Math.min(Math.max(delayMs * 2, every), 60000);
531
+ if (err instanceof GemmeinError && err.resetAt) {
532
+ const wait = new Date(err.resetAt).getTime() - Date.now();
533
+ if (Number.isFinite(wait) && wait > delayMs)
534
+ delayMs = Math.min(wait, 300000);
535
+ }
536
+ }
537
+ inFlight = false;
538
+ if (delivery) {
539
+ // The app's callback runs OUTSIDE the wire handling: its exceptions
540
+ // are its own bugs, never a reason to back off or mangle the loop.
541
+ try {
542
+ onChange(delivery);
543
+ }
544
+ catch { /* the app's error, not the wire's */ }
545
+ }
546
+ schedule();
547
+ };
548
+ const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
549
+ const schedule = () => {
550
+ if (stopped || hidden() || timer !== undefined)
551
+ return;
552
+ timer = setTimeout(() => { timer = undefined; void tick(false); }, delayMs);
553
+ // In Node a live timer pins the process open; a watcher should never
554
+ // be the reason a script can't exit.
555
+ timer.unref?.();
556
+ };
557
+ const onVisibility = () => {
558
+ if (stopped)
559
+ return;
560
+ if (hidden()) {
561
+ if (timer !== undefined) {
562
+ clearTimeout(timer);
563
+ timer = undefined;
564
+ }
565
+ }
566
+ else {
567
+ // Waking resyncs in full — the cheap answer to "what did I miss?",
568
+ // including anything erased while the tab slept. If a tick is mid
569
+ // flight, flag it rather than racing a second loop.
570
+ if (timer !== undefined) {
571
+ clearTimeout(timer);
572
+ timer = undefined;
573
+ }
574
+ delayMs = every;
575
+ if (inFlight) {
576
+ resyncPending = true;
577
+ return;
578
+ }
579
+ void tick(true);
580
+ }
581
+ };
582
+ if (typeof document !== "undefined") {
583
+ document.addEventListener("visibilitychange", onVisibility);
584
+ }
585
+ // Born hidden: wait for the tab — the visibility handler runs the first
586
+ // sync when the user actually looks.
587
+ if (!hidden())
588
+ void tick(true);
589
+ else
590
+ resyncPending = true;
591
+ return {
592
+ stop: () => {
593
+ stopped = true;
594
+ if (timer !== undefined)
595
+ clearTimeout(timer);
596
+ timer = undefined;
597
+ if (typeof document !== "undefined") {
598
+ document.removeEventListener("visibilitychange", onVisibility);
599
+ }
600
+ },
601
+ };
602
+ }
439
603
  async get(id, options = {}) {
440
604
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
441
605
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -483,15 +647,24 @@ class CollectionClient {
483
647
  * // later, to render:
484
648
  * const { url } = await g.files.link(record.poster)
485
649
  *
650
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
651
+ * file. A document always downloads — it is served as an attachment and
652
+ * never opens inside your page — so link it with `{ intent: "download" }`.
653
+ * Pass `{ name }` so the download carries a real filename, and
654
+ * `{ contentType }` when the Blob has no type of its own.
655
+ *
486
656
  * There is deliberately no `url` here. A URL that outlives a refund is the
487
657
  * bug this replaced.
488
658
  */
489
659
  async upload(file, options) {
490
660
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
661
+ // A Blob built from bytes has type "" — `contentType` names it. The
662
+ // server proves the bytes either way.
663
+ const contentType = options?.contentType ?? file.type;
491
664
  // Step 1: Get presigned upload URL
492
665
  const presign = await this.request("/upload", {
493
666
  method: "POST",
494
- body: JSON.stringify({ name, size: file.size, contentType: file.type }),
667
+ body: JSON.stringify({ name, size: file.size, contentType }),
495
668
  });
496
669
  // Step 2: Upload directly to S3 via presigned POST
497
670
  const form = new FormData();
@@ -605,6 +778,8 @@ class ServerCollectionClient {
605
778
  query.set("search", options.search);
606
779
  if (options.expand && options.expand.length > 0)
607
780
  query.set("expand", options.expand.join(","));
781
+ if (options.since !== undefined)
782
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
608
783
  // NOT query.size — absent on Node <19.8 / older browsers, where
609
784
  // `undefined > 0` would silently drop every filter.
610
785
  const qs = query.toString();
@@ -627,12 +802,10 @@ class ServerCollectionClient {
627
802
  if (response.status === 204)
628
803
  return undefined;
629
804
  if (!response.ok) {
630
- const body = await response.json().catch(() => ({ code: "request_failed", message: `Request failed: ${response.status}` }));
631
- throw new GemmeinError({
632
- status: response.status,
633
- code: body.code ?? "request_failed",
634
- message: body.message ?? `Request failed: ${response.status}`,
635
- });
805
+ // One parser for every refusal body, so a 403 entitlement_required on a
806
+ // collection surfaces `requires` exactly as it does on the runtime
807
+ // clients (the docs promise err.requires on BOTH paths).
808
+ throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
636
809
  }
637
810
  return response.json();
638
811
  }
@@ -650,12 +823,7 @@ async function handleResponse(response, config) {
650
823
  if (errorBody.code === "invalid_app_key" || errorBody.code === "missing_app_key") {
651
824
  errorBody.message += " — get your app key from the Setup page at https://app.gemmein.com (sign in with an email code, free, no card)";
652
825
  }
653
- throw new GemmeinError({
654
- status: response.status,
655
- code: errorBody.code,
656
- message: errorBody.message,
657
- resetAt: errorBody.resetAt
658
- });
826
+ throw new GemmeinError({ status: response.status, ...errorBody });
659
827
  }
660
828
  return response.json();
661
829
  }
@@ -663,19 +831,19 @@ async function readErrorBody(response) {
663
831
  try {
664
832
  const value = await response.json();
665
833
  if (typeof value === "object" && value !== null) {
666
- const code = typeof value.code === "string"
667
- ? value.code
668
- : typeof value.error === "string"
669
- ? value.error
834
+ const body = value;
835
+ const code = typeof body.code === "string" ? body.code
836
+ : typeof body.error === "string" ? body.error
670
837
  : "request_failed";
838
+ const str = (v) => (typeof v === "string" ? v : undefined);
839
+ // ENTITLE-10: `requires` is the one key the 403 names (the lock's
840
+ // first). Copied only when the server sent it as a string — never
841
+ // invented client-side, and nothing else from the body is surfaced.
671
842
  return {
672
843
  code,
673
- message: typeof value.message === "string"
674
- ? value.message
675
- : `Gemmein request failed: ${response.status}`,
676
- resetAt: typeof value.resetAt === "string"
677
- ? value.resetAt
678
- : undefined
844
+ message: str(body.message) ?? `Gemmein request failed: ${response.status}`,
845
+ resetAt: str(body.resetAt),
846
+ requires: str(body.requires)
679
847
  };
680
848
  }
681
849
  }
package/dist/index.d.cts CHANGED
@@ -65,6 +65,15 @@ export type ListResult<T extends Record<string, unknown> = Record<string, unknow
65
65
  records: GemmeinRecord<T>[];
66
66
  cursor?: string;
67
67
  hasMore: boolean;
68
+ /** W7.2: on a delta read (`since`), ids of records deleted after that
69
+ * instant — ids only, and only ones your rule scope admitted. */
70
+ deleted?: string[];
71
+ /** The instant to pass as the next `since`. On a plain list it rides
72
+ * every page (adopt the FIRST page's when you page a full sync); on a
73
+ * delta it rides only the final page. Deliberately lags the server clock
74
+ * ~2s, so a change can be delivered twice but never silently missed —
75
+ * apply records by id. */
76
+ watermark?: string;
68
77
  };
69
78
  export type ListOptions = {
70
79
  limit?: number;
@@ -78,6 +87,10 @@ export type ListOptions = {
78
87
  /** SHAPE-1: link fields to embed (up to 3) — each expanded record is only
79
88
  * what YOU could have read directly; unreadable/deleted targets are null. */
80
89
  expand?: string[];
90
+ /** W7.2: everything changed OR deleted after this instant, oldest change
91
+ * first. Pass the `watermark` from the previous answer. Incompatible with
92
+ * `sort` (delta order is fixed). */
93
+ since?: string | Date;
81
94
  };
82
95
  /**
83
96
  * Answer to "who is signed in right now?". `authenticated: false` simply
@@ -104,12 +117,23 @@ export type AuthSession = {
104
117
  export declare class GemmeinError extends Error {
105
118
  readonly status: number;
106
119
  readonly code: string;
120
+ /** Present on 429 — when the limit resets; wait until then and retry. */
107
121
  readonly resetAt?: string;
122
+ /**
123
+ * Present on `403 entitlement_required` — the plan/product key this
124
+ * collection asks for (keys are minted from plan names: `access:<slug>`;
125
+ * the console shows the plan by name). Show your upgrade screen and send
126
+ * the customer to checkout; the call succeeds once they hold it. A
127
+ * collection unlocked by several plans names ONE key here — offer your
128
+ * plans by name through checkout, the server never hands out the list.
129
+ */
130
+ readonly requires?: string;
108
131
  constructor(input: {
109
132
  status: number;
110
133
  code: string;
111
134
  message: string;
112
135
  resetAt?: string;
136
+ requires?: string;
113
137
  });
114
138
  }
115
139
  export declare class MemoryTokenStore implements TokenStore {
@@ -207,6 +231,13 @@ export declare class PurchasesClient {
207
231
  * applied — `status` is "paid", "part_refunded" or "refunded", and
208
232
  * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
209
233
  * nobody is signed in.
234
+ *
235
+ * W4.1b: a purchase whose product delivers something carries `delivery` —
236
+ * `{ type: "gemmein_file", file }` (resolve the ref with
237
+ * `g.files.link(file, { intent: "download" })`; the purchase itself is the
238
+ * authorization, re-checked on every mint, and a full refund cuts it off)
239
+ * or `{ type: "external_url", url }` (a plain handover). A refunded
240
+ * purchase never carries delivery.
210
241
  */
211
242
  mine(): Promise<Array<{
212
243
  item: string;
@@ -217,6 +248,13 @@ export declare class PurchasesClient {
217
248
  status: "paid" | "part_refunded" | "refunded";
218
249
  grants: string[];
219
250
  paidAt: string;
251
+ delivery?: {
252
+ type: "gemmein_file";
253
+ file: string;
254
+ } | {
255
+ type: "external_url";
256
+ url: string;
257
+ };
220
258
  }>>;
221
259
  }
222
260
  /**
@@ -389,6 +427,37 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
389
427
  * an object, not a bare array.
390
428
  */
391
429
  list(options?: ListOptions): Promise<ListResult<T>>;
430
+ /**
431
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
432
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
433
+ * exactly the same permission gate as list(). Nothing is pushed.
434
+ *
435
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
436
+ * // initial=true: REPLACE your state — records IS the full current
437
+ * // set, and replacing is what clears anything removed while you
438
+ * // weren't looking. initial=false: upsert records by id (a change
439
+ * // can arrive twice, never be missed); remove ids in `deleted`.
440
+ * });
441
+ * // later: w.stop()
442
+ *
443
+ * In a browser it sleeps while the tab is hidden and does a full resync on
444
+ * return (that resync is also how a record erased outright — not deleted
445
+ * by the app, erased — leaves the screen). On errors it backs off,
446
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
447
+ * page is the intended shape — share its result, don't stack watchers.
448
+ */
449
+ watch(onChange: (delta: {
450
+ records: GemmeinRecord<T>[];
451
+ deleted: string[];
452
+ initial: boolean;
453
+ }) => void, options?: {
454
+ every?: number;
455
+ where?: Record<string, unknown>;
456
+ search?: string;
457
+ limit?: number;
458
+ }): {
459
+ stop(): void;
460
+ };
392
461
  get(id: string, options?: {
393
462
  expand?: string[];
394
463
  }): Promise<GemmeinRecord<T>>;
@@ -425,11 +494,18 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
425
494
  * // later, to render:
426
495
  * const { url } = await g.files.link(record.poster)
427
496
  *
497
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
498
+ * file. A document always downloads — it is served as an attachment and
499
+ * never opens inside your page — so link it with `{ intent: "download" }`.
500
+ * Pass `{ name }` so the download carries a real filename, and
501
+ * `{ contentType }` when the Blob has no type of its own.
502
+ *
428
503
  * There is deliberately no `url` here. A URL that outlives a refund is the
429
504
  * bug this replaced.
430
505
  */
431
506
  upload(file: Blob | File, options?: {
432
507
  name?: string;
508
+ contentType?: string;
433
509
  }): Promise<{
434
510
  id: string;
435
511
  ref: FileRef;
@@ -478,6 +554,7 @@ declare class ServerCollectionClient {
478
554
  cursor?: string;
479
555
  search?: string;
480
556
  expand?: string[];
557
+ since?: string | Date;
481
558
  }): Promise<unknown>;
482
559
  update(id: string, data: Record<string, unknown>): Promise<unknown>;
483
560
  private request;
package/dist/index.d.ts CHANGED
@@ -65,6 +65,15 @@ export type ListResult<T extends Record<string, unknown> = Record<string, unknow
65
65
  records: GemmeinRecord<T>[];
66
66
  cursor?: string;
67
67
  hasMore: boolean;
68
+ /** W7.2: on a delta read (`since`), ids of records deleted after that
69
+ * instant — ids only, and only ones your rule scope admitted. */
70
+ deleted?: string[];
71
+ /** The instant to pass as the next `since`. On a plain list it rides
72
+ * every page (adopt the FIRST page's when you page a full sync); on a
73
+ * delta it rides only the final page. Deliberately lags the server clock
74
+ * ~2s, so a change can be delivered twice but never silently missed —
75
+ * apply records by id. */
76
+ watermark?: string;
68
77
  };
69
78
  export type ListOptions = {
70
79
  limit?: number;
@@ -78,6 +87,10 @@ export type ListOptions = {
78
87
  /** SHAPE-1: link fields to embed (up to 3) — each expanded record is only
79
88
  * what YOU could have read directly; unreadable/deleted targets are null. */
80
89
  expand?: string[];
90
+ /** W7.2: everything changed OR deleted after this instant, oldest change
91
+ * first. Pass the `watermark` from the previous answer. Incompatible with
92
+ * `sort` (delta order is fixed). */
93
+ since?: string | Date;
81
94
  };
82
95
  /**
83
96
  * Answer to "who is signed in right now?". `authenticated: false` simply
@@ -104,12 +117,23 @@ export type AuthSession = {
104
117
  export declare class GemmeinError extends Error {
105
118
  readonly status: number;
106
119
  readonly code: string;
120
+ /** Present on 429 — when the limit resets; wait until then and retry. */
107
121
  readonly resetAt?: string;
122
+ /**
123
+ * Present on `403 entitlement_required` — the plan/product key this
124
+ * collection asks for (keys are minted from plan names: `access:<slug>`;
125
+ * the console shows the plan by name). Show your upgrade screen and send
126
+ * the customer to checkout; the call succeeds once they hold it. A
127
+ * collection unlocked by several plans names ONE key here — offer your
128
+ * plans by name through checkout, the server never hands out the list.
129
+ */
130
+ readonly requires?: string;
108
131
  constructor(input: {
109
132
  status: number;
110
133
  code: string;
111
134
  message: string;
112
135
  resetAt?: string;
136
+ requires?: string;
113
137
  });
114
138
  }
115
139
  export declare class MemoryTokenStore implements TokenStore {
@@ -207,6 +231,13 @@ export declare class PurchasesClient {
207
231
  * applied — `status` is "paid", "part_refunded" or "refunded", and
208
232
  * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
209
233
  * nobody is signed in.
234
+ *
235
+ * W4.1b: a purchase whose product delivers something carries `delivery` —
236
+ * `{ type: "gemmein_file", file }` (resolve the ref with
237
+ * `g.files.link(file, { intent: "download" })`; the purchase itself is the
238
+ * authorization, re-checked on every mint, and a full refund cuts it off)
239
+ * or `{ type: "external_url", url }` (a plain handover). A refunded
240
+ * purchase never carries delivery.
210
241
  */
211
242
  mine(): Promise<Array<{
212
243
  item: string;
@@ -217,6 +248,13 @@ export declare class PurchasesClient {
217
248
  status: "paid" | "part_refunded" | "refunded";
218
249
  grants: string[];
219
250
  paidAt: string;
251
+ delivery?: {
252
+ type: "gemmein_file";
253
+ file: string;
254
+ } | {
255
+ type: "external_url";
256
+ url: string;
257
+ };
220
258
  }>>;
221
259
  }
222
260
  /**
@@ -389,6 +427,37 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
389
427
  * an object, not a bare array.
390
428
  */
391
429
  list(options?: ListOptions): Promise<ListResult<T>>;
430
+ /**
431
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
432
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
433
+ * exactly the same permission gate as list(). Nothing is pushed.
434
+ *
435
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
436
+ * // initial=true: REPLACE your state — records IS the full current
437
+ * // set, and replacing is what clears anything removed while you
438
+ * // weren't looking. initial=false: upsert records by id (a change
439
+ * // can arrive twice, never be missed); remove ids in `deleted`.
440
+ * });
441
+ * // later: w.stop()
442
+ *
443
+ * In a browser it sleeps while the tab is hidden and does a full resync on
444
+ * return (that resync is also how a record erased outright — not deleted
445
+ * by the app, erased — leaves the screen). On errors it backs off,
446
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
447
+ * page is the intended shape — share its result, don't stack watchers.
448
+ */
449
+ watch(onChange: (delta: {
450
+ records: GemmeinRecord<T>[];
451
+ deleted: string[];
452
+ initial: boolean;
453
+ }) => void, options?: {
454
+ every?: number;
455
+ where?: Record<string, unknown>;
456
+ search?: string;
457
+ limit?: number;
458
+ }): {
459
+ stop(): void;
460
+ };
392
461
  get(id: string, options?: {
393
462
  expand?: string[];
394
463
  }): Promise<GemmeinRecord<T>>;
@@ -425,11 +494,18 @@ export declare class CollectionClient<T extends Record<string, unknown> = Record
425
494
  * // later, to render:
426
495
  * const { url } = await g.files.link(record.poster)
427
496
  *
497
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
498
+ * file. A document always downloads — it is served as an attachment and
499
+ * never opens inside your page — so link it with `{ intent: "download" }`.
500
+ * Pass `{ name }` so the download carries a real filename, and
501
+ * `{ contentType }` when the Blob has no type of its own.
502
+ *
428
503
  * There is deliberately no `url` here. A URL that outlives a refund is the
429
504
  * bug this replaced.
430
505
  */
431
506
  upload(file: Blob | File, options?: {
432
507
  name?: string;
508
+ contentType?: string;
433
509
  }): Promise<{
434
510
  id: string;
435
511
  ref: FileRef;
@@ -478,6 +554,7 @@ declare class ServerCollectionClient {
478
554
  cursor?: string;
479
555
  search?: string;
480
556
  expand?: string[];
557
+ since?: string | Date;
481
558
  }): Promise<unknown>;
482
559
  update(id: string, data: Record<string, unknown>): Promise<unknown>;
483
560
  private request;
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export class GemmeinError extends Error {
5
5
  this.status = input.status;
6
6
  this.code = input.code;
7
7
  this.resetAt = input.resetAt;
8
+ this.requires = input.requires;
8
9
  }
9
10
  }
10
11
  export class MemoryTokenStore {
@@ -213,6 +214,13 @@ export class PurchasesClient {
213
214
  * applied — `status` is "paid", "part_refunded" or "refunded", and
214
215
  * `refundedMinor` is how much has come back. Throws GemmeinError (401) when
215
216
  * nobody is signed in.
217
+ *
218
+ * W4.1b: a purchase whose product delivers something carries `delivery` —
219
+ * `{ type: "gemmein_file", file }` (resolve the ref with
220
+ * `g.files.link(file, { intent: "download" })`; the purchase itself is the
221
+ * authorization, re-checked on every mint, and a full refund cuts it off)
222
+ * or `{ type: "external_url", url }` (a plain handover). A refunded
223
+ * purchase never carries delivery.
216
224
  */
217
225
  async mine() {
218
226
  const result = (await runtimeRequest(this.config, "/auth/purchases"));
@@ -415,11 +423,167 @@ export class CollectionClient {
415
423
  query.set("search", options.search);
416
424
  if (options.expand && options.expand.length > 0)
417
425
  query.set("expand", options.expand.join(","));
426
+ if (options.since !== undefined)
427
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
418
428
  // NOT query.size — absent on Node <19.8 / older browsers, where
419
429
  // `undefined > 0` would silently drop every filter.
420
430
  const qs = query.toString();
421
431
  return this.request(qs ? `?${qs}` : "");
422
432
  }
433
+ /**
434
+ * W7.2 — live-enough, honestly. Explicitly POLLING: a full list first,
435
+ * then "what changed?" every `every` ms (default 10s, floor 5s) through
436
+ * exactly the same permission gate as list(). Nothing is pushed.
437
+ *
438
+ * const w = g.collection("orders").watch(({ records, deleted, initial }) => {
439
+ * // initial=true: REPLACE your state — records IS the full current
440
+ * // set, and replacing is what clears anything removed while you
441
+ * // weren't looking. initial=false: upsert records by id (a change
442
+ * // can arrive twice, never be missed); remove ids in `deleted`.
443
+ * });
444
+ * // later: w.stop()
445
+ *
446
+ * In a browser it sleeps while the tab is hidden and does a full resync on
447
+ * return (that resync is also how a record erased outright — not deleted
448
+ * by the app, erased — leaves the screen). On errors it backs off,
449
+ * doubling up to 60s, and honours a rate limit's reset time. One watch per
450
+ * page is the intended shape — share its result, don't stack watchers.
451
+ */
452
+ watch(onChange, options = {}) {
453
+ const asked = options.every;
454
+ const every = Math.max(5000, Math.min(300000, Number.isFinite(asked) ? asked : 10000));
455
+ const base = { where: options.where, search: options.search, limit: options.limit ?? 100 };
456
+ let stopped = false;
457
+ let timer;
458
+ let delayMs = every;
459
+ let watermark;
460
+ // One tick at a time. A visibility flip mid-tick sets resyncPending
461
+ // instead of racing a second loop into existence (each raced loop would
462
+ // have doubled the poll rate forever).
463
+ let inFlight = false;
464
+ let resyncPending = false;
465
+ const page = async (since) => {
466
+ const records = [];
467
+ const deleted = [];
468
+ let cursor;
469
+ let mark;
470
+ do {
471
+ const result = await this.list({ ...base, cursor, ...(since !== undefined ? { since } : {}) });
472
+ records.push(...result.records);
473
+ if (result.deleted)
474
+ deleted.push(...result.deleted);
475
+ cursor = result.hasMore ? result.cursor : undefined;
476
+ if (since === undefined) {
477
+ // Full sync: keep the FIRST page's watermark — anything written
478
+ // while later pages stream must redeliver on the next poll, not
479
+ // fall below an end-of-paging mark and vanish.
480
+ mark = mark ?? result.watermark;
481
+ }
482
+ else if (!result.hasMore) {
483
+ mark = result.watermark;
484
+ }
485
+ } while (cursor && !stopped);
486
+ return { records, deleted, mark };
487
+ };
488
+ const tick = async (resync) => {
489
+ if (stopped || inFlight)
490
+ return;
491
+ inFlight = true;
492
+ let delivery;
493
+ try {
494
+ const doResync = resync || resyncPending;
495
+ resyncPending = false;
496
+ const { records, deleted, mark } = await page(doResync ? undefined : watermark);
497
+ if (stopped) {
498
+ inFlight = false;
499
+ return;
500
+ }
501
+ if (mark !== undefined)
502
+ watermark = mark;
503
+ delayMs = every;
504
+ if (doResync || records.length > 0 || deleted.length > 0) {
505
+ delivery = { records, deleted, initial: doResync };
506
+ }
507
+ }
508
+ catch (err) {
509
+ if (stopped) {
510
+ inFlight = false;
511
+ return;
512
+ }
513
+ // Back off; a rate limit says exactly when to come back.
514
+ delayMs = Math.min(Math.max(delayMs * 2, every), 60000);
515
+ if (err instanceof GemmeinError && err.resetAt) {
516
+ const wait = new Date(err.resetAt).getTime() - Date.now();
517
+ if (Number.isFinite(wait) && wait > delayMs)
518
+ delayMs = Math.min(wait, 300000);
519
+ }
520
+ }
521
+ inFlight = false;
522
+ if (delivery) {
523
+ // The app's callback runs OUTSIDE the wire handling: its exceptions
524
+ // are its own bugs, never a reason to back off or mangle the loop.
525
+ try {
526
+ onChange(delivery);
527
+ }
528
+ catch { /* the app's error, not the wire's */ }
529
+ }
530
+ schedule();
531
+ };
532
+ const hidden = () => typeof document !== "undefined" && document.visibilityState === "hidden";
533
+ const schedule = () => {
534
+ if (stopped || hidden() || timer !== undefined)
535
+ return;
536
+ timer = setTimeout(() => { timer = undefined; void tick(false); }, delayMs);
537
+ // In Node a live timer pins the process open; a watcher should never
538
+ // be the reason a script can't exit.
539
+ timer.unref?.();
540
+ };
541
+ const onVisibility = () => {
542
+ if (stopped)
543
+ return;
544
+ if (hidden()) {
545
+ if (timer !== undefined) {
546
+ clearTimeout(timer);
547
+ timer = undefined;
548
+ }
549
+ }
550
+ else {
551
+ // Waking resyncs in full — the cheap answer to "what did I miss?",
552
+ // including anything erased while the tab slept. If a tick is mid
553
+ // flight, flag it rather than racing a second loop.
554
+ if (timer !== undefined) {
555
+ clearTimeout(timer);
556
+ timer = undefined;
557
+ }
558
+ delayMs = every;
559
+ if (inFlight) {
560
+ resyncPending = true;
561
+ return;
562
+ }
563
+ void tick(true);
564
+ }
565
+ };
566
+ if (typeof document !== "undefined") {
567
+ document.addEventListener("visibilitychange", onVisibility);
568
+ }
569
+ // Born hidden: wait for the tab — the visibility handler runs the first
570
+ // sync when the user actually looks.
571
+ if (!hidden())
572
+ void tick(true);
573
+ else
574
+ resyncPending = true;
575
+ return {
576
+ stop: () => {
577
+ stopped = true;
578
+ if (timer !== undefined)
579
+ clearTimeout(timer);
580
+ timer = undefined;
581
+ if (typeof document !== "undefined") {
582
+ document.removeEventListener("visibilitychange", onVisibility);
583
+ }
584
+ },
585
+ };
586
+ }
423
587
  async get(id, options = {}) {
424
588
  const qs = options.expand && options.expand.length > 0 ? `?expand=${encodeURIComponent(options.expand.join(","))}` : "";
425
589
  return this.request(`/${encodeURIComponent(id)}${qs}`);
@@ -467,15 +631,24 @@ export class CollectionClient {
467
631
  * // later, to render:
468
632
  * const { url } = await g.files.link(record.poster)
469
633
  *
634
+ * Images (JPEG/PNG/WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per
635
+ * file. A document always downloads — it is served as an attachment and
636
+ * never opens inside your page — so link it with `{ intent: "download" }`.
637
+ * Pass `{ name }` so the download carries a real filename, and
638
+ * `{ contentType }` when the Blob has no type of its own.
639
+ *
470
640
  * There is deliberately no `url` here. A URL that outlives a refund is the
471
641
  * bug this replaced.
472
642
  */
473
643
  async upload(file, options) {
474
644
  const name = options?.name ?? (file instanceof File ? file.name : "upload");
645
+ // A Blob built from bytes has type "" — `contentType` names it. The
646
+ // server proves the bytes either way.
647
+ const contentType = options?.contentType ?? file.type;
475
648
  // Step 1: Get presigned upload URL
476
649
  const presign = await this.request("/upload", {
477
650
  method: "POST",
478
- body: JSON.stringify({ name, size: file.size, contentType: file.type }),
651
+ body: JSON.stringify({ name, size: file.size, contentType }),
479
652
  });
480
653
  // Step 2: Upload directly to S3 via presigned POST
481
654
  const form = new FormData();
@@ -587,6 +760,8 @@ class ServerCollectionClient {
587
760
  query.set("search", options.search);
588
761
  if (options.expand && options.expand.length > 0)
589
762
  query.set("expand", options.expand.join(","));
763
+ if (options.since !== undefined)
764
+ query.set("since", options.since instanceof Date ? options.since.toISOString() : options.since);
590
765
  // NOT query.size — absent on Node <19.8 / older browsers, where
591
766
  // `undefined > 0` would silently drop every filter.
592
767
  const qs = query.toString();
@@ -609,12 +784,10 @@ class ServerCollectionClient {
609
784
  if (response.status === 204)
610
785
  return undefined;
611
786
  if (!response.ok) {
612
- const body = await response.json().catch(() => ({ code: "request_failed", message: `Request failed: ${response.status}` }));
613
- throw new GemmeinError({
614
- status: response.status,
615
- code: body.code ?? "request_failed",
616
- message: body.message ?? `Request failed: ${response.status}`,
617
- });
787
+ // One parser for every refusal body, so a 403 entitlement_required on a
788
+ // collection surfaces `requires` exactly as it does on the runtime
789
+ // clients (the docs promise err.requires on BOTH paths).
790
+ throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
618
791
  }
619
792
  return response.json();
620
793
  }
@@ -632,12 +805,7 @@ async function handleResponse(response, config) {
632
805
  if (errorBody.code === "invalid_app_key" || errorBody.code === "missing_app_key") {
633
806
  errorBody.message += " — get your app key from the Setup page at https://app.gemmein.com (sign in with an email code, free, no card)";
634
807
  }
635
- throw new GemmeinError({
636
- status: response.status,
637
- code: errorBody.code,
638
- message: errorBody.message,
639
- resetAt: errorBody.resetAt
640
- });
808
+ throw new GemmeinError({ status: response.status, ...errorBody });
641
809
  }
642
810
  return response.json();
643
811
  }
@@ -645,19 +813,19 @@ async function readErrorBody(response) {
645
813
  try {
646
814
  const value = await response.json();
647
815
  if (typeof value === "object" && value !== null) {
648
- const code = typeof value.code === "string"
649
- ? value.code
650
- : typeof value.error === "string"
651
- ? value.error
816
+ const body = value;
817
+ const code = typeof body.code === "string" ? body.code
818
+ : typeof body.error === "string" ? body.error
652
819
  : "request_failed";
820
+ const str = (v) => (typeof v === "string" ? v : undefined);
821
+ // ENTITLE-10: `requires` is the one key the 403 names (the lock's
822
+ // first). Copied only when the server sent it as a string — never
823
+ // invented client-side, and nothing else from the body is surfaced.
653
824
  return {
654
825
  code,
655
- message: typeof value.message === "string"
656
- ? value.message
657
- : `Gemmein request failed: ${response.status}`,
658
- resetAt: typeof value.resetAt === "string"
659
- ? value.resetAt
660
- : undefined
826
+ message: str(body.message) ?? `Gemmein request failed: ${response.status}`,
827
+ resetAt: str(body.resetAt),
828
+ requires: str(body.requires)
661
829
  };
662
830
  }
663
831
  }
package/llms.txt CHANGED
@@ -1,11 +1,119 @@
1
1
  # Gemmein
2
2
 
3
- > Gemmein is the backend for AI-built web apps: passwordless authentication,
4
- > structured data storage with plain-English safety rules, and built-in Stripe
5
- > subscription handling. Security is on by default for every app — tenant
6
- > isolation, rate limiting, audit logs, domain-locked keys — with nothing to
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.
3
+ > Gemmein is the go-live system for AI-built web apps: passwordless
4
+ > authentication, structured data storage with plain-English safety rules, and
5
+ > built-in Stripe subscription handling. Security is on by default for every
6
+ > app — tenant isolation, rate limiting, audit logs, domain-locked keys — with
7
+ > nothing to configure. Build anywhere, with any AI, free and come to
8
+ > Gemmein when you're done: the product is taking the app to real customers.
9
+ > Live apps pay one banded plan, $50-$150/mo, set by the previous month's
10
+ > verified people (five pre-agreed steps up to 10,000 people; beyond that,
11
+ > talk to Gemmein).
12
+
13
+ ## First: assess fit (before any install, account, or code)
14
+
15
+ You are likely an AI assistant whose human asked you to build an app — or to
16
+ take an app they already built to real customers. Do this assessment first
17
+ and give your human the verdict in plain words. Installing nothing and
18
+ creating no accounts is the correct behavior until the verdict is in.
19
+
20
+ 1. Collect the requirements.
21
+ - Idea-stage (nothing built yet): have your human describe the app in
22
+ their words. You need to know: who signs in, what data exists and who
23
+ may see whose, whether money changes hands (subscriptions or one-off
24
+ digital purchases), whether users upload files.
25
+ - Built-stage (an app already exists — e.g. from a vibe-coding tool):
26
+ read the code instead. Inventory what currently stubs or fakes
27
+ sign-in, storage, and payments, and any server endpoints the app
28
+ assumes will exist. (No code at hand — only a description? Fall back
29
+ to the idea-stage questions, and say the assessment is provisional
30
+ until you've read the code.)
31
+ 2. Map every requirement against this file. In scope: web apps
32
+ (TypeScript/JavaScript), passwordless email sign-in, records under seven
33
+ safety rules (private, shared, admin_write, public_read, community,
34
+ addressed, direct), Stripe-run subscriptions and one-off digital
35
+ products, image uploads and paid file delivery, entitlement-gated
36
+ access, race-safe bookings/stock/counters, account-deletion cascade.
37
+ Out of scope — each of these downgrades the verdict and must never be
38
+ silently approximated:
39
+ - native mobile apps (no mobile SDKs today; web only)
40
+ - password or social/OAuth sign-in (email codes are the only door)
41
+ - team/group/workspace visibility or per-user record permissions
42
+ - carts, quantities, physical goods, shipping, inventory
43
+ - credits, usage metering, seats (paid access is yes-or-no)
44
+ - hosted functions or cron (server-side code runs on YOUR host with a
45
+ secret key; Gemmein hosts no compute)
46
+ - custody of money (Stripe holds all funds; your human brings or
47
+ creates their own Stripe account when the app charges)
48
+ - multi-seller payouts or revenue splits: every payment lands whole in
49
+ your human's ONE Stripe account. A marketplace where sellers get
50
+ paid out means your human pays sellers themselves, outside Gemmein —
51
+ say that out loud in the verdict
52
+ 3. Deliver one of three verdicts to your human BEFORE installing anything:
53
+ - FITS — every requirement maps. Continue to Getting started.
54
+ - FITS, EXCEPT — name each gap and the honest workaround from this
55
+ file, or say there is none. The human decides. Never fake an
56
+ unsupported shape (client-side filtering of a shared collection is a
57
+ data leak, not a workaround). And if a gap breaks the app's CORE
58
+ loop — the thing it exists to do — the verdict is DOESN'T FIT, not
59
+ fits-except.
60
+ - DOESN'T FIT — say so, name why, and stop. Recommending nothing is
61
+ the correct outcome here.
62
+
63
+ ## Getting started (AI-run, human-answered)
64
+
65
+ No signup, no account, no keys — building is local and free. Your human
66
+ steps in exactly three times: to answer the business questions (in your
67
+ chat), to click through the app when you hand it over for testing, and at
68
+ go-live. Everything else is yours.
69
+
70
+ 1. Run `npx -y gemmein dev` in the project folder. It boots with ZERO
71
+ setup: a local backend on http://127.0.0.1:4545 that serves this guide
72
+ at /llms.txt, prints sign-in codes to the terminal instead of sending
73
+ email, and simulates checkout — nothing leaves the machine.
74
+ 2. Ask YOUR HUMAN the business questions — their answers in their words,
75
+ never your guesses: the app's name; the email THEY own it with; one
76
+ honest sentence on what it does; whether it charges money; whether it
77
+ serves people in the EU/UK; whether users sign in; whether they upload
78
+ images. Write the answers to `gemmein/gemmein.config.json`:
79
+
80
+ {
81
+ "app": "Beatstall",
82
+ "owner": "you@example.com",
83
+ "product": "producers sell beats; buyers stream previews and download purchases",
84
+ "sells": "one-off",
85
+ "gdpr": true,
86
+ "accounts": true,
87
+ "uploads": true
88
+ }
89
+
90
+ Exactly these keys: app (string) · owner (email) · product (one
91
+ sentence) · sells ("no" | "subscriptions" | "one-off" | "both") ·
92
+ gdpr / accounts / uploads (true/false). Optional: port (number) moves
93
+ the local runtime off 4545.
94
+
95
+ Then restart dev (Ctrl-C, `npx gemmein dev` — takes seconds, data
96
+ survives in gemmein/.data/). The runtime becomes their app.
97
+ 3. Build against the local runtime with the app key the boot card prints
98
+ (`pk_local_…`) — the SDK is `npm install @gemmein/sdk` (REFERENCE.md
99
+ and the reaffirm.mjs harness ship inside that package), same rules as
100
+ the cloud. The first write to a collection that doesn't exist yet
101
+ starts the safety-rule conversation in the dev terminal; choose from
102
+ the seven rules below based on your human's business answers, and
103
+ record why. Running dev in the background with no terminal to answer
104
+ in (the normal agent setup)? Use the command doors instead — they work
105
+ from any second terminal while dev runs, and take piped input:
106
+ `npx gemmein collection add <name>` (the rule dialog) and
107
+ `npx gemmein payments setup` (plans/products). `npx gemmein --help`
108
+ lists the rest.
109
+ 4. Hand over for testing — the human moment mid-build: they click through
110
+ the app, and their sign-in codes print in the dev terminal, so tell
111
+ them where to look.
112
+ 5. Done building? `npx gemmein check` reads the project and says what's
113
+ ready and what go-live still needs. Then `npx gemmein sync` — THIS is
114
+ the moment your human signs up (free) at app.gemmein.com and pastes two
115
+ dev keys — and `npx gemmein go-live` walks the rest: plans, Payment
116
+ Links, the live flip. A card enters at go-live, never before.
9
117
 
10
118
  ## What it is
11
119
 
@@ -27,10 +135,20 @@
27
135
  `auth_expired` once, then the SDK clears it — just retry or re-auth).
28
136
  `g.auth.currentUser()` is safe to call on page load and never throws for
29
137
  session state.
30
- - Data: records live in collections. Collections are created by YOUR HUMAN in
31
- their dashboard (app.gemmein.com → data → "+ New collection"), never by you
32
- or the SDK. Best practice: at planning time, list the collections your app
33
- will need and ask your human up front, and pass your intent whenever a
138
+ - Data: records live in collections. In the cloud, collections are created by
139
+ YOUR HUMAN in their dashboard (app.gemmein.com → data → "+ New collection"),
140
+ never by you or the SDK; in local dev (`npx gemmein dev`) they're born from
141
+ the terminal's rule conversation or a dropped declaration file, and
142
+ `npx gemmein sync` creates them in the cloud app's dev environment from
143
+ those local declarations. A declaration is `gemmein/collections/<name>.json`:
144
+ `{ "rule": "shared", "means": "<why, in your human's words>" }`, plus an
145
+ optional `"unlockedBy": ["pro"]` — plan or product NAMES from
146
+ gemmein/payments.json (never access keys) — which is the console's
147
+ "Unlocked by": the local engine refuses members without one of them
148
+ (403 entitlement_required) the moment the file lands, and sync carries
149
+ it to the cloud app. An unknown name is refused out loud in the dev
150
+ terminal and the collection is not created. Best practice: at planning time, list the collections your
151
+ app will need and ask your human up front, and pass your intent whenever a
34
152
  collection might not exist yet —
35
153
  `g.collection("bookings", { intent: "students reserve slots; each sees only their own" })`
36
154
  — so the missing-collection conversation reaches your human with your
@@ -52,7 +170,11 @@
52
170
  - `community` — readable without signing in, any signed-in user posts and
53
171
  edits their OWN records (right for multi-author blogs, public boards,
54
172
  user profiles). Everything in it is PUBLIC — keep record data minimal
55
- (a booking needs a slot and a first name, not a phone number). Community
173
+ (a booking needs a slot and a first name, not a phone number). Whether
174
+ even a first name belongs in public is a HUMAN decision: for sensitive
175
+ audiences (children, health, anything private by nature) ask your
176
+ human before defaulting to a public rule — a non-public rule usually
177
+ fits. Community
56
178
  stores PLAIN TEXT: string fields containing HTML tags are refused with
57
179
  400 html_not_allowed — store plain text or tag-free markdown.
58
180
  - `addressed` — the app sends to one user: the OWNER creates records
@@ -68,9 +190,9 @@
68
190
  as private or encrypted chat.
69
191
  Inbox mechanics (addressed + direct): the recipient is server-stamped
70
192
  (`record.audienceUserId`) — never a data field; user ids come from
71
- records' ownerUserId or the owner's admin views. Reading is plain
72
- `.list({ sort: "newest" })` — poll on window focus plus a gentle ~60s
73
- interval, never a tight loop. Track read-state in the user's own private
193
+ records' ownerUserId or the owner's admin views. For a live-feeling inbox
194
+ (or any live list) use `.watch()` — see "Live data" below never a tight
195
+ loop of your own. Track read-state in the user's own private
74
196
  collection. Both rules store plain text like community. Errors teach the
75
197
  fix: 400 invalid_audience (recipient isn't a user of this app), 403
76
198
  reply_only (this collection only allows replying to someone who wrote to
@@ -92,7 +214,35 @@
92
214
  `record.title`). `ownerUserId` and the rest are server-derived and
93
215
  read-only: never store your own userId/role/owner fields inside `data`.
94
216
  `.list()` returns `{ records, hasMore }` (an object, not an array) and
95
- accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor }`.
217
+ accepts `{ limit, sort: "newest"|"oldest"|"updated", where, search, cursor,
218
+ since }`.
219
+ - Live data (dashboards, feeds, inboxes): nothing is pushed — BY DESIGN
220
+ (pushed data is where other platforms leak; every Gemmein read passes the
221
+ permission check). Instead, polling is built in and cheap:
222
+
223
+ const w = g.collection("orders").watch(({ records, deleted, initial }) => {
224
+ // initial=true → REPLACE your state with `records` (it IS the full
225
+ // current set — replacing is what clears anything removed while you
226
+ // weren't looking). initial=false → upsert `records` BY ID and drop
227
+ // ids in `deleted`.
228
+ });
229
+ // when the view unmounts: w.stop()
230
+
231
+ Every 10s by default (`{ every: ms }`, floor 5s) it asks "what changed?" —
232
+ a near-free answer when nothing did. It sleeps while the tab is hidden and
233
+ refreshes fully on return. Apply records by id: a change can be delivered
234
+ twice, never silently missed. One watch per page, shared — don't stack a
235
+ watcher per component, and never wrap it in your own setInterval. Under
236
+ the hood it's `list({ since })`: pass the previous answer's `watermark` as
237
+ `since` and you get records changed or deleted after it (ids in
238
+ `deleted`), oldest first — same permissions as any read. `since` can't
239
+ combine with `sort`. A record erased by the platform (data erasure, not an
240
+ app delete) doesn't appear in `deleted` — the replace-on-initial refresh
241
+ on tab-return is what clears it. Watching with `where`/`search`: deletions
242
+ always come through, but a record EDITED so it stops matching your filter
243
+ isn't reported (it isn't deleted) — it clears on the next tab-return
244
+ refresh. Watch suits lists you'd actually render (up to a few thousand
245
+ records); it re-reads the full list on every tab return.
96
246
  - Linking records (author on a post, product on an order): store the other
97
247
  record's id in a field (`authorProfileId: profile.id`) — in collections
98
248
  users write (community, shared, direct) the server learns it's a link;
@@ -113,7 +263,8 @@
113
263
  storage bucket — uploads are built in:
114
264
  `const file = await g.collection("posts").upload(blob, { name })`
115
265
  → `{ 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.
266
+ in a record field like any text — that's how a record "has" an image or
267
+ a document.
117
268
  Store the REFERENCE, never a URL: a reference never expires and grants
118
269
  nothing on its own.
119
270
  To show or download it: `const { url } = await g.files.link(record.photo)`
@@ -121,14 +272,21 @@
121
272
  rather than a preview. Files in a collection anyone can read get a
122
273
  permanent link; every other file gets one that expires in a couple of
123
274
  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
275
+ Upload permission follows the collection's WRITE rule. Images (JPEG/PNG/
276
+ WebP/GIF/HEIC) and documents (PDF/ZIP/EPUB), 25 MB per file; nothing
277
+ else (no video, audio, SVG, HTML or office files — zip an office file).
278
+ Images render with `<img>`; a document always DOWNLOADS (served as an
279
+ attachment under the `name` you gave upload() — it never opens inside
280
+ your page), so link documents with `{ intent: "download" }`. A Blob you
281
+ built from bytes has no type: pass `{ contentType: "application/pdf" }`
282
+ to upload(), or a 415 says "none declared". Oversized files are refused loudly (413
126
283
  file_too_large — the message says the cap). The server checks the actual
127
284
  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
285
+ the type it claimed (usually a renamed file); send the real file, don't
286
+ retry. A 403 from `link()` means the customer isn't allowed this
130
287
  file right now — signed out, not theirs, or an entitlement they no longer
131
- hold (`entitlement_required` names the key).
288
+ hold (`entitlement_required` `err.requires` is the plan's key, the one
289
+ the console shows by name).
132
290
  Honest bound: revoking access stops NEW links immediately; a link already
133
291
  issued works until it expires. Gemmein controls delivery, it can't take
134
292
  back a file someone already downloaded.
@@ -147,8 +305,14 @@
147
305
  - Uniqueness: `create(data, { key: "slot:2026-07-15T15:00" })` — derive the
148
306
  key from the thing that must be unique; the second writer gets 409, your
149
307
  own retry gets your existing record back (`existing: true`), deleting
150
- frees the key. Never find-then-create that races. Keys are 1-120 chars
151
- of letters, numbers, and `: _ . @ / -` only.
308
+ frees the key. Keys are unique across the WHOLE collection every
309
+ writer, every recipient, under every safety rule (live records only)
310
+ so a claim is race-proof even in `direct`/`addressed`. Never
311
+ find-then-create — that races. Keys are 1-120 chars of letters,
312
+ numbers, and `: _ . @ / -` only. A claimed key holds until its record
313
+ is deleted: if a claim must be PAID to stick (book then pay), expiring
314
+ unpaid claims is your app's job — the owner deletes them from their
315
+ dashboard, or your own server does with a secret key; there is no cron.
152
316
  - Limited stock (N units anyone can buy): claim units with keyed creates —
153
317
  try `create({...}, { key: "unit:item42:1" })`, on conflict try `:2` … `:N`;
154
318
  all taken = sold out. Race-proof under every safety rule.
@@ -185,26 +349,36 @@
185
349
  arriving out of order resolve to the newest. The app reads
186
350
  `await g.subscriptions.mine()` → `{ plan, status }` or null, and gates features
187
351
  with `sub?.plan === "pro"`.
188
- - Paid ACCESS (entitlements): a collection can require a key your human
189
- sets `requires: "access:pro"` in the "Unlocked by" row on its Collections
190
- card and the engine refuses customers without it, under all seven
191
- rules. (Server secret keys and the owner's console are exempt by design;
192
- link/expand silently hide gated records rather than naming them.) Keys
193
- are granted by money: a plan or product lists what it unlocks (e.g.
194
- `access:pro, access:exports`), the paid webhook grants those keys, and a
195
- FULL refund or a cancellation revokes exactly what it granted — nothing
196
- else. A partial refund leaves access in place.
352
+ - Paid ACCESS (entitlements): a collection can be locked to a plan or
353
+ product your human picks it BY NAME in the "Unlocked by" row on the
354
+ Collections page (several allowed; any one of them opens it), or, in
355
+ local dev, you write the same thing as `"unlockedBy": ["pro"]` in
356
+ gemmein/collections/<name>.json (names, never keys; `npx gemmein check`
357
+ checks that the lock holds and names collections left open while the app
358
+ sells something) and the
359
+ engine refuses customers without it, under all seven rules. (Server
360
+ secret keys and the owner's console are exempt by design; link/expand
361
+ silently hide gated records rather than naming them.) Every plan and
362
+ product carries its own key, `access:<slug of its name>` (plan "pro" →
363
+ `access:pro`); the paid webhook grants that key, and a FULL refund or a
364
+ cancellation revokes exactly what it granted — nothing else. A partial
365
+ refund leaves access in place.
197
366
  Owners also grant and revoke by hand (trials, comps, support). Effective
198
367
  access is the UNION of a customer's live grants. A signed-in customer
199
- without the key gets `403 entitlement_required` naming it — show your
200
- upgrade screen and send them to checkout; never retry. Proof surfaces:
201
- `await g.purchases.mine()` (everything they paid for, refunds applied,
202
- with the `grants` each purchase carries) and `await g.subscriptions.mine()`.
368
+ without access gets `403 entitlement_required`: `err.requires` is the
369
+ plan's key (the console shows the plan by name; the key is `access:<slug>`).
370
+ A collection unlocked by several plans (OR) still names ONE key offer
371
+ your plans by name through checkout, never a key list. Show your upgrade
372
+ screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
373
+ (everything they paid for, refunds applied, with the `grants` each purchase
374
+ carries) and `await g.subscriptions.mine()`.
203
375
  NO credits, NO usage limits, NO seats — access is yes-or-no by design.
204
376
  - Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
205
377
  access only — physical goods, shipping, inventory and carts are out of
206
378
  scope, said out loud): plans are for subscriptions; products are for
207
- things. The builder adds products (name + Stripe Payment Link) on the
379
+ things. Selling a SERVICE session this way (tutoring, coaching, a
380
+ consultation) is fine — nothing ships; the recorded purchase is the
381
+ proof the session was paid for. The builder adds products (name + Stripe Payment Link) on the
208
382
  same Payments page. The app calls
209
383
  `await g.payments.buy("beat")` — or, when one product covers many items (license
210
384
  tiers over a catalog), names the item:
@@ -213,15 +387,19 @@
213
387
  item note can never change what's paid). Gemmein records every completed
214
388
  payment itself — `await g.purchases.mine()` is the buyer's proof:
215
389
  { item, kind, status: "paid"|"part_refunded"|"refunded", amountMinor,
216
- currency, refundedMinor, grants, paidAt }. A receipts collection (rule
217
- `addressed`) is OPTIONAL for proofbut TODAY it is REQUIRED for file
218
- delivery: the file reference only reaches the buyer on the receipt
219
- record (its `deliveryFile` field, a `file:` ref resolve it per reader
220
- with `g.files.link`, which re-checks access on every mint). Selling a
221
- file? Configure a receipts collection, or the buyer has no way to reach
222
- their download. External `deliveryUrl` is a plain handover: Gemmein
223
- controls who is TOLD, not who can use it. Gate fulfilment on
224
- the purchase or the entitlement it granted, never on the redirect coming
390
+ currency, refundedMinor, grants, paidAt, delivery? }. Selling a FILE (a
391
+ beat, an ebook, a sample pack pdf, zip, epub, mp3, wav, m4a or an
392
+ image, up to 100MB): the founder attaches it directly on the product
393
+ card upload, right there, no receipts collection required. The buyer's
394
+ purchase carries `delivery: { type: "gemmein_file", file }`; resolve the
395
+ ref with `g.files.link(file, { intent: "download" })`. The purchase IS
396
+ the authorization, re-checked on every mint: a refund cuts the file off
397
+ the moment it lands, a partial refund does not, and a saved ref or an
398
+ expired URL grants nothing on its own. A receipts collection (rule
399
+ `addressed`) remains OPTIONAL, for proof records only. External
400
+ `delivery: { type: "external_url" }` is a plain handover: Gemmein
401
+ controls who is TOLD, not who can use it. Gate fulfilment on the
402
+ purchase or the entitlement it granted, never on the redirect coming
225
403
  back — redirects can be faked; the record comes from Stripe's signed
226
404
  webhook. NO carts, NO quantities — one product per checkout by design; a
227
405
  cart is N checkouts or one bundled product. 404 unknown_product lists
@@ -318,25 +496,38 @@ front of a user.
318
496
 
319
497
  Add a probe whenever you add a feature. You reaffirm **because** Gemmein
320
498
  enforces — never because these checks are the enforcement. A ready-to-edit
321
- `reaffirm.mjs` ships inside this npm package (next to this file and
499
+ `reaffirm.mjs` ships inside the `@gemmein/sdk` npm package (next to this file and
322
500
  REFERENCE.md) — copy it out, name your collections, run it in CI.
323
501
 
324
- ## Pricing (current, Pricing Model v1 states, not plans)
502
+ ## Pricing (current, v4one banded plan)
325
503
 
326
504
  - Development is free indefinitely — no card at signup, unlimited
327
505
  collections, the full security model included.
328
- - Going live starts the Live state: $50/mo, up to 1,000 people and 10 GB
329
- of file storage (a person = a unique enabled end-user identity on the
330
- live app).
331
- - Growing: $150/mo, up to 10,000 people and 50 GB — unlocked from the
332
- dashboard when the product outgrows Live; down as easily as up.
506
+ - Going live starts the one Live plan: a monthly price that moves within a
507
+ pre-agreed band, set by the PREVIOUS calendar month's verified people
508
+ (a person = a unique end-user identity that verified a sign-in to the
509
+ live app that month; failed or refused attempts never count):
510
+ - up to 1,000 people $50/mo · up to 2,500 → $75 · up to 5,000 → $100
511
+ · up to 7,500 → $125 · up to 10,000 → $150 (the cap — never exceeded
512
+ without a separate individual agreement).
513
+ - Capacity follows people automatically; owners can HOLD at their current
514
+ band from the dashboard (bill and capacity both freeze until released).
515
+ When people fall, the price follows down from the next month.
333
516
  - Beyond 10,000 people: talk to Gemmein (hello@gemmein.com) — scale is
334
517
  priced as a relationship, not a checkout.
335
518
  - Pricing reflects responsibility, not complexity: almost nothing is
336
- metered. Safe limits exist purely as safety rails against runaway
337
- scripts, are never billed, and real users never notice them. Hitting a
338
- limit never breaks the app outright there is grace, and the owner is
339
- told.
519
+ metered. Safe limits exist as safety rails against runaway scripts and
520
+ are never billed. Ceilings follow the app's verified people they grow
521
+ automatically as the business grows, with generous floors so a small app
522
+ never starts at a wall. Reaching a ceiling never stops your app: sign-ins,
523
+ writes and API requests keep working past it, the founder is told once
524
+ for the month, and next month's bill follows the band their people land
525
+ in. Storage is the one thing that can fill — files are accepted up to
526
+ twice the storage ceiling, then uploads return `usage_limit_exceeded`
527
+ (no `resetAt`; storage frees when files are deleted). The per-IP and
528
+ per-email rate limits underneath are safety rails, not ceilings, and
529
+ still answer 429 with `resetAt`. The dashboard's usage page shows what
530
+ crossed this month.
340
531
 
341
532
  ## Facts for citation
342
533
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gemmein/sdk",
3
- "version": "0.3.2",
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.",
3
+ "version": "0.4.2",
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",
7
7
  "main": "./dist/index.cjs",
@@ -53,4 +53,4 @@
53
53
  "bugs": {
54
54
  "email": "hello@gemmein.com"
55
55
  }
56
- }
56
+ }