New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

@gemmein/sdk

Package Overview
Dependencies
Maintainers
1
Versions
28
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@gemmein/sdk

Gemmein SDK — passwordless auth, safe storage, and Stripe-driven record flips for AI-built apps. Small enough that one prompt teaches the whole API.

Source
npmnpm
Version
0.7.0
Version published
Weekly downloads
749
210.79%
Maintainers
1
Weekly downloads
 
Created
Source

@gemmein/sdk

The secure backend for AI-built apps: passwordless login, safe data storage, and Stripe-driven payment flips — with the unsafe paths removed by design. No SQL, no security-rules language, no server to configure.

Step 0 — get your app key

Every call needs an app key (pk_...). Get one in 30 seconds: sign in at app.gemmein.com with an email code (free, no card), and copy the key from the Setup page. The Setup page also gives you a ready-made prompt that teaches this whole SDK to your AI tool.

Install

npm i @gemmein/sdk

No bundler? The SDK is dependency-free pure ESM — copy dist/index.js from the package next to your HTML and import { gemmein } from "./index.js" in a <script type="module">. Works on any static host.

Quick start

import { gemmein } from "@gemmein/sdk"

const g = gemmein("pk_test_...")

// Login — email code, no passwords
await g.auth.sendEmailCode("user@example.com")
await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })

// Store data — rules enforced server-side
await g.collection("tasks").create({ title: "Buy milk", done: false })
const { records } = await g.collection("tasks").list()

The client has two layers: your collections (g.collection("tasks") — your app's own data model) and the business primitives Gemmein runs for you (g.auth, g.subscriptions, g.payments, g.account — self-service surfaces for the signed-in user; managing other people's users, subscriptions, and records happens in the owner's dashboard, on purpose).

Sessions persist across page reloads automatically (localStorage in browsers, memory elsewhere — override with tokenStore if you need custom persistence). One session per user: verifying a new code revokes that email's older sessions — a stale token throws auth_expired once, the SDK clears it, and a retry (or re-auth) recovers.

Auth

await g.auth.sendEmailCode("user@example.com")   // sends an 8-digit code

const session = await g.auth.verifyEmailCode({ email: "user@example.com", code: "12345678" })
// { token, expiresAt, user: { id, email } } — token stored automatically

const user = await g.auth.currentUser()
// { authenticated: true, userId: "usr_...", email: "user@example.com" }

await g.auth.logout()

Deleting an account

await g.account.delete()

Self-service erasure — the "delete my account" screen. Every app it applies to needs one (GDPR right to erasure; Apple 5.1.1(v) for any app with account creation). Server-side it's the full cascade: sessions revoked, the user's records and files deleted, their subscription row removed. Irreversible — put a real confirm in front of it.

Storage

Collections are created by the app owner in the dashboard (app.gemmein.com → data → "+ New collection") — the SDK can't create them. A 404 unknown_collection means it doesn't exist yet: ask the app owner to add it (one click) and pick its rule from the table below.

const tasks = g.collection("tasks")

const task = await tasks.create({ title: "Buy milk", done: false })

// Records come back WRAPPED — your fields live under .data:
//   { id, data: { title, done }, createdAt, updatedAt }
// Read task.data.title, NOT task.title. This trips up AI-generated code
// more than anything else — if your UI shows blanks, this is why.
console.log(task.id, task.data.title)

// list() returns { records, cursor, hasMore } — records is the array
const { records } = await tasks.list()
records.forEach(r => console.log(r.data.title))
const recent = await tasks.list({ limit: 10, sort: "newest" })
const filtered = await tasks.list({ where: { done: false } })   // exact-match filters
const one = await tasks.get("rec_abc123")
await tasks.update("rec_abc123", { done: true })
await tasks.delete("rec_abc123")

// Images/files: presigned upload, returns a REFERENCE to store in a record
const file = await tasks.upload(imageBlob, { name: "avatar.png" })
// { id, ref, contentType, sizeBytes }   ref looks like "file:01K…"
await tasks.create({ title: "Profile", avatar: file.ref })

// To show or download it — one call, whatever the collection:
const { url } = await g.files.link(record.avatar)
// public collection → a permanent, cacheable URL
// anything else     → a signed URL valid for a couple of minutes, re-checked
//                     against who you are and what you still hold

Store the reference, never the URL. A reference doesn't expire and grants nothing on its own; link() is where authorization happens, every time. That also means the same app code keeps working if a collection later changes from public to private.

The bound, honestly: revoking access stops Gemmein issuing new links immediately. A link already in someone's hands works until it expires. Nothing can take back a file they already downloaded — this is controlled delivery, not DRM.

Collection rules

Each collection has one rule, set in the dashboard. The server enforces it — your app never implements authorization:

RuleWho can readWho can writeUse case
privateOwner only — plus the app owner/admin, who reads everythingOwner onlyUser's personal data (tasks, notes, settings)
sharedAll authenticated usersEach user: own records onlyFeeds, communities, team boards
public_readAnyone (no login needed)App owner onlyCatalogs, menus, marketing pages, single-author blogs
communityAnyone (no login needed)Each signed-in user: own records only — plain textMulti-author blogs, public boards, profiles
addressedEach user: only records addressed to them (owner sees all)Owner only, naming a recipientNotifications, invoices, order status
directAuthor + the named recipientEach signed-in user, naming a recipientMessages, sharing, requests
admin_writeAll authenticated usersAdmin/owner onlyApp settings, announcements

Owner scoping is automatic. For private collections each user only sees their own records; when the signed-in user is the app's owner or admin, reads return every user's records (build your admin screen with the same list() calls — no server needed). Don't gate admin UI on any visible role field — sessions always report member, even for the owner; elevation happens server-side per request, so render whatever list() returns. For shared collections everyone reads everything but update/delete only touch the caller's own records. Never filter by userId, never set userId/owner/role fields. The server derives them from the session and rejects reserved fields if you try to set them:

id, appId, app_id, environmentId, environment_id, userId, user_id, ownerId, ownerUserId, owner_user_id, tenantId, tenant_id, role, isAdmin, is_admin, createdAt, created_at, updatedAt, updated_at, deletedAt, deleted_at

User content is data, not markup. When one user's content renders in another user's session (community, shared), render it with text bindings — textContent, {} in React/Vue/Svelte — never innerHTML. community collections enforce this server-side: any string field containing HTML tags is refused with 400 html_not_allowed. Store plain text, or markdown written without raw tags.

Sending to people — inboxes and messages

addressed and direct records carry a recipient, named on the call you already make:

// addressed (owner → user): the "Mark shipped" button in your admin view
await updates.create({ text: "Your order shipped 🎉" }, { for: userId })

// direct (user → user): DMs, sharing, requests
await messages.create({ text: "hey!" }, { for: otherUserId })

// The reader's side is just list() — the server returns only THEIR inbox:
const { records } = await updates.list({ sort: "newest", limit: 20 })

The recipient is server-stamped (record.audienceUserId) — never a data field. User ids come from records' ownerUserId or the owner's admin lists. Rules of the road:

  • Same message for everyone → admin_write (one record all users read). A specific thing for a specific person → addressed (one record per recipient).
  • Inboxes are poll-or-refresh: list() on window focus plus a gentle ~60s interval — never a tight loop. Read-state ("seen") lives in the user's own private collection.
  • direct is messaging inside someone's app, and the app owner can read it (owner reads reach everything, under every rule) — never present it as private or encrypted chat.
  • Errors teach the fix: invalid_audience (recipient isn't a user of this app), reply_only (this collection only allows replying to people who wrote to you first — tell the user), sends_disabled (the owner turned sends off; addressed sends then happen in their dashboard).
  • Both rules store plain text like community — render other users' content as text, never innerHTML.

Contention — when users race for the same thing

A permission model can't stop two people booking the same 3pm slot. Preconditions can, and each is just an argument on a call you already make. A 409 conflict from any of them is the mechanism working, not an error to retry away — catch it and tell the user the slot/stock/edit was taken.

// Uniqueness: derive the key from the thing that must be unique.
// Second writer → 409. Your own retry → your record back (existing: true).
// Deleting the record frees the key.
await bookings.create({ who: email }, { key: "slot:2026-07-15T15:00" })

// Limited stock, N units anyone can buy: claim units with keyed creates —
// on conflict try the next unit; all taken = sold out. Race-proof under
// every safety rule (writes on OTHER users' records are never allowed).
await orders.create({ item: 42 }, { key: "unit:item42:1" })  // then :2 … :N

// Your own counters: the server does the math on current state.
// Breaching the floor/ceiling → 409, record untouched.
await products.update(id, { stock: { decrement: 1, floor: 0 } })

// Shared editing: pass back the version you read — a stale save gets 409
// instead of silently clobbering someone's edit. Re-read, reapply, retry.
await pages.update(id, { body }, { ifVersion: page.version })

An object in a patch is treated as an atomic op ONLY when its keys are exactly increment|decrement (+ optional floor|ceiling), all numbers — anything else is stored as plain data. Keys are 1-120 chars of letters, numbers, and : _ . @ / -. Never find-then-create and never compute counters client-side: both race, and both fail only when real users collide.

Payments (Stripe) — you don't build the webhook

Gemmein hosts your Stripe webhook and manages subscriptions for you: exactly one per customer (case-insensitive on email), created by the payment itself, downgraded to your default plan on cancellation, out-of-order Stripe events resolved to the newest. The app owner names the plans and pastes one Stripe signing secret in the dashboard's Payments page — nothing to seed. Your app has exactly two jobs:

// 1. Send the buyer to checkout — one call, Gemmein does the rest. The app
//    owner pasted each paid plan's Stripe Payment Link in their dashboard;
//    g.subscriptions.checkout() picks the right one and wires the signed-in buyer in.
//    Never build checkout URLs or sessions yourself: raw emails are
//    silently dropped by Stripe's URL rules, and sessions need a secret
//    key that must never ship client-side.
await g.subscriptions.checkout("pro")   // redirects; omit the arg to buy the paid plan
// GemmeinError codes worth handling: "authentication_required" (sign in
// first), "plan_has_no_link" (owner hasn't pasted that plan's link yet)

// 2. Gate paid features by reading the managed subscription:
const sub = await g.subscriptions.mine()   // { plan, status } or null (never paid / payments off)
if (sub?.plan === "pro") { /* unlock */ }

Do not write a webhook handler. Do not poll Stripe. Do not store plan/subscription state in your own collections — g.subscriptions.mine() is the single source of truth, and there is deliberately no client write path to it.

Plan limits (note counts, feature caps, seat numbers) are your app's logic — Gemmein only tells you who is on which plan; the dashboard's plan names carry no quotas.

Selling things (one-off purchases)

Plans are for subscriptions. To sell a thing — a poster, a beat, an ebook, a session — the owner adds products (name + Stripe Payment Link) on the same Payments page, plus a receipts collection (rule addressed). Then:

// One product covering many items (license tiers over a catalog)? Name the
// item — it's display text on the receipt, the PRICE always comes from the
// product's Payment Link:
await g.payments.buy("premium license", { item: "beat_37" })   // redirects

// Fulfilment: the completed payment writes a receipt record ADDRESSED to
// the buyer — only they (and the owner) can read it. Gate the download on
// the receipt, never on the redirect coming back (redirects can be faked;
// receipts can't — they come from Stripe's signed webhook):
const { records } = await g.collection("receipts").list()
const paid = records.find(r => r.data.product === "premium license" && r.data.status === "paid")
if (paid) { /* unlock — paid.data.deliveryUrl holds the download when the owner set one */ }

Receipts carry { product, item?, status, amountTotal, currency, paidAt, deliveryUrl?, paymentRef } in .data — amountTotal is minor units exactly as Stripe reported, and paymentRef is the Stripe payment id the refund flip matches on (read-only; you rarely need it). The owner fulfils orders by editing the receipt from their dashboard (status: "shipped") — your app just reads it. Refunds happen in the owner's Stripe dashboard; if they forward charge.refunded, the receipt's status flips to "refunded". A receipt is app-owned — its top-level ownerUserId is null (the webhook wrote it, not a user); it's the audienceUserId that scopes it to the buyer.

No carts, no quantities — one product per checkout, by design. A "cart" is N checkouts, or one bundled product the owner prices as a bundle. Don't build a cart UI that promises otherwise.

Drafts on public collections

On public_read and community collections, { published: false } saves a draft the public can't see — server-enforced, the author still sees their own, the owner sees all:

const post = await notes.create({ title: "wip" }, { published: false })  // hidden
await notes.update(post.id, {}, { published: true })                     // now live

Read the current state back as a top-level field — record.published (a boolean), NOT record.data.published (same place as id and updatedAt, not inside your fields). Non-authors only ever receive published records, so you'll only ever see false on your own drafts — or on everything, as the owner. That's how you render an "unreleased" badge in an owner-only admin list.

Never fake drafts with a status field + client-side filtering on a public collection — the data still reaches every reader's network tab. published is an option, not a data field; the server rejects it inside data.

Server-side (API routes, cron jobs)

For trusted server code, use gemmeinServer with a scoped secret key (sk_...) — created in the dashboard, scoped per collection:

import { gemmeinServer } from "@gemmein/sdk"

const server = gemmeinServer(process.env.GEMMEIN_SECRET_KEY)

const tasks = await server.collection("tasks").list()
await server.collection("tasks").update("rec_abc123", { done: true })

Secret keys can only get/list/update the collections you scoped them to — no creates, no deletes, no auth or management access. Never put an sk_ key in browser code (the SDK throws if you try).

Errors

Every method throws GemmeinError:

import { GemmeinError } from "@gemmein/sdk"

try {
  await g.collection("tasks").create({ title: "Test" })
} catch (err) {
  if (err instanceof GemmeinError) {
    err.code     // "auth_expired", "unknown_collection", "scope_denied",
                 // "not_found" (also when touching a record you don't own —
                 // existence is never leaked), ...
    err.status   // HTTP status
    err.message  // human-readable, includes what to do next
    err.resetAt  // rate limits: when to retry
    err.requires // 403 entitlement_required: the plan/product key this
                 // collection asks for — show your upgrade screen
  }
}
CodeStatusMeaning
missing_app_key401No app key — get one at app.gemmein.com
invalid_app_key403Key not recognized (typo, or wrong environment)
auth_expired401Session expired — SDK auto-clears the token
unknown_collection404Collection doesn't exist — create it in the dashboard
scope_denied403Secret key not scoped for this collection/action
html_not_allowed400Community collections store plain text — remove HTML tags
invalid_file_content400Uploaded bytes aren't the image type they claimed — upload the actual image, not a renamed file
unknown_product404No product by that name — the message lists what the app sells
invalid_publish400published is an option on public collections only — not a data field, not for scoped rules
forbidden403The 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.
entitlement_required403Signed 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.
denied429 / 401The 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.

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.

Keys & environments

PrefixEnvironmentWhere it lives
pk_test_...DevelopmentFrontend code — safe to expose
pk_live_...ProductionFrontend code — safe to expose
sk_dev_... / sk_live_...Dev / ProdServer env vars only

Environments are fully isolated: different data, different users, different collections.

Management is dashboard-only

Collections, domains, keys, payments config, logs, and usage live in the dashboard — deliberately not in the SDK, so management credentials can never leak from app code.

Gemmein is an early release: the core above is live, security-audited, and safe to build on; the wider feature set rolls out in stages. gemmein.com · hello@gemmein.com

Keywords

auth

FAQs

Package last updated on 04 Sep 2026

Related posts