Baselyra is a self-hosted backend-as-a-service: Postgres + auth + storage + realtime + an auto-generated REST API + an admin studio, running as ONE Node process next to ONE Postgres container. It is an alternative to Supabase and Appwrite that a single person can run on a small VPS.
Read this file completely before writing any code. Every module is written by a different agent in parallel; this contract is the only thing keeping them compatible. Do not invent alternative shapes for anything defined here.
- Lightweight. Runtime deps are exactly:
fastify,@fastify/cors,@fastify/rate-limit,@fastify/multipart,@fastify/websocket,@fastify/static,pg,nodemailer. Do not add any other dependency. Usenode:crypto,node:fs/promises,fetchetc. from the standard library. - Postgres enforces authorisation. Never filter rows in JavaScript for
security. Run user-facing queries through
asRole()and let RLS decide. - TypeScript, ESM, strict. Imports of local files use the
.jsextension (import { config } from '../config.js') because the project is NodeNext.noUncheckedIndexedAccessis on — index access yieldsT | undefined. - No placeholders. No
// TODO, no stubbed handlers, no mock data. Every endpoint is fully implemented and works against a real database. - Comments explain why, not what. Do not narrate obvious code. No banner
comments, no section dividers made of
=====.
src/config.ts
config.port, config.host, config.publicUrl, config.siteUrl, config.env
config.db.{url,poolMax,statementTimeoutMs}
config.jwt.{secret,accessTtlSec,refreshTtlSec,issuer}
config.auth.{confirmEmail,allowSignups,minPasswordLength,maxAttempts,attemptWindowSec,otpTtlSec}
config.storage.{root,maxFileBytes}
config.smtp.{host,port,secure,user,pass,from,enabled}
config.ai.{apiKey,baseUrl,model,maxTokens,enabled}
config.realtime.{maxChannelsPerSocket,heartbeatMs}
config.cors.originssrc/errors.ts
class ApiError { status; code; message; details; toJSON() }
badRequest(msg, details?) unauthorized(msg?) forbidden(msg?) notFound(msg?)
conflict(msg) tooLarge(msg) rateLimited(msg?) serverError(msg?)
fromPgError(err): ApiError
toApiError(err): ApiErrorThrow these from route handlers; the global error handler serialises them as
{ "error": { "code", "message", "details" } }.
src/jwt.ts
type Role = 'anon' | 'authenticated' | 'service_role'
interface Claims { sub?, role: Role, email?, app_metadata?, user_metadata?,
session_id?, iss?, aud?, iat?, exp?, nbf?, [k: string]: unknown }
signJwt(claims: Claims, ttlSec?, secret?): string
verifyJwt(token: string, secret?): Claims // throws unauthorized()
mintProjectKeys(secret?): { anonKey, serviceKey }
opaqueToken(bytes?): string // base64url randomsrc/db.ts
pool: pg.Pool
type Sql = pg.PoolClient
query<T>(text, params?): Promise<pg.QueryResult<T>> // owner role, internal tables only
tx<T>(fn: (sql: Sql) => Promise<T>): Promise<T> // owner role, transactional
asRole<T>(role, claims, fn: (sql: Sql) => Promise<T>) // RLS-enforced, USE THIS for user data
healthy(): Promise<boolean>
closePool(): Promise<void>src/context.ts
interface Caller { role: Role; claims: Claims | null; userId: string | null; isService: boolean }
ANON: Caller
callerFrom(req): Caller // throws on bad token
callerOrAnon(req): Caller // bad token degrades to anon
requireUser(caller): string // returns userId or throws 401
requireService(caller): void // throws 403 unless service keyEach route module is a Fastify plugin, default-exported:
import type { FastifyInstance } from 'fastify';
export default async function xRoutes(app: FastifyInstance) { ... }Mounted in src/index.ts as:
| Prefix | File |
|---|---|
/auth/v1 |
src/auth/routes.ts |
/rest/v1 |
src/rest/routes.ts |
/storage/v1 |
src/storage/routes.ts |
/admin/v1 |
src/admin/routes.ts |
/ai/v1 |
src/ai/routes.ts |
/realtime/v1 |
src/realtime/index.ts — exports registerRealtime(app) and shutdownRealtime() instead of a default plugin |
Shared helpers other modules import (write them exactly as named):
src/mail/index.ts→sendTemplate(template: string, to: string, vars: Record<string,string>): Promise<void>andsendMail(to, subject, html, text?): Promise<void>andverifySmtp(): Promise<{ok:boolean; error?:string}>src/auth/password.ts→hashPassword(pw: string): Promise<string>,verifyPassword(pw: string, stored: string): Promise<boolean>src/realtime/index.ts→ also exportsbroadcastToChannel(channel: string, event: string, payload: unknown): voidso other modules can push events.
Schemas: auth, storage, baselyra (internal), public (user data).
Roles: anon, authenticated, service_role (BYPASSRLS), owner baselyra_owner.
auth.users id uuid pk, email citext unique, phone text unique,
encrypted_password text, email_confirmed_at timestamptz,
phone_confirmed_at timestamptz, last_sign_in_at timestamptz,
raw_app_meta_data jsonb default '{}', raw_user_meta_data jsonb default '{}',
is_admin bool default false, banned_until timestamptz,
created_at, updated_at
auth.sessions id uuid pk, user_id uuid fk, refresh_token text unique,
parent_id uuid null, user_agent text, ip inet,
revoked_at timestamptz, expires_at timestamptz, created_at
auth.identities id uuid pk, user_id uuid fk, provider text, provider_id text,
identity_data jsonb, created_at, unique(provider, provider_id)
auth.one_time_tokens id uuid pk, user_id uuid fk, token_hash text, type text
('confirmation'|'recovery'|'email_change'|'magiclink'|'otp'),
payload jsonb, expires_at timestamptz, used_at timestamptz, created_at
auth.attempts id bigserial, identifier text, ip text, created_at -- rate limiting
storage.buckets id text pk, name text, public bool default false,
file_size_limit bigint, allowed_mime_types text[],
owner uuid, created_at, updated_at
storage.objects id uuid pk, bucket_id text fk, name text, owner uuid,
size bigint, mime_type text, checksum text,
metadata jsonb default '{}', created_at, updated_at,
unique(bucket_id, name)
baselyra.settings key text pk, value jsonb, updated_at
baselyra.email_templates name text pk, subject text, html text, text text, updated_at
baselyra.realtime_tables table_name text pk, enabled_at timestamptz
baselyra.audit_log id bigserial, actor uuid, action text, target text,
meta jsonb, ip text, created_at
Helper SQL functions every module and every user policy can call:
auth.uid() -> uuid -- current user id from request.jwt.claims, NULL for anon
auth.role() -> text -- 'anon' | 'authenticated' | 'service_role'
auth.email() -> text
auth.jwt() -> jsonb -- full claims
auth.is_admin() -> bool -- looks up auth.users.is_admin for auth.uid()
baselyra.enable_realtime(tbl regclass) -- attaches the NOTIFY trigger
baselyra.disable_realtime(tbl regclass)All request and response bodies are JSON unless stated.
POST /signup {email,password,data?} -> {user, session|null}
POST /token?grant_type=password {email,password} -> {access_token,token_type,expires_in,refresh_token,user}
POST /token?grant_type=refresh_token {refresh_token} -> same shape
POST /logout -> 204 (auth required)
GET /user -> {user} (auth required)
PUT /user {email?,password?,data?} -> {user} (auth required)
POST /recover {email} -> 200 always (no user enumeration)
POST /verify {type,token,password?} -> {session} | {user}
POST /magiclink {email} -> 200 always
POST /otp {email} -> 200 always
POST /resend {type,email} -> 200 always
GET /admin/users ?page=&per_page=&search= -> {users,total} (service key)
POST /admin/users {email,password,email_confirm?,data?,is_admin?} -> {user} (service key)
DELETE /admin/users/:id -> 204 (service key)
PUT /admin/users/:id {...} -> {user} (service key)
The user object never includes encrypted_password.
PostgREST-compatible subset over any table or view in public.
GET /:table ?select=&<col>=<op>.<val>&order=col.asc.nullslast&limit=&offset=&or=(...)&and=(...)
POST /:table body object or array; header `Prefer: return=representation|minimal`,
`Prefer: resolution=merge-duplicates` for upsert
PATCH /:table ?filters body object
DELETE /:table ?filters
POST /rpc/:fn body = named args -> function result
Operators: eq neq gt gte lt lte like ilike match imatch in is isdistinct fts plfts phfts wfts cs cd ov sl sr nxr nxl adj not. select supports column lists, aliases
(alias:col), casts (col::text), and one level of embedded resources via
foreign keys (author:users(id,name)). Range responses set Content-Range.
GET /bucket -> [bucket]
POST /bucket {id,name?,public?,file_size_limit?,allowed_mime_types?}
GET /bucket/:id
PUT /bucket/:id
DELETE /bucket/:id -- refuses if not empty
POST /object/:bucket/* multipart or raw body -> {Key}
GET /object/:bucket/* streams the file (RLS checked)
GET /object/public/:bucket/* only for public buckets, no auth
PUT /object/:bucket/* overwrite
DELETE /object/:bucket/*
POST /object/list/:bucket {prefix?,limit?,offset?,sortBy?} -> [object]
POST /object/move {bucketId,sourceKey,destinationKey}
POST /object/copy {bucketId,sourceKey,destinationKey}
POST /object/sign/:bucket/* {expiresIn} -> {signedURL}
GET /object/sign/:bucket/*?token= downloads via signature, no auth header
Objects live at ${config.storage.root}/${bucket}/${key}. Reject .. and
absolute paths in keys. Metadata rows in storage.objects are read and written
through asRole() so bucket policies apply.
Client → server frames:
{"type":"subscribe","channel":"public:messages","filter":"room_id=eq.42"}
{"type":"unsubscribe","channel":"..."}
{"type":"broadcast","channel":"room:42","event":"typing","payload":{}}
{"type":"presence","channel":"room:42","state":{"name":"Ali"}}
{"type":"ping"}Server → client frames:
{"type":"subscribed","channel":"..."}
{"type":"postgres_changes","channel":"public:messages","event":"INSERT","new":{},"old":{}}
{"type":"broadcast","channel":"room:42","event":"typing","payload":{}}
{"type":"presence_state","channel":"room:42","state":{"<socketId>":{}}}
{"type":"error","message":"..."}
{"type":"pong"}Database changes arrive over LISTEN baselyra_realtime; before delivering a row
to a subscriber, re-read it as that subscriber's role and drop it if RLS hides
it. Broadcast and presence channels are in-memory and never touch the database.
POST /login {email,password} -> {access_token,user} (rate limited, no key needed)
GET /overview -> counts, db size, table sizes, recent audit entries
GET /schema -> [{schema,name,kind,columns[],primaryKey[],foreignKeys[],policies[],rowEstimate}]
POST /sql {query,params?} -> {columns,rows,rowCount,durationMs} -- audited
GET /tables/:schema/:table/rows ?limit=&offset=&orderBy= -> rows for the grid
POST /tables {schema,name,columns[]} -- create table
DELETE /tables/:schema/:table
POST /policies {schema,table,name,command,using?,check?,roles[]}
DELETE /policies/:schema/:table/:name
GET/PUT /settings
GET/PUT /email-templates/:name
POST /email-templates/:name/test {to} -> sends a rendered preview
GET /keys -> {anonKey,serviceKey,jwtSecretSet:true}
GET /realtime -> enabled tables + live socket count
POST /realtime/:schema/:table / DELETE -- toggle the NOTIFY trigger
GET /logs ?limit= -> audit log
POST /sql {prompt} -> {sql, explanation} (admin) — schema-aware
POST /explain {sql} -> {explanation} (admin)
POST /chat {messages,system?,stream?} -> assistant reply (any authenticated user)
POST /ask {question} -> {answer, sql?, rows?} (admin) — may run a read-only query
GET /status -> {enabled, model}
config.ai.enabled is false when DEEPSEEK_API_KEY is unset; every route then
returns 503 ai_disabled rather than crashing. Use fetch against
${config.ai.baseUrl}/chat/completions with an OpenAI-compatible body.
Each agent leaves ONE runnable check for its own non-trivial logic, using
node:test + node:assert, at test/<area>.test.ts. Pure-logic tests (filter
parsing, JWT, password hashing, template rendering, path safety) must run with
no database. Do not add a test framework.
React 19 + Vite + Tailwind v4, TypeScript. Dark-first, high information density, the register of Linear / Vercel / Stripe dashboards. Concretely:
- Type: Inter (UI) and JetBrains Mono (all SQL, keys, identifiers), self-hosted woff2, never a Google Fonts CDN link.
- Neutral palette built from one slate ramp; exactly one accent used sparingly for primary actions and focus rings. No gradient headers, no glassmorphism, no purple-to-pink, no emoji as iconography, no rounded-3xl cards with heavy shadows, no centred hero marketing layout inside the app.
- Layout: fixed left sidebar (project switcher, section nav), a dense top bar with breadcrumb + actions, content that fills the viewport. Tables are real data grids: sticky headers, 32px rows, monospace for values, inline edit.
- 1px borders in a slate tone do the separation work; shadows are for popovers only. Border radius stays 4–6px.
- Responsive down to a phone: sidebar collapses to a drawer, grids scroll horizontally inside their own container, the page body never scrolls sideways.
- Every interactive element has a visible focus ring and an accessible name.
Keyboard:
Cmd+Kcommand palette,Cmd+Enterruns SQL.