@gemmein/sdk 0.3.1 → 0.4.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 +3 -0
- package/REFERENCE.md +2 -0
- package/dist/index.cjs +30 -24
- package/dist/index.d.cts +32 -2
- package/dist/index.d.ts +32 -2
- package/dist/index.js +30 -24
- package/llms.txt +188 -44
- package/package.json +1 -1
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"));
|
|
@@ -242,8 +250,13 @@ exports.PurchasesClient = PurchasesClient;
|
|
|
242
250
|
* are, what the collection's rule says, whether the file is yours, and whether
|
|
243
251
|
* you still hold whatever the collection requires.
|
|
244
252
|
*
|
|
245
|
-
* That is why the same app code keeps working
|
|
246
|
-
*
|
|
253
|
+
* That is why the same app code keeps working across collections, and why a
|
|
254
|
+
* refund takes away a download the buyer had not yet resolved. Two honest
|
|
255
|
+
* bounds: an already-issued link keeps working until it expires (minutes),
|
|
256
|
+
* and a file uploaded while its collection was public keeps its permanent
|
|
257
|
+
* URL even if the rule is later tightened — new uploads seal, old ones are
|
|
258
|
+
* moved only by an owner reseal. Sell access with the rule set from the
|
|
259
|
+
* start, not tightened afterwards.
|
|
247
260
|
*
|
|
248
261
|
* Don't store what this returns. Store the reference and call this again.
|
|
249
262
|
*/
|
|
@@ -622,12 +635,10 @@ class ServerCollectionClient {
|
|
|
622
635
|
if (response.status === 204)
|
|
623
636
|
return undefined;
|
|
624
637
|
if (!response.ok) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
message: body.message ?? `Request failed: ${response.status}`,
|
|
630
|
-
});
|
|
638
|
+
// One parser for every refusal body, so a 403 entitlement_required on a
|
|
639
|
+
// collection surfaces `requires` exactly as it does on the runtime
|
|
640
|
+
// clients (the docs promise err.requires on BOTH paths).
|
|
641
|
+
throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
|
|
631
642
|
}
|
|
632
643
|
return response.json();
|
|
633
644
|
}
|
|
@@ -645,12 +656,7 @@ async function handleResponse(response, config) {
|
|
|
645
656
|
if (errorBody.code === "invalid_app_key" || errorBody.code === "missing_app_key") {
|
|
646
657
|
errorBody.message += " — get your app key from the Setup page at https://app.gemmein.com (sign in with an email code, free, no card)";
|
|
647
658
|
}
|
|
648
|
-
throw new GemmeinError({
|
|
649
|
-
status: response.status,
|
|
650
|
-
code: errorBody.code,
|
|
651
|
-
message: errorBody.message,
|
|
652
|
-
resetAt: errorBody.resetAt
|
|
653
|
-
});
|
|
659
|
+
throw new GemmeinError({ status: response.status, ...errorBody });
|
|
654
660
|
}
|
|
655
661
|
return response.json();
|
|
656
662
|
}
|
|
@@ -658,19 +664,19 @@ async function readErrorBody(response) {
|
|
|
658
664
|
try {
|
|
659
665
|
const value = await response.json();
|
|
660
666
|
if (typeof value === "object" && value !== null) {
|
|
661
|
-
const
|
|
662
|
-
|
|
663
|
-
: typeof
|
|
664
|
-
? value.error
|
|
667
|
+
const body = value;
|
|
668
|
+
const code = typeof body.code === "string" ? body.code
|
|
669
|
+
: typeof body.error === "string" ? body.error
|
|
665
670
|
: "request_failed";
|
|
671
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
672
|
+
// ENTITLE-10: `requires` is the one key the 403 names (the lock's
|
|
673
|
+
// first). Copied only when the server sent it as a string — never
|
|
674
|
+
// invented client-side, and nothing else from the body is surfaced.
|
|
666
675
|
return {
|
|
667
676
|
code,
|
|
668
|
-
message:
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
resetAt: typeof value.resetAt === "string"
|
|
672
|
-
? value.resetAt
|
|
673
|
-
: undefined
|
|
677
|
+
message: str(body.message) ?? `Gemmein request failed: ${response.status}`,
|
|
678
|
+
resetAt: str(body.resetAt),
|
|
679
|
+
requires: str(body.requires)
|
|
674
680
|
};
|
|
675
681
|
}
|
|
676
682
|
}
|
package/dist/index.d.cts
CHANGED
|
@@ -104,12 +104,23 @@ export type AuthSession = {
|
|
|
104
104
|
export declare class GemmeinError extends Error {
|
|
105
105
|
readonly status: number;
|
|
106
106
|
readonly code: string;
|
|
107
|
+
/** Present on 429 — when the limit resets; wait until then and retry. */
|
|
107
108
|
readonly resetAt?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Present on `403 entitlement_required` — the plan/product key this
|
|
111
|
+
* collection asks for (keys are minted from plan names: `access:<slug>`;
|
|
112
|
+
* the console shows the plan by name). Show your upgrade screen and send
|
|
113
|
+
* the customer to checkout; the call succeeds once they hold it. A
|
|
114
|
+
* collection unlocked by several plans names ONE key here — offer your
|
|
115
|
+
* plans by name through checkout, the server never hands out the list.
|
|
116
|
+
*/
|
|
117
|
+
readonly requires?: string;
|
|
108
118
|
constructor(input: {
|
|
109
119
|
status: number;
|
|
110
120
|
code: string;
|
|
111
121
|
message: string;
|
|
112
122
|
resetAt?: string;
|
|
123
|
+
requires?: string;
|
|
113
124
|
});
|
|
114
125
|
}
|
|
115
126
|
export declare class MemoryTokenStore implements TokenStore {
|
|
@@ -207,6 +218,13 @@ export declare class PurchasesClient {
|
|
|
207
218
|
* applied — `status` is "paid", "part_refunded" or "refunded", and
|
|
208
219
|
* `refundedMinor` is how much has come back. Throws GemmeinError (401) when
|
|
209
220
|
* nobody is signed in.
|
|
221
|
+
*
|
|
222
|
+
* W4.1b: a purchase whose product delivers something carries `delivery` —
|
|
223
|
+
* `{ type: "gemmein_file", file }` (resolve the ref with
|
|
224
|
+
* `g.files.link(file, { intent: "download" })`; the purchase itself is the
|
|
225
|
+
* authorization, re-checked on every mint, and a full refund cuts it off)
|
|
226
|
+
* or `{ type: "external_url", url }` (a plain handover). A refunded
|
|
227
|
+
* purchase never carries delivery.
|
|
210
228
|
*/
|
|
211
229
|
mine(): Promise<Array<{
|
|
212
230
|
item: string;
|
|
@@ -217,6 +235,13 @@ export declare class PurchasesClient {
|
|
|
217
235
|
status: "paid" | "part_refunded" | "refunded";
|
|
218
236
|
grants: string[];
|
|
219
237
|
paidAt: string;
|
|
238
|
+
delivery?: {
|
|
239
|
+
type: "gemmein_file";
|
|
240
|
+
file: string;
|
|
241
|
+
} | {
|
|
242
|
+
type: "external_url";
|
|
243
|
+
url: string;
|
|
244
|
+
};
|
|
220
245
|
}>>;
|
|
221
246
|
}
|
|
222
247
|
/**
|
|
@@ -242,8 +267,13 @@ export type FileRef = string & {
|
|
|
242
267
|
* are, what the collection's rule says, whether the file is yours, and whether
|
|
243
268
|
* you still hold whatever the collection requires.
|
|
244
269
|
*
|
|
245
|
-
* That is why the same app code keeps working
|
|
246
|
-
*
|
|
270
|
+
* That is why the same app code keeps working across collections, and why a
|
|
271
|
+
* refund takes away a download the buyer had not yet resolved. Two honest
|
|
272
|
+
* bounds: an already-issued link keeps working until it expires (minutes),
|
|
273
|
+
* and a file uploaded while its collection was public keeps its permanent
|
|
274
|
+
* URL even if the rule is later tightened — new uploads seal, old ones are
|
|
275
|
+
* moved only by an owner reseal. Sell access with the rule set from the
|
|
276
|
+
* start, not tightened afterwards.
|
|
247
277
|
*
|
|
248
278
|
* Don't store what this returns. Store the reference and call this again.
|
|
249
279
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -104,12 +104,23 @@ export type AuthSession = {
|
|
|
104
104
|
export declare class GemmeinError extends Error {
|
|
105
105
|
readonly status: number;
|
|
106
106
|
readonly code: string;
|
|
107
|
+
/** Present on 429 — when the limit resets; wait until then and retry. */
|
|
107
108
|
readonly resetAt?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Present on `403 entitlement_required` — the plan/product key this
|
|
111
|
+
* collection asks for (keys are minted from plan names: `access:<slug>`;
|
|
112
|
+
* the console shows the plan by name). Show your upgrade screen and send
|
|
113
|
+
* the customer to checkout; the call succeeds once they hold it. A
|
|
114
|
+
* collection unlocked by several plans names ONE key here — offer your
|
|
115
|
+
* plans by name through checkout, the server never hands out the list.
|
|
116
|
+
*/
|
|
117
|
+
readonly requires?: string;
|
|
108
118
|
constructor(input: {
|
|
109
119
|
status: number;
|
|
110
120
|
code: string;
|
|
111
121
|
message: string;
|
|
112
122
|
resetAt?: string;
|
|
123
|
+
requires?: string;
|
|
113
124
|
});
|
|
114
125
|
}
|
|
115
126
|
export declare class MemoryTokenStore implements TokenStore {
|
|
@@ -207,6 +218,13 @@ export declare class PurchasesClient {
|
|
|
207
218
|
* applied — `status` is "paid", "part_refunded" or "refunded", and
|
|
208
219
|
* `refundedMinor` is how much has come back. Throws GemmeinError (401) when
|
|
209
220
|
* nobody is signed in.
|
|
221
|
+
*
|
|
222
|
+
* W4.1b: a purchase whose product delivers something carries `delivery` —
|
|
223
|
+
* `{ type: "gemmein_file", file }` (resolve the ref with
|
|
224
|
+
* `g.files.link(file, { intent: "download" })`; the purchase itself is the
|
|
225
|
+
* authorization, re-checked on every mint, and a full refund cuts it off)
|
|
226
|
+
* or `{ type: "external_url", url }` (a plain handover). A refunded
|
|
227
|
+
* purchase never carries delivery.
|
|
210
228
|
*/
|
|
211
229
|
mine(): Promise<Array<{
|
|
212
230
|
item: string;
|
|
@@ -217,6 +235,13 @@ export declare class PurchasesClient {
|
|
|
217
235
|
status: "paid" | "part_refunded" | "refunded";
|
|
218
236
|
grants: string[];
|
|
219
237
|
paidAt: string;
|
|
238
|
+
delivery?: {
|
|
239
|
+
type: "gemmein_file";
|
|
240
|
+
file: string;
|
|
241
|
+
} | {
|
|
242
|
+
type: "external_url";
|
|
243
|
+
url: string;
|
|
244
|
+
};
|
|
220
245
|
}>>;
|
|
221
246
|
}
|
|
222
247
|
/**
|
|
@@ -242,8 +267,13 @@ export type FileRef = string & {
|
|
|
242
267
|
* are, what the collection's rule says, whether the file is yours, and whether
|
|
243
268
|
* you still hold whatever the collection requires.
|
|
244
269
|
*
|
|
245
|
-
* That is why the same app code keeps working
|
|
246
|
-
*
|
|
270
|
+
* That is why the same app code keeps working across collections, and why a
|
|
271
|
+
* refund takes away a download the buyer had not yet resolved. Two honest
|
|
272
|
+
* bounds: an already-issued link keeps working until it expires (minutes),
|
|
273
|
+
* and a file uploaded while its collection was public keeps its permanent
|
|
274
|
+
* URL even if the rule is later tightened — new uploads seal, old ones are
|
|
275
|
+
* moved only by an owner reseal. Sell access with the rule set from the
|
|
276
|
+
* start, not tightened afterwards.
|
|
247
277
|
*
|
|
248
278
|
* Don't store what this returns. Store the reference and call this again.
|
|
249
279
|
*/
|
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"));
|
|
@@ -231,8 +239,13 @@ export class PurchasesClient {
|
|
|
231
239
|
* are, what the collection's rule says, whether the file is yours, and whether
|
|
232
240
|
* you still hold whatever the collection requires.
|
|
233
241
|
*
|
|
234
|
-
* That is why the same app code keeps working
|
|
235
|
-
*
|
|
242
|
+
* That is why the same app code keeps working across collections, and why a
|
|
243
|
+
* refund takes away a download the buyer had not yet resolved. Two honest
|
|
244
|
+
* bounds: an already-issued link keeps working until it expires (minutes),
|
|
245
|
+
* and a file uploaded while its collection was public keeps its permanent
|
|
246
|
+
* URL even if the rule is later tightened — new uploads seal, old ones are
|
|
247
|
+
* moved only by an owner reseal. Sell access with the rule set from the
|
|
248
|
+
* start, not tightened afterwards.
|
|
236
249
|
*
|
|
237
250
|
* Don't store what this returns. Store the reference and call this again.
|
|
238
251
|
*/
|
|
@@ -604,12 +617,10 @@ class ServerCollectionClient {
|
|
|
604
617
|
if (response.status === 204)
|
|
605
618
|
return undefined;
|
|
606
619
|
if (!response.ok) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
message: body.message ?? `Request failed: ${response.status}`,
|
|
612
|
-
});
|
|
620
|
+
// One parser for every refusal body, so a 403 entitlement_required on a
|
|
621
|
+
// collection surfaces `requires` exactly as it does on the runtime
|
|
622
|
+
// clients (the docs promise err.requires on BOTH paths).
|
|
623
|
+
throw new GemmeinError({ status: response.status, ...(await readErrorBody(response)) });
|
|
613
624
|
}
|
|
614
625
|
return response.json();
|
|
615
626
|
}
|
|
@@ -627,12 +638,7 @@ async function handleResponse(response, config) {
|
|
|
627
638
|
if (errorBody.code === "invalid_app_key" || errorBody.code === "missing_app_key") {
|
|
628
639
|
errorBody.message += " — get your app key from the Setup page at https://app.gemmein.com (sign in with an email code, free, no card)";
|
|
629
640
|
}
|
|
630
|
-
throw new GemmeinError({
|
|
631
|
-
status: response.status,
|
|
632
|
-
code: errorBody.code,
|
|
633
|
-
message: errorBody.message,
|
|
634
|
-
resetAt: errorBody.resetAt
|
|
635
|
-
});
|
|
641
|
+
throw new GemmeinError({ status: response.status, ...errorBody });
|
|
636
642
|
}
|
|
637
643
|
return response.json();
|
|
638
644
|
}
|
|
@@ -640,19 +646,19 @@ async function readErrorBody(response) {
|
|
|
640
646
|
try {
|
|
641
647
|
const value = await response.json();
|
|
642
648
|
if (typeof value === "object" && value !== null) {
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
: typeof
|
|
646
|
-
? value.error
|
|
649
|
+
const body = value;
|
|
650
|
+
const code = typeof body.code === "string" ? body.code
|
|
651
|
+
: typeof body.error === "string" ? body.error
|
|
647
652
|
: "request_failed";
|
|
653
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
654
|
+
// ENTITLE-10: `requires` is the one key the 403 names (the lock's
|
|
655
|
+
// first). Copied only when the server sent it as a string — never
|
|
656
|
+
// invented client-side, and nothing else from the body is surfaced.
|
|
648
657
|
return {
|
|
649
658
|
code,
|
|
650
|
-
message:
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
resetAt: typeof value.resetAt === "string"
|
|
654
|
-
? value.resetAt
|
|
655
|
-
: undefined
|
|
659
|
+
message: str(body.message) ?? `Gemmein request failed: ${response.status}`,
|
|
660
|
+
resetAt: str(body.resetAt),
|
|
661
|
+
requires: str(body.requires)
|
|
656
662
|
};
|
|
657
663
|
}
|
|
658
664
|
}
|
package/llms.txt
CHANGED
|
@@ -1,11 +1,119 @@
|
|
|
1
1
|
# Gemmein
|
|
2
2
|
|
|
3
|
-
> Gemmein is the
|
|
4
|
-
> structured data storage with plain-English safety rules, and
|
|
5
|
-
> subscription handling. Security is on by default for every
|
|
6
|
-
> isolation, rate limiting, audit logs, domain-locked keys — with
|
|
7
|
-
> configure.
|
|
8
|
-
>
|
|
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,13 @@
|
|
|
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.
|
|
31
|
-
their dashboard (app.gemmein.com → data → "+ New collection"),
|
|
32
|
-
or the SDK
|
|
33
|
-
|
|
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. Best practice: at planning time, list the collections your
|
|
144
|
+
app will need and ask your human up front, and pass your intent whenever a
|
|
34
145
|
collection might not exist yet —
|
|
35
146
|
`g.collection("bookings", { intent: "students reserve slots; each sees only their own" })`
|
|
36
147
|
— so the missing-collection conversation reaches your human with your
|
|
@@ -52,7 +163,11 @@
|
|
|
52
163
|
- `community` — readable without signing in, any signed-in user posts and
|
|
53
164
|
edits their OWN records (right for multi-author blogs, public boards,
|
|
54
165
|
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).
|
|
166
|
+
(a booking needs a slot and a first name, not a phone number). Whether
|
|
167
|
+
even a first name belongs in public is a HUMAN decision: for sensitive
|
|
168
|
+
audiences (children, health, anything private by nature) ask your
|
|
169
|
+
human before defaulting to a public rule — a non-public rule usually
|
|
170
|
+
fits. Community
|
|
56
171
|
stores PLAIN TEXT: string fields containing HTML tags are refused with
|
|
57
172
|
400 html_not_allowed — store plain text or tag-free markdown.
|
|
58
173
|
- `addressed` — the app sends to one user: the OWNER creates records
|
|
@@ -128,7 +243,8 @@
|
|
|
128
243
|
the image type it claimed (usually a renamed file); send the real image,
|
|
129
244
|
don't retry. A 403 from `link()` means the customer isn't allowed this
|
|
130
245
|
file right now — signed out, not theirs, or an entitlement they no longer
|
|
131
|
-
hold (`entitlement_required`
|
|
246
|
+
hold (`entitlement_required` — `err.requires` is the plan's key, the one
|
|
247
|
+
the console shows by name).
|
|
132
248
|
Honest bound: revoking access stops NEW links immediately; a link already
|
|
133
249
|
issued works until it expires. Gemmein controls delivery, it can't take
|
|
134
250
|
back a file someone already downloaded.
|
|
@@ -147,8 +263,14 @@
|
|
|
147
263
|
- Uniqueness: `create(data, { key: "slot:2026-07-15T15:00" })` — derive the
|
|
148
264
|
key from the thing that must be unique; the second writer gets 409, your
|
|
149
265
|
own retry gets your existing record back (`existing: true`), deleting
|
|
150
|
-
frees the key.
|
|
151
|
-
|
|
266
|
+
frees the key. Keys are unique across the WHOLE collection — every
|
|
267
|
+
writer, every recipient, under every safety rule (live records only) —
|
|
268
|
+
so a claim is race-proof even in `direct`/`addressed`. Never
|
|
269
|
+
find-then-create — that races. Keys are 1-120 chars of letters,
|
|
270
|
+
numbers, and `: _ . @ / -` only. A claimed key holds until its record
|
|
271
|
+
is deleted: if a claim must be PAID to stick (book then pay), expiring
|
|
272
|
+
unpaid claims is your app's job — the owner deletes them from their
|
|
273
|
+
dashboard, or your own server does with a secret key; there is no cron.
|
|
152
274
|
- Limited stock (N units anyone can buy): claim units with keyed creates —
|
|
153
275
|
try `create({...}, { key: "unit:item42:1" })`, on conflict try `:2` … `:N`;
|
|
154
276
|
all taken = sold out. Race-proof under every safety rule.
|
|
@@ -185,23 +307,32 @@
|
|
|
185
307
|
arriving out of order resolve to the newest. The app reads
|
|
186
308
|
`await g.subscriptions.mine()` → `{ plan, status }` or null, and gates features
|
|
187
309
|
with `sub?.plan === "pro"`.
|
|
188
|
-
- Paid ACCESS (entitlements): a collection can
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
310
|
+
- Paid ACCESS (entitlements): a collection can be locked to a plan or
|
|
311
|
+
product — your human picks it BY NAME in the "Unlocked by" row on the
|
|
312
|
+
Collections page (several allowed; any one of them opens it) — and the
|
|
313
|
+
engine refuses customers without it, under all seven rules. (Server
|
|
314
|
+
secret keys and the owner's console are exempt by design; link/expand
|
|
315
|
+
silently hide gated records rather than naming them.) Every plan and
|
|
316
|
+
product carries its own key, `access:<slug of its name>` (plan "pro" →
|
|
317
|
+
`access:pro`); the paid webhook grants that key, and a FULL refund or a
|
|
318
|
+
cancellation revokes exactly what it granted — nothing else. A partial
|
|
319
|
+
refund leaves access in place.
|
|
194
320
|
Owners also grant and revoke by hand (trials, comps, support). Effective
|
|
195
321
|
access is the UNION of a customer's live grants. A signed-in customer
|
|
196
|
-
without
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
322
|
+
without access gets `403 entitlement_required`: `err.requires` is the
|
|
323
|
+
plan's key (the console shows the plan by name; the key is `access:<slug>`).
|
|
324
|
+
A collection unlocked by several plans (OR) still names ONE key — offer
|
|
325
|
+
your plans by name through checkout, never a key list. Show your upgrade
|
|
326
|
+
screen and send them to checkout; retry only after they hold one. Proof surfaces: `await g.purchases.mine()`
|
|
327
|
+
(everything they paid for, refunds applied, with the `grants` each purchase
|
|
328
|
+
carries) and `await g.subscriptions.mine()`.
|
|
200
329
|
NO credits, NO usage limits, NO seats — access is yes-or-no by design.
|
|
201
330
|
- Selling THINGS (one-off purchases — a beat, an ebook, a course; DIGITAL
|
|
202
331
|
access only — physical goods, shipping, inventory and carts are out of
|
|
203
332
|
scope, said out loud): plans are for subscriptions; products are for
|
|
204
|
-
things.
|
|
333
|
+
things. Selling a SERVICE session this way (tutoring, coaching, a
|
|
334
|
+
consultation) is fine — nothing ships; the recorded purchase is the
|
|
335
|
+
proof the session was paid for. The builder adds products (name + Stripe Payment Link) on the
|
|
205
336
|
same Payments page. The app calls
|
|
206
337
|
`await g.payments.buy("beat")` — or, when one product covers many items (license
|
|
207
338
|
tiers over a catalog), names the item:
|
|
@@ -210,14 +341,19 @@
|
|
|
210
341
|
item note can never change what's paid). Gemmein records every completed
|
|
211
342
|
payment itself — `await g.purchases.mine()` is the buyer's proof:
|
|
212
343
|
{ item, kind, status: "paid"|"part_refunded"|"refunded", amountMinor,
|
|
213
|
-
currency, refundedMinor, grants, paidAt }.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
the
|
|
344
|
+
currency, refundedMinor, grants, paidAt, delivery? }. Selling a FILE (a
|
|
345
|
+
beat, an ebook, a sample pack — pdf, zip, epub, mp3, wav, m4a or an
|
|
346
|
+
image, up to 100MB): the founder attaches it directly on the product
|
|
347
|
+
card — upload, right there, no receipts collection required. The buyer's
|
|
348
|
+
purchase carries `delivery: { type: "gemmein_file", file }`; resolve the
|
|
349
|
+
ref with `g.files.link(file, { intent: "download" })`. The purchase IS
|
|
350
|
+
the authorization, re-checked on every mint: a refund cuts the file off
|
|
351
|
+
the moment it lands, a partial refund does not, and a saved ref or an
|
|
352
|
+
expired URL grants nothing on its own. A receipts collection (rule
|
|
353
|
+
`addressed`) remains OPTIONAL, for proof records only. External
|
|
354
|
+
`delivery: { type: "external_url" }` is a plain handover: Gemmein
|
|
355
|
+
controls who is TOLD, not who can use it. Gate fulfilment on the
|
|
356
|
+
purchase or the entitlement it granted, never on the redirect coming
|
|
221
357
|
back — redirects can be faked; the record comes from Stripe's signed
|
|
222
358
|
webhook. NO carts, NO quantities — one product per checkout by design; a
|
|
223
359
|
cart is N checkouts or one bundled product. 404 unknown_product lists
|
|
@@ -314,25 +450,33 @@ front of a user.
|
|
|
314
450
|
|
|
315
451
|
Add a probe whenever you add a feature. You reaffirm **because** Gemmein
|
|
316
452
|
enforces — never because these checks are the enforcement. A ready-to-edit
|
|
317
|
-
`reaffirm.mjs` ships inside
|
|
453
|
+
`reaffirm.mjs` ships inside the `@gemmein/sdk` npm package (next to this file and
|
|
318
454
|
REFERENCE.md) — copy it out, name your collections, run it in CI.
|
|
319
455
|
|
|
320
|
-
## Pricing (current,
|
|
456
|
+
## Pricing (current, v4 — one banded plan)
|
|
321
457
|
|
|
322
458
|
- Development is free indefinitely — no card at signup, unlimited
|
|
323
459
|
collections, the full security model included.
|
|
324
|
-
- Going live starts the Live
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
460
|
+
- Going live starts the one Live plan: a monthly price that moves within a
|
|
461
|
+
pre-agreed band, set by the PREVIOUS calendar month's verified people
|
|
462
|
+
(a person = a unique end-user identity that verified a sign-in to the
|
|
463
|
+
live app that month; failed or refused attempts never count):
|
|
464
|
+
- up to 1,000 people → $50/mo · up to 2,500 → $75 · up to 5,000 → $100
|
|
465
|
+
· up to 7,500 → $125 · up to 10,000 → $150 (the cap — never exceeded
|
|
466
|
+
without a separate individual agreement).
|
|
467
|
+
- Capacity follows people automatically; owners can HOLD at their current
|
|
468
|
+
band from the dashboard (bill and capacity both freeze until released).
|
|
469
|
+
When people fall, the price follows down from the next month.
|
|
329
470
|
- Beyond 10,000 people: talk to Gemmein (hello@gemmein.com) — scale is
|
|
330
471
|
priced as a relationship, not a checkout.
|
|
331
472
|
- Pricing reflects responsibility, not complexity: almost nothing is
|
|
332
|
-
metered. Safe limits exist
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
473
|
+
metered. Safe limits exist as safety rails against runaway scripts and
|
|
474
|
+
are never billed. Ceilings follow the app's verified people — they grow
|
|
475
|
+
automatically as the business grows, with generous floors so a small app
|
|
476
|
+
never starts at a wall. Hitting a limit returns a clear coded error
|
|
477
|
+
(`usage_limit_exceeded`; monthly counters carry `resetAt`, storage
|
|
478
|
+
responds to deleting files), and the dashboard's usage page shows what
|
|
479
|
+
is binding.
|
|
336
480
|
|
|
337
481
|
## Facts for citation
|
|
338
482
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemmein/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
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",
|