Baselyra is small on purpose. The most valuable contribution is usually the one that removes something, or that makes an existing thing correct in a case it currently gets wrong.
- Bug report — open an issue with the version (
GET /health), what you did, what happened, and the smallest reproduction you can manage. A failingcurlis ideal. - Feature — open an issue first. Anything that adds a runtime dependency, a container, or a concept will be declined; see Non-negotiables.
- Security — do not open a public issue. See Security.
src/
index.ts Fastify bootstrap; mounts every prefix, serves the Studio
config.ts All configuration, read from the environment
db.ts two pools: pool (project) + controlPool (control database);
query(), tx(), asRole() ← route user data through asRole
jwt.ts signJwt / verifyJwt / mintProjectKeys
context.ts Caller from headers; projectUserId()
errors.ts ApiError and the SQLSTATE → HTTP map
auth/ /auth/v1 — routes, service, password hashing
rest/ /rest/v1 — catalog introspection and the query compiler
storage/ /storage/v1 — buckets, objects, signed URLs
realtime/ ws /realtime/v1 — change feeds, broadcast, presence
admin/ /admin/v1 — the Studio's API, schema tooling
platform/ Studio accounts: control.platform_users, tokens, roles
import/ /admin/v1/import — Supabase, Postgres, Appwrite, Firebase, SQL dumps
ai/ /ai/v1 — the DeepSeek relay
mail/ SMTP and template rendering
metrics/ request metering, flushed to the control database
db/project/ *.sql for the project database (auth, storage, public)
db/control/ *.sql for the control database (Studio accounts, audit)
studio/ React 19 + Vite + Tailwind v4 admin console
sdk/js/ @baselyra/client — zero-dependency JS/TS client
scripts/ setup, migrate, smoke, backup, restore
deploy/ nginx and Apache vhosts
test/ node:test, one file per area
You need Node 22+ and a Postgres 17 you can reach.
git clone https://github.com/baselyra/baselyra.git
cd baselyra
npm install
cp .env.example .env # or ./scripts/setup.shPoint DATABASE_URL at your database. The quickest one is the compose file's:
docker compose up -d db
# then in .env:
DATABASE_URL=postgres://baselyra:<password>@127.0.0.1:5432/baselyra(That needs the db port published — add a compose.override.yml with
ports: ["127.0.0.1:5432:5432"]; the shipped file deliberately publishes
nothing.)
node scripts/migrate.js # creates both databases, applies db/project + db/control,
# creates the first Studio account
npm run dev # node --watch --experimental-strip-types src/index.tsThe server listens on PORT (3000 by default). The Studio's dev server proxies
to 127.0.0.1:3000, so leave that port alone while developing the frontend:
cd studio
npm install
npm run dev # http://localhost:5173Build both the way the Docker image does:
npm run build # tsc -> dist/
cd studio && npm run build # tsc --noEmit && vite build -> studio/dist/npm test # node --experimental-strip-types --test test/*.test.tsEvery test runs with no database, no build step and no environment. That is a hard rule: the pure logic worth testing — filter parsing, JWT handling, password hashing, path safety, template rendering, platform role decisions, the import module's safety rules — must be reachable on a bare checkout.
Two consequences you will hit immediately:
- Test files import
../src/foo/bar.tswith a.tsextension.node --teststrips types in place; it does not remap a.jsspecifier onto a TypeScript file the way a bundler does. - A module that cannot be loaded without a database is a module whose logic
wants extracting.
src/import/types.tsis the pattern: it imports nothing local at all, which is what lets its rules be tested directly.
Add one runnable check per piece of non-trivial logic you write. Not a suite, not fixtures, not a framework — the smallest thing that fails if the logic breaks.
Against a running instance, the end-to-end check is:
./scripts/smoke.sh http://127.0.0.1:3130 admin@example.com 'password'It exercises auth, REST, storage, realtime, the admin API and the Studio, and cleans up after itself. A change is not finished until it passes.
These are the constraints that keep Baselyra worth choosing. A pull request that breaks one will be declined however good it is otherwise.
Dependencies are frozen. The server has exactly eight: fastify,
@fastify/cors, @fastify/rate-limit, @fastify/multipart,
@fastify/websocket, @fastify/static, pg, nodemailer. The Studio has
react, react-dom, react-router-dom and Tailwind v4. The client SDK has
none. Use node:crypto, node:fs/promises, fetch and the rest of the
standard library, or write the twenty lines by hand.
Postgres enforces authorisation. Never filter rows in JavaScript for
security. User-facing queries go through asRole() and RLS decides. A route
handler that checks ownership itself is a bug even when it happens to be
correct, because the same data is reachable through three other paths.
Every SQL value is a bound parameter. Identifiers are validated against the
catalog or a strict regex and quoted with quoteIdent. There is no third
option. If you find yourself interpolating a request value into a statement,
stop.
TypeScript, ESM, strict, noUncheckedIndexedAccess. Local imports in src/
carry a .js extension (NodeNext). The Studio uses bundler resolution and no
extension.
No placeholders. No TODO, no stubbed handler, no mock data, no "coming
soon". If it is in the tree, it works against a real database.
Studio colours come only from the CSS variables in studio/src/styles.css,
written as bg-[var(--panel)], text-[var(--text-muted)],
border-[var(--border)]. No hex, no Tailwind palette colours — the theme switch
depends on it. The rest of the design contract is in STUDIO.md.
Comments explain why, not what. Do not narrate obvious code. No banner
comments, no ===== dividers. A comment earns its place by recording a decision
someone would otherwise undo.
- Small modules with one job. Routes unpack and shape; decisions live in a service module beside them.
- Errors come from
src/errors.tsand are thrown, not returned. The global handler serialises them as{ error: { code, message, details } }. - Prefer an explicit
ifover a clever expression. The person reading it is on-call at 3am. - Studio pages are lazy-imported by
App.tsxand default-export a component named after the file. They import from../components/ui,../components/icons,../components/AppLayout,../lib/hooks,../lib/types,../lib/formatand../lib/api— neverfetchdirectly. - After any DDL from the Studio, call
invalidateSchema()from../lib/hooks.
Add a new numbered file in db/project/ or db/control/, whichever database
the change belongs to. Never edit an applied migration in a release that
shipped.
Which one: anything the application uses — auth, storage, public, the
project's baselyra configuration — is a project migration. Anything about
operating Baselyra — Studio accounts, the audit log, import history, request
metering — is a control migration. If a new feature stores operator state, it
goes in the control database and reaches it through controlQuery() /
controlTx(), never query(). Putting it in the project database would hand it
to the SQL editor and to anything holding the service key.
Every file must be re-runnable: create table if not exists, drop policy if exists before create policy, catalog checks before an alter.
scripts/migrate.js checksums each file and re-runs one that changed, which is
the intended way to evolve the schema during development — and it is only safe
because of that rule.
Each file runs inside one transaction, so a migration that fails halfway leaves nothing behind.
If your change creates a table in public, enable RLS in the same file.
ALTER DEFAULT PRIVILEGES grants anon and authenticated full DML on new
tables there, so a table without RLS is world-writable the moment it exists.
- One logical change per pull request.
- Present-tense subject describing the change:
reject unfiltered DELETE, notfixed stuff. - Say what you ran:
npm test,scripts/smoke.sh, manual steps. - Update the docs in the same pull request. A behaviour change with stale docs is an incomplete change.
- New behaviour that a self-hoster can misconfigure gets a line in the relevant
docs/page, not just a code comment.
Do not open a public issue for a vulnerability. Email the maintainers privately with a description, an assessment of impact, and a reproduction. You will get an acknowledgement, and a fix or an explanation.
Areas where a report is especially welcome:
- Anything that reads or writes rows RLS should have hidden.
- Anything that lets a project user reach
/admin/v1, or turns an application token into a platform one. - Path traversal or symlink escape in storage keys.
- SQL injection through an identifier path — a column name, a table name, a sort column, a cast.
- Token handling: reuse detection, session revocation, signature verification.
- A credential appearing in a log, an audit row, an error message or an import progress stream.
By contributing you agree that your contribution is licensed under the Apache License 2.0, the same as the project. See LICENSE.