Skip to content

Latest commit

 

History

History
306 lines (269 loc) · 14.9 KB

File metadata and controls

306 lines (269 loc) · 14.9 KB

Baselyra — build contract

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.

Non-negotiables

  • 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. Use node:crypto, node:fs/promises, fetch etc. 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 .js extension (import { config } from '../config.js') because the project is NodeNext. noUncheckedIndexedAccess is on — index access yields T | 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 =====.

Already written — import these, do not recreate

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.origins

src/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): ApiError

Throw 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 random

src/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 key

Module contract

Each 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.tssendTemplate(template: string, to: string, vars: Record<string,string>): Promise<void> and sendMail(to, subject, html, text?): Promise<void> and verifySmtp(): Promise<{ok:boolean; error?:string}>
  • src/auth/password.tshashPassword(pw: string): Promise<string>, verifyPassword(pw: string, stored: string): Promise<boolean>
  • src/realtime/index.ts → also exports broadcastToChannel(channel: string, event: string, payload: unknown): void so other modules can push events.

Database schema (written by the db agent, relied on by everyone)

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)

HTTP surface

All request and response bodies are JSON unless stated.

Auth — /auth/v1

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.

REST — /rest/v1

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.

Storage — /storage/v1

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.

Realtime — ws /realtime/v1?apikey=<jwt>

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.

Admin — /admin/v1 (all require is_admin on the user, or the service key)

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

AI — /ai/v1 (DeepSeek)

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.

Testing

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.

Studio (admin UI) design direction

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+K command palette, Cmd+Enter runs SQL.