@zerostackai/sdk
v0.1.2
Published
ZeroStack SDK — one authorized, isomorphic interface over data, storage, and LLM calls.
Maintainers
Readme
@zerostackai/sdk
One authorized, isomorphic interface over your app's database, file storage, and LLM — for apps generated by ZeroStack.
createZeroStack(config) returns a single client with four surfaces:
| Surface | What it talks to | Client-side (browser / React Native) | Server-side (Node / Cloudflare Workers) |
| --- | --- | --- | --- |
| auth | ZeroStack edge (/auth) | email + password → JWT the Data API trusts | n/a (server holds a token already) |
| db | Neon Data API (PostgREST-compatible) | signed-in user's JWT → RLS enforced | Same, or a scoped API key |
| storage | Cloudflare R2 | via the edge presign endpoint (no secrets in bundle) | via direct S3 credentials |
| llm | DeepSeek / OpenAI-compatible | via the edge LLM proxy (company key stays server-side) | direct provider key |
Auth is built in. Configure auth and zs.auth.signIn(email, password) returns a token the Neon Data API accepts — zs.db then uses it automatically. No Neon Auth / Stack Auth setup needed.
The same code runs in both environments — you just pass the transports that make sense for where it runs. No secrets ever need to reach a browser bundle.
- Dual ESM + CJS builds, full TypeScript types.
- Zero framework assumptions — works in React, React Native (Expo), Next.js, Node, and Cloudflare Workers.
- One runtime dependency (
aws4fetch, for server-side S3 signing).
Install
npm install @zerostackai/sdkimport { createZeroStack } from '@zerostackai/sdk'Quickstart
Client (browser / React Native) — no secrets in the bundle
import { createZeroStack } from '@zerostackai/sdk'
const zs = createZeroStack({
data: { url: import.meta.env.VITE_ZS_DATA_API_URL }, // Neon Data API URL — no token: auth feeds it
auth: {
url: import.meta.env.VITE_ZS_EDGE_URL, // edge base — hosts /auth/signup + /auth/login
apiKey: import.meta.env.VITE_ZS_APP_KEY
},
storage: {
presignUrl: import.meta.env.VITE_ZS_EDGE_URL + '/presign',
apiKey: import.meta.env.VITE_ZS_APP_KEY
},
llm: {
proxyUrl: import.meta.env.VITE_ZS_EDGE_URL + '/llm',
apiKey: import.meta.env.VITE_ZS_APP_KEY
}
})
// Sign in → the token is stored and used by db automatically. RLS is enforced by it.
await zs.auth!.signIn('[email protected]', 'password')
const bookings = await zs.db.from('bookings').select('*').eq('status', 'confirmed').limit(20)
const { key } = await zs.storage!.upload(`avatars/${userId}.jpg`, file, { contentType: 'image/jpeg' })
const reply = await zs.llm!.chat([{ role: 'user', content: 'Summarize my week.' }])Server (Node / Cloudflare Workers) — scoped credentials allowed
const zs = createZeroStack({
data: { url: process.env.ZS_DATA_API_URL!, apiKey: process.env.ZS_SERVICE_KEY },
storage: {
accountId: process.env.CF_ACCOUNT_ID!,
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
bucket: process.env.R2_BUCKET!
},
llm: { provider: 'deepseek', apiKey: process.env.DEEPSEEK_API_KEY! }
})storage and llm are optional — omit them and those fields are undefined. data is always required.
Configuration reference
createZeroStack(config: ZeroStackConfig) => { db, storage?, llm? }
interface ZeroStackConfig {
data: DataConfig // required
storage?: StorageConfig
llm?: LlmConfig
}data: DataConfig
| Field | Type | Notes |
| --- | --- | --- |
| url | string (required) | Neon Data API base URL for the project (PostgREST-compatible). |
| token | string \| () => string \| null \| Promise<...> | End-user JWT. A getter is re-read on every request, so token refresh just works. |
| apiKey | string | Optional API-key header for server contexts. |
storage: StorageConfig — one of:
Presign (client):
| Field | Type | Notes |
| --- | --- | --- |
| presignUrl | string | Edge endpoint that mints short-lived, tenant-scoped upload URLs. |
| apiKey | string? | App-level key sent to the endpoint. |
| token | TokenProvider? | End-user JWT, forwarded so the URL can be scoped to the user. |
R2 direct (server-only):
| Field | Type |
| --- | --- |
| accountId, accessKeyId, secretAccessKey, bucket | string |
| publicBaseUrl | string? (if the bucket is served publicly) |
⚠️ Never put R2 secret credentials in a browser bundle. Use the presign config client-side.
llm: LlmConfig — one of:
Proxy (client): { proxyUrl: string; apiKey: string } — the edge proxy holds the real provider key and meters usage per tenant.
Direct (server-only): { provider: 'deepseek' | 'openai'; apiKey: string; baseUrl?: string; model?: string }.
auth — end-user sign-in
Configure auth: { url, apiKey } (the edge base + tenant app key). zs.auth signs users in
against the ZeroStack edge, which issues an RS256 JWT the Neon Data API is configured to
trust — so signed-in users can read/write via db with RLS scoped to their sub. No Neon
Auth / Stack Auth, no manual token wiring.
await zs.auth!.signUp('[email protected]', 'password') // create + sign in
await zs.auth!.signIn('[email protected]', 'password') // sign in
zs.auth!.isAuthenticated // boolean
zs.auth!.user // { id, email } | null
zs.auth!.signOut()The token is held in memory and persisted to localStorage in browsers (key zs_jwt; React
Native is in-memory). Once configured, you don't pass data.token — db reads the
signed-in token from auth automatically.
db — query builder
zs.db.from(table) returns a chainable builder. It is thenable — await it to run the query and get T[], or call .single() for T | null.
zs.db.from<T = any>(table: string)
.select(columns = '*') // 'id, name' or '*'
.insert(rows) // Partial<T> | Partial<T>[]
.update(patch) // Partial<T>
.delete()
.eq(column, value) // column = value
.in(column, values[]) // column IN (…)
.order(column, { ascending }) // default ascending: true
.limit(n)
.single(): Promise<T | null> // first row or null// Read
const users = await zs.db.from('users').select('id, email').eq('active', true).order('created_at', { ascending: false }).limit(50)
// Read one
const user = await zs.db.from('users').select('*').eq('id', id).single()
// Insert
await zs.db.from('notifications').insert({ user_id: id, title: 'Welcome' })
// Update
await zs.db.from('profiles').update({ bio: 'hi' }).eq('user_id', id)
// Delete
await zs.db.from('sessions').delete().eq('id', sessionId)Every call returns this, so order is flexible; the request fires when you await (or call .single()).
storage
zs.storage!.upload(
key: string,
data: Blob | ArrayBuffer | Uint8Array | string,
opts?: { contentType?: string; expiresIn?: number }
): Promise<{ key: string; url?: string }>
zs.storage!.getSignedUrl(key: string, expiresIn = 3600): Promise<string>const { key } = await zs.storage!.upload(`u/${id}/photo.jpg`, file, { contentType: 'image/jpeg' })
const url = await zs.storage!.getSignedUrl(key, 600) // 10-minute download URLClient uploads go through the edge presign flow (no secrets in the bundle); server uploads sign directly against R2 (S3) with aws4fetch. Same method, either way.
llm
zs.llm!.chat(
messages: { role: 'system' | 'user' | 'assistant'; content: string }[],
opts?: { model?: string }
): Promise<{ role: 'assistant'; content: string }>const reply = await zs.llm!.chat([
{ role: 'system', content: 'You are terse.' },
{ role: 'user', content: 'One-line summary of RLS.' }
])
console.log(reply.content)Client calls hit the edge proxy (company key stays server-side, usage metered per tenant); server calls go direct to the provider.
Environments & safety
import { isBrowser, ZeroStackError } from '@zerostackai/sdk'isBrowser—truein a browser or React Native. Use it to branch config.- Privileged config paths (R2 direct, direct LLM keys) are guarded: attempting them in a browser throws a
ZeroStackError, so a mis-wired bundle fails loudly instead of leaking a key. ZeroStackError— every SDK error is an instance; it carries an optionalstatusanddetail.
try {
await zs.db.from('users').select('*')
} catch (e) {
if (e instanceof ZeroStackError) console.error(e.status, e.message, e.detail)
}For AI agents / tooling
- Entry:
createZeroStack(config)— the only constructor. Returns{ db, auth?, storage?, llm? }. - Auth: if
config.authis set,zs.auth.signIn/signUp(email, password)obtains a token the Data API trusts anddbuses it automatically — don't passdata.tokenand don't wire Neon Auth. - Required config:
data.url(throwsZeroStackErrorif missing). - Auth model: client passes an end-user JWT via
data.token(a getter, re-read per request); the Neon Data API enforces Postgres RLS. Never embed service credentials client-side. dbis thenable —await zs.db.from(t).select()yieldsT[];.single()yieldsT | null.- Optional surfaces: guard with
zs.storage?/zs.llm?(or!when you configured them). - Module formats:
import(ESM) andrequire(CJS) both work; types are bundled.
License
MIT
