A backend for the sites you didn't want to build a backend for.
You hand an AI a prompt and it generates a slick HTML page — then you hit a wall: there's nowhere to save data, store files, call AI, or push realtime updates. This project is that missing backend. Drop it on a single VM, point your generated frontend at it, and the endpoints just work. No database to provision, no schema to declare, no buckets to create, no auth provider to wire.
Think Firebase / PocketBase, but truly zero-config and pinned to one box.
npx zero-config-data-api # once published to npm
npx github:jwicks31/zero-config-data-api # from source, available todayThat's it — no clone, no config, no database to provision. Open
http://localhost:3737 (demo), /examples.html (sample apps), or /admin
(dashboard). State lives in ./.data; set PORT, DATA_DIR, ADMIN_KEY, or
ANTHROPIC_API_KEY to taste — all optional.
| Capability | What it does |
|---|---|
| Data | Schemaless collections — CRUD + bulk, filter/sort (incl. OR groups & timestamp ranges), field projection, count, aggregation (sum/avg/min/max), full-text search, optimistic-concurrency writes, and live subscriptions |
| Access control | Per-collection public/private read & write, with field redaction for anonymous readers |
| Files | Upload / download / list / delete — local disk by default, S3 (or any S3-compatible store) optional |
| AI | Chat (JSON or SSE streaming) over a pluggable provider — Anthropic, any OpenAI-compatible endpoint (OpenAI, LiteLLM, Ollama…), or Amazon Bedrock |
| Realtime | Websocket pub/sub with presence (join/leave/typing + roster), history, and an optional Redis fan-out for multi-process scale |
| Webhooks | Outbound HTTP on data/file events — HMAC-signed, with a persisted, retried delivery queue and delivery history |
| Multi-tenant | Optional isolated projects — one VM hosts many sites (set ADMIN_KEY) |
| Backup | Whole-instance export / restore as one JSON file |
Plus a zero-build browser SDK (/sdk.js, typed), a self-describing API
(/v1 + /llms.txt) an AI can consume in one read, an admin dashboard
(/admin), ops endpoints (/v1/stats, Prometheus /metrics, a /ready probe),
per-IP rate limiting, and one-command
deploys (npx · Docker Compose · systemd). TypeScript + Fastify, single process,
SQLite + local disk by default — Postgres and S3 optional. CI, MIT.
Two audiences, two paths through these docs:
- Building a frontend against it (you call the API) → Browser SDK
and API below, plus the in-product reference at
/docsand the one-read/llms.txtfor AI agents. You never need to know which database or storage backend the operator chose — the API is identical. - Running / deploying it (you stand up the server) → Deploy and Configuration, including the optional Postgres and S3 backends. Everything here is set with environment variables at deploy time.
npm install
npm run dev # http://localhost:3737 (live reload)Then open:
- http://localhost:3737 — a live demo page that reads and writes through the API
- http://localhost:3737/examples.html — a gallery of small apps (collaborative to-do, live chat, notes + AI) each built on the SDK in a single HTML file
- http://localhost:3737/admin — a dashboard to browse collections, files, and realtime channels, manage projects, and test the AI endpoint
No environment variables required.
Run the test suite (spins up the server on an ephemeral port and exercises every module over HTTP + websockets):
npm testdocker compose up -d # builds, runs, persists to a named volume, restarts on bootSet ADMIN_KEY / API_KEY / ANTHROPIC_API_KEY in docker-compose.yml (all optional). Includes a /health healthcheck.
A pre-built image is published to GHCR on each vX.Y.Z release tag:
docker run -d -p 3737:3737 -v zcda-data:/data ghcr.io/jwicks31/zero-config-data-api:latestdocker build -t zero-config-data-api .
docker run -d --name zcda -p 3737:3737 -v zcda-data:/data \
-e ANTHROPIC_API_KEY=sk-ant-... `# optional, enables AI` \
zero-config-data-apiAll state lives in the /data volume — back it up and you've backed up everything.
npm ci && npm run build
# then install the unit and start it:
sudo cp deploy/zero-config-data-api.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now zero-config-data-apiSee deploy/zero-config-data-api.service for the unit (paths, env, hardening).
The server ships a zero-build client at /sdk.js — the intended way for a
(generated) frontend to talk to the backend. No npm, no bundler.
<script src="/sdk.js"></script>
<script>
const api = Zero(); // same-origin; or Zero('https://my-vm:3737', { apiKey })
await api.data('guestbook').create({ name, message });
await api.data('guestbook').list({ where: { done: false }, sort: '-createdAt', limit: 20 });
const meta = await api.files.upload(fileInput.files[0]); // -> { id, url, size, ... }
const { text } = await api.ai.chat('Write a haiku about SQLite');
await api.ai.stream('Tell me a story', (chunk) => append(chunk));
const room = api.realtime('room-42');
room.subscribe((msg) => render(msg.data));
room.publish({ hello: 'world' });
// Live data — every write to a collection is pushed to subscribers
api.data('todos').subscribe((change) => {
// { type: 'created'|'updated'|'deleted', collection, id, document? }
refresh(change);
});
</script>Filter shorthand: where: { age: { op: 'gte', value: 18 } } (ops: eq ne gt gte lt lte like in nin); a bare value means equality, an array means in. Queries also take or, select, limit, offset, sort, and after (cursor). The collection client adds createMany, count, page (returns nextCursor), deleteWhere, getAcl/setAcl, getSchema/setSchema/clearSchema, getIndexes/setIndexes, and optimistic-concurrency updates (update(id, patch, { ifVersion })); the top-level client adds stats(). Failed calls throw an Error with .status and .body.
TypeScript: types are served at /sdk.d.ts — reference them for a fully typed client:
/// <reference path="./sdk.d.ts" />
const api = Zero(); // Zero.Client
const todos = await api.data('todos').list({ where: { done: false } }); // Zero.Doc[]- Zero config for the app, configurable for the operator. Every setting has a working default; the server boots with no env vars. The promise is to the API consumer (the generated site) — so an operator can swap in production backends (Postgres, S3, …) via env at deploy time without the API surface changing at all.
- One VM, one folder — until you grow. By default all state — the SQLite
database and uploaded file blobs — lives under
DATA_DIR(default./.data); backup = copy the folder. Point data at Postgres (DB_DRIVER=postgres) and/or files at S3 (STORAGE_DRIVER=s3) when you outgrow a single disk, with no change to the API the frontends call. - Self-describing.
GET /v1returns the capability + endpoint map, and/llms.txtis a one-read guide so an AI can wire a page to the backend zero-shot. Working in the repo? SeeAGENTS.md.
# Create (the "notes" collection is created on first write)
curl -X POST localhost:3737/v1/data/notes \
-H 'content-type: application/json' \
-d '{"title":"hello","done":false}'
curl localhost:3737/v1/data/notes # list (newest first)
# Bulk create (one transaction)
curl -X POST localhost:3737/v1/data/notes/bulk \
-H 'content-type: application/json' -d '[{"title":"a"},{"title":"b"}]'
# Query: filter + sort + paginate
curl 'localhost:3737/v1/data/notes?f.done=false&sort=-createdAt&limit=20'
curl 'localhost:3737/v1/data/notes?sort=-createdAt&limit=20&after=<nextCursor>' # stable cursor paging
curl 'localhost:3737/v1/data/notes?f.priority=gte:3' # ops: eq ne gt gte lt lte like in nin
curl 'localhost:3737/v1/data/notes?f.status=in:active,pending' # value in a set
curl 'localhost:3737/v1/data/notes?f.createdAt=gte:1700000000000' # filter on timestamps too
curl 'localhost:3737/v1/data/notes?f.done=false&or.pinned=true&or.priority=gte:5' # AND + OR group
curl 'localhost:3737/v1/data/notes?select=title,done' # project: only these fields (+ id/timestamps)
curl 'localhost:3737/v1/data/notes/count?f.done=false' # -> { count } without the documents
curl 'localhost:3737/v1/data/orders/aggregate?op=sum&field=amount' # -> { value } (count|sum|avg|min|max)
# Full-text search (ranked, auto-indexed)
curl 'localhost:3737/v1/data/notes/search?q=hello+world'
curl localhost:3737/v1/data/notes/<id> # read one (sends an ETag = version)
curl -X PATCH localhost:3737/v1/data/notes/<id> \
-H 'content-type: application/json' -d '{"done":true}' # merge
curl -X PATCH localhost:3737/v1/data/notes/<id> \
-H 'if-match: <updatedAt>' -d '{"done":true}' # optimistic concurrency → 409 if stale
curl -X PUT localhost:3737/v1/data/notes/<id> -d '{...}' # replace
curl -X DELETE localhost:3737/v1/data/notes/<id>
curl -X DELETE 'localhost:3737/v1/data/notes?f.done=true' # bulk delete by filter (?all=true to clear)
curl localhost:3737/v1/data # list all collections
# Declared indexes — back hot filter/sort fields with real indexes for large collections
curl -X PUT localhost:3737/v1/data/notes/indexes \
-H 'content-type: application/json' -d '{"fields":["done","createdAt"]}' # declarative set
curl localhost:3737/v1/data/notes/indexes # -> { fields: [...] }Every document gets a generated id, createdAt, and updatedAt. Pass your own
id in the body to choose it.
Queries work on any field with no setup. For large collections, declare indexed
fields so filters and sorts on them use a real database index (a SQLite/Postgres
expression index) instead of a scan — PUT /v1/data/:collection/indexes { fields }
is declarative, so it reconciles to exactly the set you send. It's key-gated and
never anonymous. On Postgres the index is built CONCURRENTLY, so declaring one
on a large, live collection doesn't block writes.
Collections are schemaless by default. Opt into validation per collection by
declaring a JSON Schema; writes that violate it then fail with 422 and a list of
problems (PATCH validates the merged result, and bulk is all-or-nothing):
curl -X PUT localhost:3737/v1/data/members/schema \
-H 'content-type: application/json' \
-d '{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer","minimum":0}},"required":["name"]}'
curl -X POST localhost:3737/v1/data/members -H 'content-type: application/json' -d '{"age":-1}'
#→ 422 { "error":"validation_failed", "errors":[{"field":"name","message":"..."},{"field":"age","message":"..."}] }
curl -X DELETE localhost:3737/v1/data/members/schema # back to schemalessLists support both offset (?limit&offset) and cursor paging: a list
response includes a nextCursor; pass it back as ?after=<cursor> to fetch the
next page in stable (sort, id) order — no skips or repeats when rows are
inserted mid-iteration, the way offset drifts. With the SDK:
const page = await api.data('notes').page({ sort: '-createdAt', after });
then follow page.nextCursor until it's null. Back a hot sort field with a
declared index and cursor paging stays fast at
any size.
Via the SDK: api.data('members').setSchema(schema) / getSchema() / clearSchema().
By default every collection is private: the API key (if set) governs all access.
Mark a collection's read (or write) public so an anonymous frontend can reach
it even on an API_KEY-locked instance, and hide sensitive fields from those
anonymous reads. Managing rules always requires the key.
# Expose "posts" for anonymous reads, but redact the author's email
curl -X PUT localhost:3737/v1/data/posts/acl \
-H "authorization: Bearer $API_KEY" -H 'content-type: application/json' \
-d '{"read":"public","hidden":["authorEmail"]}'
curl localhost:3737/v1/data/posts # now works without a key; authorEmail omittedKey-holders still see every field. This works in both modes: in multi-tenant
mode, a read:public (or write:public) collection is reachable anonymously by a
project's non-secret project id — pass it as X-Project-Id: <prj_…> (or
?project=<prj_…> for links/downloads), e.g. Zero(base, { project: 'prj_…' }).
That lets a published frontend read public content without embedding the secret
project key; private collections still require it. A read:public collection's
live feed is public too: Zero(base, { project }).data('posts').subscribe(…)
streams changes over a read-only websocket with no secret.
curl -X POST localhost:3737/v1/files -F file=@photo.jpg # -> { id, url, size, ... }
curl localhost:3737/v1/files # list metadata
curl localhost:3737/v1/files/<id> -o out.jpg # download
curl -X DELETE localhost:3737/v1/files/<id>Blobs land on local disk by default (under DATA_DIR). For durable or
multi-instance deploys, set STORAGE_DRIVER=s3 + S3_BUCKET (works with AWS S3
or any S3-compatible store like MinIO via S3_ENDPOINT); credentials come from
the standard AWS chain. The upload/download API and SDK are identical either way
— see Configuration.
One endpoint, any backend — pick with AI_PROVIDER (anthropic · openai ·
bedrock; see AI providers). Returns 503 until the selected
provider is configured — the rest of the server still runs without it.
# Non-streaming → { text, model, stop_reason, usage }
curl -X POST localhost:3737/v1/ai/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"Write a haiku about SQLite"}]}'
# Streaming (Server-Sent Events): text deltas as data: frames, then event: done
curl -N -X POST localhost:3737/v1/ai/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"hi"}],"stream":true}'Optional body fields: system, model (defaults to a Claude model on
anthropic; required for openai/bedrock), max_tokens (capped at 64000).
The provider defaults to the instance-wide AI_PROVIDER, but a project can
override it — point /v1/ai/chat at its own provider/model/key/gateway:
curl -X PUT localhost:3737/v1/ai/config -H 'content-type: application/json' -d '{
"provider": "openai", "model": "gpt-4o",
"baseUrl": "http://localhost:4000", "apiKey": "sk-…", "headers": {"x-org": "acme"}
}'
curl localhost:3737/v1/ai/config # redacted (keys never returned); DELETE to resetKeys are stored server-side and never read back (GET shows hasApiKey, not the
key). Key-gated and per-project; absent an override, the instance default applies.
// Pass an optional presence identity with ?as=
const ws = new WebSocket('ws://localhost:3737/v1/realtime/room-42?as=ada');
ws.onmessage = (e) => {
const f = JSON.parse(e.data);
if (f.type === 'presence') console.log(f.event, f.member ?? f.members); // sync|join|leave|typing
else console.log('peer said', f.data); // { type:'message', id, channel, data, at }
};
ws.onopen = () => ws.send(JSON.stringify({ text: 'hello room' })); // broadcast to peers
// Typing indicator (a control frame, not published):
ws.send(JSON.stringify({ $presence: { typing: true } }));Each connection appears in the channel's presence roster (with the optional
?as= identity); peers get join/leave/typing events and a sync snapshot
on connect. With the SDK: const room = api.realtime('room-42', { as: 'ada' }); room.presence(e => …); room.typing(true); await room.members();
On connect you replay the channel's recent history. Servers and non-WS clients can publish over HTTP, and you can inspect channels:
curl -X POST localhost:3737/v1/realtime/room-42 \
-H 'content-type: application/json' -d '{"text":"from the server"}'
curl localhost:3737/v1/realtime # active channels + client counts
curl localhost:3737/v1/realtime/room-42/history # recent messages
curl localhost:3737/v1/realtime/room-42/presence # current members [{ cid, identity }]Single-process and in-memory by default. For horizontal scale, set
REDIS_URL and run multiple server processes: messages and presence fan out
across them via Redis, with no change to the API or SDK.
In multi-tenant mode, the live feed of a read:public collection
(data:<collection>) can be followed anonymously with just the non-secret
project id ({ project }) — a read-only subscription, so a published frontend
gets live updates without a secret. Other channels still require a project key.
Register a URL to receive data.* / file.* events as HTTP POSTs. Deliveries are
persisted and retried with exponential backoff (so a brief outage on your side
doesn't drop events), and you can inspect delivery history.
# Subscribe to created/updated docs in the "orders" collection
curl -X POST localhost:3737/v1/webhooks -H 'content-type: application/json' -d '{
"url": "https://example.com/hook",
"events": ["data.created", "data.updated"],
"collections": ["orders"]
}' # -> { id, url, events, secret: "whsec_…", ... } (secret shown once)
curl localhost:3737/v1/webhooks # list (without secrets)
curl localhost:3737/v1/webhooks/<id>/deliveries # recent attempts + status
curl -X POST localhost:3737/v1/webhooks/<id>/rotate-secret
curl -X DELETE localhost:3737/v1/webhooks/<id>Event types use a resource.action shape (data.created, data.updated,
data.deleted, file.created, file.deleted); subscribe to exact types, a prefix
(data.*), or all (*), and optionally filter by collections. Each delivery is a
JSON POST { id, event, createdAt, project, data } with headers:
| Header | Meaning |
|---|---|
X-Zero-Event |
the event type |
X-Zero-Delivery |
unique delivery id |
X-Zero-Timestamp |
send time (ms) |
X-Zero-Signature |
sha256=<hex> — HMAC-SHA256 of "<timestamp>.<rawBody>" keyed by the hook's secret |
Verify by recomputing the HMAC over `${timestamp}.${rawBody}` with your stored
secret and comparing to X-Zero-Signature. Management is key-gated (and per-project
in multi-tenant); since webhooks make outbound requests, run with a key and restrict
egress if the instance is internet-exposed.
Optionally verify an end-user token so calls carry a trusted identity. Configure
either a shared HS256 secret (AUTH_JWT_SECRET) or an OIDC JWKS endpoint
(AUTH_JWKS_URL, with AUTH_JWT_ISSUER / AUTH_JWT_AUDIENCE). A caller presents the
token as Authorization: Bearer <jwt>, an X-User-Token header (so it can sit
beside a project key), or ?token= (for websockets):
curl localhost:3737/v1/me -H "Authorization: Bearer $JWT" # -> { user: { sub, claims } }A verified token is echoed at /v1/me, becomes the trusted realtime presence
identity (overriding ?as=), and on an API_KEY-locked instance stands in for
the key — so end-users authenticate with their own IdP tokens instead of sharing a
secret. Zero(base, { token }) sends it for you. An invalid/expired token is a
401; identity is off until configured. (Row-level per-user authorization is the
natural next step and isn't included yet.)
By default the server runs in open mode: one implicit project, no auth — exactly
the zero-config experience. Set ADMIN_KEY to switch to multi-tenant mode,
where one VM hosts many isolated sites:
- Each project has its own key and a fully isolated slice of every collection, file, and realtime channel.
- Data requests must carry a project key (
Authorization: Bearer pk_…, or?key=for websockets). The SDK takes it asZero(base, { apiKey: '<project key>' }). - Public access without a secret: a project's collections marked
read/writepublicare reachable by the non-secret project id alone — passX-Project-Id: prj_…(or?project=), i.e.Zero(base, { project: 'prj_…' }). Ideal for a published frontend that reads public content; private collections still require the key. - The
ADMIN_KEYalso acts as the access key for the shared/default space, so the operator (and the dashboard's “— shared —” context) can read/write shared data alongside the isolated apps. - Projects are managed via the admin API, authenticated with
ADMIN_KEY:
# Create a project (returns its key — hand it to that site)
curl -X POST localhost:3737/v1/admin/projects \
-H "Authorization: Bearer $ADMIN_KEY" -H 'content-type: application/json' \
-d '{"name":"my-blog"}' # -> { id, name, key: "pk_…", createdAt }
curl -H "Authorization: Bearer $ADMIN_KEY" localhost:3737/v1/admin/projects # list
curl -X POST -H "Authorization: Bearer $ADMIN_KEY" \
localhost:3737/v1/admin/projects/<id>/rotate-key # new key; old one stops working, data kept
curl -X DELETE -H "Authorization: Bearer $ADMIN_KEY" \
localhost:3737/v1/admin/projects/<id> # deletes the project AND its dataGET /v1 reports the current mode. Existing single-tenant databases migrate in
place on boot (all data lands under the default project).
Whole-instance snapshot as one JSON document (all documents + base64 file blobs).
Gated: these endpoints only work when API_KEY is set — they expose and
overwrite everything, so they stay dormant (403) on an open instance.
# Snapshot the entire backend to a file
curl -H "Authorization: Bearer $API_KEY" localhost:3737/v1/export > backup.json
# Restore it (upserts documents and files by id) onto any instance
curl -X POST localhost:3737/v1/import \
-H "Authorization: Bearer $API_KEY" -H 'content-type: application/json' \
--data-binary @backup.jsonOperator docs. Everything below is for whoever runs the server. None of it changes the API your frontends call — it's how you tune and scale a deployment. Every value is optional: the server boots zero-config on SQLite + local disk. See
.env.examplefor the complete list. A.envin the working directory is auto-loaded at startup; real environment variables win.
| Variable | Default | Purpose |
|---|---|---|
PORT / HOST |
3737 / 0.0.0.0 |
Listen address |
DATA_DIR |
./.data |
Where SQLite + local file blobs live (one folder = one backup) |
API_KEY |
(unset) | If set, locks the whole instance with one bearer key |
ADMIN_KEY |
(unset) | If set, enables multi-tenant projects (manage via /v1/admin) |
CORS_ORIGIN |
(unset → *) |
Comma-separated browser origin allowlist; unset = open CORS |
AUTH_JWT_SECRET |
(unset) | HS256 secret to verify end-user identity tokens (req.user, /v1/me) |
AUTH_JWKS_URL |
(unset) | OIDC JWKS endpoint (RS/ES) — alternative to AUTH_JWT_SECRET |
AUTH_JWT_ISSUER / AUTH_JWT_AUDIENCE |
(unset) | Required iss / aud enforced on tokens (recommended) |
LOG_LEVEL |
info |
Log verbosity: trace…fatal, or silent |
CORS is browser-ready out of the box: every REST method (GET/POST/PUT/PATCH/
DELETE + OPTIONS preflight) is allowed cross-origin, the API's request headers
(Authorization, Content-Type, If-Match, X-Project-Id, X-User-Token) are
accepted, and its response headers (ETag, X-Request-Id, the rate-limit headers,
Content-Disposition) are exposed so the SDK can read them. CORS_ORIGIN only
restricts which origins may call the instance.
Data lives in a single SQLite file under DATA_DIR out of the box. For durable or
multi-instance deploys, point it at Postgres — the schema is created automatically
on first boot, and the API + SDK are byte-for-byte identical.
| Variable | Default | Purpose |
|---|---|---|
DB_DRIVER |
sqlite |
sqlite (file under DATA_DIR) or postgres |
DATABASE_URL |
(unset) | Postgres connection string when DB_DRIVER=postgres |
PG* |
(unset) | Standard libpq vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE) — an alternative to DATABASE_URL |
# Postgres (or set the PG* vars instead of DATABASE_URL)
DB_DRIVER=postgres DATABASE_URL=postgres://user:pass@host:5432/appUploads land on local disk under DATA_DIR. For durable or multi-instance deploys,
point file storage at S3 or any S3-compatible store. Credentials always come from
the standard AWS chain (env, shared profile, or instance/IRSA role) — never config.
| Variable | Default | Purpose |
|---|---|---|
STORAGE_DRIVER |
disk |
disk (under DATA_DIR) or s3 |
S3_BUCKET |
(unset) | Bucket (required when STORAGE_DRIVER=s3) |
S3_REGION |
(unset) | Region (falls back to AWS_REGION) |
S3_ENDPOINT |
(unset) | Custom endpoint for S3-compatible stores (MinIO/LocalStack) |
S3_PREFIX |
(unset) | Optional key namespace (one bucket → several instances) |
S3_FORCE_PATH_STYLE |
false |
Path-style addressing (MinIO/LocalStack usually need true) |
# AWS S3
STORAGE_DRIVER=s3 S3_BUCKET=my-bucket S3_REGION=us-east-1
# MinIO / LocalStack (S3-compatible)
STORAGE_DRIVER=s3 S3_BUCKET=dev S3_ENDPOINT=http://localhost:9000 S3_FORCE_PATH_STYLE=trueWebsocket pub/sub and presence run in-process by default. Set REDIS_URL and run
multiple server processes to fan messages + presence across them — horizontal
scale with an identical API/SDK.
| Variable | Default | Purpose |
|---|---|---|
REDIS_URL |
(unset) | Redis connection string; enables cross-process realtime fan-out, cluster-wide presence (entries auto-expire if a process dies), and a shared per-IP rate-limit window |
REDIS_URL=redis://localhost:6379 # then run N server processes behind a load balancer/v1/ai/chat speaks one wire format regardless of backend; pick it with AI_PROVIDER.
| Variable | Default | Purpose |
|---|---|---|
AI_PROVIDER |
anthropic |
anthropic, openai, or bedrock |
AI_MODEL |
(provider default) | Default model id (Claude for anthropic; required for openai/bedrock) |
AI_MAX_TOKENS |
4096 |
Default output cap (per-request override allowed) |
ANTHROPIC_API_KEY |
(unset) | Key for provider=anthropic |
AI_BASE_URL / AI_API_KEY |
(unset) | OpenAI-compatible endpoint + key for provider=openai |
AI_HEADERS |
(unset) | Extra request headers (JSON) for provider=openai — non-Bearer auth / gateway headers |
AWS_REGION |
(unset) | Region for provider=bedrock (creds via the standard AWS chain) |
# Anthropic (default)
ANTHROPIC_API_KEY=sk-ant-...
# Any OpenAI-compatible endpoint — OpenAI, Ollama, vLLM, and LiteLLM:
AI_PROVIDER=openai AI_BASE_URL=http://localhost:4000 AI_API_KEY=sk-... AI_MODEL=gpt-4o
# ^ point AI_BASE_URL at your LiteLLM proxy to use any model it fronts (incl. Bedrock)
# Behind a corporate gateway with non-Bearer auth? Add extra headers as JSON
# (custom keys override the default Authorization):
# AI_HEADERS='{"api-key":"…","x-org-id":"acme"}'
# Amazon Bedrock directly (Converse API; creds from env/profile/instance role):
AI_PROVIDER=bedrock AWS_REGION=us-east-1 AI_MODEL=anthropic.claude-3-5-sonnet-20240620-v1:0GET /v1 reports the active provider; /v1/stats reports the active storage backend.
| Variable | Default | Purpose |
|---|---|---|
MAX_UPLOAD_BYTES |
52428800 (50MB) |
Per-file / body size cap |
RATE_LIMIT_MAX |
600 |
Requests per window per IP (0 disables) |
RATE_LIMIT_WINDOW_MS |
60000 |
Rate-limit window length |
SHUTDOWN_TIMEOUT_MS |
10000 |
Max drain time on SIGINT/SIGTERM before forced exit |
src/
index.ts # entrypoint: boot + graceful shutdown
server.ts # builds the Fastify app, registers every module
config.ts # zero-config defaults
db.ts # async DB facade + driver selection (DB_DRIVER)
db/ # sqlite (default) + postgres drivers; per-dialect SQL
lib/id.ts # id generation
modules/
projects/ # ✅ tenants (store.ts) — namespacing for everything below
admin/ # ✅ project management API (ADMIN_KEY-gated)
data/ # ✅ document store (store.ts) + REST routes
acl/ # ✅ per-collection access rules (store.ts)
files/ # ✅ blob storage (storage.ts) + blob.ts driver (disk/s3) + REST routes
ai/ # ✅ chat routes + provider.ts + providers/{anthropic,openai,bedrock}.ts
backup/ # ✅ whole-instance export/import (API_KEY-gated)
meta/ # ✅ /v1 discovery, /v1/stats, /admin
realtime/ # ✅ websocket pub/sub hub (hub.ts) + routes
public/
sdk.js # zero-build browser client (served at /sdk.js)
sdk.d.ts # TypeScript types for the SDK
index.html # demo page built on the SDK
admin.html # dashboard (served at /admin)
llms.txt # one-read API guide for AI agents
examples.html # gallery; examples/*.html — runnable single-file apps
test/
api.test.ts # end-to-end suite over HTTP + websockets
ai-provider.test.ts # AI provider selection + message normalization
migration.test.ts # in-place schema migration
Dockerfile # multi-stage build → slim runtime image
deploy/
*.service # systemd unit for bare-metal installs
Shipped: pluggable backends — optional Postgres (data) and S3 (files)
alongside the SQLite + disk defaults; declared indexed fields (built
CONCURRENTLY on Postgres) for fast queries on large collections; per-collection
JSON Schema validation (opt-in); cursor pagination for stable iteration;
per-project public collections + live feeds via a non-secret project id;
realtime presence (join/leave/typing) with an optional Redis fan-out,
Redis-backed rate limiting, and presence TTLs for multi-process scale;
observability — Prometheus /metrics + a /ready probe; outbound webhooks
(persisted, retried, HMAC-signed); per-project AI provider config; and
verifiable identity (JWT/OIDC) — trusted req.user / /v1/me, trusted presence,
and token auth for locked instances.
Next: row-level per-user authorization (own-your-rows ACL) built on the new verified identity; publish to npm + GHCR (the release workflow is wired; the first tag publishes).