# Palbase — getting-started, backend, server, machines > Palbase — the backend for apps. Every project has auth, storage, realtime, > flags, notifications, secrets, analytics, AI and purchases, called from web > apps with the publishable key (`palbase/web`), from iOS (Swift) and Android > (Kotlin) with the native SDKs, and from servers with the secret key > (`palbase/server`). Your own backend runs on a machine built from a GitHub > branch on every push, beside a Postgres database of its own. Backends are > `palbase/backend`: `@Controller` classes whose methods are routes, > `@Injectable` services that arrive by constructor, a `@Module` that lists what > exists, `PalbaseFactory.create(AppModule)` in `src/main.ts`, and the schema > declared with Palgres in `src/db/public.ts`. A push to the branch is the > deploy; the CLI (`npx palbase`) does the rest — client apps, the purchase > catalog, environments, tokens. # Introduction Palbase is a backend platform in two halves. **Every project has a set of modules** — auth, storage, realtime, flags, notify, secrets and analytics — that an app uses with no backend of its own, the way it would use Firebase: sign users in, keep their files, push them messages, read flags, count events. And when a project needs code of its own, it runs on **machines** built from a GitHub branch on every push, beside **Postgres databases** of its own — with a TypeScript framework, `palbase/backend`, that is made for exactly that. You can use either half without the other. Most projects start with the modules from an app, and add a backend when a rule has to live somewhere an app cannot change it. ## A taste An app signs a user up with the project's publishable key: ```ts // app/signup.ts import { createClient } from "palbase/web"; const palbase = createClient({ url: "https://.palbase.studio", publishableKey: "pb__c…" }); const { user } = await palbase.auth.signUp({ email: "ada@example.com", password: "a passphrase of fifteen+" }); console.log(user.id); ``` A server — any server — sends that user an email with the secret key: ```ts // server/welcome.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); await palbase.notify.sendEmail({ body: { to: "ada@example.com", subject: "Welcome", text: "Glad you are here." } }); ``` And a backend of your own is a class whose methods are routes, served on a machine: ```ts // src/modules/notes/notes.controller.ts import { Body, Controller, Database, Post, User, z, type UserT } from "palbase/backend"; const CreateNote = z.object({ title: z.string().min(1).max(200) }); type CreateNote = z.infer; @Controller("/notes") export class NotesController { // Every route needs a signed-in user unless it says otherwise; @User() is them. @Post("") async create(@Body(CreateNote) body: CreateNote, @User() user: UserT): Promise<{ id: string }> { const [row] = await Database.$query<{ id: string }>("insert into notes (owner, title) values ($1, $2) returning id", [user.id, body.title]); return row; } } ``` Push it to its branch, and the machine builds it and rolls it out beside the version that serves. The [Quickstart](/docs/getting-started/quickstart) does all three, end to end. ## What every project has | Module | What it does | From an app (publishable key) | From a server (secret key) | |---|---|---|---| | **auth** | Users, sessions, passwords, passkeys, second factors, magic links, phone codes, social sign-in, test users | [Web Auth](/docs/web/auth) | [Authentication](/docs/backend/authentication), [Test Users](/docs/backend/test-users) | | **storage** | Buckets and files, public reads, signed URLs, image renditions | [Uploads](/docs/web/uploads) — through a URL a server signed | [Storage](/docs/backend/storage), [Direct Uploads](/docs/backend/direct-uploads) | | **realtime** | Channels over one socket: broadcasts, shared state, presence; a backend's gateways | [Web Realtime](/docs/web/realtime), [Web Gateways](/docs/web/gateways) | [Realtime](/docs/backend/realtime), [Gateways](/docs/backend/gateways), [Channels](/docs/backend/channels) | | **flags** | Feature flags and remote config, per-user values | [Web Flags](/docs/web/flags) | [Flags](/docs/backend/flags) | | **notify** | Email, push, SMS and WhatsApp; templates, topics, preferences | a user's devices, preferences and topics | [Notifications](/docs/backend/notifications) | | **secrets** | The project's secrets, versioned, in two sets | — | [Secrets](/docs/backend/secrets) | | **analytics** | Events, identifies, page views; summaries, funnels and retention | [Web Analytics](/docs/web/analytics) | [Analytics](/docs/analytics/overview), [Reading Analytics](/docs/analytics/reading) | | **ai** | The platform's models: answers, tools, agents, and chats with the members a backend names | Through a backend's gateway: a chat's members join it ([Web Gateways](/docs/web/gateways#chat-gateways)) | [AI](/docs/backend/ai) | Each module answers at the project's address, under a path of its own (`/auth/…`, `/storage/…`, `/flags/…`), and each has a contract — the OpenAPI document the SDK's clients are generated from. The SDKs are a convenience over those paths, not a requirement: anything that speaks HTTPS can call them. And for code of your own: | | | |---|---| | **Machines** | Your program, built from a repository's branch and directory on every push; sizes that sleep and sizes that never do; logs, builds and rollback — [Machines](/docs/machines/overview) | | **Databases** | Postgres 18 with pgvector and PostGIS, backups and point-in-time restore, a read-only SQL console in Studio — [Databases](/docs/machines/databases) | | **`palbase/backend`** | Controllers, services and modules in TypeScript, secure by default, with the project's modules and its database built in — [Backend SDK](/docs/backend/overview) | ## The surfaces | Surface | What it is | Docs | |---|---|---| | `palbase/web` | The client for web and JavaScript apps: `createClient` with the publishable key — auth, the session, the realtime socket | [Web SDK](/docs/web/overview) | | `palbase/server` | The client for servers: `createServer` with a secret key — every module's server calls, and the receiver for the project's webhooks | [Server SDK](/docs/server/overview) | | `palbase/backend` | The framework a machine runs: `PalbaseFactory`, `@Module`, `@Controller`, and `Palbase`, `Database` and `Log` | [Backend SDK](/docs/backend/overview) | | `palgres` | The database half of the framework: the schema in TypeScript, typed queries, and migrations that run beside the version that serves | [Schema](/docs/backend/schema), [Schema changes](/docs/backend/migrations) | | Studio | Organizations, projects and environments; every module's screens; machines, databases, keys and secrets | [Organizations](/docs/getting-started/organizations) | Palbase has SDKs in TypeScript (`palbase/web`, `palbase/server`, `palbase/backend`), Swift and Kotlin. An iOS app takes the Swift SDK, `palbase-swift` with Swift Package Manager ([iOS SDK](/docs/ios/overview)), and an Android app the Kotlin one, `studio.palbase:palbase` and an artifact per module ([Android SDK](/docs/android/overview)). An app on another platform, such as a game engine, calls the modules over HTTPS with the publishable key in the `apikey` header and the user's access token as `Authorization: Bearer …`, the same requests the SDKs make. The TypeScript SDKs are one npm package, `palbase`, whose parts are its subpaths — the CLI comes with it (`npx palbase`) — and `palgres`, which `palbase/backend` brings: `npm install palbase` (or `bun add palbase`). They ship compiled, under the Palbase SDK License ([Project Structure](/docs/getting-started/project-structure)). ## Organizations, projects and environments An **organization** is a team, a **project** is an app in it, and an **environment** is the thing that runs: each one a project of the platform's own, with **nothing shared** with another environment — its own address, its own keys, its own users and files, its own machines and databases. A staging copy of production is a second environment, with its machines built from another branch. Everyone in the organization reaches its projects at their role, owner, admin or member: members, invitations, roles and the audit log are in [Organizations](/docs/getting-started/organizations). A project's **first environment** has the slug `main`. Every environment answers at one address, named by its **ref**: ```text https://.palbase.studio ``` The ref is on the environment's **Settings**, and it never changes. A machine of the environment answers under the app domain — `https://.app.palbase.studio` for the one named `backend` — and a database at `-.db.palbase.studio`. ## The keys An environment has **publishable keys** — each [registered app](/docs/apps/overview)'s own, the one the environment was made with, and any you make — and **any number of secret keys**, on **Settings › API keys**: | Key | Looks like | Role | Where it goes | |---|---|---|---| | Publishable | `pb__c…` | `anon` | In apps: a browser bundle, a mobile app. What it reaches is what an app may do, and the signed-in user's token decides whose data. | | Secret | `pb__s…` | `service_role` | On servers only: a machine's secrets, a server's environment. It opens the whole project — every user, every file, every secret. | The ref is in the key, so a key names its project. A secret key is made with a name (**Create secret key**), **shown once**, and revoked on its own (**Revoke**) — give each server its own, and a leaked one is replaced without touching the others. `createClient` refuses a secret key, and `createServer` refuses to run in a browser. Every request to the project carries one of them in the `apikey` header; a request with none is `401 missing_api_key`, and a key of another project is the same as none (`401 invalid_api_key`). A user's access token, when one is sent, must be one this project issued (`401 invalid_token` otherwise). Past the project's requests-per-second limit, a request is `429 rate_limited` with `Retry-After: 1`; past its requests for the period, under a quota that refuses, `429 quota_exceeded` with `Retry-After` until the next period. > **Note:** The project's address answers a browser on any site. A browser app calls it directly, with the publishable key and the signed-in user's token, as [Web SDK](/docs/web/overview#calling-the-project-from-a-browser) explains. Your own machine's backend answers cross-origin requests itself, for the origins it names. ## How code reaches a machine One way: **a push to the machine's branch.** 1. [Connect GitHub](/docs/machines/github) — install **Palbase Cloud** on the account that owns the repository, and connect it to the environment. 2. Open a machine on a repository, a branch and a directory of it (**Backend › Machines › New machine**). 3. Push. A push that changes the machine's directory is built — its `Dockerfile`, or what Railpack detects — and rolled out beside the version that serves; it takes traffic once it answers its health check. There is no deploy command and no tool to install. A release whose machine migrates a database runs the migration itself, beside the version that serves; a release that does not come up never takes a request; and any build that succeeded can be returned to ([Builds, Logs & Rollback](/docs/machines/builds)). ## Related - [Quickstart](/docs/getting-started/quickstart) — a web app, a backend, a machine, end to end - [Architecture](/docs/getting-started/architecture) — how a backend's code is shaped - [Project Structure](/docs/getting-started/project-structure) — what a backend repository holds - [Web SDK](/docs/web/overview) · [Server SDK](/docs/server/overview) · [Backend SDK](/docs/backend/overview) - [Machines](/docs/machines/overview) · [Databases](/docs/machines/databases) # Quickstart In this guide you create a Palbase project in Studio, sign a user up and in from a web app, write a small backend with `palbase/backend`, put it on a machine by pushing it to GitHub, and call it from the web app as the signed-in user. Nothing here is run from a command line of Palbase's — the project is Studio's, the deploy is `git push`. ## Before you start | You need | Why | |---|---| | **A Studio account** | where the project lives — Palbase is opening step by step: [request access](/signup) | | **A GitHub repository** you can push to | a machine is built from a branch of it | | **Bun 1.4** — [bun.sh](https://bun.sh) | the backend runs on Bun, and `bun install` writes the lockfile its build installs from | | **Node.js 22** | the web app (Next.js) | > **Note:** The SDK is one npm package, `palbase` — `palbase/web` for the app, `palbase/backend` for the backend (it brings `palbase/server` and `palgres`), and the CLI (`npx palbase`): `npm install palbase` (or `bun add palbase`). ## 1. Create a project In Studio, **Projects › Create project**: a **Project name**, its **First environment**, and the **Organization** it belongs to (asked only when you have more than one). The first environment's slug is always `main`. Open the environment. Everything in this guide is that environment's — it is a project of its own, with its own address, keys, users, machines and databases: - its **address**, `https://.palbase.studio` — the ref is on **Settings**; - its **publishable key**, `pb__c…`, on **Settings › API keys** — the key apps ship. ## 2. Sign a user up from a web app Start a Next.js app and give it the web SDK: ```json { "dependencies": { "palbase": "^0.10.0", "next": "^16.3.4", "react": "^19.2.7", "react-dom": "^19.2.7" } } ``` and three settings, in `.env.local`: ```text NEXT_PUBLIC_PALBASE_URL=https://.palbase.studio NEXT_PUBLIC_PALBASE_PUBLISHABLE_KEY=pb__c… NEXT_PUBLIC_BACKEND_URL=https://.app.palbase.studio ``` **A browser calls the project's address directly.** The address answers a page on any site, so nothing stands between the app and the project ([Web SDK](/docs/web/overview#calling-the-project-from-a-browser) says what it answers, and why the browser is the one to call it). Next.js needs no setting for the SDK: the package is compiled JavaScript. The client is made once, in the browser, with the project's address: ```ts // lib/palbase.ts import { createClient, type PalbaseClient } from "palbase/web"; let client: PalbaseClient | undefined; /** The project's client: its publishable key, and the signed-in user's session. */ export function palbase(): PalbaseClient { client ??= createClient({ url: process.env.NEXT_PUBLIC_PALBASE_URL!, publishableKey: process.env.NEXT_PUBLIC_PALBASE_PUBLISHABLE_KEY!, }); return client; } ``` And a page that signs up, signs in, and — after step 6 — calls the backend: ```tsx // app/page.tsx "use client"; import { useState } from "react"; import { PalbaseError } from "palbase/web"; import { palbase } from "../lib/palbase"; export default function Home() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [said, setSaid] = useState(""); async function attempt(work: () => Promise) { try { setSaid(await work()); } catch (e) { setSaid(e instanceof PalbaseError ? `${e.status} ${e.code}: ${e.message}` : String(e)); } } const signUp = () => attempt(async () => { const { user } = await palbase().auth.signUp({ email, password }); const session = await palbase().auth.currentSession(); return session ? `signed up and in as ${user.id}` : `signed up as ${user.id}: verify the address, then sign in`; }); const signIn = () => attempt(async () => { const result = await palbase().auth.signIn(email, password); return result.user ? `signed in as ${result.user.id}` : "this account asks for a second factor"; }); const callBackend = () => attempt(async () => { const session = await palbase().auth.currentSession(); if (!session) return "sign in first"; const res = await fetch(`${process.env.NEXT_PUBLIC_BACKEND_URL}/me`, { headers: { authorization: `Bearer ${session.accessToken}` }, }); return `${res.status} ${await res.text()}`; }); return (
setEmail(e.target.value)} placeholder="email" /> setPassword(e.target.value)} placeholder="password (15+ characters)" type="password" />

{said}

); } ``` Run it (`npm run dev`), sign up, and the user is in **Auth › Users** in Studio. A password of a single factor is 15 characters at least. A project that asks for a verified email answers the sign-up without a session; the verification is [Web Auth](/docs/web/auth)'s to explain. You have used one module — auth — with no backend at all. Storage, realtime, flags, notify and analytics are the same: every project has them, the publishable key reaches what an app may do, and the secret key reaches the rest ([Introduction](/docs/getting-started/introduction)). ## 3. Write the backend A backend is its own directory — its own repository here — that a machine builds: ```text notes-backend/ ├── Dockerfile ├── .env.example ├── package.json ├── bun.lock ├── tsconfig.json └── src/ ├── main.ts ├── app.module.ts ├── me.controller.ts └── modules/notes/ ├── dto.ts ├── notes.controller.ts ├── notes.module.ts └── notes.service.ts ``` ```json { "name": "notes-backend", "private": true, "type": "module", "scripts": { "start": "bun run src/main.ts" }, "dependencies": { "palbase": "^0.10.0" } } ``` The decorators read the types TypeScript emits, so `tsconfig.json` turns both on: ```json { "compilerOptions": { "target": "es2023", "module": "esnext", "moduleResolution": "bundler", "strict": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "allowImportingTsExtensions": true, "noEmit": true, "skipLibCheck": true }, "include": ["src/**/*.ts"] } ``` The schemas of a domain sit in its `dto.ts` — each one a value for `@Body` and a type of the same name: ```ts // src/modules/notes/dto.ts import { z } from "palbase/backend"; export const CreateNote = z.object({ title: z.string().min(1).max(200) }); export type CreateNote = z.infer; export const Note = z.object({ id: z.string(), owner: z.string(), title: z.string(), created_at: z.string() }); export type Note = z.infer; ``` The service holds the logic. This one keeps notes in the machine's memory — enough to see the whole path work; [step 8](#8-where-next) gives it a database: ```ts // src/modules/notes/notes.service.ts import { Injectable, NotFound } from "palbase/backend"; import type { CreateNote, Note } from "./dto"; // In this process only: a machine that sleeps, or a second copy, does not have them. @Injectable() export class NotesService { private readonly notes = new Map(); list(owner: string): Note[] { return [...this.notes.values()].filter((n) => n.owner === owner); } create(owner: string, input: CreateNote): Note { const note: Note = { id: crypto.randomUUID(), owner, title: input.title, created_at: new Date().toISOString() }; this.notes.set(note.id, note); return note; } get(owner: string, id: string): Note { const note = this.notes.get(id); if (!note || note.owner !== owner) throw new NotFound(`no note ${id}`); return note; } } ``` The controller is HTTP only — and every route needs a signed-in user unless it says otherwise: ```ts // src/modules/notes/notes.controller.ts import { Body, Controller, Get, Param, Post, User, type UserT } from "palbase/backend"; import { CreateNote, type Note } from "./dto"; import { NotesService } from "./notes.service"; @Controller("/notes") export class NotesController { constructor(private readonly notes: NotesService) {} @Get("") list(@User() user: UserT): Note[] { return this.notes.list(user.id); } @Post("", { rateLimit: { max: 30, window: 60 } }) create(@Body(CreateNote) body: CreateNote, @User() user: UserT): Note { return this.notes.create(user.id, body); } @Get("/{id}") read(@Param("id") id: string, @User() user: UserT): Note { return this.notes.get(user.id, id); } } ``` ```ts // src/modules/notes/notes.module.ts import { Module } from "palbase/backend"; import { NotesController } from "./notes.controller"; import { NotesService } from "./notes.service"; @Module({ controllers: [NotesController], providers: [NotesService] }) export class NotesModule {} ``` `/me` answers who the caller is: ```ts // src/me.controller.ts import { Controller, Get, User, type UserT } from "palbase/backend"; @Controller("/me") export class MeController { @Get("") me(@User() user: UserT): { id: string; email: string | null; emailVerified: boolean } { return { id: user.id, email: user.email, emailVerified: user.emailVerified }; } } ``` The root module lists what exists, and `main.ts` serves it. The browser calls the backend from the web app's origin, so the backend names that origin (CORS is the backend's own to answer): ```ts // src/app.module.ts import { Module } from "palbase/backend"; import { MeController } from "./me.controller"; import { NotesModule } from "./modules/notes/notes.module"; @Module({ imports: [NotesModule], controllers: [MeController] }) export class AppModule {} ``` ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; // Serves on $PORT. The project's address, id and secret key come from the // environment: the machine is started with them. await PalbaseFactory.create(AppModule, { cors: { origins: (process.env.WEB_ORIGINS ?? "http://localhost:3000").split(",") }, }); ``` How the machine runs it: ```dockerfile FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile --production COPY tsconfig.json ./ COPY src ./src USER bun CMD ["bun", "run", "src/main.ts"] ``` And what it needs before it can start — the build reads this file, and waits rather than roll out an app that would not: ```text PALBASE_URL= PALBASE_PROJECT_ID= PALBASE_SECRET_KEY= ``` Run `bun install` once to write `bun.lock`, and commit everything. ## 4. Give it the secret key The machine is started with `PALBASE_URL` and `PALBASE_PROJECT_ID` by the platform. `PALBASE_SECRET_KEY` is yours to give — the backend uses it to reach the project's modules and to check its callers' sessions. 1. **Settings › API keys › Secret keys**: a **Key name** (`notes backend`), **Create secret key**. It is shown **once** — copy it now. 2. **Environment › Variables**, **Add secret**: the name `PALBASE_SECRET_KEY`, the key as its value. The secret is in the environment's **production** set. A machine starts with the set it names, which you choose in the next step. ## 5. Connect GitHub and open the machine **Backend › Machines › New machine**: 1. **Connect GitHub** — GitHub asks where to install **Palbase Cloud**; choose the repository, and connect it to this project by writing its ref when Palbase asks. Back in Studio, **Refresh** ([Connecting GitHub](/docs/machines/github)). 2. **Repository**: `you/notes-backend`; **Branch**: `main`; **Directory**: empty (the repository's root). 3. **Name**: `backend` — the machine of that name answers at `https://.app.palbase.studio`. **Size**: `shared-256`. **Port**: `8080`. 4. **Create machine**. The branch's head is built at once. Its build stops at **Waiting for variables**, `PALBASE_SECRET_KEY` missing: the machine starts with no secrets yet. Open it, **Edit settings**, **Secrets: Production secrets**, **Save settings** — and the waiting build rolls out by itself. `.env.example` is what caught it ([Builds](/docs/machines/builds#envexample-what-the-program-must-be-given)). ## 6. Push From now on, a push is the deploy: ```bash git commit -am "Notes: read one by id" git push origin main ``` The commit gets a **Palbase Cloud** check on GitHub — *Building*, then *Rolled out* — and the machine page lists the build with its log. The new version starts beside the old one and takes traffic once it answers its health check; a version that does not come up never takes a request. ## 7. Call it Without a token, the backend refuses — in the envelope every refusal comes in: ```bash curl -i https://.app.palbase.studio/me # HTTP/2 401 # {"error":"unauthorized","error_description":"This route needs a signed-in user","status":401,"request_id":"req_…"} ``` From the web app, signed in, **Call the backend** answers `200` with the user's id and email: the browser sent the session's access token, and the backend checked it against the project. Notes work the same way — `POST /notes` with `{"title": "…"}` and the same header. What the backend writes is on its machine page under **Logs**, and `Log` from `palbase/backend` puts the request's id on every line ([Builds, Logs & Rollback](/docs/machines/builds#logs)). Every request to the environment's address, whichever module answers it, is on Studio's **Logs** page ([Logs](/docs/getting-started/logs)). ## 8. Where next - **A database.** **Database › Databases › New database**, then give it to the machine with **Run migrations** on. Declare the table in `src/db/public.ts`, pass it to `PalbaseFactory.create(AppModule, { db: { schemas: [schema] } })`, and the next push migrates it before the new version serves: [Databases](/docs/machines/databases), [Database](/docs/backend/database), [Schema](/docs/backend/schema). - **Who sees what.** Row-level security in the schema keeps each user's rows theirs, whatever a route forgets: [Row-Level Security](/docs/backend/row-level-security). - **The other modules** from the backend — files, email, flags, realtime — are `Palbase.storage`, `Palbase.notify`, `Palbase.flags`, `Palbase.realtime`: [Storage](/docs/backend/storage), [Notifications](/docs/backend/notifications). - **More environments.** A staging environment is another environment of the project, with machines built from another branch. ## Related - [Introduction](/docs/getting-started/introduction) — the modules, the keys, and environments - [Architecture](/docs/getting-started/architecture) — how a backend's code is shaped - [Project Structure](/docs/getting-started/project-structure) — every file of the backend above - [Machines](/docs/machines/overview) · [Connecting GitHub](/docs/machines/github) · [Builds, Logs & Rollback](/docs/machines/builds) - [Web SDK](/docs/web/overview) · [Web Auth](/docs/web/auth) — the client in full - [Calling Your Backend](/docs/web/backend-calls) — the browser side of step 7 # Organizations Studio has three levels. An **organization** is a team: its people and their roles, its access tokens and its audit log. A **project** is an app, in one organization. An **environment** is what runs: each one a Palbase project of its own, with its own address, keys, users, files, machines and databases, and nothing shared with another. ```text Organization its members and their roles, access tokens, the audit log └── Project an app └── Environment main, staging, a developer's own: address, keys, users, files, flags, machines, databases ``` Everyone in an organization reaches every project and environment of it, at their role in the organization. There is no role per project or per environment. ## Organizations Before your first project, Studio asks you to create your organization, and you are its **owner**. Any account makes more: ```sh palbase orgs:create "Acme" palbase orgs:list # your organizations and your role in each ``` An organization's **Projects** page lists its projects, and an admin renames it under **General**. ## Projects and environments An admin creates a project, in Studio (**New project**) or from the CLI; it opens with its first environment: ```sh palbase projects:create shop --org acme # its first environment, main, and its keys palbase envs:create staging -P shop # a second environment palbase envs:create staging -P shop --from main # opened with main's module settings ``` - **The first environment's slug is `main`**, whatever it is called on screen: it is the project's production one, and a CLI change to it asks for confirmation. - **Another environment's slug** starts with a letter and continues with up to 38 letters, digits or hyphens; `local` is not one. An environment's name on screen can change; its slug never does. - **`--from`** opens the new environment with another's module settings: secrets by name, not by value. With `--with-cloud` its machines and databases are declared alike too, built from the same branches, with no data. Each environment has its own address and keys ([Introduction](/docs/getting-started/introduction#the-keys)), and its own usage ([Usage & Limits](/docs/getting-started/usage-and-limits)). An admin deletes an environment under its **Settings › Danger zone**, or with `palbase envs:delete `; deleting a project, under its **Settings › Delete project** or with `palbase projects:delete `, deletes every environment of it. An environment's users, files, flags, keys and secrets go with it, and its machines and databases are deleted too. ## Members and invitations An organization's **Members** page lists its people, each with their role and when they joined; its admins also see the invitations still open. An admin invites someone by email, as an **admin** or a **member** (member unless said), with **Add member** or from the CLI: ```sh palbase orgs:members:invite ada@example.com --role admin --org acme palbase orgs:members:list --org acme palbase orgs:members:remove ada@example.com --org acme # a member, or an invitation still open ``` - **Accepting.** The invited person signs in to Studio with the email the invitation names, and accepts it there. The email must be verified: an account whose address is not is refused (`403 email_not_verified`). An invitation does not expire; it waits until it is taken or withdrawn. - **Inviting again** keeps the invitation, with the new role and the new inviter. - **The mail.** An invitation is mailed from Palbase when the inviter's own email is verified. An address gets one invitation mail a day from an organization and five a day from all of them together; past that the invitation still waits for it in Studio, unmailed. - **How many.** An inviter makes 20 invitations an hour and 100 a day, and an organization 20 an hour, however many admins it has (`429` with `Retry-After`). A member leaves an organization with `palbase orgs:members:remove` and their own email. An admin takes away a member or an admin; only an owner takes away an owner. The organization's last owner stays: make another owner first (`409 last_owner`). A person who leaves, or is taken away, takes with them what they held at the organization's environments: the CLI's keys they were given, and the access tokens they made. - **Owners hear of every new role.** When someone — or an access token — is given a role in one of the organization's environments, or a higher one, each owner is mailed: who, which role, which environments, when. An owner whose own owner role is taken is mailed too. The same person and role is mailed to an owner once a day for an environment, the environments it came in named in one mail. The mail comes from Palbase's platform itself, not from Studio, to the address the owner signs in with: if you did not expect it, take the person away under **Members** and write to security@pallasite.net. ## Roles | | Member | Admin | Owner | |---|:---:|:---:|:---:| | Open every project and environment, and read its screens: users, files, flags, logs, usage, machines and their builds | ✓ | ✓ | ✓ | | See which secrets there are and their versions, and the API keys by their last characters | ✓ | ✓ | ✓ | | Read with a module's questions that change nothing: a funnel, a retention table, the database's read-only SQL console, a machine's `GET` from **Try it** | ✓ | ✓ | ✓ | | Change anything in an environment: settings, users, flags, files, templates, machines, databases, secrets | | ✓ | ✓ | | Read a secret's value, a task's payload, a coupon's one-time codes | | ✓ | ✓ | | Make and revoke API keys; take a CLI key (`palbase env:pull`, `palbase run`); mint environment tokens | | ✓ | ✓ | | Create, rename and delete projects and environments | | ✓ | ✓ | | Invite and take away members and admins; withdraw invitations; rename the organization | | ✓ | ✓ | | Manage access tokens; read the audit log | | ✓ | ✓ | | Make or unmake an owner; take away an owner | | | ✓ | A member's change to an environment is refused with `403 console_role_required`, and the refusal is kept in the audit log. In Studio, an owner changes a member's role from the **Members** list. A role is read on every request: a member made less than an admin keeps none of the CLI keys they had at the organization's environments, and the admin tokens they made stop working at once. An access token acts at its role, member or admin and never owner, and no more than its maker's role now ([Access Tokens](/docs/getting-started/access-tokens)). ## Your Studio account **Account security**, in the account menu at the top right, is your own account's: - **Two-factor authentication**: a second step when you sign in, with an authenticator app or by SMS, and **recovery codes** for when you have neither. They are shown once: keep them where you keep passwords. - **Passkeys**: sign in with this device's fingerprint, face or screen lock, or a security key, instead of a password. Add one, rename it, or remove it. - **Connected accounts**: sign in with Google or GitHub, when Studio offers them. The last way you sign in cannot be removed: add a password or a passkey first. A change to how you sign in needs a sign-in from the last five minutes; an older one is asked to sign in again first. Turning off a second factor asks for your current password. The CLI signs in as you, and holds a session of yours ([CLI](/docs/cli/overview#signing-in)). ## The audit log An organization's **Audit log** says who changed what at its environments, newest first: the change, the environment, who made it — a member by their email, an access token or an environment token by its name — the method and path, and the status it was answered. It holds every change made through Studio, the CLI and tokens; a change refused for its role; and every read of a secret's value. The path is kept without its query and the body not at all, since what was sent can hold a secret. Plain reads are not kept. Its admins read it in Studio, or from the CLI: ```sh palbase orgs:audit --org acme --limit 100 ``` A module keeps a change made through Studio as Studio's: the audit log is where whose it was is kept. See [`palbase orgs:audit`](/docs/cli/reference#palbase-orgsaudit). ## Related - [Access Tokens](/docs/getting-started/access-tokens) — for CI and agents, at a role of the organization - [Usage & Limits](/docs/getting-started/usage-and-limits) — what each environment uses - [CLI](/docs/cli/overview) — signing in, and choosing an environment - [CLI reference](/docs/cli/reference#orgs) — `orgs`, `projects` and `envs` # Architecture A Palbase backend is a plain TypeScript repository, and **one declaration says what exists, who owns it and what it may reach**: a `@Module` lists a domain's classes — its `controllers`, its `providers`, what it `exports` and whose exports it `imports` — and a class that no module lists is **refused at boot**, by name. The root module is the one `src/main.ts` hands to `PalbaseFactory.create`; every other module is reached from it through `imports`. Dependencies arrive through the **constructor**, and the container supplies them: a dependency is named by its parameter's **type** and by nothing else. There is no `@Inject`, no `useFactory`, no `forwardRef` and no token registry. What the platform gives every handler — the project's modules, the request's database, the log — is **imported**, never injected: `Palbase`, `Database` and `Log` from `palbase/backend`. This page answers one question: **what shape does the code take?** It is the shape of Palbase's own examples and of Studio's backend; [Project Structure](/docs/getting-started/project-structure) lists the files, and [Modules & Dependency Injection](/docs/backend/modules) the full declaration surface. ## The layers | Layer | What it does | What it never does | |---|---|---| | `src/main.ts` | Boots the app: `PalbaseFactory.create(AppModule, config)` — the database's schemas, CORS, the routes' default auth and limit | holds routes or logic | | `src/app.module.ts` | The root: `imports` every domain's module | owns a domain's classes | | `src/modules//.module.ts` | The declaration: which classes the domain owns, and which of them another domain may reach | holds logic · lists a class another module owns | | `src/modules//.controller.ts` | HTTP only: validates input through a schema, picks the caller, turns the service's answer into the response. Listed in `controllers` | imports `Database` · holds business rules · keeps per-request state on the instance | | `src/modules//.service.ts` | The decisions: which rows, whose, in what order; the calls to the project's modules. `@Injectable()`, listed in `providers` | knows about HTTP (no request, no response) · builds its own dependencies | | `src/modules//dto.ts` | Schemas — each a **value** for `@Body` and a same-named **type** | holds logic | | `src/db/public.ts` | **Is** the database: tables, columns, indexes, [row-level security](/docs/backend/row-level-security) | produces migration files — there are none | In one line: **controllers thin, services thick, schemas quiet, `db/` the single truth — and the module is the registration.** A domain is a **folder** with a module in it, and adding one is adding a folder and naming its module in the root's `imports`. The file suffixes (`.controller.ts`, `.service.ts`) are a convention that helps a reader and decides nothing: what decides is the list a class appears in and the decorator it carries. The rule that carries the most weight is the narrowest: **a controller does not import `Database`.** When a route starts reaching for a table, the logic has moved into the layer that is hardest to test, and the layer that was meant to hold it becomes decoration. ## Where does this go | The need | Its home | |---|---| | Validate input | a schema in the module's `dto.ts`, passed to `@Body(Schema)` / `@QueryParams(Schema)` | | Enforce ownership | a row-level-security policy in `src/db/public.ts` **and** the service's `where` — the policy is the backstop, not a reason to stop writing the filter | | A business rule, a calculation, an ordering | the service | | Answer with an HTTP status | `throw new NotFound(…)` (or `Forbidden`, `Conflict`, `PalError`) — from the service or the controller; no response object is needed | | Call another domain | name its class in your constructor — its module `exports` it, and yours `imports` that module | | Send an email, sign a file URL, read a secret | `Palbase.notify`, `Palbase.storage`, `Palbase.secrets` — in the service | | Work at start-up or shutdown | an `onStart()` / `onShutdown()` method on a provider — awaited in dependency order | | Work on a schedule | a [cron](/docs/backend/cron) — not a timer: a machine that sleeps runs none | | Work done once, now or later | a [task](/docs/backend/tasks) on the machine's queue — not a cron that looks in a table every minute | | Register a new class | add it to a module's `providers` (or `controllers`) — nothing else registers anything | | A setting | an environment variable of the machine; a secret is a project secret the machine starts with | ## One vertical, end to end A `notes` domain, in the order you would build it. ### 1. The database ```ts // src/db/public.ts import { defineSchema, defineTable, index, policy, text, timestamp, uuid } from "palbase/backend"; // Each user's notes, theirs alone: row-level security says so, whatever a route forgets. const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: text(), title: text(), created_at: timestamp().defaultNow(), }, policies: (p) => [ policy("notes_own").for("all").to("authenticated").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid())), ], indexes: (p) => [index("notes_owner_created").on(p.col("owner"), p.col("created_at"))], }); export default defineSchema("public", { tables: [notes] }); ``` One file per schema, each with a default export. There are no migration files: a release migrates the database to this declaration beside the version that serves ([Schema changes](/docs/backend/migrations)). The request runs as its caller — `authenticated`, with the token's claims — so `auth.uid()` is the signed-in user, and the policy holds for every query of the request. > **Note:** `ownedByUser()` is the column type made for `owner`: when the user is deleted, their rows go. The platform tells the app through the project's webhook (`user.deleted`), so an app whose schema has an `ownedByUser()` or `userRef()` column does not boot without `PALBASE_WEBHOOK_SECRET`, the secret of the project's webhook endpoint — and an endpoint cannot be made from Studio. A project without one keeps its owner column a `text()` with a policy, as here. See [Event Hooks](/docs/backend/hooks). ### 2. The schemas ```ts // src/modules/notes/dto.ts import { z } from "palbase/backend"; export const CreateNote = z.object({ title: z.string().min(1).max(200) }); export type CreateNote = z.infer; export const ListNotes = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }); export type ListNotes = z.infer; export const Note = z.object({ id: z.string(), title: z.string(), created_at: z.string() }); export type Note = z.infer; ``` Each schema is exported twice under one name: the **value** a decorator validates with, and the **type** a signature reads. Any Standard Schema works — zod, valibot, arktype; `z` is re-exported from `palbase/backend` so the app and the framework agree on one zod. ### 3. The service ```ts // src/modules/notes/notes.service.ts import { Database, Injectable, Log, NotFound } from "palbase/backend"; import type { CreateNote, Note } from "./dto"; @Injectable() export class NotesService { async list(owner: string, limit: number): Promise { // RLS already narrows this to the caller's rows; the filter says what the route means. return Database.public.notes.findMany({ where: { owner }, select: ["id", "title", "created_at"], orderBy: { column: "created_at", direction: "desc" }, limit, }); } async create(owner: string, input: CreateNote): Promise { const note = await Database.public.notes.insert({ owner, title: input.title }); Log.info("note created", { note: note.id }); return { id: note.id, title: note.title, created_at: note.created_at }; } async remove(owner: string, id: string): Promise { const gone = await Database.public.notes.deleteMany({ where: { id, owner } }); if (gone === 0) throw new NotFound(`no note ${id}`); } } ``` `Database` is **the request's own transaction**: it is opened by the first query, committed when the handler returns — before the answer goes — and rolled back when it throws. A service the handler calls shares it, so a route's writes land together or not at all. It is reached inside a request (a handler, a service it calls, an `@On` handler), never at import time or from a timer. `Database.public.notes` is typed by the declaration file Palgres writes from `src/db/public.ts` ([Database](/docs/backend/database)): a column that does not exist, or a value of the wrong type, is a compile error. Raw SQL is `Database.$query(sql, params)`. ### 4. The controller ```ts // src/modules/notes/notes.controller.ts import { Body, Controller, Delete, Get, Param, Post, QueryParams, User, type UserT } from "palbase/backend"; import { CreateNote, ListNotes, type Note } from "./dto"; import { NotesService } from "./notes.service"; @Controller("/notes") export class NotesController { // The container supplies this. Nothing here constructs or imports an instance. constructor(private readonly notes: NotesService) {} @Get("") list(@QueryParams(ListNotes) query: ListNotes, @User() user: UserT): Promise { return this.notes.list(user.id, query.limit); } @Post("", { rateLimit: { max: 30, window: 60 } }) create(@Body(CreateNote) body: CreateNote, @User() user: UserT): Promise { return this.notes.create(user.id, body); } @Delete("/{id}") remove(@Param("id") id: string, @User() user: UserT): Promise { return this.notes.remove(user.id, id); } } ``` Every route needs a signed-in user unless it, its controller or the app's config says otherwise (`{ auth: false }`); `@User()` is that user. A body that fails its schema is a `400` naming the fields, a caller over the route's limit a `429`, a thrown `NotFound` a `404` — all in the platform's one envelope ([Responses & Errors](/docs/backend/errors)). A handler that returns nothing answers `204`; anything else is JSON. ### 5. The module ```ts // src/modules/notes/notes.module.ts import { Module } from "palbase/backend"; import { NotesController } from "./notes.controller"; import { NotesService } from "./notes.service"; @Module({ controllers: [NotesController], // its entry points providers: [NotesService], // the classes it owns exports: [], // which of them another module may reach imports: [], // whose exports this module may reach }) export class NotesModule {} ``` That list **is** the registration, and the only place ownership is decided. `exports` stays empty until another domain needs one of these classes; until a name appears there, nothing outside the module can reach it. Imports are not transitive: a module reaches the exports of the modules it imports, and of no others. ### 6. The root and the boot ```ts // src/app.module.ts import { Module } from "palbase/backend"; import { NotesModule } from "./modules/notes/notes.module"; @Module({ imports: [NotesModule] }) export class AppModule {} ``` ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; import schema from "./db/public"; // Serves on $PORT with its database; run by a release with PALBASE_DB_MIGRATE, // it migrates the database to this schema and exits. await PalbaseFactory.create(AppModule, { db: { schemas: [schema] } }); ``` At boot the container reads every module, refuses what is wrong — a class no module lists, a dependency from a module that is not imported or a class it does not export, a cycle — naming the path that led to it, builds every class once, runs their `onStart()` and serves. The project's address, id and secret key come from the machine's environment. ## Never write this These are the shapes a code assistant reaches for by default. Every one is wrong here: - **Express-style handlers** — `app.get(...)`, `(req, res) => …`. A route is a **method on a class**. - **A `ctx` object or middleware** — there is no context to thread and no pipeline to mount; auth, validation, limits and the error envelope are the framework's. - **A class no module lists** — refused at boot, by name. - **`@Inject`, `useFactory`, `forwardRef`** — a dependency is named by its constructor parameter's type and nothing else. - **An `interface` as a dependency** — it has no runtime existence. Use a class, or an `abstract class` with one listed subclass: the container gives the one owned subclass for it, and refuses by name when there are none or two. - **NestJS lifecycle names** — `onModuleInit`, `onApplicationBootstrap`, `onModuleDestroy` … are refused at boot. The hooks are `onStart()` and `onShutdown()`. - **A hand-wired singleton** — `export const notes = new NotesService()`. Mark it `@Injectable()`, list it, and let the container build it once. - **I/O in a constructor** — it runs while the app comes up, synchronously. Start-up work goes in `onStart()`, which is awaited before the first request. - **A timer for scheduled work** — a machine that sleeps runs no timer, and three copies run three. Use a [cron](/docs/backend/cron), or a [task](/docs/backend/tasks) for work done once. - **State in the instance that must outlive a request** — a machine may run several copies, and sleeps; what must be shared goes in the database. - **A migration file** — there are none. The schema file is the declaration. - **`try { … } catch { return 500 }`** — throw the error class and let the framework build the envelope; swallowing it turns a `404` into a `500`. - **Hand-built responses** — no `new Response(...)`, no `JSON.stringify`. Return the value; return nothing for `204`. - **A secret in the repository** — the secret key and every credential are project secrets the machine starts with; `.env.example` names them, never their values. ## Adding a feature 1. `src/db/public.ts` — declare the table and its policies. 2. `src/modules//dto.ts` — the schemas, each a value and a type. 3. `src/modules//.service.ts` — the logic; `@Injectable()`, its dependencies as constructor parameters. 4. `src/modules//.controller.ts` — the routes; the service in the constructor, never `Database`. 5. `src/modules//.module.ts` — list them; name the module in the root's `imports`. 6. Regenerate the tables' declaration file, if the schema changed ([Database](/docs/backend/database)). 7. Push. The release migrates the database beside the version that serves, then rolls the new version out. ## Give this to your agent For the full reference — every decorator, every module call, every schema builder — point your coding assistant at **`/llms.txt`**, the index of these docs, or **`/llms-backend.txt`**, the slice for writing a backend: getting started, the backend SDK, the server SDK and machines, in one file. Assistants fetch those when you name them; they do not go looking. ## Related - [Modules & Dependency Injection](/docs/backend/modules) — the four lists, and what is refused at boot - [Project Structure](/docs/getting-started/project-structure) — every file of a backend repository - [Controllers & Routing](/docs/backend/controllers) — the routing and decorator surface - [Request Validation](/docs/backend/validation) — `@Body`, `@QueryParams`, `@Param`, `@Headers` - [Responses & Errors](/docs/backend/errors) — the envelope, and the error classes - [Database](/docs/backend/database) · [Schema](/docs/backend/schema) · [Row-Level Security](/docs/backend/row-level-security) — the database half - [Testing](/docs/backend/testing) — services and the HTTP layer, with no network # Project Structure A Palbase backend is a directory a [machine](/docs/machines/overview) builds: a TypeScript app that Bun runs as it is, a `Dockerfile` that says how, and a `.env.example` that names what it must be given. Nothing in it is generated by a tool of Palbase's, and nothing is read by its location but the file `src/main.ts` starts with — what exists is what the modules list. This page walks that directory file by file. The layout is the one Palbase's own examples and Studio's backend use. ## The layout ```text notes-backend/ ← the machine's directory (its `path`) ├── Dockerfile how the machine builds and runs it ├── .env.example the variables it must be given ├── package.json its dependencies — `palbase` among them ├── bun.lock what `bun install --frozen-lockfile` installs ├── tsconfig.json decorators on, with their emitted types └── src/ ├── main.ts PalbaseFactory.create(AppModule, config) ├── app.module.ts the root module: imports every domain ├── db/ │ └── public.ts the `public` schema: tables, indexes, policies └── modules/ ├── notes/ │ ├── dto.ts schemas: each a value and a type │ ├── notes.controller.ts routes │ ├── notes.service.ts logic │ └── notes.module.ts what the domain owns, and exports └── core/ ├── core.module.ts └── health.controller.ts ``` Two rules decide everything else: - **A class exists when a module lists it.** A `@Controller`, an `@Injectable()` or an `@On` handler that no module lists stops the boot, naming it. A folder grants nothing. - **A domain is a folder with a module in it**, named in the root module's `imports` (or in the imports of a module the root imports). ## `src/main.ts` The process starts here, and it is short: ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; import schema from "./db/public"; await PalbaseFactory.create(AppModule, { db: { schemas: [schema] }, cors: { origins: ["https://app.example.com"] }, }); ``` `PalbaseFactory.create` builds every class, runs their `onStart()` and serves — on `$PORT`, which the machine sets. Everything in its config is the backend's own, with a default: | Option | Default | | |---|---|---| | `palbase` | from the environment | The project: `url` (`PALBASE_URL`), `projectId` (`PALBASE_PROJECT_ID`), `secretKey` (`PALBASE_SECRET_KEY`), `webhookSecret` (`PALBASE_WEBHOOK_SECRET`) | | `auth` | a signed-in user | Every route's auth unless the route or its controller says otherwise | | `rateLimit` | 300 a minute per caller | Every route's limit unless it says otherwise; `false` for none | | `bodyLimit` | 1 MiB | The largest request body | | `cors` | off | The origins browsers may call from: `{ origins: [...] }` or `"*"` | | `db` | none | The app's database: its `schemas`, and its address unless `PALBASE_DB_URL` says | | `openapi` | `true` | Serve the app's contract to the platform, which Studio's backend screens read | | `port`, `host` | `$PORT` (else 8080), `0.0.0.0` | Where it listens | | `boot` | 5 attempts, 1 s doubling | A start whose `onStart()` fails is tried again from scratch | A value missing or malformed stops the boot before any request is served, naming both ways to give it. What cannot heal by waiting — a missing setting, a DI mistake — stops at once; a database not up yet is waited for. See [Backend SDK](/docs/backend/overview). ## `src/app.module.ts` ```ts // src/app.module.ts import { Module } from "palbase/backend"; import { CoreModule } from "./modules/core/core.module"; // One import per domain: NotesModule joins this list beside CoreModule. @Module({ imports: [CoreModule] }) export class AppModule {} ``` The root owns nothing of its own here; it is the list of domains. A small app may list a controller or two in it directly. ## `src/modules//` One folder per domain, and in it the four files of [Architecture](/docs/getting-started/architecture): the module, the controller, the service and the `dto.ts`. A domain that needs another's class names it in a constructor, and its module imports the module that exports it: ```ts // src/modules/core/health.controller.ts import { Controller, Get } from "palbase/backend"; @Controller("/health", { auth: false }) export class HealthController { @Get("") health(): { ok: true } { return { ok: true }; } } ``` ```ts // src/modules/core/core.module.ts import { Module } from "palbase/backend"; import { HealthController } from "./health.controller"; @Module({ controllers: [HealthController] }) export class CoreModule {} ``` A route's path is its own: `/health` above is the app's, at the machine's address. Two first segments are not yours — `webhooks`, where the platform delivers the app's events and each `@Webhook` class takes a third party's ([Webhooks](/docs/backend/webhooks)), and `_palbase`, the platform's own way in — and a controller mounted under either is refused. ## `src/db/` One file per Postgres schema, each with a **default export**, `defineSchema("", { tables })`: `public.ts` for `public`, `billing.ts` for a `billing` schema. `src/main.ts` hands them to `db.schemas`. ```ts // src/db/public.ts import { defineSchema, defineTable, text, timestamp, uuid } from "palbase/backend"; const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: text(), title: text(), created_at: timestamp().defaultNow(), }, }); export default defineSchema("public", { tables: [notes] }); ``` A release migrates the database to them beside the version that serves; there are no migration files ([Schema](/docs/backend/schema), [Schema changes](/docs/backend/migrations)). `Database.public.` is typed by a declaration file written from those schemas with Palgres' `makeEnvDts` — commit it beside them, and write it again when a schema changes ([Database](/docs/backend/database)). An app with no database has no `src/db/`, and no `db` in its config. ## `package.json` ```json { "name": "notes-backend", "private": true, "type": "module", "scripts": { "start": "bun run src/main.ts" }, "dependencies": { "palbase": "^0.10.0" } } ``` The SDK is one npm package, `palbase`: `npm install palbase` (or `bun add palbase`). A backend imports the framework from `palbase/backend`, which brings what it is built on — `palbase/server` (the project's modules, as `Palbase`) and `palgres` (the database) — and `reflect-metadata`, which the framework loads itself. The package ships compiled JavaScript with its type declarations; Bun runs the app's own TypeScript as it is. `z` from `palbase/backend` is the zod the framework is built with: import it from there, and the app and the framework agree on one. `palbase init` writes this `package.json`. ## `tsconfig.json` ```json { "compilerOptions": { "target": "es2023", "module": "esnext", "moduleResolution": "bundler", "strict": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "allowImportingTsExtensions": true, "noEmit": true, "skipLibCheck": true }, "include": ["src/**/*.ts"] } ``` `experimentalDecorators` and `emitDecoratorMetadata` are what make constructor injection work: the container reads each constructor's parameter **types** from the metadata TypeScript emits. Bun reads them from this file. A runner that does not emit the metadata — esbuild, and the tools built on it — leaves the container nothing to read, and the boot stops naming the class and the cure. `noEmit` is right: nothing is compiled ahead of time, and `tsc --noEmit` is the type check. ## `Dockerfile` ```dockerfile FROM oven/bun:1.4.2-alpine@sha256:d888c0ae6c86d7866ff10c5aafdd9077b36aee6455b33dd270fb93c0dd5cef6f WORKDIR /app COPY package.json bun.lock ./ RUN bun install --frozen-lockfile --production COPY tsconfig.json ./ COPY src ./src USER bun CMD ["bun", "run", "src/main.ts"] ``` The machine builds the directory's `Dockerfile` when it has one; without one, Railpack detects what the directory is and plans the image ([Builds](/docs/machines/builds)). A backend ships its own, for two reasons: the decorators need `tsconfig.json` beside the sources, and `bun run src/main.ts` is exactly how the app is meant to start. Pin the base image by digest, so a rebuild of the same commit builds the same image. The process listens on `$PORT` and stops cleanly on `SIGTERM`: it finishes the requests in flight (20 seconds at most), runs `onShutdown()`, and exits — which is what a rollout asks of the version it replaces. ## `.env.example` ```text # The names this backend reads. The machine is started with them; # their values are never in the repository. PALBASE_URL= PALBASE_PROJECT_ID= PALBASE_SECRET_KEY= ``` A build reads it as the list of variables the program needs, and does not roll out until the machine has every one — its own environment, its secret set, or what the platform gives it. It names variables, never their values: commit it, and keep the values in the project's secrets ([Builds](/docs/machines/builds)). ## What the process is given On a machine, the program finds these in its environment: | Variable | Set by | | |---|---|---| | `PORT` | the platform | The port to listen on | | `PALBASE_URL` | the platform | `https://.palbase.studio` | | `PALBASE_PROJECT_ID` | the platform | The project's id — the `project_id` its access tokens carry | | `PALBASE_REPLICAS` | the platform | How many copies of the machine run | | `PALBASE_PLATFORM_SECRET` | the platform | The key the platform signs its calls to the machine with | | `PALBASE_DB_URL`, `PALBASE_DB_CONNECTIONS` | the platform, given a database | Its address as the app login, and one copy's share of its connections | | `PALBASE_SECRET_KEY` | the platform | The machine's own secret key of the project (`machine: ` among its API keys): `Palbase.*` calls, and checking callers' sessions | | `PALBASE_WEBHOOK_SECRET` | the platform | The secret the project's events reach the machine signed with, at the endpoint the platform keeps for it | And what it answers the platform, at paths the internet never reaches: `GET /_palbase/health` — `200` while it serves, `503` while it stops, the machine's health check. Its contract is written by its build (run with `PALBASE_CONTRACT_OUT`, the app writes it and exits), not asked of it while it serves. ## Running it on your own machine The same directory runs anywhere Bun does: ```bash PALBASE_URL=https://.palbase.studio \ PALBASE_PROJECT_ID= \ PALBASE_SECRET_KEY=pb__s… \ PORT=8080 bun run src/main.ts ``` It is a client of the project like any other: the secret key reaches the project's modules, and a caller's token is checked against the project. Studio does not show the project's id, which a machine is given; it is the `project_id` claim of any access token the project issues — the one your web app's session holds. Off a machine there is no `PALBASE_PLATFORM_SECRET`, so the contract is not served. An app with a database also needs `PALBASE_DB_URL`, a database whose schema it can bind to; a release migrates the database before the version that reads it starts — locally, run the app once with `PALBASE_DB_MIGRATE=start` and the owner's address in `PALBASE_DB_OWNER_URL` ([Schema changes](/docs/backend/migrations)). ## In a repository of several apps A backend need not be a repository of its own. In a monorepo it is one directory of it — the machine's `path` — and an app that imports a sibling package is built with the repository's root as its `context`, its `Dockerfile` copying from there ([Connecting GitHub](/docs/machines/github#one-repository-several-machines)). A push builds the machines whose directory it changed, and no other. ## Related - [Architecture](/docs/getting-started/architecture) — what goes in each file, and why - [Quickstart](/docs/getting-started/quickstart) — this layout, end to end - [Modules & Dependency Injection](/docs/backend/modules) — the four lists, and what the boot refuses - [Builds, Logs & Rollback](/docs/machines/builds) — what the Dockerfile and `.env.example` decide - [Machines](/docs/machines/overview) — the environment, the health check, and the port # Access Tokens An **access token** lets a CI job or an agent act on an organization without a person signing in. It is set as the `PALBASE_TOKEN` secret of the job, and works the way a fine-grained personal token on GitHub or an access token on Vercel does, at the level of the organization. Admins and owners manage tokens in Studio, under **Organization › Access tokens** (`/organizations/{id}/tokens`). Open the organization switcher at the top left and choose **Access tokens**, or choose it in an organization's left menu, or from **Access** in any of its environments. Members do not see them. ## Creating one **Create token** asks for: | Field | Meaning | |---|---| | Name | What the token is for, up to 100 characters on one line: `GitHub Actions` | | Role | **Member** or **Admin**. Never owner | | Expires after | 7, 30, 90 (the default), 180 or 365 days, or a number of days from 1 to 365 | The token starts with `pbo_`. Studio shows it **once**, when it is created. Store it as your CI's `PALBASE_TOKEN` secret; you will not see it again. Afterwards the list shows only its last four characters, as `pbo_…a1b2`, with who made it, when it was made, when it expires and when it was last used. If the answer to creating one is lost, Studio does not send the request again: it asks you to check the list. A token that was made but whose value was lost is deleted and made again. ## What a token can do - It acts on the organization's projects and environments with its role: member or admin, never owner. - An admin token opens projects and environments, registers a project's apps and sets them in each environment, and adds an environment's webhook endpoints — what a program that opens a project for each product does with no one signed in, as a Firebase service account adds an app. - An admin token can get a CLI key. That key lives at most one day and appears in the environment's API keys as `CLI — token ()`. - An admin token links a GitHub installation that another environment of the organization linked to an environment, with no GitHub sign-in: `POST /v1/environments/{ref}/github/installations` with `{ "installation_id": 123, "from": "" }`. The environment reaches the repositories the GitHub user who linked it there can push to, and repositories created later that they can push to. With `"repositories": ["owner/name", …]` (the CLI's `github:connect --from --repo owner/name`) it reaches those alone, and never one created later: what a program that opens a project for each product gives each product, so that one product's environment builds nothing of another's. - An admin token mints [environment tokens](#environment-tokens). ## What a token cannot do - It cannot do what only a person can: manage the organization, its members, roles and invitations, or tokens; create or revoke an environment's API keys; or reach Palbase's own environment. - It never has more than its creator's **current** role. If the creator becomes a member, their admin tokens stop working at once. If the creator leaves the organization or is deleted, their tokens go with them. ## Deleting one Deleting a token makes its next request fail, and the CLI keys it got are revoked. Deleting does not undo what the token did: the deploys it made and the settings it changed stay. The webhook endpoints a token made stay too, and keep receiving events: an endpoint's secret signs what is delivered to it and opens nothing of the platform, so it belongs to its environment, as with Stripe's and GitHub's webhooks. Each endpoint names who made it in **Settings › Webhooks**, and when you delete a token Studio lists the endpoints it made, by address and environment, before you confirm (`GET /v1/orgs/{id}/tokens/{token}/webhooks`). Remove them in their environment if you no longer want them. API keys, which do open the platform, are made by a person only. An expired token is marked **Expired** in the list and no longer works; delete it to tidy the list. ## Environment tokens An **environment token** is for a program that works in **one** environment — an AI agent building one product — for a few hours, the way a GitHub App's installation token is for one installation. It starts with `pbe_`, and it is set as that program's `PALBASE_TOKEN`. It is minted with `POST /v1/environments/{ref}/tokens`, by an admin of the organization (signed in, or with an admin access token) or with the environment's own **secret key** in the `apikey` header and no one signed in: ```json { "expires_in_minutes": 60, "name": "agent" } ``` Studio shows it **once**. It lives 60 minutes unless you ask otherwise, 240 at most, and never longer than the access token or key that minted it. ### What it can do In its environment it does what the role of whoever minted it does there, as their own CLI would: an admin's token reads and changes the environment's machines, databases, users, flags, apps and webhooks, and sets its secrets. A token minted with the secret key acts as an admin. The role is read on every request, so a token never holds more than its minter holds now. Its changes appear in the organization's audit log under its name. Outside its environment it does nothing. Another environment of the project answers `404`. The project itself and the organization answer `403 token_out_of_scope`, and so does anything that changes the project (its name, its apps, its environments). When it reads its project, it sees only its own environment. **A full token of the admin role, or one minted with the secret key, is, in what it can reach, an admin of its environment.** It deploys machines, and the code it deploys runs with a secret key of the environment (`PALBASE_SECRET_KEY`, which the platform gives every machine) and reads the environment's secrets. What that code takes, it keeps after the token has ended. So: - give a full token only to an agent you would trust as an admin of that environment; - for narrow work, mint a token with [`scopes`](#narrowing-it): no scope deploys anything; - the closed list below closes the routes that make or show a credential in their own answer, not what deployed code can do. ### What no environment token can do These routes are closed to every environment token: each one makes an identity, or makes or shows a credential that would outlive the token, in its own answer. Everything else is open at the minter's role, deploying code included (see above). They answer `403 token_out_of_scope` (or `person_only`), whoever minted the token, in Studio and at the environment's modules alike: - mint a token: an environment token, or an organization's access token; - create, revoke or list API keys, or take a CLI key; - change the organization's members, roles or invitations; - delete the environment or the project; - link a GitHub installation from another environment; - sign in as one of the environment's users, or make a password reset for one; - set a user's password or email: create or import users, change a user's email (test users are its to make); - create an OAuth client or a service account, rotate one's key, or change where an OAuth client's codes go; - define a role, or give or take one from a user; make, change or remove an auth hook, whose claims go into every user's token, an app's role among them; - create a database, or make new passwords for one: both answers show its passwords; - read a secret's value, a task's payload (it may carry a token) or a coupon's one-time codes. Setting a secret is allowed; - set or remove a sign-in provider's client secret, or change which apps' sign-ins with it the environment takes (its client ids); - bring or remove a message provider (where the project's mail and texts are sent, and with whose credentials); - override or remove a template that carries a sign-in or reset credential — a reset or magic-sign-in link, a verification or invitation link, a one-time code — on email, SMS or WhatsApp. The app's own notification templates stay open, so an agent still brands them. The last three close the roundabout ways to a user's own credential: a sign-in provider's client of one's own, a message provider aimed at a server of one's own, or a reset template that tucks the link into one it fetches, would catch a sign-in the way signing in as the user would. The sender's own settings stay open — a token sets the project's `from` address and reply-to (`/notify/settings`), and the site and redirect URLs a new product signs in against (`/admin/auth-settings`). These change where a link points, but no credential leaves unless the user follows the link themselves. Each module refuses these itself, on the route it matched, so writing the path another way does not get past this list: an escape, a dot segment, other letter case or a slash at the end all name the same thing. A link the token signs does not outlive it either. A signed download or upload URL, a package's upload link, a link to hand over an Apple or Stripe key, a signed bundle's download link and the link that connects GitHub all end no later than the token. The answer's `expires_at` says when. Test users, and the sign-ins made for them, are open: they are test fixtures, and `auth:test-users` is for them. ### Narrowing it `scopes` narrows a token to what they name, and nothing else is reached, a read as much as a change: ```json { "scopes": ["machines:read", "machines:logs", "auth:test-users"], "expires_in_minutes": 60, "name": "agent" } ``` | Scope | What it reaches | |---|---| | `machines:read` | The environment's machines, their builds, crons and queues (not a task's payload), contracts and metrics | | `machines:logs` | A machine's logs and its builds' logs | | `apps:sync` | What `palbase apps:sync` reads: the project's apps as they are in this environment, their client settings, the machines' contracts | | `auth:test-users` | The environment's test users: made with their sign-ins, listed, deleted. Not other users | | `support` | A question to Palbase's support line (`palbase support:ask`, `POST /ai/support-questions`): an agent asks Palbase what it cannot find out, not the person it works for | ### When it stops working It stops working when it expires, when it is revoked (`DELETE /v1/environments/{ref}/tokens/{id}`, by an admin, by a token of an admin's role, or by a narrowed token itself), and when what minted it no longer holds: the admin is no longer an admin, the access token is deleted, or the secret key is revoked (within a minute). `GET /v1/environments/{ref}/tokens` lists the live ones by their last four characters. # Billing Each environment is billed on its own, by the calendar month (UTC): it has its own plan, its own credit and its own free quota, as a project of its own. The organization holds only what pays — its cards, its billing details and its bank transfers — and an environment's credit is never another's unless an owner or an admin moves it. Each environment is on one of two plans: - **Free**: charged nothing. Every item has a free quota each month, and past it the environment is slowed or stopped ([Usage & Limits](/docs/getting-started/usage-and-limits#what-a-free-project-gets)); its machines and databases are of the free sizes. Every environment starts on Free. Until one of the organization's environments has paid a top-up, a person opens at most 2 projects of 2 environments each — one deleted counts until the month is out, so deleting and opening again brings no new month of free quotas — and a Free project is opened by a person, not an organization's access token. - **Pay as you go**: the environment has credit, loaded ahead in US dollars; its first top-up by card moves it there. What it uses past its own free quota is priced and taken from its credit as it is used, every few minutes; it may open every size of machine and database. There is no monthly fee and no bill afterwards. When an environment's credit runs out, or its month reaches its limit, only it falls back to the free quotas; the organization's other environments go on. The prices, the free quotas and the terms are on the [billing terms](https://palbase.studio/legal/billing) page. ## Opening the payment account An owner or admin opens it in Studio, on the organization's **Billing** page, by accepting the terms for the organization (Studio records who, when, and which version). Nothing is charged. From then on each environment is moved to Pay as you go by its first top-up by card, of $10 or more: **Billing › Environments › Upgrade** on the environment, paid on Stripe's page the first time, which asks for the card, the billing address and, for a business, its VAT number, and saves the card for the organization. Tax is added on top as the billing address requires. Stripe emails an invoice for every top-up, naming its environment; **Billing › Invoices** lists them. Once one of its environments has paid a top-up, the organization is not held to the Free limits on projects and environments. ## Top-ups Each environment is topped up on its own, from **Billing › Environments › Manage**: - **Now**: **Top up** — a usual amount or any other, from any of the organization's saved cards, or another card on Stripe's page (it is saved for next time). - **Automatic**: when the environment's credit comes under a threshold, it is topped up by an amount, at most once an hour; on by default ($20 when under $5), and never past what the environment's monthly limit still needs. **Automatic top-up** changes both or turns it off. It is taken from the organization's default card; when that card declines, the same invoice is paid from the organization's other cards in turn — it is paid once at most. When every card declines, it is tried again once a day for two days, the owners and admins are emailed each time, and while a card declined today no other environment's automatic top-up tries it again that day. A declined invoice is voided (**Invoices** shows it as declined), and each try is a new one. A refund or a dispute of an environment's top-up turns its automatic top-up off. - **By bank transfer**: **Top up › Bank transfer** makes an invoice with bank details of the organization's own; the environment gets the amount when the transfer arrives — one on Free is then moved to Pay as you go with **Manage › Move to Pay as you go**. What the organization's transfers paid past their invoices stays with it, and pays the next top-up of any of its environments first, when it covers the whole of it — a card is charged only otherwise; what is left of it 75 days after it arrived, Stripe returns to the account it came from. An organization with no card is topped up automatically from what its transfers left, while it covers the top-up. Credit can be spent for 12 months; what is left of it then expires. The environment's sheet lists each top-up, bonus and credit moved in with what is left of it and when it expires; the one closest to expiring is spent first. ## Moving credit between environments An owner or an admin moves an environment's credit to another environment of the same organization: all of it, or an amount (**Manage › Move credit**). Each part keeps its expiry. Credit never leaves the organization, and a member cannot move it. An environment whose credit is moved away entirely is moved to Free. Deleting an environment, or a project and its environments, offers to move their credit to another environment first. A deleted environment keeps its credit until it is purged, 7 days after it was deleted — **Billing › Environments** offers to move it until then — and what is left of it is lost when it is purged. The organization's owners and admins are emailed when an environment is deleted holding credit, with how much and when it is lost. ## Cards and billing details **Billing › Payment** lists the organization's cards, shared by its environments. **Add card** saves one on Stripe's page — nothing is charged — and makes it the default unless you say otherwise; any other card can be made the default or removed. The default card is removed only when it is the last one; the automatic top-ups then wait for a card, or a bank transfer. **Edit billing details** sets the name, address and tax number (a VAT number in the EU) the next invoices are made out to. The address is checked for tax at once: one that cannot be placed is refused, and nothing changes. ## What is charged **Usage** shows the month: each environment's plan and what it used, and to an owner or an admin what that came to before its free quota (gross) and what it was charged past it. Any member reads what was used; the amounts are an owner's or an admin's. **Download CSV** gives it item by item — a line per environment and item, units, gross and charged, with its total and what its credit gave — for an organization that bills its own customers per environment. Palbase AI's models, on Palbase's own provider keys, are their provider's price and 10%, and each month brings each environment $1.00 of them free. A project may have a monthly AI budget of its own ([AI](/docs/backend/ai#what-it-costs)). A call under the project's own provider key is the provider's to bill. Requests, push notifications, and email, SMS and WhatsApp sent through the project's own provider are not charged. ## Each environment's own For each environment, an owner or an admin can, on **Billing › Environments › Manage** or from the API: - set a **monthly limit**: at it, the environment falls back to the free quotas until the month ends, while the others go on; - move it to **Free**: it is charged nothing and held to the free quotas; its credit stays, spent once it is back on Pay as you go; - **freeze** it: its requests are refused (`403 project_frozen`), its machines and databases stop, its data is kept, and only what it stores is charged. Unfreezing brings it back. ## When an environment's credit runs out Nothing is owed. What it pays for stops: its machines and databases of a paid size stop (`402 machine_not_paid` while it lasts), Palbase's AI models past its month's free dollar are refused (`402 ai_payment_required`; a project's own provider key goes on), and it is held to its free quotas, as on Free; what is within them goes on. Usage measured in the few minutes before it stopped is taken from its next top-up; what it uses after it is not charged. The organization's other environments are not touched. A top-up brings it back by itself. ## Emails and webhooks The organization's owners and admins are emailed when an environment's credit comes under the days of spend set for it (3 by default, in **Automatic top-up**) — at most once a day —, when it runs out, when its automatic top-up is declined, and when its month reaches its limit — and when an environment is deleted holding credit, a few minutes after, unless it was moved or the environment restored meanwhile. What happens at once to several environments comes in one email, naming each. Each environment's own webhooks are told the same as it happens, as `billing.low_balance`, `billing.balance_exhausted`, `billing.topup_failed` and `billing.limit_reached` ([Webhooks](/docs/server/webhooks#the-events)) — an organization that runs its customers' apps tells them from there. They carry no amount: the amounts are the organization's owners' and admins', on its bill. ## From the terminal The CLI does what the Billing page does, as you or with an admin's [access token](/docs/getting-started/access-tokens) in CI — save opening the payment account and paying on Stripe's page, which are a person's: ```bash palbase billing # each environment's plan, credit, month and limit palbase billing:env shop/staging # an environment's own: its plan, credit, limit and automatic top-up palbase billing:env shop/staging --limit 25 --auto-topup yes --threshold 10 --amount 50 palbase billing:env shop/staging --plan free # or pay_as_you_go; --frozen yes|no palbase billing:topup shop/staging 20 # from the saved card, $10 to $10,000; a Free one moves to Pay as you go palbase billing:bank-transfer shop/staging 500 # an invoice to pay by bank transfer palbase billing:transfer shop/staging shop/main 5 # credit moved, with its expiry; all of it when no amount is named palbase billing:ledger shop/staging --limit 50 # every change of its credit, newest first palbase billing:invoices # the top-ups' invoices, each with its environment palbase billing:charges --period 2026-10 --csv # the month's charges by environment and item, as the page downloads them ``` The environment is `project/environment` or its ref, else the one chosen for the directory; the organization is `--org`'s, else that environment's. Amounts are in US dollars, to the cent. A member reads the plans; the money is an owner's or an admin's. ## The API What Studio's Billing page does, an admin's [access token](/docs/getting-started/access-tokens) does too — save opening the payment account and paying on Stripe's page, which are a person's. The calls are on Studio's API, under the organization: | Call | What it does | |---|---| | `GET /v1/orgs/{id}/billing` | The payment account, and each environment's plan, credit, month and limit | | `GET /v1/orgs/{id}/billing/environments/{ref}` | An environment's own: its plan, credit, automatic top-up, limit and month | | `PATCH /v1/orgs/{id}/billing/environments/{ref}` | Its `plan` (`free`, `pay_as_you_go`), `frozen`, `monthly_limit_micros` (`null`: none), `auto_topup`, `auto_threshold_micros`, `auto_amount_micros`, `low_balance_days` | | `POST /v1/orgs/{id}/billing/environments/{ref}/topups` | A top-up of it from what bank transfers left, else the default card or the one named: `{ "amount_micros": 20000000, "payment_method": "pm_…" }`; `"plan": "pay_as_you_go"` moves a Free one with it | | `POST /v1/orgs/{id}/billing/environments/{ref}/bank-transfers` | An invoice to pay by bank transfer, for it | | `GET /v1/orgs/{id}/billing/environments/{ref}/ledger` | Every change of its credit; `?kind=topup,bonus,transfer` for the credits alone | | `POST /v1/orgs/{id}/billing/transfers` | Credit moved: `{ "from": "", "to": "", "amount_micros": 5000000 }`, all of it without `amount_micros` | | `DELETE /v1/projects/{id}?move_credit_to=` | Deletes a project, its environments' credit moved first; `DELETE /v1/environments/{ref}?move_credit_to=` an environment | | `GET /v1/orgs/{id}/billing/cards` | The cards, the default first | | `PUT /v1/orgs/{id}/billing/cards/{card}/default` | Makes a card the default | | `DELETE /v1/orgs/{id}/billing/cards/{card}` | Removes a card; the default only when it is the last | | `GET` / `PUT /v1/orgs/{id}/billing/customer` | The name, `address` and `tax_id` (`{ "type": "eu_vat", "value": "…" }`, or `null`) the invoices are made out to | | `GET /v1/orgs/{id}/billing/charges?period=2026-10` | The month's charges, by environment and item: units, gross and charged | | `GET /v1/orgs/{id}/billing/charges.csv?period=2026-10` | The same, as CSV | | `GET /v1/orgs/{id}/billing/invoices` | The top-ups' invoices, each with its environment; `?environment=` one's alone | Amounts are in micro-dollars (1,000,000 is $1), in whole cents. A member reads the plans and what was used; amounts and money are an admin's. ## Related - [Usage & Limits](/docs/getting-started/usage-and-limits) — what is metered, and what a free environment gets - [Organizations](/docs/getting-started/organizations) — roles, and who is emailed - [Access Tokens](/docs/getting-started/access-tokens) — an admin token for the API # Usage & Limits Every environment's use is metered by the calendar month (UTC): what its modules did, what its machines and databases ran, and what it sent. Studio shows it on the project's **Settings › Usage**, environment by environment. An environment is held to limits of three kinds, and each one says so in its answer: - **Rates**, per second: a call over one is refused for that second (`429` with `Retry-After: 1`). - **Limits on the month**: a limit set on one of the environment's metrics refuses that use, or only counts it, until the month ends. - **What a free project gets**: its traffic is slowed past 5 GB, its chat messages past a million a month or 1 GiB kept, and a free database sleeps past its 400 hours. None of them stops the project or loses its data. ## Where usage is shown **Project › Settings › Usage** shows one environment at a time (choose it at the top when the project has more than one) over the last 3, 6 or 12 months: - this month's API requests, monthly active users, stored data and machine hours, each beside its months; - **Limits**: each limit set on the environment, with what this month has used against it, and **Blocked** once a limit that refuses is reached; - a chart per month of requests, active users, notifications sent by channel, stored data, download traffic, realtime messages, analytics events, machine hours, machine internet traffic, response traffic and database hours by size; - **Every metered item**: every metric the environment used, this month and the month before. Every member of the organization reads it ([Organizations](/docs/getting-started/organizations#roles)). The organization's owners are also mailed when a free project has sent 80% of its month's traffic and when it has sent all of it, and when a free database has been awake 80% of its hours and all of them. ## What is metered | Metric | What counts | |---|---| | `requests` | **API requests**: each request to the environment's address that a module answers. Not among them: the cache's calls, the stores' reports to purchases (`/purchases/webhooks/…`), and the files the platform moves for a module's own work, such as an AI workspace's | | `auth.mau` | **Monthly active users**: the users who signed in or kept a session alive in the month | | `notify.email`, `notify.push`, `notify.sms`, `notify.whatsapp` | **Email**, **Push**, **SMS** and **WhatsApp** sent: an email through any sender (those through Palbase's shared sender are also `notify.email.shared`), a push delivered to a user on any of their devices, a text or a verification code, a WhatsApp message | | `storage.bytes` | **Stored data**: the objects and their renditions, the month's largest | | `storage.egress` | **Download traffic**: what storage sent, a part of response traffic | | `storage.operations` | The requests storage served | | `storage.transforms` | **Image transformations**: a picture made over from an original (resized, cropped, re-encoded); one answered from what was made before is not one | | `cache.bytes`, `cache.egress` | **Cache memory**, the month's largest, and **Cache reads**, a part of response traffic ([Cache](/docs/backend/cache)) | | `realtime.messages` | **Realtime messages**: each one delivered — a broadcast to ten subscribers is ten | | `realtime.connections` | Realtime connections open at once, the month's largest | | `messaging.messages` | **Chat messages**: each message the project's people, or its server, sent in its conversations; one sent again with the same client id is one | | `messaging.bytes` | **Chat message storage**: what the project's messages weigh where they are kept — each one's sealed text and 768 bytes for its row and indexes — the month's largest | | `flags.reads` | The flag reads clients made, answered in full or not modified | | `analytics.events` | **Analytics events**: captures, identifies, aliases, screens and page views (setting a person's properties is not counted) | | `analytics.perf`, `analytics.traces`, `analytics.console` | Performance samples kept, request traces taken, console records kept ([Analytics](/docs/analytics/overview)) | | `machines.seconds` | **Machine hours**: how long the machines were awake, each copy counted; `machines.seconds.` the same by size | | `machines.egress` | **Machine internet traffic**: what the machines sent to the internet | | `edge.egress` | **Response traffic**: what the environment's answers sent its clients, its modules' and its machines' alike | | `machines.builds.seconds` | **Build hours** | | `machines.images.bytes`, `machines.logs.bytes` | **Machine images** kept, the month's largest, and **Machine logs** kept | | `databases.seconds.` | **Database hours**: how long the databases of each size were awake | | `databases.bytes`, `databases.backups` | What the databases hold, and what their backups hold, the month's largest | | `databases.traffic` | What the databases read from and wrote to their disks | | `databases.egress` | **Database client traffic**: what the databases sent clients that connect to them over their own addresses (`psql`, a backend on your computer) | | `ai.tokens...` | **AI tokens** with Palbase's provider keys, by model and kind: `input`, `image_input`, `output`, `cache_read`, `cache_write`. A model used with the project's own provider key is the provider's to count | | `ai.images..` | **AI images** made, by model | | `ai.machines.seconds`, `ai.voice.seconds`, `ai.web_search.calls` | **AI workspace hours**, **AI voice hours** and **AI web searches** | A metric that is "the month's largest" is a level, not a sum: what the environment held at its most during the month. ## Rates | What | How much | Past it | |---|---|---| | The environment's requests | 200 a second, unless Palbase set another | `429 rate_limited`, `Retry-After: 1` | | What apps send to analytics | 1,000 events, samples, traces and records a second, counted apart from the requests | `429 rate_limited` | | The cache's calls | As many a second as the requests, counted apart from them; 8 MiB read and 2 MiB written a second | `429 rate_limited` | | Your backend's own routes | What each route's `rateLimit` says ([Controllers & Routing](/docs/backend/controllers)) | `429 too_many_requests` | Realtime has rates of its own, which a project may lower ([Web Realtime](/docs/web/realtime)), and so do sign-ins and the messages a project sends. The SDKs send a refused call again on their own, waiting what `Retry-After` asks, when it is a read or a write that cannot be done twice ([Server SDK](/docs/server/overview#retries)). ## Limits on the month A limit on the month names one metric of one environment and how much of it the month may use, and is one of two kinds: one that **refuses** past it, and one that only **counts**. Palbase sets them; the **Limits** panel lists those set on the environment. A month's use starts again at 00:00 UTC on its first day. When a limit that refuses is reached, that use is refused until the month ends, and the refusal's `Retry-After` is the seconds until then: | Limit on | What is refused | |---|---| | API requests | Every request to the environment's address but those not counted (above): `429 quota_exceeded` | | Monthly active users | New sign-ups: `403 signups_closed`. Sign-ins go on: the people already counted keep their accounts | | Email, push, SMS or WhatsApp | Sending on that channel: `429 quota_exceeded` | | Stored data | Writing to storage: `429 quota_exceeded`. Reads go on | | Realtime messages or connections | A new socket: `429 quota_exceeded`, with `Retry-After: 3600` (an hour, not the month's end); and a server's broadcast: `429 quota_exceeded` | | Flag reads | Reading the flags: `429 quota_exceeded` | | Chat messages, or what they weigh | Sending a message, a person's or the server's: `429 quota_exceeded` | | Analytics events, samples, traces or console records | Sending that kind: `429 quota_exceeded` | | Machine hours | The machines: they are stopped, dedicated sizes too, and a request to one, a new machine, a cron or a task is `429 quota_exceeded` | A limit that counts refuses nothing: the use goes on and is metered. ## What a free project gets An environment is free while it is on the Free plan — every environment starts on it — and while its credit has run out or its month is at its monthly limit ([Billing](/docs/getting-started/billing)). Each environment is free on its own: one running out holds no other. - **Sizes.** Its machines are of the `shared-256` size, the one free machine size, and it has one database at most, of the `free` size: a project holds one database of a free size at a time ([Databases](/docs/machines/databases#sizes)). Every other size is paid for: opening a machine or a database of one, or moving one to it, is `403 size_not_available`. `palbase machines:sizes` and `palbase databases:sizes` list the sizes and which of them the environment may open now ([Machines](/docs/machines/overview#sizes)). - **Traffic.** It sends 5 GB a month at full speed: its machines' internet traffic and its response traffic together. Past it, until the month ends, its machines' traffic and answers and the files its storage hands out go at about 256 kbit/s; nothing is stopped, and the API's other answers are not slowed. A slowed project's downloads are paced, and one more than it may have under way at once is `429 rate_limited` with `Retry-After`. See [Reaching the internet](/docs/machines/overview#reaching-the-internet). - **Chat messages.** 1 million messages a month, and 1 GiB of them kept. Past either, until the month ends, the project's messages go at about one a second, all its people and its server together: a send waits its turn, 10 seconds at the most, and one whose turn is further off is `429 slowed` with `Retry-After` — the SDKs keep it in their outbox and send it again. No message is lost, and reading, syncing and everything else go on at full speed. What is kept counts at its month's largest, so a project that keeps more than 1 GiB is slowed again early each month: remove old messages, or set the project's retention ([Messaging](/docs/messaging/overview#the-projects-settings)), to stay under it. - **A free database** is awake 400 hours a month at most. Past them it sleeps until the month ends, refusing connections, with its data kept; from then its next connection wakes it. - **AI.** $1.00 of Palbase's models a month, at the provider's price and 10%. Past it, until the month ends, a call to them is `402 ai_payment_required`; a call under the project's own provider key goes on ([AI](/docs/backend/ai#what-it-costs)). An environment on Pay as you go with credit is never slowed for its traffic, and its machines and databases may be of any size; a database of the `free` size keeps its 400 hours whoever holds it. What it uses past its own free quotas is charged from its own credit ([Billing](/docs/getting-started/billing)). ## Each module's own limits | Module | Limits | |---|---| | Storage | 5 GiB an object unless the bucket says less; a signed URL lives 7 days at most ([Storage](/docs/backend/storage)) | | Cache | 25 MiB of entries, the ones nearest their end dropped to make room; 512 KiB a value ([Cache](/docs/backend/cache)) | | Tasks | 100,000 tasks not yet done, or 256 MiB of what they carry ([Tasks](/docs/backend/tasks)) | | Machines | 5 machines, 1 to 5 copies each, 50 builds a day, logs kept 7 days ([Machines](/docs/machines/overview#limits)) | | Databases | 3 a project, one of them of a free size at a time; a database full to its size's storage turns read-only ([Databases](/docs/machines/databases#sizes)) | | Analytics | Events kept 90 days unless changed, 365 at most ([Analytics](/docs/analytics/overview#how-long-it-is-kept)) | ## Related - [Billing](/docs/getting-started/billing) — each environment's plan and credit, and what is charged - [Logs](/docs/getting-started/logs) — every request, with its status - [Organizations](/docs/getting-started/organizations) — who sees the usage page, and who is mailed - [Machines](/docs/machines/overview) — sizes, sleep, and traffic to the internet - [Databases](/docs/machines/databases) — sizes, sleep and storage # Logs Studio's **Logs** page lists what happened in an environment, newest first, one category at a time: | Category | A line is | Its source | Its level | |---|---|---|---| | **Requests** | A request to the environment's address, whichever module it went to, as the platform's edge answered it — refused ones too | The module: `auth`, `storage`, `realtime`, `flags`, `notify`, `analytics`, `secrets`, `machines` (databases included), `stores`, or `other` for a path no module answers | Info below 400, warn a 4xx, error a 5xx or no answer at all | | **Auth** | An event of auth's audit trail: a sign-in, a sign-up, a password or session change | The event, such as `auth.login.success` | Warn a failure | | **Notifications** | An email, SMS, WhatsApp message or push sent, and what became of it | The channel | Error one that did not arrive (failed, bounced, rejected, expired) | | **Machines** | What a machine wrote, its migration runs included ([Builds, Logs & Rollback](/docs/machines/builds#logs)) | The machine | The level the line says of itself | | **Builds** | A build of a machine's commit | The machine | Error a failed build, warn a cancelled one | Each category is narrowed by text, by source and by level, over the last 15 minutes up to the last 7 days. **Live** reads again every 5 seconds; **Paused** holds the list still while you read it. A line opens whole, each of its fields with a copy button. The address of the page carries the category and the filters, so a view can be shared. ## Request lines A request line holds what the edge knows of the request, and nothing it carried: | Field | | |---|---| | `ts` | When the request arrived | | `module` | The module it went to | | `method`, `path` | The path **without its query**: a signed URL's signature and a token travel in the query, so it is never kept. Past 2,048 bytes it is cut | | `status` | What the client was answered; `0` when it got no answer | | `duration_ms` | Until the last byte of the answer was sent | | `client_ip`, `user_agent` | Who asked: the client's address as the edge saw it, and its user agent (its first 512 bytes) | | `request_id` | The `X-Request-Id` the client was answered with — the one to quote when asking about a request | | `flags` | Present when the request ended short of a module's answer: `UAEX` the key or rate check refused it, `NR` no module answers its path, `UF` or `UH` the module could not be reached, `UT` it did not answer in time, `DC` the client went away | Not its headers, not its body. A line is kept **7 days** and deleted within a day after; only the project it was made for reads it. Anyone can send a project requests, and a refused one is a line too: under a flood, each of the platform's edges keeps about 1 MiB of a project's lines a second and drops the rest, so a flood to one project does not cost the others their lines. From a server, `GET /analytics/requests` with the project's secret key answers the same lines, newest first: | Query | | |---|---| | `window_seconds` | How far back, 1 to 604,800 (a week); an hour when left out | | `module` | One module's requests only | | `level` | `info`, `warn`, `error`, one or more, comma-separated | | `q` | Text found in the path or the user agent, or a request id or a client address, exactly | | `limit` | How many lines at most, 1 to 500 (100 by default) | | `cursor` | The `next_cursor` of the page before; absent on the last page | ```ts // scripts/errors.ts type RequestLine = { ts: string; module: string; method: string; path: string; status: number; request_id: string }; const res = await fetch(`${process.env.PALBASE_URL}/analytics/requests?window_seconds=600&level=error`, { headers: { apikey: process.env.PALBASE_SECRET_KEY! }, }); const { requests } = (await res.json()) as { requests: RequestLine[]; next_cursor?: string }; for (const r of requests) console.log(r.ts, r.status, r.module, r.method, r.path, r.request_id); ``` A secret key never goes in a browser: a call that carries one from a web page is refused (`401 secret_key_in_browser`). # Overview `palbase/backend` is the framework you write a Palbase backend in. You write plain TypeScript: class controllers whose methods are routes, `@Injectable()` services that hold the logic, schemas that validate what comes in, and a `@Module` per domain that says which of those classes exist. One call in `src/main.ts`, `PalbaseFactory.create(AppModule)`, builds them and serves them over HTTP. Your repository is built into a [machine](/docs/machines/overview) on every push to its branch, and the machine runs that file. There is no deploy command and no router file, and nothing reads a directory by its name. The modules and the decorators are the whole contract between your source and the framework. ## Quick example Four files make a working backend: the entry point, the root module, and one domain. ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; // Serves on $PORT (8080 unless the machine says). The project's address, id // and secret key come from the environment: on a machine the platform sets // PALBASE_URL, PALBASE_PROJECT_ID and the machine's own PALBASE_SECRET_KEY. await PalbaseFactory.create(AppModule); ``` ```ts // src/app.module.ts import { Module } from "palbase/backend"; import { NotesModule } from "./modules/notes/notes.module"; @Module({ imports: [NotesModule] }) export class AppModule {} ``` ```ts // src/modules/notes/notes.module.ts import { Module } from "palbase/backend"; import { NotesController } from "./notes.controller"; import { NotesService } from "./notes.service"; @Module({ controllers: [NotesController], providers: [NotesService] }) export class NotesModule {} ``` ```ts // src/modules/notes/notes.controller.ts import { Body, Controller, Get, Param, Post, User, z, type UserT } from "palbase/backend"; import { NotesService, type Note } from "./notes.service"; const CreateNote = z.object({ title: z.string().min(1).max(200) }); type CreateNote = z.infer; @Controller("/notes") export class NotesController { constructor(private readonly notes: NotesService) {} @Post("") create(@Body(CreateNote) body: CreateNote, @User() user: UserT): Note { return this.notes.create(user.id, body.title); } @Get("/{id}") read(@Param("id") id: string, @User() user: UserT): Note { return this.notes.get(user.id, id); } } ``` ```ts // src/modules/notes/notes.service.ts import { Injectable, Log, NotFound } from "palbase/backend"; export interface Note { id: string; owner: string; title: string; } // Kept in memory to keep the example small; a real app keeps them in its // database (see Database). @Injectable() export class NotesService { private readonly notes = new Map(); create(owner: string, title: string): Note { const note: Note = { id: crypto.randomUUID(), owner, title }; this.notes.set(note.id, note); Log.info("note created", { id: note.id }); return note; } get(owner: string, id: string): Note { const note = this.notes.get(id); if (!note || note.owner !== owner) throw new NotFound(`no note ${id}`); return note; } } ``` That serves `POST /notes` and `GET /notes/{id}`, both for signed-in users only, because every route needs a user unless it says otherwise. A client calls them at the machine's address, `https://.app.palbase.studio/notes`, with the user's access token as `Authorization: Bearer …`. See [Calling Your Backend](/docs/web/backend-calls). ## The mental model | Concept | What it is | |---|---| | **Module** | A class decorated with `@Module({ imports, controllers, providers, exports })`. It decides ownership: a `@Controller` or `@Injectable()` class no module lists stops the boot by name. The root module (`AppModule` above) is the one `PalbaseFactory.create` is given, and every other module is reached through `imports`. | | **Controller** | A class decorated with `@Controller(basePath)`. Each method decorated with `@Get`, `@Post`, `@Put`, `@Patch`, `@Delete` or `@Query` is one route. | | **Service** | An `@Injectable()` class listed in its module's `providers`. It holds the business logic, so controllers stay thin. | | **Dependency injection** | A class names what it needs as constructor parameters, and the container supplies them by **type**. There is no `@Inject` and no token registry. | | **Schema** | Any [Standard Schema](https://standardschema.dev) (zod, valibot, arktype) given to `@Body`, `@QueryParams` or `@Headers`. `z` is re-exported from `palbase/backend`. | | **Answer** | What the method returns is the response: `200` with its JSON, or `204` when it returns `undefined` or `null`. | | **Errors** | Throw `NotFound`, `Conflict`, `Forbidden`, … or a `PalError` of your own. Every refusal is sent in one envelope. | | **Webhook** | A `@Webhook` class listed in `providers`: a third party's signed deliveries (Stripe, GitHub, Slack, …) at `POST /webhooks/`, each handed to the `@On` of its event. See [Webhooks](/docs/backend/webhooks). | | **Gateway** | A `@Gateway(pattern)` class listed in `providers`: a realtime channel of the app, with who may join, its typed events, and its members' messages, each answered with an ack. Apps join it through their generated client. See [Gateways](/docs/backend/gateways). | ## What a handler imports A handler or a service reaches three things by import. Nothing is injected for them, and there is no `ctx` parameter to pass around. | Import | What it is | Docs | |---|---|---| | `Palbase` | The project's modules, called with the app's secret key: `Palbase.auth`, `Palbase.storage`, `Palbase.notify`, `Palbase.secrets`, `Palbase.flags`, `Palbase.analytics`, `Palbase.realtime`, `Palbase.machines`. It is the `palbase/server` client, made for you, with two more: `Palbase.ai`, the project's models, agents and chats, and `Palbase.gateways`, the app's gateways. | [Server SDK](/docs/server/overview), [AI](/docs/backend/ai), [Gateways](/docs/backend/gateways) | | `Database` | The request's own transaction against the app's Postgres: `Database.public.
`, `$query`, `$transaction`, `$atomic`, `$asService()`. Committed when the handler returns, rolled back when it throws. | [Database](/docs/backend/database) | | `Log` | Structured logging: one JSON line per call, carrying the request's id. | [Logging](#logging) | `Palbase` is bound when the app is served. Reaching it before `PalbaseFactory.create` has run throws `Palbase. is reached before the app is served`. `Database` is the request's, so it is reached inside a request: from a handler, a service a handler calls, or an `@On` handler. Reaching it at import time, from a timer or from `onStart()` throws and names where it was reached. Per-request values come from parameter decorators instead: `@User()`, `@OptionalUser()`, `@Body()`, `@QueryParams()`, `@Param()`, `@Headers()`, `@Req()`, `@RequestId()` and `@Client()`. See [Request Validation](/docs/backend/validation) and [Authentication](/docs/backend/authentication). ## `PalbaseFactory.create(root, config?)` The bootstrap takes the root module (or an array of them) and the backend's own settings. Each setting has one place: the app's defaults are here, a route's own auth and limit are on its decorator, and a module's settings (auth providers, storage buckets, notify senders) stay in that module, in Studio. | Setting | Default | What it does | |---|---|---| | `palbase` | from the environment | `{ url, projectId, secretKey, webhookSecret }`: the project this backend belongs to. Each value is read from `PALBASE_URL`, `PALBASE_PROJECT_ID`, `PALBASE_SECRET_KEY` and `PALBASE_WEBHOOK_SECRET` unless given here. | | `auth` | `true` | Every route's auth unless its controller or the route says otherwise. See [Authentication](/docs/backend/authentication). | | `rateLimit` | `{ max: 300, window: 60 }` | Every route's limit per caller, unless the route says otherwise. `false` for none. | | `bodyLimit` | 1 MiB | Bytes a request body may be. A larger one is `413 payload_too_large`. | | `cors` | off | `{ origins: ["https://app.example.com"], maxAge? }`, or `origins: "*"`. A backend that browsers call from another origin names them here. | | `db` | none | `{ schemas: [schema] }`: the app's database. See [Database](/docs/backend/database). | | `port`, `host` | `$PORT` or 8080, `0.0.0.0` | Where it listens. `port: 0` takes a free one (tests). | | `boot` | `{ attempts: 5, backoffMs: 1000 }` | How often a failed `onStart()` is tried again. | | `exitOnFatal` | `true` | Exit 1 on an uncaught error. A test turns it off. | | `env` | `process.env` | Where the `PALBASE_*` values are read from. | A value that is missing or malformed stops the boot before any request is served, naming each problem and both ways to give it: ```text [palbase] the app cannot start: - palbase.secretKey / PALBASE_SECRET_KEY is the project secret key (pb__s…), kept in the project's Secrets ``` On a machine, the platform sets `PORT`, `PALBASE_URL` (the project's address, `https://.palbase.studio`), `PALBASE_PROJECT_ID`, `PALBASE_REPLICAS`, `PALBASE_PLATFORM_SECRET`, the machine's own `PALBASE_SECRET_KEY` and its `PALBASE_WEBHOOK_SECRET`, plus `PALBASE_DB_URL` and `PALBASE_DB_CONNECTIONS` when the machine is given a database. Nothing is set by hand. The machine's key is a secret key of the project, listed under **Settings › API keys** as `machine: `: revoked, it is replaced and the machine rolls out with the new one, and it goes with the machine. See [Secrets](/docs/backend/secrets) and [Machines](/docs/machines/overview). ## What one request goes through Every request takes the same path. Each step answers in the [error envelope](/docs/backend/errors) when it refuses: 1. **The route.** No route for the method and path: `404 not_found`. 2. **The caller.** A bearer token is verified against the project's keys. A route that needs a user and has none answers `401`. When tokens cannot be checked right now, the answer is `503 auth_unavailable` with `Retry-After`. 3. **A verified email**, when the route asks for one: `403 email_not_verified`. 4. **The route's rate limit**, spent before the body is read: `429 too_many_requests` with `Retry-After`. 5. **The body's size**: `413 payload_too_large`. 6. **The parameters**, against their schemas: `400 bad_request`, naming the fields. 7. **The handler**, inside the request's database transaction when the app has a database. 8. **The answer**: `204` for `undefined` or `null`, `200` with JSON otherwise. Two first path segments are the platform's and never your controllers': `_palbase` (the app's health at `GET /_palbase/health`, and the platform's signed calls: its [crons](/docs/backend/cron) and [tasks](/docs/backend/tasks), an upload's steps, a [gateway](/docs/backend/gateways)'s, an agent's [tools](/docs/backend/ai#tools)) and `webhooks` (the platform delivers [events](/docs/backend/hooks) to `POST /webhooks/palbase`, and a [`@Webhook`](/docs/backend/webhooks) class takes a third party's at `POST /webhooks/`). A `@Controller` mounted under either is refused when it is declared. ## Lifecycle - **Boot.** Every class is built once, dependencies first. Then each one's `onStart()` runs, in that order. A boot that fails in `onStart()` (a database not up yet) is tried again from scratch after 1, 2, 4 and 8 seconds. If it still fails, the process exits 1 and the machine starts it again. What waiting cannot fix, such as a missing setting or a [DI mistake](/docs/backend/modules), stops at once. - **Serving.** `GET /_palbase/health` answers `200 {"ok":true}` while the app serves. It is the machine's health check by default. - **Stopping.** On `SIGTERM` the app stops taking connections and gives the requests in flight up to 20 seconds. Then it runs every `onShutdown()`, in reverse order. Health answers `503` meanwhile. A machine's sandbox sends `SIGTERM` more than once, milliseconds apart: the app stops once, and a later signal changes nothing. - **A crash.** An uncaught error or an unhandled rejection is logged. The requests in flight get a few seconds, and the process exits 1 for the machine to start it again, rather than run half-alive. - **A migration run.** Started with `PALBASE_DB_MIGRATE`, the same image migrates its database and exits without serving anything. See [Schema changes](/docs/backend/migrations). ## Setup A backend is an ordinary TypeScript project. The examples run on [Bun](https://bun.sh), which runs TypeScript and its decorator metadata as written, and whose SQL client the database uses by default. `tsconfig.json` needs two settings for the container to read constructor types: ```json { "compilerOptions": { "target": "es2023", "module": "esnext", "moduleResolution": "bundler", "strict": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "allowImportingTsExtensions": true, "noEmit": true }, "include": ["src/**/*.ts"] } ``` Without `emitDecoratorMetadata`, a class with constructor parameters stops the boot with `metadata missing`. A build that strips types without emitting metadata (esbuild) does the same; tsc, Bun and SWC emit it. How the repository is laid out and built is in [Project Structure](/docs/getting-started/project-structure). ## Logging `Log` writes one JSON line per call: `time`, `level`, `msg`, the request's `request_id` when there is a request, and the fields you pass. An `Error` among them is written with its name, message and stack. `debug` and `info` go to stdout, `warn` and `error` to stderr. What a machine writes is in Studio's machine page and in its logs API. See [Builds, Logs & Rollback](/docs/machines/builds). ```ts // src/modules/notes/notes.audit.ts import { Injectable, Log } from "palbase/backend"; @Injectable() export class NotesAudit { record(noteId: string, payload: unknown, err: unknown): void { Log.info("note created", { noteId }); Log.warn("rate approaching limit"); Log.error("import failed", { error: err }); Log.debug("raw payload", { payload }); } } ``` ```text {"time":"2026-10-08T09:12:44.012Z","level":"info","msg":"note created","request_id":"req_6f1c…","noteId":"3b0e…"} ``` The framework logs its own lines the same way, prefixed `[palbase]`: the routes it serves at boot, a handler's unhandled error (with the route), a module that did not answer. ## The rules checklist 1. **A class exists because a module lists it.** `@Controller` classes go in `controllers`, `@Injectable()` services and `@On` handler classes go in `providers`. A declared class no module lists stops the boot with `unowned class`. Export every class by name: modules import them as `import { X } from "./x"`. 2. **The root module is the one you pass.** `PalbaseFactory.create(AppModule)` reaches every other module through `imports`, and imports are not transitive. 3. **Providers are `@Injectable()`.** A class in `providers` without it (and without an `@On` method) stops the boot with `undeclared provider`. 4. **Constructor parameters are classes.** An `interface`, a union or a primitive has no class to build, so it stops the boot with `unresolvable dependency`. Use a class or an `abstract class`. 5. **`onStart()` and `onShutdown()` are the lifecycle.** NestJS's `onModuleInit` and the rest are refused by name, so they are never silently ignored. 6. **Every route needs a signed-in user unless it says `auth: false`.** `@User()` on a route that does not need one stops the boot: use `@OptionalUser()`. 7. **Schemas are Standard Schema values.** Import `z` from `palbase/backend` and pass the schema itself: `@Body(CreateNote)`. 8. **Return the value, or nothing.** A handler's return is its `200` JSON; `undefined` or `null` is `204`. There is no response object to write to. 9. **Throw to refuse.** `throw new NotFound("…")` from a controller or a service. Anything that is not an `HttpError` is a `500`, logged with its stack. 10. **`experimentalDecorators` and `emitDecoratorMetadata`** in `tsconfig.json`. ## Where to next | Topic | Page | |---|---| | `@Module`'s four lists, injection by type, `onStart` and `onShutdown` | [Modules & Dependency Injection](/docs/backend/modules) | | Routes, paths, rate limits | [Controllers & Routing](/docs/backend/controllers) | | `@Body`, `@QueryParams`, `@Param`, `@Headers`, and the 400 they answer | [Request Validation](/docs/backend/validation) | | Default auth, `@User`, `@OptionalUser`, verified email | [Authentication](/docs/backend/authentication) | | The answer, the envelope, the error classes | [Responses & Errors](/docs/backend/errors) | | Postgres, declared in TypeScript | [Database](/docs/backend/database) · [Schema](/docs/backend/schema) · [Row-Level Security](/docs/backend/row-level-security) · [Schema changes](/docs/backend/migrations) | | The project's modules from your backend | [Storage](/docs/backend/storage) · [Secrets](/docs/backend/secrets) · [Flags](/docs/backend/flags) · [Notifications](/docs/backend/notifications) · [Realtime](/docs/backend/realtime) | | Realtime channels of your app, and chats of the project's agents | [Gateways](/docs/backend/gateways) · [AI](/docs/backend/ai) | | The platform's events | [Event Hooks](/docs/backend/hooks) | | Tests | [Test Users](/docs/backend/test-users) · [Testing](/docs/backend/testing) | | Where it runs | [Machines](/docs/machines/overview) · [Connecting GitHub](/docs/machines/github) | ## Related - [Project Structure](/docs/getting-started/project-structure): how a backend repository is laid out - [Architecture](/docs/getting-started/architecture): how the pieces fit together - [Machines](/docs/machines/overview): what runs your backend - [Server SDK](/docs/server/overview): the client `Palbase` is # Modules & Dependency Injection One `@Module` declaration says **what exists, who owns it, and what it may reach**. It is the only place ownership is decided. A `@Controller` or `@Injectable()` class that no module lists does not exist, and the app refuses to start rather than serve without it. Being in a particular folder grants nothing. A dependency is named by its constructor parameter's **type**, and by nothing else. There is no `@Inject`, no `useFactory`, no `forwardRef` and no token registry. ## The four lists A module is named after its **domain** and lives with it: `src/modules/notes/` holds the module, the controller, the service and the schemas that belong to notes. ```ts // src/modules/notes/notes.module.ts import { Module } from "palbase/backend"; import { NotesController } from "./notes.controller"; import { NotesService } from "./notes.service"; @Module({ controllers: [NotesController], providers: [NotesService], exports: [NotesService], // the class another module may reach imports: [], // whose exports THIS module may reach }) export class NotesModule {} ``` ```ts // src/modules/notes/notes.service.ts import { Injectable, NotFound } from "palbase/backend"; export interface Note { id: string; title: string; } @Injectable() export class NotesService { private readonly notes = new Map(); get(id: string): Note { const note = this.notes.get(id); if (!note) throw new NotFound(`no note ${id}`); return note; } } ``` ```ts // src/modules/notes/notes.controller.ts import { Controller, Get, Param } from "palbase/backend"; import { NotesService, type Note } from "./notes.service"; @Controller("/notes") export class NotesController { // The container supplies this. Nothing here constructs or imports an instance. constructor(private readonly notes: NotesService) {} @Get("/{id}") get(@Param("id") id: string): Note { return this.notes.get(id); } } ``` Four lists answer four different questions, and nothing else answers them: | List | The question it answers | |---|---| | `providers` | which classes does this module **own** (`@Injectable()` services, classes with [`@On`](/docs/backend/hooks) handlers, and [`@Webhook`](/docs/backend/webhooks) classes) | | `controllers` | which `@Controller` classes it owns | | `exports` | which of its own providers **another** module may reach | | `imports` | whose exports **this** module may reach | Every list is optional and empty by default. A module with nothing but `imports` is how the root module gathers the domains: ```ts // src/app.module.ts import { Module } from "palbase/backend"; import { NotesModule } from "./modules/notes/notes.module"; @Module({ imports: [NotesModule] }) export class AppModule {} ``` ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; await PalbaseFactory.create(AppModule); ``` `PalbaseFactory.create` takes the root module, or an array of them. Every module the app has is reached from there through `imports`. A module nothing imports is not part of the app. ## A class no module lists does not exist `@Controller`, `@Injectable()` and `@On` record the class they decorate when its file is imported. When the app is built, every recorded class must be owned by exactly one module the root reaches. Otherwise the app stops before it serves anything, and the error names the class: ```text DiError: Kind: unowned class Path: PricingService At: PricingService PricingService is declared but no module lists it: it would never be built Potential solutions: - list it in a module's controllers or providers - or delete it ``` Every container error is a `DiError` with a `kind`, the path that led to it, and what to do. They are all raised at boot, before the first request, and none is retried: waiting does not fix a wiring mistake. | `kind` | What it means | |---|---| | `unowned class` | A declared class no module lists, or a dependency nobody owns | | `duplicate ownership` | Two modules list the same class, or an abstract dependency has more than one owned subclass | | `undeclared provider` | A class in `providers` that is not `@Injectable()` (nor has an `@On` method), or a class in `controllers` that is not a `@Controller` | | `unknown export` | A module exports a class it does not own | | `unknown import` | Something in `imports` is not a `@Module`, or a module imports itself | | `missing import` | A class needs another module's class, and its module does not import that module | | `private dependency` | The other module owns the class but does not export it | | `unresolvable dependency` | A constructor parameter typed as an interface, a union or a primitive | | `metadata missing` | A class takes constructor parameters but no types were emitted | | `dependency cycle` | `A → B → A` | | `nest lifecycle name` | A class has `onModuleInit` or another NestJS hook, which would never be called | ## `exports` and `imports`: the boundary A module reaches another module's class when **both** sides say so: the owner exports it, and the module that needs it imports the owner directly. ```ts // src/modules/billing/billing.service.ts import { Injectable } from "palbase/backend"; @Injectable() export class BillingService { planOf(userId: string): "free" | "pro" { return userId.startsWith("usr_") ? "free" : "pro"; } } ``` ```ts // src/modules/billing/billing.module.ts import { Module } from "palbase/backend"; import { BillingService } from "./billing.service"; @Module({ providers: [BillingService], exports: [BillingService] }) export class BillingModule {} ``` ```ts // src/modules/reports/reports.controller.ts import { Controller, Get, User, type UserT } from "palbase/backend"; import { BillingService } from "../billing/billing.service"; @Controller("/reports") export class ReportsController { constructor(private readonly billing: BillingService) {} @Get("/plan") plan(@User() user: UserT): { plan: "free" | "pro" } { return { plan: this.billing.planOf(user.id) }; } } ``` ```ts // src/modules/reports/reports.module.ts import { Module } from "palbase/backend"; import { BillingModule } from "../billing/billing.module"; import { ReportsController } from "./reports.controller"; @Module({ imports: [BillingModule], controllers: [ReportsController] }) export class ReportsModule {} ``` Leave `BillingModule` out of `imports` and the boot stops with `missing import`, naming both modules. Leave `BillingService` out of `exports` and it stops with `private dependency`. **Imports are not transitive.** If `ReportsModule` imports `BillingModule` and `BillingModule` imports `AccountsModule`, `ReportsModule` still cannot reach `AccountsModule`'s classes without importing it itself. ## Dependency injection: the constructor is the seam Every owned class is built **once**, dependencies first, and every class that names it gets the same instance. A class names what it needs as constructor parameters; the container reads their types from the metadata TypeScript emits (`design:paramtypes`) and passes the instances in. Controllers take services this way, and services take other services. ```ts // src/modules/notes/clock.ts import { Injectable } from "palbase/backend"; @Injectable() export class Clock { now(): Date { return new Date(); } } ``` ```ts // src/modules/notes/reminders.service.ts import { Injectable } from "palbase/backend"; import { Clock } from "./clock"; @Injectable() export class RemindersService { constructor(private readonly clock: Clock) {} isDue(at: Date): boolean { return at.getTime() <= this.clock.now().getTime(); } } ``` ### What the container cannot inject A parameter's type must be a **class**: something that exists at run time. An `interface`, a type alias, a union, `string` or `number` erases to `Object` (or nothing) in the emitted metadata, and the boot stops with `unresolvable dependency`, naming the class and the parameter. Configuration that is a value (a URL, a limit) is read where it is used, from `process.env`. It is not injected. ## The abstraction is the type, the implementation is the provider To depend on an abstraction, make it an `abstract class` and list a subclass in `providers`. A parameter typed as the abstract class gets the **one** owned subclass: ```ts // src/modules/mail/mailer.ts import { Injectable, Log } from "palbase/backend"; export abstract class Mailer { abstract send(to: string, subject: string): Promise; } @Injectable() export class LogMailer extends Mailer { async send(to: string, subject: string): Promise { Log.info("mail", { to, subject }); } } ``` ```ts // src/modules/mail/welcome.service.ts import { Injectable } from "palbase/backend"; import { Mailer } from "./mailer"; @Injectable() export class WelcomeService { constructor(private readonly mailer: Mailer) {} welcome(email: string): Promise { return this.mailer.send(email, "Welcome"); } } ``` ```ts // src/modules/mail/mail.module.ts import { Module } from "palbase/backend"; import { LogMailer } from "./mailer"; import { WelcomeService } from "./welcome.service"; @Module({ providers: [LogMailer, WelcomeService] }) export class MailModule {} ``` Two owned subclasses of `Mailer` make the choice ambiguous, and the boot stops with `duplicate ownership`. Ask for the subclass itself where you mean one. In a test, the same seam lets you pass any `Mailer` to `new WelcomeService(...)` directly. See [Testing](/docs/backend/testing). ## Why there is no `@Inject`, `useFactory` or `forwardRef` Each of them answers a question the constructor already answers. `@Inject(TOKEN)` names a dependency by a token instead of a type, so the type and the token can disagree. `useFactory` builds an instance outside the container, where its dependencies are not checked. `forwardRef` exists to make a cycle work, and a cycle is a design problem: the boot names it (`A -> B -> A`) and the fix is a third class both depend on. ## `onStart()` and `onShutdown()` A constructor is pure wiring: synchronous, no I/O, because the container runs it at boot. Startup work is a method on the class: ```ts // src/modules/catalog/catalog.service.ts import { Injectable, Log, Palbase } from "palbase/backend"; @Injectable() export class CatalogService { private bucketReady = false; // Awaited before the first request, in construction order. async onStart(): Promise { await Palbase.storage.putBucket({ path: { bucket: "catalog" }, body: { public: true } }); this.bucketReady = true; } // Run on SIGTERM, after the requests in flight have finished, in reverse order. async onShutdown(): Promise { Log.info("catalog stopping", { bucketReady: this.bucketReady }); } ready(): boolean { return this.bucketReady; } } ``` - `onStart()` runs once per class, in construction order: a class's dependencies have started before it does. `Palbase` is bound by then. `Database` is not, because it is a request's. - When one throws, the classes already started are shut down and the whole boot is tried again from scratch, waiting 1, 2, 4 and 8 seconds (`boot: { attempts, backoffMs }` in `PalbaseFactory.create`). Every attempt builds new instances. After the last attempt the error is logged and the process exits, and the machine starts it again. - `onShutdown()` runs in reverse construction order, and every one is tried even when one throws. NestJS's names (`onModuleInit`, `onApplicationBootstrap`, `onModuleDestroy`, `beforeApplicationShutdown`, `onApplicationShutdown`) are refused at boot with `nest lifecycle name`. They would never be called here, so a class that relied on them would fail silently. ## `emitDecoratorMetadata` is what makes injection work The container reads constructor types from the metadata TypeScript emits with `"experimentalDecorators": true` and `"emitDecoratorMetadata": true`. Bun, tsc and SWC emit it. esbuild does not. Without it, a class with constructor parameters stops the boot with `metadata missing`: ```text DiError: Kind: metadata missing Path: ReportsController At: ReportsController ReportsController takes 1 constructor parameter(s) but no types were emitted ``` Every class with a constructor parameter must carry a decorator (`@Injectable()`, `@Controller`), because TypeScript emits the parameter types only for a decorated class. > **Note:** A class is recorded once its file is imported, process-wide. Two copies of `palbase/backend` in one app share the record, so a class declared through either is seen. A test that builds only part of the app must pass modules that own every declared class its imports bring in. ## A domain is a folder `src/modules//` is a convention, not a rule the framework reads: nothing scans a directory. A domain's module, controllers, services and schemas sit together because they change together, and the module is what makes them exist. The one other place the layout matters is the schema: the database's tables are declared in `src/db/` and given to `PalbaseFactory.create(…, { db: { schemas } })`. See [Project Structure](/docs/getting-started/project-structure) and [Schema](/docs/backend/schema). ## Related - [Overview](/docs/backend/overview): the authoring model in one page - [Controllers & Routing](/docs/backend/controllers): what a controller declares - [Testing](/docs/backend/testing): constructing a class with your own dependencies - [Architecture](/docs/getting-started/architecture): how modules, controllers and services fit # Controllers & Routing Routes are declared in code, not in a router. A controller is a class decorated with `@Controller(basePath)`, and each route is a method decorated with `@Get`, `@Post`, `@Put`, `@Patch`, `@Delete` or `@Query`. The file lives with the domain that owns it (`src/modules/todos/todos.controller.ts`), and it is served because its module's `controllers` list names the class. There is no central router to register with. A `@Controller` class no module lists stops the boot, by name. See [Modules & Dependency Injection](/docs/backend/modules). ## Quick example ```ts // src/modules/todos/dto.ts import { z } from "palbase/backend"; export const CreateTodo = z.object({ title: z.string().min(1).max(200) }); export type CreateTodo = z.infer; export interface Todo { id: string; owner: string; title: string; done: boolean; } ``` ```ts // src/modules/todos/todos.service.ts import { Injectable, NotFound } from "palbase/backend"; import type { CreateTodo, Todo } from "./dto"; @Injectable() export class TodosService { private readonly todos = new Map(); list(owner: string): Todo[] { return [...this.todos.values()].filter((t) => t.owner === owner); } create(owner: string, input: CreateTodo): Todo { const todo: Todo = { id: crypto.randomUUID(), owner, title: input.title, done: false }; this.todos.set(todo.id, todo); return todo; } get(id: string): Todo { const todo = this.todos.get(id); if (!todo) throw new NotFound(`no todo ${id}`); return todo; } } ``` ```ts // src/modules/todos/todos.controller.ts import { Body, Controller, Get, Param, Post, User, type UserT } from "palbase/backend"; import { CreateTodo, type Todo } from "./dto"; import { TodosService } from "./todos.service"; @Controller("/todos") export class TodosController { // The container supplies this. Nothing here constructs or imports an instance. constructor(private readonly todos: TodosService) {} @Get("") list(@User() user: UserT): Todo[] { return this.todos.list(user.id); } @Post("", { rateLimit: { max: 10, window: 60 } }) create(@Body(CreateTodo) body: CreateTodo, @User() user: UserT): Todo { return this.todos.create(user.id, body); } @Get("/{id}", { auth: false }) get(@Param("id") id: string): Todo { return this.todos.get(id); } } ``` ```ts // src/modules/todos/todos.module.ts import { Module } from "palbase/backend"; import { TodosController } from "./todos.controller"; import { TodosService } from "./todos.service"; @Module({ controllers: [TodosController], providers: [TodosService] }) export class TodosModule {} ``` That serves three routes: | Method | Verb | Full path | Auth | Limit | |---|---|---|---|---| | `list` | GET | `/todos` | a signed-in user (the default) | 300 a minute (the default) | | `create` | POST | `/todos` | a signed-in user | 10 a minute | | `get` | GET | `/todos/{id}` | anyone | 300 a minute | At boot the app logs them: `[palbase] serving 3 route(s) on :8080`, with the list. Studio's **Backend › Endpoints** screen shows the same table, read from the app's own contract. ## `@Controller(basePath, options?)` - `basePath` is where every route of the class is served (`"/todos"`). It must be a string. - `options.auth` sets the controller-wide auth, which a route's own `auth` overrides. It is `true`, `false`, or `{ required?, verifiedEmail? }`. See [Authentication](/docs/backend/authentication). Controllers are built **once**, at boot, and that one instance serves every request. A field is fine for wiring (the services the constructor took), but never store per-request state in one: concurrent requests share it. Per-request data comes from the parameter decorators. A `@Controller` class with no routes stops the boot (`@Controller X has no routes`): a controller with nothing to serve is a mistake. > **Note:** Export the class by name (`export class TodosController`). Its module takes it with `import { TodosController } from "./todos.controller"`, and curly braces cannot see a default export. ## Method decorators Six decorators declare a route. Each takes `(path, options?)`: | Decorator | HTTP method | Notes | |---|---|---| | `@Get` | GET | its body is never read | | `@Post` | POST | | | `@Put` | PUT | | | `@Patch` | PATCH | | | `@Delete` | DELETE | | | `@Query` | QUERY | a safe, idempotent read that carries a body | The options are `RouteOptions`, and the type is exported: ```ts // src/modules/todos/route-options.ts import type { RouteOptions } from "palbase/backend"; // What a method decorator's second argument accepts. export const strict: RouteOptions = { // true, false, or { required?, verifiedEmail? } auth: { required: true, verifiedEmail: true }, // this route's own limit per caller, window in SECONDS; false for none rateLimit: { max: 10, window: 60 }, }; ``` That is all a route option says: who may call it and how often. Input comes from the parameter decorators ([Request Validation](/docs/backend/validation)), and the response is whatever the method returns ([Responses & Errors](/docs/backend/errors)). The path argument must be a string. `@Get({ … })` throws when the class is declared, because the query string is read with `@QueryParams(schema)`, not with the path. ### `@Query`: reads with a body `@Query(path, options?)` serves the route on the HTTP `QUERY` method. It is **safe and idempotent like GET, but carries a request body like POST**. Use it for reads whose input is too structured for a query string, such as search, nested filters or batch lookups: ```ts // src/modules/todos/search.controller.ts import { Body, Controller, Query, User, z, type UserT } from "palbase/backend"; export const SearchTodos = z.object({ titleContains: z.string().max(100).optional(), ids: z.array(z.string()).max(100).default([]), }); export type SearchTodos = z.infer; @Controller("/todos") export class SearchController { @Query("/search") search(@Body(SearchTodos) body: SearchTodos, @User() user: UserT): { owner: string; asked: SearchTodos } { return { owner: user.id, asked: body }; } } ``` A QUERY route's input rides in the **body**, read with `@Body`. Don't confuse the `@Query` *method* decorator with the `@QueryParams` *parameter* decorator (query-string input). Choosing a verb for a read: - **GET**: no input, or input that fits a few query-string values (`@QueryParams`). - **QUERY**: a read whose input is a structured object. - **POST**: the request changes state. ### Paths A route's full path is `basePath + path`, split on `/` with empty segments dropped. So `@Controller("/todos")` with `@Get("")`, `@Get("/")` or `@Get("//")` is served at `/todos`. A `{name}` segment is a path parameter, read by name with `@Param("name")`. `:name` means the same: ```ts // src/modules/comments/comments.controller.ts import { Controller, Get, Param } from "palbase/backend"; @Controller("/todos") export class CommentsController { @Get("/{id}/comments/{commentId}", { auth: false }) getComment(@Param("id") id: string, @Param("commentId") commentId: string): { todo: string; comment: string } { return { todo: id, comment: commentId }; } } ``` - Matching is a walk over segments with an **exact segment count**: `/todos/{id}` never matches `/todos/a/b`. Literal segments compare exactly, case included. - The **first** route in declaration order whose method and segments match wins. One declared later that can never be reached is listed in the app's contract under `x-palbase-unreachable`, with the route that answers instead. - A parameter's value is one segment, percent-decoded, always a string. A segment that is not valid percent-encoding is `400 bad_request`. - `@Param("x")` naming no `{x}` in the route's path stops the boot. ### Paths that are the platform's A machine's address is yours: your routes are served from its root, and nothing of the platform's modules (`/auth`, `/storage`, …) lives there. Two first segments are the platform's own: - `_palbase`: the app's health (`GET /_palbase/health`) and the platform's signed calls (its crons and tasks, an upload's steps, a gateway's, an agent's tools), which the platform makes from inside. A request from outside to a `_palbase` path never reaches the app. - `webhooks`: the platform delivers [events](/docs/backend/hooks) to `POST /webhooks/palbase`, and each [`@Webhook`](/docs/backend/webhooks) class takes a third party's at `POST /webhooks/`. `@Controller` throws when it is declared if its base path's first segment is either one, and the app does not start with a route whose full path begins with one, so the mistake is a boot error, not a route that is never called. ## Parameters your app makes: `@Resolve` Most routes of an app with teams, workspaces or stores start the same way: find what the path names and check that the caller may touch it. Write that once, as a provider, and take its result as a parameter: ```ts // src/modules/teams/current-team.ts import { Forbidden, Injectable, NotFound, Resolve, type ParamResolver, type ResolveContext } from "palbase/backend"; export type TeamRole = "member" | "owner"; export interface CurrentTeamT { id: string; role: TeamRole; } @Injectable() export class TeamOfRequest implements ParamResolver { // A real app reads the membership from its database here (Database.public.team_members.findUnique). private readonly members = new Map([["team_1:user_1", "owner"]]); resolve({ params, user }: ResolveContext, atLeast: TeamRole | undefined): CurrentTeamT { const role = user ? this.members.get(`${params.teamId}:${user.id}`) : undefined; // A team the caller is not in is one that does not exist: a different answer would let anyone count them. if (!role) throw new NotFound("no such team"); if (atLeast === "owner" && role !== "owner") throw new Forbidden("owners only"); return { id: params.teamId, role }; } } /** The team the path names, the caller's role in it checked. */ export const CurrentTeam = (atLeast?: TeamRole) => Resolve(TeamOfRequest, atLeast); ``` ```ts // src/modules/teams/notes.controller.ts import { Body, Controller, Delete, Get, Param, Post, z } from "palbase/backend"; import { CurrentTeam, type CurrentTeamT } from "./current-team"; const NewNote = z.object({ title: z.string().min(1) }); @Controller("/teams/{teamId}/notes") export class NotesController { @Get("") list(@CurrentTeam() team: CurrentTeamT) { return { team: team.id, notes: [] }; } @Post("") create(@CurrentTeam() team: CurrentTeamT, @Body(NewNote) body: z.infer) { return { team: team.id, title: body.title }; } @Delete("/{id}") remove(@CurrentTeam("owner") team: CurrentTeamT, @Param("id") id: string) { return { removed: id, from: team.id }; } } ``` ```ts // src/modules/teams/teams.module.ts import { Module } from "palbase/backend"; import { TeamOfRequest } from "./current-team"; import { NotesController } from "./notes.controller"; @Module({ controllers: [NotesController], providers: [TeamOfRequest] }) export class TeamsModule {} ``` - **A provider.** The resolver is an `@Injectable()` its module lists, and it takes its dependencies by constructor. The controller's module reaches it as it would a constructor's dependency: listed by the same module, or exported by one it imports. A resolver it cannot reach stops the boot, naming the route and the class. - **What it is given.** `ResolveContext` has the path's parameters (decoded), the signed-in user (`null` on a route that needs none and was called by nobody), the request's headers and its id. The second argument is what the decorator was given: here, the role the route needs. - **Before the body.** Resolvers run before every other parameter. A caller with no right to the team hears your `404` or `403`, not a `400` about a body they should never have been asked for. - **In the request's transaction.** The resolver uses the request's `Database`, as the handler does: a write it made goes back with the handler's if the handler throws. A resolver that only read, before a handler that waits on something else, has its connection given back during the wait like any read of the request ([A handler that waits on something else](/docs/backend/database#a-handler-that-waits-on-something-else)). - **Not in the contract.** The app's OpenAPI document shows the path's parameters, never the resolver. A guard (`@UseGuards` and `@Roles` in other frameworks) answers only yes or no. The resolver answers with what it found, so the handler does not look it up a second time. ## Rate limiting Every route has a limit: 300 requests a minute **per caller** unless `PalbaseFactory.create(…, { rateLimit })` or the route says otherwise. `rateLimit: { max, window }` allows at most `max` requests per `window` **seconds**. `rateLimit: false` turns it off for one route, and `rateLimit: false` in the bootstrap turns it off for every route that does not set its own. A list of windows holds a caller to each of them — a burst, a minute and an hour: `rateLimit: [{ max: 10, window: 10 }, { max: 30, window: 60 }, { max: 300, window: 3600 }]`. A request over any of them is refused and told to wait the longest of their waits, and it is not counted in the windows that had room: a caller told to wait out a burst has not spent its hour on the requests it was refused. ```ts // src/modules/todos/import.controller.ts import { Body, Controller, Post, User, z, type UserT } from "palbase/backend"; export const ImportTodos = z.object({ titles: z.array(z.string().max(200)).max(1000) }); export type ImportTodos = z.infer; @Controller("/todos") export class ImportController { @Post("/import", { rateLimit: { max: 5, window: 60 } }) importTodos(@Body(ImportTodos) body: ImportTodos, @User() user: UserT): { owner: string; imported: number } { return { owner: user.id, imported: body.titles.length }; } } ``` Over the limit, the route answers `429` with a `Retry-After` header in seconds and this body: ```json { "error": "too_many_requests", "error_description": "Rate limit exceeded for this endpoint", "status": 429, "request_id": "req_6f1c…", "data": { "retryAfter": 42 } } ``` How the limit counts, because it decides what the limit protects: - **The caller** is the signed-in user (by the token's user id), or else the client's address. On a machine that address is the last `X-Forwarded-For` entry, the one the platform's edge appended; every entry before it is whatever the client sent. An IPv6 address counts by its /64. - **The limit is spent before the body is read.** A caller over the limit is not read for, whatever it sends. - **The window is fixed and kept in the process's memory**, per route and caller, for up to 100,000 keys; the key used longest ago goes first. A machine running several copies counts in each copy, so the limit holds per copy. A restart forgets every window. There are no `X-RateLimit-*` headers: a refusal carries `Retry-After`, and a request that passed carries nothing. > **Note:** This is your route's own limit. The project as a whole also has a requests-per-second ceiling at the platform's edge, which answers `429` above it. To refuse inside your own logic (a per-resource cooldown, say), throw `new TooManyRequests({ retryAfter })`. It is sent the same way, with its `Retry-After`. See [Responses & Errors](/docs/backend/errors). ## CORS A backend that browsers call from another origin names those origins in the bootstrap. Off unless given: ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; await PalbaseFactory.create(AppModule, { cors: { origins: ["https://app.example.com", "http://localhost:3000"], maxAge: 600 }, }); ``` ```ts // src/app.module.ts import { Module } from "palbase/backend"; @Module({}) export class AppModule {} ``` - A preflight (`OPTIONS` with `Access-Control-Request-Method`) from a named origin is answered `204`. One from any other origin is `403`, and its real request gets no CORS headers. - Answers to a named origin carry `Access-Control-Allow-Origin` and expose `Retry-After` and `X-Request-Id` to the page. - Origins are `https://host[:port]`, with `http://` only for `localhost` and `127.0.0.1`, and no path. Anything else stops the boot. `origins: "*"` allows any origin. - The credential is the bearer token in `Authorization`, never a cookie, so there is no credentials mode to set. ## What a handler returns The method's return is the answer. An object or an array is `200` with its JSON, and `undefined` or `null` is `204` with no body. A `Promise` is awaited first. A refusal is a throw ([Responses & Errors](/docs/backend/errors)). When the answer needs its own status, headers or body, return a `Response`. It is sent as it was built, with the request's `X-Request-Id` and CORS' headers added: ```ts // src/modules/files/files.controller.ts import { Controller, Get, Param, Post } from "palbase/backend"; @Controller("/files") export class FilesController { // 201 with where the new one is. @Post("") create() { return Response.json({ id: "f_1" }, { status: 201, headers: { location: "/files/f_1" } }); } // Any status, with its headers: here a 412 and the version the client must send. @Get("/{id}/stale") stale(@Param("id") id: string) { return Response.json({ error: "precondition_failed", id }, { status: 412, headers: { etag: '"v7"' } }); } // Not JSON: a CSV, downloaded as a file. @Get("/report.csv") report() { return new Response("id,name\n1,Ada\n", { headers: { "content-type": "text/csv; charset=utf-8", "content-disposition": 'attachment; filename="report.csv"' } }); } // A stream, sent as it is made: one JSON line at a time. @Get("/feed") feed() { const encoder = new TextEncoder(); const body = new ReadableStream({ start(controller) { for (let i = 1; i <= 3; i++) controller.enqueue(encoder.encode(`${JSON.stringify({ line: i })}\n`)); controller.close(); }, }); return new Response(body, { headers: { "content-type": "application/x-ndjson" } }); } } ``` The request's transaction ends when the handler returns, before the answer goes. A streamed body is read after that, so read what it needs from `Database` first. A returned `Response` is an answer, whatever its status: what the handler wrote commits. Only a throw rolls it back. A `fetch()` answer can be returned as it is: its body is already decoded, so its `Content-Encoding` and `Content-Length` are not passed on, and neither is a connection's own header (`Connection`, `Transfer-Encoding`, …). ## Keep controllers thin A controller reads the request (its parameters, the user, what [`@Resolve`](#parameters-your-app-makes-resolve) found) and hands it to a service. The logic and the database work belong in an `@Injectable()` service the controller takes in its constructor, which can be tested on its own and shared across controllers. See [Modules & Dependency Injection](/docs/backend/modules) and [Testing](/docs/backend/testing). ## Related - [Request Validation](/docs/backend/validation): `@Body`, `@QueryParams`, `@Param`, `@Headers` - [Authentication](/docs/backend/authentication): route and controller `auth` - [Responses & Errors](/docs/backend/errors): the answer and the envelope - [Calling Your Backend](/docs/web/backend-calls): calling these routes from a browser # Request Validation A handler never reads raw input. Each part of the request it uses arrives through a **parameter decorator**, and every decorator that takes a schema validates before the handler runs. `@Body`, `@QueryParams` and `@Headers` take any [Standard Schema](https://standardschema.dev): zod (re-exported as `z` from `palbase/backend`), valibot, arktype or another library that implements it. A request that fails one is answered `400` with the fields that failed, and the handler is never called. ## Quick example ```ts // src/modules/todos/dto.ts import { z } from "palbase/backend"; export const CreateTodo = z.object({ title: z.string().min(1).max(200), dueAt: z.iso.datetime().optional(), tags: z.array(z.string().max(30)).max(10).default([]), }); export type CreateTodo = z.infer; // The query string arrives as strings: coerce what is a number. export const ListTodos = z.object({ limit: z.coerce.number().int().min(1).max(100).default(20), done: z.enum(["true", "false"]).optional(), }); export type ListTodos = z.infer; ``` ```ts // src/modules/todos/todos.controller.ts import { Body, Controller, Get, Param, Post, QueryParams, User, type UserT } from "palbase/backend"; import { CreateTodo, ListTodos } from "./dto"; @Controller("/todos") export class TodosController { @Post("") create(@Body(CreateTodo) body: CreateTodo, @User() user: UserT): { owner: string; todo: CreateTodo } { // body.tags is [] when the client sent none: the schema's output is what arrives. return { owner: user.id, todo: body }; } @Get("") list(@QueryParams(ListTodos) query: ListTodos): { limit: number; done: string | null } { return { limit: query.limit, done: query.done ?? null }; } @Get("/{id}") get(@Param("id") id: string): { id: string } { return { id }; } } ``` The handler gets the schema's **output**: defaults filled in, coercions and transforms applied. That is why the parameter's type is `z.infer`, written once beside the schema as a same-named type. ## The parameter decorators | Decorator | What it reads | Validated | |---|---|---| | `@Body(schema)` | The JSON body | always (a schema is required) | | `@QueryParams(schema)` | The query string, as an object of strings | always (a schema is required) | | `@Headers(schema?)` | The request's headers, names lower-cased | when given a schema | | `@Param("name")` | One path segment, `{name}` in the route's path | no: always a string | | `@User()` / `@OptionalUser()` | The signed-in user | see [Authentication](/docs/backend/authentication) | | `@Req()` | The `Request` itself | no | | `@RequestId()` | The request's id (`@TraceId()` is the same) | no | | `@Client()` | `{ sdkVersion, appVersion, platform, osVersion }`, from the client's headers | no | A schema passed to `@Body`, `@QueryParams` or `@Headers` must be a Standard Schema value, the object itself. Anything else (a type, a plain object, `undefined`) throws when the class is declared: `@Body(schema): schema is a Standard Schema (a zod, valibot or arktype schema)`. ### `@Body` The body is read whole, up to the body limit (1 MiB unless `PalbaseFactory.create(…, { bodyLimit })` says otherwise). Past it, the answer is `413 payload_too_large`, before the schema sees anything. The body is parsed as JSON **whatever its `Content-Type`**. An empty body, or one that is not JSON, reaches the schema as `{}`, so the caller hears a validation error that names what was missing. A `GET` never reads its body: a `@Body` on a GET route validates `{}`. Use `@QueryParams` for a GET, or a [`@Query`](/docs/backend/controllers) route when the input is structured. There is no multipart and no file upload in a request body: a body is JSON. To take a file, the backend grants a [signed upload URL](/docs/backend/direct-uploads) and the client sends the bytes straight to storage. ### `@QueryParams` The query string becomes an object of **strings**, `?limit=20&done=true` as `{ limit: "20", done: "true" }`. A key given twice keeps its last value. Coerce what is a number or a boolean in the schema (`z.coerce.number()`), and keep the schema an object of scalars. ### `@Headers` Without a schema, `@Headers()` hands over every header as an object of strings, names lower-cased. With one, the object is validated and the schema's output arrives. A header schema should let other headers through (zod's objects strip unknown keys, so the handler simply gets the ones you named): ```ts // src/modules/sync/sync.controller.ts import { Controller, Headers, Post, User, z, type UserT } from "palbase/backend"; const SyncHeaders = z.object({ "x-device-id": z.string().min(8).max(64) }); type SyncHeaders = z.infer; @Controller("/sync") export class SyncController { @Post("") sync(@Headers(SyncHeaders) headers: SyncHeaders, @User() user: UserT): { device: string; user: string } { return { device: headers["x-device-id"], user: user.id }; } } ``` ### `@Param` `@Param("id")` is the segment `{id}` of the route's path, percent-decoded, always a string. It takes no schema. Check its shape in the service (or with `schema.parse(id)`) when it must be a UUID or a number. A `@Param` that names no segment of its route stops the boot. ### `@Req`, `@RequestId` and `@Client` `@Req()` is the web `Request`. For a route with a body, the body has already been read by the time the handler runs, so read it through `@Body`. `@RequestId()` is the request's id: the `X-Request-Id` the caller sent when it is 8 to 128 characters of `A–Z a–z 0–9 . _ : -`, otherwise one made for it (`req_…`). It is in every answer's `X-Request-Id` header, in the error envelope and in every `Log` line of the request. `@Client()` reads what a client says of itself: `x-palbase-sdk-version`, `x-palbase-client-version`, `x-platform` and `x-os-version`, each `null` when absent. ```ts // src/modules/support/support.controller.ts import { Client, Controller, Get, RequestId, type ClientInfo } from "palbase/backend"; @Controller("/support", { auth: false }) export class SupportController { @Get("/whoami") whoami(@RequestId() requestId: string, @Client() client: ClientInfo): { requestId: string; platform: string | null } { return { requestId, platform: client.platform }; } } ``` ## Where schemas live Schemas are ordinary values. Put a domain's in its own `dto.ts` beside its controller, each exported as a value and a same-named type, so the controller reads `@Body(CreateTodo) body: CreateTodo`. Nothing discovers the file: it is an ordinary import. A schema two domains share belongs to the module that owns the concept, or to a small shared file both import. Import `z` from `palbase/backend` rather than installing zod yourself. The framework re-exports the zod it is built with, so your schemas and the framework agree on one version. ## What a validation failure looks like A failed schema answers `400 bad_request` in the [error envelope](/docs/backend/errors), with each failed field in `data.fields`: ```json { "error": "bad_request", "error_description": "Request body failed validation", "status": 400, "request_id": "req_6f1c…", "data": { "fields": [ { "field": "title", "message": "Too small: expected string to have >=1 characters" }, { "field": "tags.3", "message": "Too big: expected string to have <=30 characters" } ] } } ``` - `error_description` says which part failed: `Request body failed validation`, `Query parameters failed validation` or `Request headers failed validation`. - `field` is the issue's path, dotted (`tags.3`). It is `""` for the value as a whole. - `message` is the schema library's own message. - At most 20 fields are listed. When there were more, `data.more` says how many: a body of a million bad items is not answered with a million lines. A `BadRequest` you throw yourself has the same shape: `throw new BadRequest({ fields: [{ field: "name", message: "…" }] })`. A client handles both the same way. ## The body is data, never structure Everything a request sent is marked as **data** where it enters: the body (as `JSON.parse` made it), the query, the headers and the user's metadata. The database takes values from data, but never a filter's operators, its branches, a `select`, an `orderBy` or a column name. `{ token: body.token }` cannot become `{ token: { neq: null } }` because the client sent an object there; the query refuses it with a `400` naming the field. What a schema builds is the route's own: `z.object({ title: z.string() })` makes a new object, and its `title` is a value. What a schema lets through untouched (`z.unknown()`, `z.any()`, a passthrough's extra keys) stays the request's, and stays marked. Declare the shape of whatever you filter by. See [Database](/docs/backend/database). ## The answer is not validated A schema validates what comes in. What the handler returns is sent as it is, so return the fields you mean to: build the answer from the row rather than returning the row, when the row holds more than the caller should see. ## What Studio shows Each schema is also written into the app's contract as JSON Schema, for libraries that implement the Standard JSON Schema converter (zod does). Studio's **Backend › Endpoints** screen shows each route's body, query and header schemas from there. A schema whose library has no converter is described as any value. It is still validated. ## Related - [Controllers & Routing](/docs/backend/controllers): the routes these decorators sit on - [Responses & Errors](/docs/backend/errors): the envelope a `400` arrives in - [Authentication](/docs/backend/authentication): `@User` and `@OptionalUser` - [Direct Uploads](/docs/backend/direct-uploads): files, which never ride in a request body # Authentication Every route needs a signed-in user unless something says otherwise. A caller proves who they are with the access token the project's auth gave them, sent as `Authorization: Bearer `. The app checks that token itself, against the project's signing keys, before the handler runs. The handler receives the user as a parameter: `@User()` where one is required, `@OptionalUser()` where anyone may call. Signing up, signing in and keeping a session are the client's job ([Web Auth](/docs/web/auth)). A backend only ever sees the token. ## Quick example ```ts // src/modules/profile/profile.controller.ts import { Controller, Get, OptionalUser, Post, User, type UserT } from "palbase/backend"; @Controller("/profile") export class ProfileController { // The default: a signed-in user, or 401. @Get("/me") me(@User() user: UserT): { id: string; email: string | null; verified: boolean } { return { id: user.id, email: user.email, verified: user.emailVerified }; } // Anyone; a valid token makes the caller a user. @Get("/greeting", { auth: false }) greeting(@OptionalUser() user: UserT | null): { text: string } { return { text: user ? `Hello, ${user.email ?? "friend"}` : "Hello, guest" }; } // A signed-in user whose email is verified, or 403. @Post("/publish", { auth: { verifiedEmail: true } }) publish(@User() user: UserT): { published: string } { return { published: user.id }; } } ``` ```ts // src/modules/profile/profile.module.ts import { Module } from "palbase/backend"; import { ProfileController } from "./profile.controller"; @Module({ controllers: [ProfileController] }) export class ProfileModule {} ``` ## Where a route's auth comes from A route's auth is the first of these that says anything: 1. the route's own option: `@Get("/x", { auth })`; 2. its controller's: `@Controller("/x", { auth })`; 3. the app's default: `PalbaseFactory.create(AppModule, { auth })`; 4. otherwise `true`: a signed-in user. ```ts // src/modules/catalog/catalog.controller.ts import { Controller, Get, Post, User, type UserT } from "palbase/backend"; // Public by default: everything in this controller answers anyone… @Controller("/catalog", { auth: false }) export class CatalogController { @Get("") list(): { items: string[] } { return { items: ["tea", "coffee"] }; } // …except this route, which says otherwise. @Post("/favourites", { auth: true }) favourite(@User() user: UserT): { user: string } { return { user: user.id }; } } ``` ### The `auth` value | Value | Meaning | |---|---| | `true` | A signed-in user is required. | | `false` | Anyone may call. A valid token still makes the caller a user (`@OptionalUser()`, and the database's identity). | | `{ required?: boolean }` | The same as `true` or `false`; `required` defaults to `true`. | | `{ verifiedEmail: true }` | A signed-in user whose token says `email_verified: true`, or `403 email_not_verified`. | | `{ aal: "aal2" }` | A user who signed in with a second factor (a passkey that verified its user, an authenticator or SMS code, a step-up), or `401 insufficient_user_authentication`. `"aal3"` asks more: a key that never leaves its device; `"aal1"` is a sign-in of any kind. An email code is not a second factor ([below](#second-factors-and-their-strength)). Implies a signed-in user: with `required: false` the app stops at boot. | | `{ maxAge: 300 }` | A user who last proved who they are — signed in, or stepped up since — within the last 300 seconds, or `401 insufficient_user_authentication` with `max_age`. With `aal`, both: `{ aal: "aal2", maxAge: 300 }` is "a second factor within the last 5 minutes", what sending money or changing the account's email asks. Whole seconds above 0. Implies a signed-in user; an `@Upload` route cannot ask for it (the app stops at boot). | `role` and `permission` fields are refused when the app boots. Its token carries no application roles, and Palbase does not check them against a database, so the boot stops with an error naming the route, saying the role check has no database to run against and that the role is to be checked in the service. How to check one is [below](#roles). ## What the caller is answered | The request | On a route that needs a user | On a route that does not | |---|---|---| | no `Authorization` header | `401 unauthorized` | handled, user `null` | | `Authorization` that is not `Bearer ` | `401 invalid_token` | handled, user `null` | | a token that does not verify | `401 invalid_token`, `data.reason` says why | handled, user `null` | | a valid token | handled, `@User()` is the user | handled, `@OptionalUser()` is the user | | a valid token below the route's `aal` | `401 insufficient_user_authentication` | — | | a valid token whose last authentication is older than the route's `maxAge` | `401 insufficient_user_authentication` | — | | tokens cannot be checked right now | `503 auth_unavailable`, `Retry-After: 1` | handled, user `null` | `insufficient_user_authentication` is RFC 9470's step-up challenge: `WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="urn:nist:800-63:aal2"` and `data.acr_values: "aal2"`; a route with `maxAge` adds `max_age=300` and `data.max_age: 300`. The session is fine: the app steps the user up (their second factor) and calls again. Palbase's clients name it apart from a signed-out caller: `stepUpRequired` on iOS, `StepUpRequired` on Android, `kind: "stepUpRequired"` on the web; none refreshes or ends the session for it. The step-up is one of: - `pb.auth.mfa.stepUp(code)` with the authenticator app's code; - `pb.auth.mfa.sendStepUpSmsCode()`, then `pb.auth.mfa.stepUpWithSms(code)` with the code it texted (`stepUp(smsCode:)` on iOS); - `stepUp()` on the client's passkeys: Face ID, a fingerprint or the device's screen lock. The session takes the token at the new level, and its refreshes stay there. A step-up is the session's last authentication: the token's `auth_time` is its moment, and every token the session is refreshed to keeps it, so a `maxAge` route counts from the step-up, not from the sign-in. ```ts // src/modules/admin/approvals.controller.ts import { Controller, Param, Post, User, type UserT } from "palbase/backend"; @Controller("/admin") export class ApprovalsController { // Approving needs the admin's second factor, whatever their session. @Post("/approvals/{id}", { auth: { aal: "aal2" } }) approve(@User() user: UserT, @Param("id") id: string) { return { approvedBy: user.id, id, how: user.amr }; } } ``` `data.reason` on an `invalid_token` refusal is one of `malformed`, `algorithm`, `unknown_key`, `signature`, `issuer`, `audience`, `project`, `expired`, `not_yet_valid`, `not_a_user`, `bound`, `not_a_session` or `session_ended`. A client that gets `expired` refreshes its session and calls again. `palbase/web` does that by itself for the project's modules. See [Calling Your Backend](/docs/web/backend-calls). ```ts // src/modules/payments/transfers.controller.ts import { Controller, Param, Post, User, type UserT } from "palbase/backend"; @Controller("/payments") export class TransfersController { // Sending money asks for a second factor given in the last 5 minutes. @Post("/transfers/{id}/send", { auth: { aal: "aal2", maxAge: 300 } }) send(@User() user: UserT, @Param("id") id: string) { return { transfer: id, sentBy: user.id, provedAt: user.authTime }; } } ``` ### Second factors and their strength The token says how its user last proved who they are: the level in `acr`, the method in `amr` (RFC 8176). | Method | `acr` | `amr` | | |---|---|---|---| | A key that never leaves its device (a security key, a device-bound passkey) | `aal3` | `hwk` | Resists phishing. | | A synced passkey: Face ID, a fingerprint, the screen lock | `aal2` | `swk` | Resists phishing. | | An authenticator app's code | `aal2` | `otp` | | | A code texted to the user's phone | `aal2` | `sms` | NIST calls it restricted (a SIM can be swapped): offer an app or a passkey beside it. | | A code emailed to the user | `aal1` | `email` | Not a second factor: the inbox is where the password is reset (NIST SP 800-63B-4 §3.1.3.1). It is no way to step up. | | A recovery code | `aal1` | `recovery` | A way back in, not a second factor. | A sign-in with a password and a second factor carries both (`["pwd", "otp"]`); a step-up's token carries the step-up's method alone, the authentication its `auth_time` is the moment of. A route that will take only a passkey checks `amr` in the service. ## How the token is verified A machine's address takes requests without an API key, and nothing in front of the app has looked at the token. The token's own signature is the only thing that says who the caller is, so the app checks all of it: - it is a JWS of three parts, signed `ES256` or `PS256` with a `kid`. `none`, HMAC and every other algorithm are refused before any key is looked at; - the `kid` is a key of the project's key set, `https://.palbase.studio/.well-known/jwks.json`, and the signature verifies with it. The key set is kept as long as its answer says (an hour at most), and read again when an unknown `kid` arrives, at most every 30 seconds; - `iss` is the project's address, `aud` names it, and `project_id` is the project's id; - `exp` is ahead and `nbf` (when present) behind, within a minute of clock skew. `auth_time` is present: it is a user's token, not a client's; - it is not bound to a key the caller must prove (`cnf`, DPoP); - it belongs to a session (`session_id`), and that **session still stands**. The app asks the project (`GET /auth/user` with the token) at most every 15 seconds per session. A user who signs out, whose session is revoked or who is banned loses their tokens within that time, not at their expiry. When the key set cannot be read and none is held, or a session's standing cannot be asked, the answer is `503 auth_unavailable`, not a `401`: the token may be fine. A key set the app already holds is used for up to ten more minutes past its age while it cannot be read again. ## Injecting the user ### `@User()`: routes that need one `@User()` is the signed-in user. It is used on a route whose auth is required. On a route that does not require a user it would be `null`, so the app stops at boot: `@User() on a route that does not need a signed-in user — use @OptionalUser() (null when nobody is)`. ### `@OptionalUser()`: routes anyone may call `@OptionalUser()` is the user when the request carried a valid token, and `null` otherwise. A token that does not verify makes the caller nobody here; it is not refused. ### The `UserT` shape `@User()` is a value (the decorator) and `UserT` is a type. Import both from `palbase/backend`: | Field | Type | What it is | |---|---|---| | `id` | `string` | The user's id (the token's `sub`) | | `email` | `string \| null` | The user's email, when they have one | | `emailVerified` | `boolean` | The token's `email_verified` | | `role` | `string \| null` | The token's role (`authenticated` for a user) | | `metadata` | `Record` | The token's `custom_claims` | | `sessionId` | `string \| null` | The session the token belongs to | | `test` | `boolean` | A [test user](/docs/backend/test-users) of the project | | `acr` | `"aal1" \| "aal2" \| "aal3" \| null` | How the user signed in (the token's `acr`): `aal2` with a second factor. `null` where no token says it: an upload's or a gateway's caller, whom the platform names | | `amr` | `string[]` | The methods the sign-in used (the token's `amr`: `pwd`, `otp`, `hwk`, …) | | `authTime` | `number \| null` | When the user signed in or last stepped up, in Unix seconds (the token's `auth_time`) | The object is marked as request data: the user writes their own metadata, so the database takes values from it but never treats it as a filter's structure. See [Request Validation](/docs/backend/validation). ## Device attestation A route marked `attest: true` is called only by your own app, on a genuine device: Palbase's [iOS](/docs/ios/backend-calls#device-attestation) and [Android](/docs/android/backend-calls#device-attestation) SDKs attach a proof to the call by themselves. On iOS the proof is Apple's App Attest; on Android a key in the phone's secure hardware that Google's attestation vouches for: a locked, verified phone running your app as you signed it. A script, an emulator, a modified app or a call replayed with another body has none that verifies. ```ts // src/modules/location/location.controller.ts import { Body, Controller, Post, z } from "palbase/backend"; const Report = z.object({ lat: z.number(), lng: z.number() }); @Controller("/location", { auth: false }) export class LocationController { // A report made up with curl, or sent from a Simulator, does not get in. @Post("/report", { attest: true }) report(@Body(Report) report: z.infer) { return { received: report }; } } ``` `attest: true` goes on a route or on a `@Controller(path, { attest: true })`, where a route's `attest: false` leaves it out. Whether it is asked is the environment's switch, not the code's: **App Attest mode** in Studio's Authentication settings (Device trust), or `palbase auth:settings:set app_attest_mode= -E `. A change applies within 30 seconds, with no deploy (the app reads it every 25). | Mode | What an attested route does | |---|---| | `off` (the default) | Nothing: every call is handled. | | `monitor` | Every call's proof is verified, and nobody is refused. Studio's Device trust shows the last 24 hours: proofs verified, proofs refused, devices that cannot attest, clients without attestation support. | | `enforce` | A call without a proof that verifies is answered `401 app_attest_required`. | The refusal carries `data.reason` (`app_attest_required`: no proof; `assertion_invalid`; `assertion_stale`: another call used the device's counter first; `device_unknown`: the device is not enrolled) and `data.challenge`, also in the `X-Palbase-Attest-Challenge` header: the SDK enrols the device when it must, proves the call with that challenge and sends it once more. Every answer of an attested route carries the next challenge in the same header, so a device's next call needs no extra round trip. When the proof cannot be checked (auth is not reachable, or the environment's mode was never read since the machine started) the route answers `503 attest_unavailable` with `Retry-After: 1`. A route that decides by device — one account per phone, a shared tablet at a desk — takes the device whose proof verified with `@AttestedDevice()`: its id and platform, as auth names them. ```ts // src/modules/shifts/shifts.controller.ts import { AttestedDevice, Controller, Post, User, type UserT } from "palbase/backend"; @Controller("/shifts") export class ShiftsController { @Post("/clock-in", { attest: true }) clockIn(@User() user: UserT, @AttestedDevice() device: AttestedDevice | null) { // { id: "dev_…", platform: "ios" | "android" } under enforce; null under off and monitor. return { user: user.id, device: device?.id ?? null }; } } ``` It is the device auth verified, not the `X-Palbase-Device-Id` header the client sends: that one is only the client's word, under `off` and `monitor` too. Under `off` nothing is verified and under `monitor` nothing is refused, so `@AttestedDevice()` is `null` in both: a route does not act on a device for some calls and not for others. On a route that is not `attest: true` it is refused when the app starts. The environment's iOS and Android apps are its registered apps (`palbase apps:env:set`): an iOS app's App Attest key is taken for its bundle and team, an Android app's key for its package and the signing certificates registered with it (Play's app signing key and your upload key; not a debug key, whose build anyone with the phone can drive to sign requests). An Android enrolment is checked again on every call: a certificate of its chain revoked since, or a signing certificate taken off the app, stops it, and after 30 days the phone enrols again. Calls an app makes at once may reach the backend in any order: each is taken once. A browser cannot attest: under `enforce`, a web app's calls to an attested route are refused. So are calls from older app versions that predate attestation; set the app's minimum version before turning `enforce` on. ## Roles The token says who the user is, not which application roles they hold. Roles are defined and assigned in the project's auth module, with the secret key: `PUT /admin/roles/{name}` defines one with its permissions, and `PUT /admin/users/{uid}/roles/{role}` gives it to a user. A backend that needs one asks for the user's roles where it decides: ```ts // src/modules/admin/admin-guard.service.ts import { Forbidden, Injectable, Palbase, type UserT } from "palbase/backend"; @Injectable() export class AdminGuard { async assertAdmin(user: UserT): Promise { const { data } = await Palbase.auth.adminListUserRoles({ path: { uid: user.id } }); if (!data.roles.includes("admin")) throw new Forbidden("This needs the admin role"); } } ``` ```ts // src/modules/admin/admin.controller.ts import { Controller, Delete, Param, User, type UserT } from "palbase/backend"; import { AdminGuard } from "./admin-guard.service"; @Controller("/admin-tools") export class AdminController { constructor(private readonly guard: AdminGuard) {} @Delete("/posts/{id}") async removePost(@Param("id") id: string, @User() user: UserT): Promise<{ removed: string }> { await this.guard.assertAdmin(user); return { removed: id }; } } ``` ```ts // src/modules/admin/admin.module.ts import { Module } from "palbase/backend"; import { AdminController } from "./admin.controller"; import { AdminGuard } from "./admin-guard.service"; @Module({ controllers: [AdminController], providers: [AdminGuard] }) export class AdminModule {} ``` Assigning a role from your own code is the same client: `Palbase.auth.adminAssignUserRole({ path: { uid, role: "admin" } })`. A role that is not defined is `400 role_not_defined`. ## Auth and the database When the app has a database, each request runs in a transaction **as its caller**. A signed-in caller is the `authenticated` role with the token's claims, so `auth.uid()` in a policy is their id. Anyone else is `anon`. That is what makes row-level security hold for every query a handler runs, whoever wrote it. An app whose every request is its operator's sets `db: { requests: "service" }` and decides access in its routes. See [Row-Level Security](/docs/backend/row-level-security). ## Who may call at all A route with `auth: false` answers the internet: the machine's address takes requests from anyone. Give it a [rate limit](/docs/backend/controllers) that suits it, and keep what it returns to what anyone may see. A browser on another origin needs the origin named in [CORS](/docs/backend/controllers). The machine's address is never a reason to skip auth: there is no network boundary that only your own apps can cross. ## Related - [Web Auth](/docs/web/auth): signing users in, and the session the token comes from - [Calling Your Backend](/docs/web/backend-calls): sending the token - [Row-Level Security](/docs/backend/row-level-security): the caller in the database - [Test Users](/docs/backend/test-users): `user.test` and making test users - [Responses & Errors](/docs/backend/errors): the envelope these refusals arrive in # Responses & Errors A handler answers by returning and refuses by throwing. What it returns is sent as JSON with `200`, or as `204` when it returns nothing. What it throws is sent in **one envelope**, the same one the framework uses for its own refusals and the project's modules use for theirs, so a client handles every refusal one way. ## Quick example ```ts // src/modules/invites/invites.service.ts import { Conflict, Forbidden, Injectable, NotFound, PalError, UniqueViolation } from "palbase/backend"; export interface Invite { code: string; team: string; usedBy: string | null; } @Injectable() export class InvitesService { private readonly invites = new Map(); accept(code: string, userId: string): Invite { const invite = this.invites.get(code); if (!invite) throw new NotFound("No invite with that code", "invite_not_found"); if (invite.usedBy === userId) throw new Conflict("You already joined", "already_joined"); if (invite.usedBy) throw new Forbidden("This invite was used", "invite_used"); if (invite.team === "closed") { // Any status and code of your choosing, with typed details in data. throw new PalError(422, "team_closed", "The team takes no new members", { team: invite.team }); } invite.usedBy = userId; return invite; } // A database refusal, translated into one the client can act on. rename(e: unknown): never { if (UniqueViolation.is(e) && e.constraint === "teams_name_key") throw new Conflict("That name is taken", "name_taken"); throw e; } } ``` ```ts // src/modules/invites/invites.controller.ts import { Controller, Param, Post, User, type UserT } from "palbase/backend"; import { InvitesService, type Invite } from "./invites.service"; @Controller("/invites") export class InvitesController { constructor(private readonly invites: InvitesService) {} @Post("/{code}/accept") accept(@Param("code") code: string, @User() user: UserT): Invite { return this.invites.accept(code, user.id); } } ``` A missing code answers: ```json { "error": "invite_not_found", "error_description": "No invite with that code", "status": 404, "request_id": "req_6f1c…" } ``` ## What a handler returns | The method returns | The answer | |---|---| | an object, an array, a string, a number | `200`, `Content-Type: application/json`, the value as JSON | | `undefined` or `null` (or `Promise`) | `204`, no body | | a `Promise` | awaited, then as above | The return type is yours to write. The framework does not read it: an interface, a zod-inferred type and an inline object type all work. Every answer carries `X-Request-Id`. There is no way to set a status or a header on a success: a handler that needs a different status for a refusal throws. ## The error envelope Every refusal, whether the framework's, a handler's or the database's, is: | Field | Always | What it is | |---|---|---| | `error` | yes | A stable code in snake_case. Branch on this. | | `error_description` | yes | What went wrong, for a person. Do not parse it. | | `status` | yes | The HTTP status, the same as the response's. | | `request_id` | yes | The request's id, also in `X-Request-Id` and in every `Log` line of the request. Quote it when you look for the request in the machine's logs. | | `data` | when there are details | A validation's `fields`, a token's `reason`, a limit's `retryAfter`, or what your own error carries. | A refusal that ends with time also carries a `Retry-After` header in seconds: `429` and `503` always do. ### Codes the framework generates | Status | `error` | When | |---|---|---| | 400 | `bad_request` | A parameter failed its schema (`data.fields`), or a path segment is not valid percent-encoding | | 401 | `unauthorized` | The route needs a signed-in user and the request has no token | | 401 | `invalid_token` | The token does not verify (`data.reason`), or `Authorization` is not a bearer token | | 403 | `email_not_verified` | The route asks for a verified email | | 404 | `not_found` | No route for the method and path | | 413 | `payload_too_large` | The body is over the body limit | | 429 | `too_many_requests` | The route's limit for this caller (`data.retryAfter`, `Retry-After`) | | 500 | `internal_error` | The handler threw something that is not an `HttpError` | | 503 | `auth_unavailable` | Tokens cannot be checked right now (`Retry-After`) | | 503 | `service_unavailable` | A module the request called did not answer (`Retry-After`) | The database adds its own: `409 unique_violation` (and `serialization_failure`, `deadlock_detected`), `400 invalid_value` for a value its column cannot take, `403 permission_denied` for a write RLS or a missing grant refuses (`401` to an anonymous caller), `429 too_many_requests` when its queue is full, and `503 database_unavailable` or `database_busy`. See [Database](/docs/backend/database). ## Built-in error classes Import them from `palbase/backend` and throw them from a controller or a service: | Class | Status | Default `error` | Constructor | |---|---|---|---| | `BadRequest` | 400 | `bad_request` | `(data: { fields: { field, message }[] }, message?)` | | `Unauthorized` | 401 | `unauthorized` | `(message?, code?, data?)` | | `Forbidden` | 403 | `forbidden` | `(message?, code?, data?)` | | `NotFound` | 404 | `not_found` | `(message?, code?, data?)` | | `Conflict` | 409 | `conflict` | `(message?, code?, data?)` | | `TooManyRequests` | 429 | `too_many_requests` | `(data: { retryAfter }, message?)`, sent with `Retry-After` | | `PalError` | any | yours | `(status, code, description, data?)` | | `HttpError` | any | yours | `(status, error, errorDescription, data?, retryAfter?)` | The message-first classes take an optional `code`, which becomes `error`. It is the field a client branches on, so give a refusal its own code (`invite_used`) rather than the class's default when the client should act on it. The message defaults to the class name in words (`Not found`). `BadRequest` carries fields the way a failed schema does, so a check of your own reads like the framework's: ```ts // src/modules/dates/dates.service.ts import { BadRequest, Injectable } from "palbase/backend"; @Injectable() export class DatesService { range(from: Date, to: Date): number { if (to <= from) throw new BadRequest({ fields: [{ field: "to", message: "must be after from" }] }); return to.getTime() - from.getTime(); } } ``` `UniqueViolation`, `SerializationFailure` and `DeadlockDetected` are the database's. It throws them, and you catch them. `UniqueViolation.is(e)` holds for one whichever copy of the package made it, and `e.constraint` names the constraint Postgres reported. The constraint is a field, never part of the message, because a constraint name tells a stranger how the schema is built. An error is recognised by a brand, not by `instanceof`, so two copies of the package in one app agree. Anything with a status from 400 to 599, an `error` and an `errorDescription` is sent as it says. ## Errors from the project's modules A call through `Palbase` (storage, notify, auth…) that the module refuses throws a `PalbaseError` with the module's `status`, `code`, `requestId` and `retryAfter`. What the framework does with one that reaches it uncaught: - **The module did not answer** (no connection, `429`, `502`, `503`, `504`): `503 service_unavailable` with `Retry-After`. The request can be tried again, and nothing about it was the caller's fault. - **Any other refusal** (a `404` for an object that is not there, a `400` for a bad input): `500 internal_error`, logged. The module refused something the handler asked for, which is the handler's to decide. So catch the refusals your code expects and answer them yourself: ```ts // src/modules/files/files.service.ts import { Injectable, NotFound, Palbase } from "palbase/backend"; import { PalbaseError } from "palbase/server"; @Injectable() export class FilesService { async link(path: string): Promise<{ url: string }> { try { const signed = await Palbase.storage.createSignedUrl({ path: { bucket: "files" }, body: { path, expires_in: 300 } }); return { url: signed.data.url }; } catch (e) { if (e instanceof PalbaseError && e.status === 404) throw new NotFound("No such file", "file_not_found"); throw e; } } } ``` ## What is logged The caller sees the envelope. The machine's logs see more: - an `HttpError` with a status of 500 or more is logged with the route and the error; - anything that is not an `HttpError` is logged as `unhandled error in `, with its stack. The caller sees only `The request could not be completed`, never the message, which may hold a query or a value; - a module that did not answer, and tokens that cannot be checked, are logged as warnings. A `4xx` you throw is not logged: it is an answer, not a failure. Use `Log` for what you want to keep. See [Overview](/docs/backend/overview). ## 503 or 500 The difference matters to every client. A `503` with `Retry-After` says "this was not you, and trying again is reasonable": an outage, a module that did not answer, tokens that cannot be checked. A `500` says the handler failed, and sending the same request again will fail the same way. Throw an `HttpError` with status 503 and a `retryAfter` when your own dependency is out. Let a bug be a 500. ## Related - [Request Validation](/docs/backend/validation): the `400` a schema answers - [Authentication](/docs/backend/authentication): `401`, `403` and `503 auth_unavailable` - [Database](/docs/backend/database): the database's refusals - [Error Handling](/docs/web/errors): how a browser client reads this envelope # Database A machine given one of the project's [databases](/docs/machines/databases) is started with `PALBASE_DB_URL`, and a `palbase/backend` app that declares its schemas talks to it through `Database`. There is no client to construct: you import `Database` from `palbase/backend` and call it. Two things about it are load-bearing. `Database.public.
` is typed against the tables your [schema](/docs/backend/schema) declares, and every statement runs inside **the request's own transaction, as the caller** — a signed-in user is Postgres' `authenticated` role with their claims, anyone else `anon` — so [Row-Level Security](/docs/backend/row-level-security) filters what your code can even see. The one deliberate exception is `Database.$asService()`, which opens a separate transaction as the service role: explicit, greppable, never the default. ## Quick example The schema the examples on this page use: ```ts // src/db/public.ts import { boolean, defineSchema, defineTable, integer, numeric, ownedByUser, policy, text, timestamp, userRef, uuid, } from "palbase/backend"; export const lists = defineTable("lists", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), title: text() }, policies: (p) => [policy("lists_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export const todos = defineTable("todos", { columns: { id: uuid().primaryKey().defaultRandom(), list_id: uuid().references(() => lists.id, { onDelete: "cascade" }), owner: ownedByUser(), title: text(), done: boolean().default(false), priority: integer().default(0), tags: text().array().default([]), created_at: timestamp().defaultNow(), }, policies: (p) => [policy("todos_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export const list_members = defineTable("list_members", { columns: { list_id: uuid().references(() => lists.id, { onDelete: "cascade" }), user_id: userRef({ onDelete: "cascade" }), role: text().default("member"), }, primaryKey: ["list_id", "user_id"], policies: (p) => [policy("members_self").for("all").using(p.col("user_id").eq(p.auth.uid())).withCheck(p.col("user_id").eq(p.auth.uid()))], }); export const payments = defineTable("payments", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), idem_key: text().unique(), amount: numeric({ precision: 12, scale: 2 }), }, policies: (p) => [policy("payments_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("public", { tables: [lists, todos, list_members, payments] }); ``` A service that reads and writes it, and the controller and module around it: ```ts // src/modules/todos/todos.service.ts import { Database, Injectable, NotFound } from "palbase/backend"; import type { Tables } from "palbase/backend/env"; export type Todo = Tables["todos"]["row"]; @Injectable() export class TodosService { open(): Promise { // No owner filter: RLS scopes this to the caller's own rows. return Database.public.todos.findMany({ where: { done: false }, orderBy: { column: "created_at", direction: "desc" } }); } create(owner: string, listId: string, title: string): Promise { return Database.public.todos.insert({ owner, list_id: listId, title }); } async finish(id: string): Promise { const todo = await Database.public.todos.update({ where: { id }, set: { done: true } }); if (!todo) throw new NotFound("todo not found"); return todo; } } ``` ```ts // src/modules/todos/todos.controller.ts import { Body, Controller, Get, Param, Post, User, z, type UserT } from "palbase/backend"; import { TodosService, type Todo } from "./todos.service"; const CreateTodo = z.object({ listId: z.string().uuid(), title: z.string().min(1).max(200) }); type CreateTodo = z.infer; @Controller("/todos") export class TodosController { constructor(private readonly todos: TodosService) {} @Get("") list(): Promise { return this.todos.open(); } @Post("") create(@Body(CreateTodo) body: CreateTodo, @User() user: UserT): Promise { return this.todos.create(user.id, body.listId, body.title); } @Post("/{id}/done") finish(@Param("id") id: string): Promise { return this.todos.finish(id); } } ``` ```ts // src/modules/todos/todos.module.ts import { Module } from "palbase/backend"; import { TodosController } from "./todos.controller"; import { TodosService } from "./todos.service"; @Module({ controllers: [TodosController], providers: [TodosService] }) export class TodosModule {} ``` The app passes its schemas to the framework — `PalbaseFactory.create(AppModule, { db: { schemas: [schema] } })` in `src/main.ts` — and that is what opens the database at boot. `Database` is imported by the service, not by the controller: which rows, whose, and in what order are the decisions worth testing, and they belong in the class you test. ## One request, one transaction Each request is one transaction, opened by its first statement: - **committed when the handler returns** — before the answer goes — and **rolled back when it throws**, whatever it wrote; - reached from the handler, from any service it calls, and from an [`@On` handler](/docs/backend/hooks) (a delivery is one transaction too) — **never at import time or from a timer**: outside a request `Database.` throws, naming what was reached; - **a request that only read** (`findMany`, `findUnique`, `findById`, `count`, `aggregate`, `page`) does not wait for its `COMMIT` — a read of one statement is one round trip — and gives its connection back while the handler [waits on something else](#a-handler-that-waits-on-something-else); - **a client that hangs up cancels it**: the statement in flight is cancelled and its connection goes back; - **bounded**: a statement runs at most 55 seconds and waits at most 10 seconds for a lock (5 on `$asService()`), so nothing a request runs outlives the edge in front of the machine. A database that slept, restarted or failed over closes the connections the app holds. A request that meets a closed one on its first round trip opens another and sends it again — nothing of it had committed — and the caller sees nothing. A connection lost later answers `503 database_unavailable`, because part of the work may have committed. ### A handler that waits on something else There is nothing to set. A request whose transactions have only read gives its connections back once it has sat a millisecond with no statement running — a call to another service, a timer — and its next statement opens another transaction, bound to the caller again — never refused because the database's queue is full, and ahead of the caller's own requests that have not begun. Reads one after another, and your own code between them, stay in one transaction: giving a connection back and taking one again costs a fraction of a millisecond, so it happens only where a wait is worth it. ```ts // src/modules/todos/todos-print.service.ts import { Database, Injectable, NotFound } from "palbase/backend"; @Injectable() export class TodosPrintService { async pdf(id: string) { const todo = await Database.public.todos.findUnique({ where: { id }, select: ["id", "title", "done"] }); if (!todo) throw new NotFound("no such todo"); // The connection is back in the pool while the other service works. const rendered = await fetch("https://pdf.example.com/render", { method: "POST", body: JSON.stringify(todo) }); return new Response(rendered.body, { headers: { "content-type": "application/pdf" } }); } } ``` What is held to the end of the request, because ending it there could change what comes after: - **after a write** (`insert`, `update`, `delete`, `put`, …), **a lock** (`lock` on `findUnique`, `$lockRows`, `$lockRowsWhere`, `$advisoryXactLock`), **a savepoint** (`$attempt`, `$transaction`) or **SQL of your own** (`$query` — the engine cannot know what it does): everything after it is the same transaction, so a throw still undoes all of it; - **under a database whose default isolation is repeatable read or serializable**, where one snapshot spans the transaction's statements; - **while an `$atomic` of the request is open** — it is a transaction of its own, and nothing of the request is given back beside it. So make the outside call before the first write, and outside `$atomic`. A request that holds a transaction with no statement running for over a second says so in the app's log, once, naming the route: ```text [palbase] POST /invoices: a transaction of the request could not be given back — it wrote, locked, is an $atomic or an $attempt, ran SQL of its own, or the database keeps one snapshot for it — and sat 2140 ms with no statement running: the handler waited on something else, and a database connection waited with it. Make the outside call before the first write, or outside $atomic. ``` Why giving it back is safe: under read committed, Postgres' default, every statement sees what was committed when it started, and a transaction that only read holds no row lock — the next statement sees what it would have seen in the same transaction. What does move across a wait: `now()` is the new transaction's, a migration's change to a table can land between two reads, and a policy or view that calls a function which writes has that write committed. ## Where the types come from `Database.public.
` is typed by a declaration file, `palbase/palbase-env.d.ts`, that augments `palbase/backend/env`'s `Tables` (and `Schemas`, for every other schema). The CLI writes it from your schema files — each table's row, its insert, its unique keys and its relations both ways (`with`, `has`) — between its markers in that file; `palbase init --db` writes the first one. Run it again after you change a schema file, and commit the file: ```bash palbase db:types ``` It reads the app's entry (`--entry ` when it is not the one the project names) and writes `palbase/palbase-env.d.ts` (`--file ` for another); the app's tsconfig must include the file. A schema that has not changed writes the same bytes, so a check in CI can run it and fail on a diff. A build that cannot run the CLI writes the same declarations with palgres' `makeEnvDts([schema])`. When you need a row type by name, import it: ```ts import type { Tables } from "palbase/backend/env"; type Todo = Tables["todos"]["row"]; type NewTodo = Tables["todos"]["insert"]; ``` > **Note:** If `Database.public.todos` is a compile error saying the property does not exist, the file has not been written for that table yet. Before any schema exists, `Tables` is empty and every `Database.public.*` access is a compile error — the types are only ever what your schema declares. The runtime never needs them — Bun runs the app without type-checking it — so the file is for your editor and `tsc`. ## The full surface `Database` carries your schemas by name — `Database.public.
`, `Database..
` — and operations that begin with `$`. A schema or table name can never start with `$`, so a table called `transaction` is `Database.public.transaction` and the operation is `Database.$transaction`. | Member | What it is | |---|---| | `public.
`, `.
` | the typed table operations below | | `$query(sql, params?)` | raw SQL on the request's transaction — **not** read-only | | `$transaction(fn)` | a **plan** run inside one savepoint of the request's transaction | | `$atomic(fn, opts?)` | an independently committed transaction, with retries | | `$attempt(fn)` | `fn` inside a `SAVEPOINT`: its failure rolls back only what its handle wrote | | `$asService()` | the same surface as the service role, past RLS | | `$lockRows(table, ids)` | locks those rows in primary-key order — never the caller's order, so two callers cannot deadlock on it | | `$lockRowsWhere(table, where, { mode })` | locks the rows a filter matches, in one statement | | `$advisoryXactLock(key)` | locks a **name** until the transaction ends — "one invoice run at a time" | Every typed table method also has a string-keyed twin — `$insert(table, data)`, `$findMany(table, where, opts)`, `$updateMany`, `$put`, `$claim`, `$count`, `$aggregate`, `$page` and the rest — for code where the table's name is a variable. The name is `"todos"` for `public` and `"ledger.accounts"` for any other schema. ## Typed tables Every read and every bulk write takes one object — `where`, `select`, `orderBy`, `limit`, `with` sit side by side in it. | Method | Returns | |---|---| | `insert(data)` | the inserted row | | `insertMany(rows, { onConflict?, action?, returning? })` | the rows written, or their count with `returning: false` — `action: "ignore"` skips a duplicate | | `findById(id)` / `findUnique({ where, lock? })` | the row, or `null` — with `lock`, locked until the transaction ends | | `findMany({ where?, select?, orderBy?, limit?, offset?, with? })` | the matching rows | | `page({ where?, orderBy?, first?, after?, last?, before?, select?, with? })` | `{ rows, pageInfo }` — keyset pages with sealed cursors | | `count({ where? })` | how many rows match | | `aggregate({ where?, sum?, avg?, min?, max?, count?, groupBy? })` | the aggregate, or one row per group | | `update({ where: { id }, set })` | the updated row, or `null` | | `updateMany({ where, set, returning? })` | the updated rows — an empty `where` is refused | | `delete(id)` / `deleteMany({ where })` | nothing / how many rows went | | `put({ data, onConflict })` | insert, or overwrite the row it collided with | | `claim(unique, extra?)` | `{ inserted, row }` — own an idempotency key, never overwrite | `update`, `delete` and `findById` address a row by its `id` column; a table keyed otherwise is reached with `findUnique`, `updateMany` and `deleteMany`. ### Filtering and ordering A field takes a value for equality or an operator object; fields are `AND`ed. ```ts import { Database } from "palbase/backend"; declare const since: string; const urgent = await Database.public.todos.findMany({ where: { done: false, priority: { gte: 2 }, created_at: { gt: since }, tags: { overlaps: ["work", "urgent"] }, OR: [{ title: { icontains: "invoice" } }, { title: { startsWith: "Pay" } }], }, select: ["id", "title", "priority"], orderBy: [{ column: "priority", direction: "desc" }, { column: "created_at" }], limit: 50, }); ``` | Operator | SQL | |---|---| | a value | `=` — `null` is `IS NULL` | | `{ in: [...] }` | `IN (…)` — an empty list matches no row | | `{ neq }`, `{ gt }`, `{ gte }`, `{ lt }`, `{ lte }` | `<>`, `>`, `>=`, `<`, `<=` | | `{ isNull: true }` / `{ isNull: false }` | `IS NULL` / `IS NOT NULL` | | `{ contains }`, `{ icontains }`, `{ startsWith }`, `{ endsWith }` | `LIKE` / `ILIKE` on text, the pattern's `%` and `_` escaped | | `{ contains: [...] }`, `{ overlaps: [...] }` | on an array column: `@>` (all of them), `&&` (any) | | `OR`, `AND`, `NOT` | the branches | | `col("other")` | compare with another column: `{ balance: { lt: col("limit") } }` | | `now()`, `ago({ hours: 1 })` | the server's clock, and the server's clock less a span: `{ created_at: { gt: ago({ days: 7 }) } }` | | `sqlFragment` | SQL of your own, values bound: `` where: sqlFragment`similarity(title, ${q}) > 0.3` `` | `col`, `sqlFragment`, `increment`, `decrement`, `now`, `ago` and `withRetry` are imported from `palgres`, the package `palbase/backend` is built on. `select` narrows the returned row type; a column name in `orderBy` or `select` is checked against the schema before any SQL is written. ### Time on the database's clock: `now()` and `ago()` ```ts import { Database } from "palbase/backend"; import { ago } from "palgres"; // Opened in the last week, by the database's clock. const recent = await Database.public.todos.findMany({ where: { created_at: { gt: ago({ days: 7 }) } }, select: ["id", "title"] }); // Finished a month ago or more: they go. const swept = await Database.public.todos.deleteMany({ where: { done: true, created_at: { lt: ago({ days: 30 }) } } }); ``` `ago(span)` is `now()` less a span — whole `days`, `hours`, `minutes` and `seconds`, none negative — and it stands wherever `now()` does: a filter, an `insert`, an update's `set`, a plan. Both sides of the comparison are read where the data is. A window counted from `new Date()` moves with every machine's drift from the database, and a row stamped by `defaultNow()` was stamped by the database's clock. ### Relations: `has` and `with` Every foreign key is a relation both ways — `todos.list` (forward) and `lists.todos` (reverse); [Schema](/docs/backend/schema) says how they are named. `has` filters through one, `with` fetches it in the same statement: ```ts import { Database } from "palbase/backend"; // Lists that still have an open todo, each with its three newest. const lists = await Database.public.lists.findMany({ where: { has: { todos: { done: false } } }, select: ["id", "title"], with: { todos: { select: ["id", "title"], orderBy: { column: "created_at", direction: "desc" }, limit: 3 } }, }); for (const list of lists) console.log(list.title, list.todos.length); ``` A `many` relation comes back as an array (`[]` when there is none), a `one` relation as a row or `null`. `with` nests three levels deep. ### Pages `page()` is keyset pagination: the primary key is appended to the ordering, and its cursors are sealed to the query and to the caller, so a cursor from one user's query does not open another's. ```ts import { Database } from "palbase/backend"; declare const cursor: string | undefined; const page = await Database.public.todos.page({ orderBy: { column: "created_at", direction: "desc" }, first: 20, after: cursor, select: ["id", "title", "created_at"], }); const next = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null; ``` `first`/`after` page forward and `last`/`before` back; a page is 50 rows when no size is given. ### Aggregates ```ts import { Database } from "palbase/backend"; const totals = await Database.public.todos.aggregate({ where: { done: false }, count: true, sum: ["priority"] }); // { count: number; sum: { priority: string | null } } const byList = await Database.public.todos.aggregate({ groupBy: ["list_id"], count: true }); // { list_id: string; count: number }[] // Several counts, one statement: each name counts what its filter matches, within `where`. const board = await Database.public.todos.aggregate({ where: { done: false }, count: { all: {}, urgent: { priority: { gte: 2 } }, work: { tags: { overlaps: ["work"] } } }, }); // { count: { all: number; urgent: number; work: number } } ``` `count: true` counts the rows; `count: { : }` counts, in the same statement, the rows each filter matches — `count(*) FILTER (WHERE …)`, one scan however many names — within `where`, per group with `groupBy`, and `{}` is every row. Five limits checked as five `count()` calls are five round trips; as one `aggregate` they are one. `sum` and `avg` are strings — `sum(integer)` is a `bigint` and `sum(numeric)` a `numeric`, both lossy as a JS number — and an empty set sums to `null`, not 0. ### `put()`, `claim()` and skipping what is there Read-then-write loses a race, and catching the unique violation does not rescue it: the failed insert aborts the request's transaction. Each of these is one statement. ```ts import { Database } from "palbase/backend"; declare const listId: string; declare const userId: string; declare const idemKey: string; // Write it, and overwrite the row it collided with. await Database.public.list_members.put({ data: { list_id: listId, user_id: userId, role: "editor" }, onConflict: ["list_id", "user_id"] }); // Write it once: a second call with the same key gets the FIRST call's row back. const { inserted, row } = await Database.public.payments.claim({ idem_key: idemKey }, { owner: userId, amount: "19.90" }); // Write the ones not there yet, whichever unique key they would collide on: what comes back is what was written. const written = await Database.public.payments.insertMany( [{ owner: userId, idem_key: idemKey, amount: "19.90" }], { action: "ignore" }, ); ``` `onConflict` must name columns a unique constraint or index covers. `put` overwrites; `claim` never does, and `inserted: false` is not an error — it is the answer that the work was already done. `insertMany` with `action: "ignore"` and no `onConflict` skips a row that collides with **any** unique key — the primary key or another — as Prisma's `skipDuplicates` and Drizzle's `onConflictDoNothing()` do; with `onConflict` it skips only a collision on those columns, and any other is still a `UniqueViolation`. An overwrite (`action: "update"`) has to name its columns. A skipped row is not an error and is not returned; a row a policy refuses is still refused. ### Locking the row you read: `findUnique({ lock })` A decision read from a row is only as good as the row staying what it was. `lock` reads the row and locks it **in the same statement**, until the transaction ends — a read and then a lock would decide on a row another request changed in between: ```ts import { Conflict, Database, NotFound } from "palbase/backend"; declare const listId: string; declare const userId: string; // A second request changing this member waits here, then reads what the first committed. const member = await Database.public.list_members.findUnique({ where: { list_id: listId, user_id: userId }, select: ["role"], lock: "update" }); if (!member) throw new NotFound("no such member"); if (member.role === "owner") throw new Conflict("an owner is not demoted here", "owner"); await Database.public.list_members.updateMany({ where: { list_id: listId, user_id: userId }, set: { role: "viewer" } }); ``` - **`"update"`** is `FOR UPDATE` — the row is about to change or go; **`"noKeyUpdate"`** `FOR NO KEY UPDATE` — it changes, its key stays; **`"share"`** `FOR SHARE` — it must not change while you read on, and other readers may share it. - **It is held until the transaction ends** — the request's, or the `$atomic` callback's. Another transaction asking for the row waits — 10 seconds at most on a request, `lockTimeoutMs` in `$atomic`, then `503 database_busy` — and reads what the first committed: the new values, or `null` if it deleted the row. - **One row has no lock order to get wrong.** For many rows use `$lockRowsWhere`, which takes them in primary-key order. Only the table's own row is locked, never what `with` fetches beside it. - **Postgres checks a locking read against the table's update policies** as well as its select ones: a row the caller may read but not update comes back `null` with a `lock`. ### `$attempt()`: a failure that does not poison the request When the recovery is not a `put`, put the write that may fail inside `$attempt`. It takes a `SAVEPOINT`, so only that write rolls back and the request goes on. ```ts import { Database, UniqueViolation } from "palbase/backend"; declare const listId: string; declare const userId: string; const joined = await Database.$attempt(async (tx) => { await tx.insert("list_members", { list_id: listId, user_id: userId }); return true; }).catch((e: unknown) => { if (UniqueViolation.is(e)) return false; throw e; }); ``` The handle is a parameter, not the ambient `Database`: only what `tx` writes is inside the savepoint, so a concurrent branch of the same request is never rolled back with it. ## `null` means "no row" — including rows RLS hides `findById`, `findUnique` and `update` resolve to `null` when no row matched, and that covers two cases you cannot tell apart from inside the handler: the id does not exist, or a policy hides the row from this caller. Not telling them apart is the point — a distinguishable answer would be an oracle for rows the caller may not read. Map it yourself where a missing row is the client's error: ```ts import { Database, NotFound } from "palbase/backend"; declare const id: string; const todo = await Database.public.todos.findById(id); if (!todo) throw new NotFound("todo not found"); ``` ## Raw SQL with `$query()` `$query(sql, params?)` runs SQL on the request's transaction and returns rows as plain objects, typed as the `T` you say they are — only you know what raw SQL returns. ```ts import { Database } from "palbase/backend"; declare const ids: string[]; const rows = await Database.$query<{ id: string; title: string }>( "SELECT id, title FROM todos WHERE id = ANY($1::uuid[]) AND NOT done ORDER BY created_at DESC LIMIT $2", [ids, 20], ); ``` > **Warning:** `$query` is **not** read-only. It runs on the same transaction the typed tables write through, so a write in it — a plain `UPDATE`, a CTE, a function that writes — commits with the request. What stands between it and your data is RLS and the caller's role, exactly as for everything else on this page. Parameters are `$1…$n`, never interpolated. Each is sent as the text Postgres reads and the statement's casts decide its type: a string as it is; a number, bigint or boolean as its literal; a `Date` as ISO 8601; an array of scalars as a Postgres array (`= ANY($1::uuid[])`); an object, or an array holding objects, as JSON. For a `jsonb` parameter pass `JSON.stringify(value)` — an empty array alone goes as the Postgres array `{}`, which `::jsonb` reads as an object. Rows come back as the driver parses them: `bigint` and `numeric` as strings, a timestamp as an ISO string. While a release that renames or retypes a column is between `start` and `complete`, name tables bare in the new version's raw SQL (`todos`, not `public.todos`) and name an `ON CONFLICT` target by its columns, not its constraint — the new version reads through views that hold the new names. The typed tables already work this way. See [Schema changes](/docs/backend/migrations). ## Transactions are a plan, not a session `Database.$transaction(fn)` does not open an interactive session. The callback **describes** what should happen; nothing has run when it returns. The engine then runs the whole plan inside one savepoint of the request's transaction and answers once. ```ts import { Conflict, Database } from "palbase/backend"; declare const userId: string; declare const titles: string[]; const { listId } = await Database.$transaction((tx) => { const list = tx.public.lists.insert({ owner: userId, title: "Groceries" }).expectOne(new Conflict("the list was not written")); tx.public.todos.insertMany(titles.map((title) => ({ list_id: list.id, owner: userId, title }))); return { listId: list.id }; }); ``` Three rules follow, and the first two are the compiler's: 1. **The callback is synchronous.** `async` on it and `await` in it are compile errors — there is nothing to await. 2. **An operation gives a handle, not a row.** Reading a field takes `.expectOne(err)` first, which makes "what if the row is not there" an argument you cannot skip. 3. **A field you read is a `Ref`** — a value the server will produce. It can be written into a later operation and returned from the callback (it is replaced by the real value before `$transaction` resolves); it cannot be branched on. | Plan method | What it does | |---|---| | `insert(values)` | one row | | `insertMany(rows, { onConflict?, action? }?)` | many rows in one statement, every row the same columns — `action: "ignore"` alone skips a collision with any unique key | | `put(values, { onConflict })` | insert, or overwrite what it collided with | | `updateWhere(where, set)` | every matching row — an unfiltered update is refused | | `deleteWhere(where)` | every matching row — an unfiltered delete is refused | | `select(where?, { limit?, lock? })` | read rows; `lock: "update"` is a real `FOR UPDATE` | `increment(n)`, `decrement(n)`, `now()` and `ago(span)` read the column's current value and the server's clock where the plan runs — `increment` and `decrement` in an update's `set`, `now()` and `ago()` wherever a value goes. The typed `update` and `updateMany` take them too: ```ts import { Database, NotFound } from "palbase/backend"; import { increment } from "palgres"; declare const todoId: string; await Database.$transaction((tx) => { tx.public.todos.updateWhere({ id: todoId }, { priority: increment(1) }).expectOne(new NotFound("todo not found")); }); ``` ### Guards | Guard | Meaning | |---|---| | `expectOne(error)` | exactly one row — and the only way to read one | | `expectNone(error)` | no row matched | | `expectAtLeast(n, error)` / `expectAtMost(n, error)` | a bound on the count | A failed guard throws the error you gave and rolls the whole plan back. That replaces reading a value and branching on it: `updateWhere({ id, done: false }, { done: true }).expectOne(new Conflict("already done"))` is a race-free compare-and-set, checked where the rows are. > **Warning:** Coercing a `Ref` (`String(ref)`, `+ref`), awaiting it or serialising it throws `TxRefError` as the plan is built. Bare truthiness is the one hole JavaScript does not let a proxy close: `if (row.done)` inside the callback is always true. Read a value before the plan and branch there, or say it as a guard. A plan holds at most 1000 operations and 5000 rows per `insertMany`; a reference points only backwards, at an operation known to yield one row. A plan that breaks a rule throws `TxPlanError` at the line that wrote it, before anything is sent. ## `$atomic()` — a transaction of your own, with retries `$transaction` is a savepoint of the request's transaction, so it cannot retry: a savepoint keeps its parent's snapshot. When the work needs its own boundary — a transfer, anything that must survive a serialization failure — use `$atomic`. The ledger lives in a schema of its own: ```ts // src/db/ledger.ts import { defineSchema, defineTable, numeric, ownedByUser, policy, timestamp, uuid } from "palbase/backend"; export const accounts = defineTable("accounts", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), balance: numeric({ precision: 14, scale: 2 }).default("0") }, policies: (p) => [policy("accounts_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export const entries = defineTable("entries", { columns: { id: uuid().primaryKey().defaultRandom(), account_id: uuid().references(() => accounts.id), owner: ownedByUser(), amount: numeric({ precision: 14, scale: 2 }), created_at: timestamp().defaultNow(), }, policies: (p) => [policy("entries_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("ledger", { tables: [accounts, entries] }); ``` ```ts // src/modules/ledger/ledger.service.ts import { Conflict, Database, Injectable, NotFound } from "palbase/backend"; import { decrement } from "palgres"; @Injectable() export class LedgerService { withdraw(accountId: string, amount: string) { return Database.$atomic( async (tx) => { // Ordinary async reads and writes: this callback is not a plan. const account = await tx.ledger.accounts.findById(accountId); if (!account) throw new NotFound("account not found"); await tx.ledger.entries.insert({ account_id: accountId, owner: account.owner, amount: `-${amount}` }); // The compare-and-set is a plan, so the check happens where the row is. return tx.$transaction((plan) => plan.ledger.accounts .updateWhere({ id: accountId, balance: { gte: amount } }, { balance: decrement(amount) }) .expectOne(new Conflict("insufficient balance")), ); }, { isolation: "serializable", retry: 3, lockTimeoutMs: 1000 }, ); } } ``` - **The callback is `async`**, and its promise resolves after `COMMIT`. - **`retry` is 0 unless you say** (at most 10), and only serialization and deadlock failures are retried. Every retry runs the whole callback again on a fresh transaction, so keep external effects out of it. - **It commits on its own.** A later error in the handler does not undo it, and it is not atomic with the request's transaction — so do not write the same rows on both sides. - **Options:** `isolation` (`"read committed"`, `"repeatable read"`, `"serializable"`), `readOnly`, `lockTimeoutMs` (5000 by default), `statementTimeoutMs`, `timeoutMs` for the whole scope, retries and queueing included, and a `signal`. `withRetry(fn, { retry })` from `palgres` gives the same narrow retry policy to work that is not one transaction. ## When the database says no A statement Postgres refuses throws an error carrying its SQLSTATE in `sqlstate`. The ones an app branches on have classes of their own, exported by `palbase/backend`: | Error | When | Left to reach the client | |---|---|---| | `UniqueViolation` | a duplicate; the constraint is in `e.constraint` | 409 | | `SerializationFailure`, `DeadlockDetected` | retry them — `$atomic` does when asked | 409 | | `BadRequest` | request data used as a filter's structure — see below | 400 `bad_request`, naming the field | | an `HttpError` | a value its column cannot take (not a uuid, out of range, too long) | 400 `invalid_value`; the value is not repeated | | an `HttpError` | a row a policy's `WITH CHECK` refuses, or a table the caller's role has no grant on (SQLSTATE `42501`) | 403 `permission_denied` to a signed-in caller, 401 to an anonymous one; nothing of the schema is said. On the service role it is the app's own grants: the driver's error, a 500 | | `TooManyRequests` | the app's database queue is full | 429 with Retry-After | ```ts // src/modules/payments/payments.service.ts import { Conflict, Database, Injectable, UniqueViolation } from "palbase/backend"; @Injectable() export class PaymentsService { async record(owner: string, idemKey: string, amount: string) { try { return await Database.public.payments.insert({ owner, idem_key: idemKey, amount }); } catch (e) { if (UniqueViolation.is(e) && e.constraint === "payments_idem_key_key") throw new Conflict("this payment was already recorded", "duplicate_payment"); throw e; } } } ``` `UniqueViolation.is(e)` matches by shape and holds across two copies of the package; `instanceof` holds too for what the engine throws. The constraint's name is on `e.constraint`, for you, and never in what reaches the client. Rethrow, as above: a request that swallows a refusal and returns cannot commit — Postgres aborted its transaction — and the commit fails with `TransactionAborted` (exported by `palgres`). Swallowing a failure is what `$attempt` is for. An outage is never a 500: the database unreachable or refusing connections is `503 database_unavailable` with Retry-After, a connection lost mid-request `503 database_unavailable`, and a lock waited for past its bound `503 database_busy`. See [Responses & Errors](/docs/backend/errors). ### Data never becomes structure A `where` value is a value or an operator — and anything a request sent is only ever a value. The framework marks a request body as `JSON.parse` made it, and Palgres refuses a marked object wherever structure goes — a filter's operators and branches, `select`, `orderBy`, `with`, an aggregate's columns, a claim's key, `onConflict` — with a 400 naming the field. Without that, `{ token: body.token }` with a body of `{ "token": { "neq": null } }` would match every row. Write the structure in code around the values: `{ token: body.token }` with `token: z.string()`, `{ id: { in: body.ids } }`. What a route's schema builds is the route's own, so `z.object({ startsWith: z.string() })` runs as the operator you wrote; what it lets through untouched (`z.unknown()`, a passthrough's extra keys) stays the request's. ## Who each statement runs as A transaction opens with one statement that binds the caller, its values written as literals: ```sql SELECT set_config('role', 'authenticated', true), set_config('search_path', '…', true), set_config('application_name', '…', false), set_config('request.jwt.claims', '{"sub":"…","role":"authenticated",…}', true) ``` The role is `authenticated` for a request with a valid token, and `anon` with no claims for one without. `auth.uid()` in a policy reads `sub` from those claims. An operator's app whose every table is the service role's — every request deciding for itself who sees what — says `db: { schemas, requests: "service" }`, and its requests run as `service_role` with the caller's claims kept. ## `$asService()` — past RLS `Database.$asService()` returns the same typed surface running as `service_role`, which bypasses row-level security. It is for code that legitimately reaches across users: an admin route, a cleanup, a count over everyone. ```ts import { Database } from "palbase/backend"; const openEverywhere = await Database.$asService().public.todos.count({ where: { done: false } }); ``` - **It is a second transaction, on a second connection**, opened by its first use. The role is bound once, when a transaction begins, so a sibling on the request's own transaction would still run as the caller. - **It keeps the caller's claims.** It changes what may be touched, not who is asking. - **The two commit as two** — the caller's first — and neither is atomic with the other. Do not write the same row from both in one request: the service side waits at most 5 seconds for a lock the caller's transaction holds, then fails. - **It does not see what the request wrote.** The caller's transaction has not committed while the handler runs, so a row written through `Database` and read back through `$asService()` reads as it was before: a route that saves a profile and then answers it as the service answers the old one. Read it back through `Database`, or do the write and the read both as the service in one `$asService().$atomic(cb)`. - **Inside `$asService().$atomic(cb)`, use the handle `cb` is given.** The shared `Database` is refused there: read as the service role, a helper that counts on the caller's RLS would read every user's rows. Your own code is the only authorisation left inside it, so check the caller's right first, and call it at the point of use rather than keeping the client in a field — the value of the escape hatch is that a reviewer can grep for it. ## Connections and drivers On a machine, the app may hold `PALBASE_DB_CONNECTIONS` connections — its share of what the database's size allows, counted across every copy of every machine given the database. Told nothing (a laptop), it holds 31. A third of the pool serves requests' first transactions and two thirds their further ones (`$asService`, `$atomic`), so an admitted request never waits for a connection only another request can free. A request that finds the queue full, or waits in it past about a second, answers `429` with Retry-After. The default driver is Bun's built-in SQL, which is why the examples' images run the app under Bun. Off Bun there is no built-in SQL client, so give one: `db: { schemas, driver }`, with postgres.js created with `{ types: POSTGRES_JS_TYPES }` and its engine `postgresJsDriver(pool, control)`, both from `palgres`. `db.url` overrides `PALBASE_DB_URL`, for a test database of your own. ## Related - [Schema](/docs/backend/schema) — the declarations `Database.public.*` is typed from - [Row-Level Security](/docs/backend/row-level-security) — policies, roles, and what a policy-less table returns - [Schema changes](/docs/backend/migrations) — how a release migrates the database beside the version serving - [Databases](/docs/machines/databases) — opening one, its credentials, and giving it to a machine - [Responses & Errors](/docs/backend/errors) — the envelope a thrown error becomes - [Event Hooks](/docs/backend/hooks) — `@On` handlers, which run in a transaction of their own # Schema Your database schema lives in your repository, in TypeScript, one file per Postgres schema: `src/db/public.ts` declares `public`, `src/db/billing.ts` declares `billing`. Each file default-exports a `defineSchema("", { tables })` call naming its tables, columns, foreign keys, [Row-Level Security policies](/docs/backend/row-level-security), constraints, indexes and extensions. The DSL is Palgres', re-exported by `palbase/backend`, and it is the only description of the schema you write. There are no migration files: every release of your machine migrates its database to what these files declare, beside the version still serving — see [Schema changes](/docs/backend/migrations). ## Quick example ```ts // src/db/public.ts import { boolean, defineSchema, defineTable, enumType, index, integer, jsonb, ownedByUser, policy, text, timestamp, uuid, } from "palbase/backend"; export const lists = defineTable("lists", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), title: text(), }, policies: (p) => [ policy("lists_own").for("all").to("authenticated") .using(p.col("owner").eq(p.auth.uid())) .withCheck(p.col("owner").eq(p.auth.uid())), ], }); export const todos = defineTable("todos", { columns: { id: uuid().primaryKey().defaultRandom(), list_id: uuid().references(() => lists.id, { onDelete: "cascade" }), owner: ownedByUser(), title: text(), status: enumType("todo_status", ["open", "done", "archived"]).default("open"), priority: integer().default(0), done: boolean().default(false), meta: jsonb().nullable(), created_at: timestamp().defaultNow(), }, // `policies` and `indexes` are CALLBACKS: `p` carries this table's own column // names, so `p.col("ownr")` is a compile error, not a refusal from Postgres. policies: (p) => [ policy("todos_own").for("all").to("authenticated") .using(p.col("owner").eq(p.auth.uid())) .withCheck(p.col("owner").eq(p.auth.uid())), ], indexes: (p) => [index("todos_owner_created").on(p.col("owner"), p.col("created_at"))], }); export default defineSchema("public", { tables: [lists, todos] }); ``` The app hands its schemas to the framework in `src/main.ts`. That is how they are found — no directory is scanned: ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; import schema from "./db/public"; // Serves on $PORT with its database. Run by a release with PALBASE_DB_MIGRATE, // the same image migrates the database to this schema and exits. await PalbaseFactory.create(AppModule, { db: { schemas: [schema] } }); ``` ```ts // src/app.module.ts import { Module } from "palbase/backend"; @Module({ imports: [] }) export class AppModule {} ``` Five things to notice: - **A table is a value that knows its own name.** `defineTable("todos", …)` returns a binding, and `defineSchema` reads the name off it. That is what lets `todos` point at `lists`: one place writes each name, so a reference never resolves against a table whose identity is not set yet. - **`defineSchema` takes an array** of those bindings, and its first argument is the schema's own name. - **The file `export default`s the `defineSchema(...)` call.** `main.ts` imports that value; Palgres' type generator reads the same value. - **Columns are `NOT NULL` by default**; `.nullable()` opts in to `NULL`. - **RLS is on by default**, and declaring `policies` forces it on. A table with RLS and no policy denies everything — the most common reason a fresh backend's first `GET` returns `[]`. Read [Row-Level Security](/docs/backend/row-level-security) before your first release. ## One file per schema ```text src/db/ public.ts → defineSchema("public", …) billing.ts → defineSchema("billing", …) ``` Every schema the app declares is in the `schemas` list it gives `PalbaseFactory.create`, and its tables are reached as `Database..
` — `Database.public.todos`, `Database.billing.invoices`. A foreign key into another schema imports the table binding and points at it: ```ts // src/db/billing.ts import { defineSchema, defineTable, numeric, uuid } from "palbase/backend"; import { lists } from "./public"; const invoices = defineTable("invoices", { columns: { id: uuid().primaryKey().defaultRandom(), list_id: uuid().references(() => lists.id), amount: numeric({ precision: 12, scale: 2 }), }, rls: false, }); export default defineSchema("billing", { tables: [invoices] }); ``` Export the tables another file references (`export const lists = defineTable(…)`). The written foreign key names its target with its schema — `billing.invoices.list_id` points at `public.lists`, never at a same-named table the `search_path` happens to find. A reference into a table no `defineSchema` has claimed is refused with both names, so declare the referenced schema's file and import it. ## `defineTable` `defineTable(name, input)`. Only `columns` is required. | Field | Type | What it is | |---|---|---| | `columns` | `{ [name]: ColumnBuilder }` | **required** — the table's columns | | `rls` | `boolean` | defaults to `true`; `false` is the explicit opt-out for a public table | | `policies` | `(p) => PolicyBuilder[]` | RLS policies; a non-empty list forces `rls` on | | `primaryKey` | `string[]` | composite primary key, in order | | `unique` | `{ name, columns }[]` | named multi-column `UNIQUE` constraints | | `checks` | `{ name, expr }[]` or `(c) => CheckDef[]` | `CHECK` — raw SQL, or the typed `check(name, c.col("total").gt(0))` | | `indexes` | `(c) => IndexBuilder[]` | `index("name").on(c.col("x"))`, partial, expression, unique, covering | | `foreignKeys` | `(c) => ForeignKeyBuilder[]` | multi-column `FOREIGN KEY` | | `raw` | `raw(name, sql)[]` | what the typed fields cannot say — another index method, an `EXCLUDE` | | `ignored` | `boolean` | "this release no longer reads this table" — the first half of dropping it | Every callback field is a callback for the same reason: the argument carries this table's own column names as a literal union, so a misspelt column is a compile error on the line you wrote it, not an error from Postgres in the middle of a release. The returned value **is its columns**: `todos.id` is the `id` column builder, which is what makes `references(() => todos.id)` an ordinary expression your editor completes. The table's own metadata hangs off a symbol, so a column may be called `name`, `columns` or `rls` without shadowing anything. Each table gets its own copy of the column builders it is given, so a shared spread (`...audit`) of columns in two tables is two sets of columns. ## `defineSchema` `defineSchema(name, input)`. | Field | Type | What it is | |---|---|---| | `tables` | `TableHandle[]` | **required** — the `defineTable` bindings, in any order | | `extensions` | `string[]` | Postgres extensions the migration creates — see [Postgres extensions](#postgres-extensions) | | `raw` | `raw(name, up, { drop })[]` | schema-level units: views, functions, triggers over several tables | `defineSchema` is where every pending reference is resolved: the `() => lists.id` callbacks run here, once every binding exists. Two tables of one name in one schema are refused, and so are two raw units of one name. ## Column types All factories are imported from `palbase/backend`. The third column is the type `palbase-env.d.ts` gives the value on `Database.public.*` (see [Where the types come from](/docs/backend/database)). | Factory | Postgres type | Row type | |---|---|---| | `uuid()` | `uuid` | `string` | | `text()` | `text` | `string` | | `integer()` | `integer` | `number` | | `bigint()` | `bigint` | `string` | | `numeric()` / `numeric({ precision, scale })` | `numeric` / `numeric(p,s)` | `string` | | `boolean()` | `boolean` | `boolean` | | `timestamp()` | `timestamp with time zone` | `string` | | `date()` | `date` | `string` (`"YYYY-MM-DD"`) | | `jsonb()` | `jsonb` | `Json` | | `enumType(name, values)` | a Postgres enum type named `name` | the union of the values | | `sqlType("inet")` | the Postgres type, as Postgres spells it | `string` — `number` for `smallint`, `real`, `double precision` | A `.nullable()` column's row type becomes `T | null`. `.array()` makes any scalar an array — `text().array()` is `text[]`, surfaced as `string[]`, filtered with `contains` and `overlaps`; the only default an array takes is the empty one, `.default([])`. `bigint()` and `numeric()` surface as **`string`**: a JavaScript number cannot hold a 64-bit integer past 2^53 or an arbitrary-precision decimal without rounding. Keep money in them and parse with `BigInt(row.amount)` or a decimal library — or declare `.asNumber()` on a column whose values you know fit in a JS number, and it surfaces as `number`. `date()` is a calendar day, not an instant: a JS `Date` would pick a time zone for it. `sqlType()` is the escape hatch for a type the DSL does not offer — `inet`, `interval`, `citext`, `character varying(20)`, `vector(1536)`. Spell it as Postgres does (`character varying(20)`, not `varchar(20)`); a type the DSL has is refused by name (`sqlType("text")` asks for `text()`), so each type has one spelling. ### jsonb A `jsonb()` column takes a plain object or array on insert and update — do not `JSON.stringify` it — and reads back as the value. `jsonb()` takes a payload type, but **the type does not reach `palbase-env.d.ts`**: the generated file types every `jsonb` column as `Json`. Narrow it where you read it, with a schema you already have: `TagList.parse(row.tags)`. ### Enums `enumType(name, values)` makes a real Postgres enum type, and the TypeScript side is the narrow union of the values: ```ts import { defineTable, enumType, uuid } from "palbase/backend"; export const tickets = defineTable("tickets", { columns: { id: uuid().primaryKey().defaultRandom(), // row type: "open" | "pending" | "closed" status: enumType("ticket_status", ["open", "pending", "closed"]).default("open"), tags: enumType("ticket_tag", ["bug", "billing", "other"]).array().default([]), }, }); ``` The first argument is the Postgres type's name, not the column's. Adding a value is an ordinary migration; removing one is refused (Postgres cannot drop an enum value) — see [Schema changes](/docs/backend/migrations). ## Chain methods Every builder method returns a builder, so modifiers chain. | Method | Effect | |---|---| | `.primaryKey()` | `PRIMARY KEY` | | `.notNull()` / `.nullable()` | `NOT NULL` (the default) / allow `NULL` | | `.default(value)` | a literal `DEFAULT` | | `.defaultRandom()` | `uuid()` — `DEFAULT gen_random_uuid()` | | `.defaultNow()` | `timestamp()` — `DEFAULT now()`; `date()` — `CURRENT_DATE` | | `.defaultSql(expr)` | a default the database computes, written as SQL | | `.dbAssigned()` | a trigger or rule writes the value: optional on insert, no `DEFAULT` in the DDL | | `.unique()` | single-column `UNIQUE` | | `.array()` | an array of this type | | `.identity()` | `GENERATED ALWAYS AS IDENTITY` on `integer()`/`bigint()` — off every insert and update; `{ always: false }` is `BY DEFAULT` | | `.generated(expr, { stored? })` | `GENERATED ALWAYS AS (expr)` — virtual by default (Postgres 18), `stored: true` on disk | | `.references(() => t.col, opts?)` | a foreign key — see [Foreign keys](#foreign-keys) | | `.selfReferences("column", opts?)` | a foreign key onto this same table | | `.onDelete(action)` | `ON DELETE` of a foreign-key column | | `.asNumber()` / `.asDecimal()` | surface a `numeric`/`bigint` column as `number`, or say `string` explicitly | | `.renamedFrom(previous)` | this column used to be called `previous` | | `.fillWith(sql)` | how to fill a `NOT NULL` column for rows that do not supply it — see [Schema changes](/docs/backend/migrations) | | `.ignored()` | this release no longer reads or writes the column — the first half of dropping it | ## Foreign keys The target is a **callback**: `references(() => lists.id)`, not `references(lists.id)`. It runs inside `defineSchema`, where every binding exists, which is what makes a cycle expressible. | Option | What it does | |---|---| | `onDelete` | `"cascade"`, `"set null"`, `"set default"`, `"restrict"` or `"no action"` (the default) | | `onUpdate` | the same actions, on a change of the referenced key | | `as` | the forward relation's name — `list_id` gives `list` by default | | `reverseAs` | the reverse relation's name on the parent — this table's name by default | | `index` | `false` leaves the column without the index Palgres makes for a foreign key | Postgres does not index a foreign key's column by itself, and every join through it and every `ON DELETE CASCADE` pays for that. Palgres makes `
__idx` for each one unless you say `index: false` — which is right only when the column is updated often enough that an index on it would cost more than it saves. ### Pointing at this same table A parent pointer — category trees, replies, org charts — takes the column's name and no callback: ```ts import { defineTable, text, uuid } from "palbase/backend"; export const categories = defineTable("categories", { columns: { id: uuid().primaryKey().defaultRandom(), name: text(), parent_id: uuid().nullable().selfReferences("id"), }, rls: false, }); ``` ### Two tables that point at each other A cycle needs an explicit return type on **one** side, and one is enough: ```ts import { type AnyColumn, defineTable, uuid } from "palbase/backend"; export const members = defineTable("members", { columns: { id: uuid().primaryKey().defaultRandom(), // the annotated side — without it TypeScript chases its own tail (TS7022) primary_team_id: uuid().nullable().references((): AnyColumn => teams.id), }, }); export const teams = defineTable("teams", { columns: { id: uuid().primaryKey().defaultRandom(), lead_id: uuid().nullable().references(() => members.id), }, }); ``` ### Two foreign keys to the same table Every foreign key gives two relation names, which [`with` and `has`](/docs/backend/database) read: the forward one on the child (`billing_address_id` → `billing_address`) and the reverse one on the parent (by default the child table's name). Two keys from one table onto one parent collide on the reverse side, so name at least one: ```ts import { defineTable, text, uuid } from "palbase/backend"; export const addresses = defineTable("addresses", { columns: { id: uuid().primaryKey().defaultRandom(), line1: text() }, }); export const orders = defineTable("orders", { columns: { id: uuid().primaryKey().defaultRandom(), billing_address_id: uuid().references(() => addresses.id, { reverseAs: "billed_orders" }), shipping_address_id: uuid().references(() => addresses.id, { reverseAs: "shipped_orders" }), }, }); ``` A collision is refused when the schema is built, naming both columns and the option that fixes that direction. ### Composite foreign keys A key of more than one column is declared on the table, with its own name: ```ts import { defineTable, foreignKey, text, uuid } from "palbase/backend"; export const projects = defineTable("projects", { columns: { org_id: uuid(), id: uuid().defaultRandom(), name: text() }, primaryKey: ["org_id", "id"], }); export const tasks = defineTable("tasks", { columns: { id: uuid().primaryKey().defaultRandom(), org_id: uuid(), project_id: uuid() }, // A task's project must be of the same organization — a per-column key cannot say so. foreignKeys: (c) => [ foreignKey("tasks_project_fkey").on(c.col("org_id"), c.col("project_id")).references(projects, "org_id", "id").onDelete("cascade"), ], }); ``` ## Rows that belong to a user Your users are the project's [auth](/docs/web/auth) users, and they live in the platform, not in your database — so a column holding a user's id is plain `text`, with no foreign key. Two factories declare one, and say what happens to the row when that user is deleted: | Factory | Means | When the user is deleted | |---|---|---| | `ownedByUser()` | this row **belongs to** the user — `text`, `NOT NULL` | the row is deleted | | `userRef({ onDelete })` | this row **points at** a user — `created_by`, `edited_by` | `"cascade"`: deleted; `"set null"`: the column is set to `NULL` (declare it `.nullable()`) | ```ts import { defineTable, ownedByUser, text, userRef, uuid } from "palbase/backend"; export const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), edited_by: userRef({ onDelete: "set null" }).nullable(), body: text(), }, }); ``` **At most one `ownedByUser()` per table**: which column's user takes the row with them must not depend on the order the columns are declared in. Deleting what a schema keeps of a user is the framework's job, not yours: when the platform delivers `user.deleted`, `palbase/backend` deletes the user's owned rows and nulls the `set null` references, in one transaction as the service role. That needs the delivery to arrive, so **an app whose schema declares either factory refuses to start without `PALBASE_WEBHOOK_SECRET`** — on a machine the platform gives it, and keeps the machine's `/webhooks/palbase` endpoint for `user.deleted`. See [Event Hooks](/docs/backend/hooks) for what that endpoint takes. > **Note:** A plain `text()` column holding a user's id is not one the framework knows to clear: nothing deletes what it keeps when the user goes, and that erasure is yours to do. ## What is optional on insert A column is optional on insert when the database can fill it: `.nullable()`, any default (`.default()`, `.defaultRandom()`, `.defaultNow()`, `.defaultSql()`), or `.dbAssigned()`. An `.identity()` (always) or `.generated()` column is on the row and off the insert entirely. Everything else is required. For `todos` above, `Database.public.todos.insert(…)` takes `list_id`, `owner` and `title`, and may take `id`, `status`, `priority`, `done`, `meta` and `created_at`. ## Constraints and indexes ```ts import { check, defineTable, index, integer, numeric, raw, sqlType, text, timestamp, uuid } from "palbase/backend"; export const products = defineTable("products", { columns: { id: uuid().primaryKey().defaultRandom(), sku: text(), email: text().nullable(), price: numeric({ precision: 12, scale: 2 }), stock: integer().default(0), embedding: sqlType("vector(3)").nullable(), deleted_at: timestamp().nullable(), }, rls: false, unique: [{ name: "products_sku_key", columns: ["sku"] }], checks: (c) => [check("products_price_positive", c.col("price").gt(0)), check("products_stock_not_negative", c.col("stock").gte(0))], indexes: (c) => [ // partial: only the live rows index("products_live").on(c.col("sku")).where(c.col("deleted_at").isNull()), // partial UNIQUE: NULLs free, non-NULLs unique index("products_email_unique").unique().onExpression("lower(email)").where(c.col("email").isNull(true)), // covering, newest first index("products_stock").on(c.col("stock")).desc().include(["price"]), ], // What the typed fields cannot say: another index method, an operator class. raw: [raw("products_embedding", "CREATE INDEX IF NOT EXISTS products_embedding ON public.products USING hnsw (embedding vector_cosine_ops)")], }); ``` - **`unique`** renders a `UNIQUE` constraint and cannot carry a condition, because Postgres has no partial unique constraint. `index(name).unique().where(…)` is the declarative way to say "NULLs are free, non-NULLs are unique". - **`checks`** comes in two forms: the typed callback above, whose column names are checked as you type, or `[{ name, expr }]` with the SQL written out. The typed form refuses a subquery or `auth.uid()` — a `CHECK` cannot hold either. - **`indexes`** — `.on(...cols)` (order kept), `.onExpression(sql)`, `.where(pred)` in the same expression language as policies, `.unique()`, `.include(cols)`, `.asc()`/`.desc()`, `.nulls("first" | "last")`. A migration builds each one `CONCURRENTLY`, so a write never waits for it. An index whose definition changes under the same name is built beside the old one, which the version still serving reads through, and takes its name when the release completes. - **`raw`** on a table runs its SQL as it is, as the tables' owner. A raw index is known by its name too, so a changed body under the same name is refused: give it a new name. ### Schema-level raw units What belongs to no single table — a view, a function, a trigger over several tables — is a unit of the schema, and unlike a table's raw entry it has a teardown the migration runs: ```ts import { boolean, defineSchema, defineTable, ownedByUser, policy, raw, text, uuid } from "palbase/backend"; export const tasks = defineTable("tasks", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), title: text(), done: boolean().default(false) }, policies: (p) => [policy("tasks_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("public", { tables: [tasks], raw: [ raw( "open_tasks", "CREATE VIEW public.open_tasks WITH (security_invoker = true) AS SELECT id, owner, title FROM public.tasks WHERE NOT done", { drop: "DROP VIEW public.open_tasks" }, ), ], }); ``` A new unit runs its `up`; a unit whose `up` changed runs its previous `drop`, then the new `up`; a unit removed from the declaration runs its `drop`. A view must be `security_invoker = true`, so the reader's policies apply through it. A view, a trigger or a rule with no `drop` is refused — a rollback could not take it back. `{ before: true }` makes a unit before the tables: a domain, a type or a function a column's default calls. ## Postgres extensions ```ts import { defineSchema, defineTable, sqlType, text, uuid } from "palbase/backend"; const places = defineTable("places", { columns: { id: uuid().primaryKey().defaultRandom(), name: text(), at: sqlType("geography(Point,4326)"), embedding: sqlType("vector(1536)").nullable(), }, rls: false, }); export const geo = defineSchema("public", { tables: [places], extensions: ["postgis", "vector"] }); ``` The migration runs `CREATE EXTENSION IF NOT EXISTS` for each, as the database's owner, who is not a superuser — so an extension must be one the owner may create. A Palbase database is Postgres 18 and makes these trusted beside Postgres' own trusted extensions (`pg_trgm`, `citext`, `pgcrypto`, `unaccent` and the rest of contrib's trusted set): `vector` (pgvector), `postgis`, `postgis_topology`, `postgis_raster` and `pg_stat_statements`. A typed column of an extension's type is an `sqlType()`; an index of its method is a `raw` entry. Extensions are only ever added. ## Row-Level Security in the schema RLS is declared beside the columns, in each table's `policies`, and it is on for every table that does not say `rls: false`. The builder, the roles a policy names, membership policies and the service role are on [Row-Level Security](/docs/backend/row-level-security). ## From a declaration to a database Edit `src/db/public.ts`, commit and push to the machine's branch. The machine builds your image, and before the new version serves, the platform runs that image once with `PALBASE_DB_MIGRATE=start`: it migrates the database to the schema the image carries, while the version before keeps serving. Once nothing of the old version is left, it runs `complete`. You run nothing yourself, and a change that cannot run under both versions is refused before anything changes. How each kind of change goes — and which take two releases — is [Schema changes](/docs/backend/migrations). ## Related - [Database](/docs/backend/database) — the typed `Database.public.*` surface these declarations type, and `palbase-env.d.ts` - [Row-Level Security](/docs/backend/row-level-security) — policies, roles, and `$asService()` - [Schema changes](/docs/backend/migrations) — how a release migrates, `renamedFrom`, `fillWith`, `ignored`, and what is refused - [Databases](/docs/machines/databases) — giving a machine a database, and its migrations - [Event Hooks](/docs/backend/hooks) — `user.deleted`, and the webhook secret an app that keeps users' ids needs # Row-Level Security Row-Level Security (RLS) makes **Postgres** decide who may see and write which rows. The rule lives beside the table, declared in TypeScript in your [schema](/docs/backend/schema), instead of being scattered across handlers where one forgotten `WHERE owner = …` leaks somebody's data. Two facts decide whether your first release works: **RLS is on by default**, and **a table with RLS and no policies denies everything** — a table you have not thought about returns nothing, rather than every row to every caller. If your routes answer `[]` and `null` on data you know is there, that default is the first thing to check. > **Warning:** This is the first release's silent failure. Declare `todos` with columns and no `policies`, push it, and the `POST` fails while the `GET` returns an empty array — no error names RLS, because nothing went wrong: the database was asked for rows and answered honestly that there are none this caller may see. The rule is one line, `rls = policies.length > 0 || table.rls !== false`, and it is fail-closed on purpose: an empty result is the cost of not thinking about a table, and a leak is the other one. ## Quick example ```ts // src/db/public.ts import { boolean, defineSchema, defineTable, ownedByUser, policy, text, timestamp, uuid } from "palbase/backend"; export const todos = defineTable("todos", { columns: { id: uuid().primaryKey().defaultRandom(), // The user this row belongs to: text, NOT NULL, deleted with the user. owner: ownedByUser(), title: text(), done: boolean().default(false), created_at: timestamp().defaultNow(), }, // A CALLBACK: `p` carries this table's own column names, so a misspelt column // is a compile error rather than a refusal from Postgres mid-release. policies: (p) => [ policy("todos_own") .for("all") .to("authenticated") .using(p.col("owner").eq(p.auth.uid())) .withCheck(p.col("owner").eq(p.auth.uid())), ], }); export default defineSchema("public", { tables: [todos] }); ``` ```ts // src/modules/todos/todos.service.ts import { Database, Injectable } from "palbase/backend"; import type { Tables } from "palbase/backend/env"; type Todo = Tables["todos"]["row"]; @Injectable() export class TodosService { // No owner filter anywhere: the policy is the filter. list(): Promise { return Database.public.todos.findMany({ orderBy: { column: "created_at", direction: "desc" } }); } // The policy's WITH CHECK refuses a row whose owner is not the caller. add(owner: string, title: string): Promise { return Database.public.todos.insert({ owner, title }); } } ``` The service never says whose rows it wants. Postgres reads the caller off the transaction and applies `owner = auth.uid()` to every statement — the typed tables, `$query`, a `$transaction` plan — so a route that forgets a filter cannot read past it. ## How the caller reaches the database Every request is one transaction, and it begins by binding the caller: ```sql SELECT set_config('role', 'authenticated', true), set_config('request.jwt.claims', '{"sub":"…","role":"authenticated",…}', true) -- and the version's search_path and application_name ``` Both settings are transaction-local, so nothing carries over to the next request on a pooled connection. The claims are the verified access token's, and `auth.uid()` reads `sub` out of them. Everything on the default `Database` surface runs inside that transaction; there is no opt-in step and nothing to forget. Going past it takes the explicit [`$asService()`](#asservice-past-rls). ## The roles a policy names Three Postgres roles exist, and what the request is decides which one it runs as: | Role | Who runs as it | Policies | |---|---|---| | `authenticated` | a request with a valid access token — `auth.uid()` is the user's id | apply | | `anon` | a request with no token (a route with `auth: false`), with no claims — `auth.uid()` is `NULL` | apply | | `service_role` | `Database.$asService()`, and every request of an app that says `db: { requests: "service" }` | never apply — it has `BYPASSRLS` | `.to()` defaults to `"authenticated"`, so a policy you write without one does nothing for an anonymous caller. A table that anonymous callers read needs a policy that names `anon`: ```ts import { boolean, defineTable, policy, text, uuid } from "palbase/backend"; export const posts = defineTable("posts", { columns: { id: uuid().primaryKey().defaultRandom(), title: text(), published: boolean().default(false) }, policies: (p) => [policy("posts_read_published").for("select").to("anon", "authenticated").using(p.col("published").eq(true))], }); ``` `.to()` with no arguments is `PUBLIC` — every role. The machine connects as the database's app login, which may become `anon`, `authenticated` or `service_role` and nothing else; it cannot bypass RLS on its own. The owner — the role migrations run as — owns the tables, and the app never connects as it. > **Note:** These roles are the database's, not the API keys'. The project's publishable key and secret key choose what a call to the [modules](/docs/getting-started/introduction) may do. A call to your machine's backend is decided by the user's access token, and inside it the only switch to the service role is the `$asService()` you write. ## Enabling RLS on a table | You declare | Result | |---|---| | neither `rls` nor `policies` | **RLS on, deny-all** — the default | | a non-empty `policies` | **RLS on**, whatever `rls` says — a policy on a table without RLS would be inert | | `rls: false`, no policies | RLS off: every role reads and writes every row. An explicit opt-out for a public table | A write a `WITH CHECK` refuses, or a statement on a table the caller's role has no grant on, throws an `HttpError` carrying Postgres' SQLSTATE `42501` in its `sqlstate`, and the request's transaction rolls back. Left to reach the client it answers as PostgREST does: `403 permission_denied` to a signed-in caller, `401 permission_denied` to an anonymous one, for whom signing in may be what is missing. The answer names nothing of the schema — the policy, the table and the row stay in the log. On the service role — `$asService()`, or every request of an app that says `db: { requests: "service" }` — nobody's rights were judged: a `42501` there is the app's own grants, and passes through as Postgres' error, a `500 internal_error`. When the client should know why, check what the caller may write before writing it and say so with your own `Forbidden` and code. ## The `policy()` builder `policy(name)` starts a builder whose methods chain; the name is unique per table. | Method | Default | Notes | |---|---|---| | `.for(command)` | `"all"` | `"select"`, `"insert"`, `"update"`, `"delete"` or `"all"` | | `.to(...roles)` | `"authenticated"` | `"anon"`, `"authenticated"`, `"service_role"`; no arguments is `PUBLIC` | | `.using(expr)` | none | `USING (…)` — which existing rows a command may see or touch | | `.withCheck(expr)` | none | `WITH CHECK (…)` — which written rows are allowed | | `.memberOf(table, fk, opts?)` | — | the membership subquery below; it **is** the `using`, so it refuses to combine with one | | `.as(mode)` | `"permissive"` | `"restrictive"` ANDs the policy on top of the permissive ones | | `.withoutIndexes()` | — | the columns it reads get no index of their own from it | `using` and `withCheck` take the typed expression — built from the callback's `p` — or a raw SQL string for what the expressions cannot say. Prefer the typed form: `p.col(…)` is checked against this table's columns as you write it, a string only by Postgres when the policy is created. | Expression | SQL | |---|---| | `p.col("owner")` | this table's column | | `p.auth.uid()` | `(select auth.uid())` — evaluated once per statement, not per row | | `.eq(x)`, `.neq(x)`, `.gt(x)`, `.gte(x)`, `.lt(x)`, `.lte(x)` | comparisons; `x` is another expression or a plain value | | `.isNull()` / `.isNull(true)` | `IS NULL` / `IS NOT NULL` | | `.and(…)`, `.or(…)`, `.not()`, `p.and(…)`, `p.or(…)`, `p.not(…)` | combinations | | `p.lit(v)` | a value, said explicitly | | `p.existsIn(table, (row) => …)` | a correlated `EXISTS` subquery into another table | A plain value on the right of `.eq()` is a literal, never a column: `.eq("owner")` compares with the string `"owner"`. A column is always `p.col("owner")`. Every column a policy reads gets an index unless one already leads with it (`
__idx`), because it is read on every query of the table. `.withoutIndexes()` opts a policy out of that. ### Per-command policies Split the policies when reads and writes have different rules — anyone signed in reads, only the owner changes: ```ts import { boolean, defineTable, ownedByUser, policy, text, uuid } from "palbase/backend"; export const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), body: text(), pinned: boolean().default(false), }, policies: (p) => [ policy("notes_read").for("select").using("true"), policy("notes_insert_own").for("insert").withCheck(p.col("owner").eq(p.auth.uid())), policy("notes_update_own").for("update").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid())), policy("notes_delete_own").for("delete").using(p.col("owner").eq(p.auth.uid())), ], }); ``` Permissive policies for the same command **OR** together — a row is visible if any of them allows it. Use `.as("restrictive")` for a condition that must **also** hold whatever else is declared, such as a tenant guard. ## Membership: the pattern that does not recurse Anything shared — a channel, a project, an organisation — has the same shape: a **membership table** joining users to the thing, and a policy on the thing asking whether the caller is in it. Written by hand the two policies point at each other, and Postgres refuses the pair when it is queried: ```text ERROR: infinite recursion detected in policy for relation "channel_members" ``` The way out is to make it asymmetric. The membership table is protected by the caller's identity **alone** — it never looks at the thing it points to — and every other table asks it: ```ts import { defineSchema, defineTable, policy, text, userRef, uuid } from "palbase/backend"; export const channels = defineTable("channels", { columns: { id: uuid().primaryKey().defaultRandom(), name: text() }, // 2. The thing asks the membership table; memberOf() writes the subquery. policies: () => [policy("channels_member_read").for("select").memberOf("channel_members", "channel_id")], }); export const channel_members = defineTable("channel_members", { columns: { id: uuid().primaryKey().defaultRandom(), channel_id: uuid().references(() => channels.id, { onDelete: "cascade" }), user_id: userRef({ onDelete: "cascade" }), }, // 1. The membership table names auth.uid() and nothing else, so evaluating // it never reaches another policy. policies: (p) => [policy("members_own").for("select").using(p.col("user_id").eq(p.auth.uid()))], }); export const messages = defineTable("messages", { columns: { id: uuid().primaryKey().defaultRandom(), channel_id: uuid().references(() => channels.id, { onDelete: "cascade" }), author: userRef({ onDelete: "set null" }).nullable(), body: text(), }, // 3. Anything hanging off the thing asks the membership table too, typed: // `m` is channel_members' columns, `p` this table's. policies: (p) => [ policy("messages_member_read") .for("select") .using(p.existsIn(channel_members, (m) => m.col("channel_id").eq(p.col("channel_id")).and(m.col("user_id").eq(p.auth.uid())))), policy("messages_member_write") .for("insert") .withCheck( p.col("author").eq(p.auth.uid()).and( p.existsIn(channel_members, (m) => m.col("channel_id").eq(p.col("channel_id")).and(m.col("user_id").eq(p.auth.uid()))), ), ), ], }); export default defineSchema("public", { tables: [channels, channel_members, messages] }); ``` `memberOf(membershipTable, foreignKey)` compiles to: ```sql "id" IN (SELECT "channel_id" FROM "channel_members" WHERE "user_id" = (select auth.uid())) ``` `{ column }` is the column of this table the subquery matches (`id` by default — pass the foreign key's name on a child table), and `{ userColumn }` the membership table's user column when it is not `user_id`. `p.existsIn` is the typed form of the same idea: the subquery's columns come from the membership table's own declaration, so a misspelt one is a compile error too. > **Note:** A policy `.for("select")` decides only what is visible. Writes need their own — `.for("insert")` with a `withCheck`, as `messages` has — or `.for("all")` when one condition governs both. ## RLS guards reads, not foreign-key checks A `SELECT`, `UPDATE` or `DELETE` only ever reaches rows the caller may see. A **foreign-key check is not filtered**: when a row's foreign key points at a parent, Postgres validates that the parent exists whoever owns it. Without a policy that says otherwise, a caller can attach a child row to a parent it cannot read. Nothing leaks — it still cannot read the parent — but say what you mean, in the child's `withCheck`: ```ts import { defineTable, ownedByUser, policy, text, uuid } from "palbase/backend"; export const orders = defineTable("orders", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), note: text().nullable() }, policies: (p) => [policy("orders_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export const order_items = defineTable("order_items", { columns: { id: uuid().primaryKey().defaultRandom(), order_id: uuid().references(() => orders.id, { onDelete: "cascade" }), sku: text() }, // Readable and writable only under an order the caller owns. policies: (p) => [ policy("order_items_via_order") .for("all") .using(p.existsIn(orders, (o) => o.col("id").eq(p.col("order_id")).and(o.col("owner").eq(p.auth.uid())))) .withCheck(p.existsIn(orders, (o) => o.col("id").eq(p.col("order_id")).and(o.col("owner").eq(p.auth.uid())))), ], }); ``` The `existsIn` runs as the caller, so the `orders` it sees are already the caller's own — the subquery asks the question the foreign-key check does not. ## `$asService()`: past RLS Some code legitimately reaches across users — an admin route, a cleanup, a count over everyone. `Database.$asService()` returns the same typed surface running as `service_role`, which bypasses RLS: ```ts import { Database } from "palbase/backend"; // The default: only the caller's own rows. const mine = await Database.public.todos.findMany(); // The service role: every user's rows. Explicit, and greppable in review. const all = await Database.$asService().public.todos.findMany(); ``` - **It is a second transaction, on a second connection**, opened by its first use — the role is bound once, when a transaction begins. The two commit as two, the caller's first. - **It keeps the caller's claims**, so `auth.uid()` still resolves in a trigger or a default inside it. It changes what may be touched, not who is asking. - **It does not nest**: the surface it returns has no `$asService()` of its own. - **Inside `$asService().$atomic(cb)`, use the handle `cb` is given** — the shared `Database` is refused there, so a helper written against the caller's RLS cannot quietly read everyone's rows. Inside it your own code is the only authorisation left, so check the caller's right first and call it at the point of use. An app whose every table is the service role's — an operator's tool deciding in its routes who sees what — says so once, `db: { schemas, requests: "service" }`, and every request runs as `service_role`. ## Changing a policy Edit `policies` in the schema file, commit and push to the machine's branch. The release's migration step changes the policy in place. Postgres keeps one set of policies per table, so a changed policy holds for **both** versions from `start` on — the version still serving is held to it too. A policy that reads a column (or another table) the same release renames is refused: rename in one release, change the policy in the next. See [Schema changes](/docs/backend/migrations). ## Related - [Schema](/docs/backend/schema) — `defineTable`, `ownedByUser()`, and where policies sit - [Database](/docs/backend/database) — the request's transaction, `$query`, and `$asService()` - [Authentication](/docs/backend/authentication) — who the caller is, and routes with `auth: false` - [Schema changes](/docs/backend/migrations) — how a release applies a policy change # Field Encryption A column marked `.encrypted()` is sealed by your app before it is written and opened when it is read, with the project's own field keys. The database, its backups and a dump of it hold ciphertext; your code reads and writes the plain value, as with any other column. It is on every plan, at no cost. It protects a field from whoever reaches the database without the app: a leaked dump or backup, a stolen database password, a read-only SQL injection. It does not protect it from your app itself — the app holds the keys while it runs. ## Quick example ```ts // db/public.ts import { defineSchema, defineTable, text, uuid } from "palbase/backend"; export const patients = defineTable("patients", { columns: { id: uuid().primaryKey().defaultRandom(), name: text(), // Found by equality, and kept unique: { lookup: true } keeps a keyed hash beside it. national_id: text().encrypted({ lookup: true }).unique(), // Read and written whole, never searched. notes: text().encrypted().nullable(), }, }); export default defineSchema("public", { tables: [patients] }); ``` ```ts // src/modules/patients/patients.service.ts import { Database, Injectable } from "palbase/backend"; @Injectable() export class PatientsService { async admit(name: string, nationalId: string) { return Database.public.patients.insert({ name, national_id: nationalId, notes: "allergy: penicillin" }); } async byNationalId(nationalId: string) { return Database.public.patients.findUnique({ where: { national_id: nationalId } }); } } ``` What the database holds: ```text name | national_id | national_id_lookup ------+---------------------------------------------------------+------------------------------ Ayşe | pbe1.k1.MzMzMzMzMzMzMzMztbNidvqiRRyDjN1Zkdx-38ei7bu3V… | pbl1.cypF9YEDilHu_qYJy15Snch… ``` ## What an encrypted column is asked The database holds the column's ciphertext, not its value, so it can compare nothing there. An encrypted column takes text; what a query may ask of it is fixed by its type: | | `.encrypted()` | `.encrypted({ lookup: true })` | |---|---|---| | Read and write the value | yes | yes | | `isNull`, `null` | yes | yes | | Equality: `{ col: v }`, `in`, `neq`, `findUnique` | — | yes, through the lookup | | `.unique()`, `onConflict` | — | yes, kept on the lookup | | `orderBy`, `gt`/`lt`, `contains`/`startsWith`, `search`, `min`/`max`, `groupBy`, an index | — | — | What is refused is a compile error in a typed query, and an error naming the column when the query is built otherwise. The lookup is a keyed hash (HMAC-SHA256) of the value, kept in `_lookup`, which your code never names; equal values have equal lookups, so a lookup tells which rows hold the same value — keep it off a column whose few possible values that would give away. An encrypted column is not a default, a primary key, a foreign key, a generated column or a search column, and has no codec: `defineTable` refuses each, naming the column. ## Keys Each environment has its own field keys, kept by the platform's secrets module and derived from the environment's data key, which the platform's key-encryption key (Azure Key Vault) wraps. Your app reads them with its secret key when it starts and every minute after; a schema without an encrypted column never reads them. The secrets audit trail (`palbase secrets:audit`) has each app key's first read in the hour, and every download by a developer. A value is bound to its column — `table.column`, or `schema.table.column` outside public: moved to another column, or into another environment, it does not open, and a value changed in the database is refused when read, as is a plain value where an encrypted one belongs. ## Encrypting a column that holds rows Add `.encrypted()` to the column and deploy. The release migrates beside the version that serves, as every [schema change](/docs/backend/migrations) does: - While both versions run, the column keeps the form the old version reads — plain — and the new version reads either form. - Once the old version is gone, the release seals every row, in batches, and from then on a plain value in the column is refused. Taking `.encrypted()` off goes the same way the other way round. An encrypted column, or its table, is not renamed while it holds rows: its values are bound to its name. ## Rotating the keys ```bash palbase fields:rotate ``` The new key comes into force five minutes later — every running app has fetched it by then. New values are sealed with it from then on, and a `palbase/backend` app seals the older values again with it in the background. The older keys go on opening what they sealed. An owner or an admin rotates; `palbase fields:list` shows the keys and which is in force. ## In Studio The database's **Tables** screen shows an encrypted value masked. An owner or an admin of the organization opens one with **Show**: the secrets module opens it — the keys never reach Studio — and records who opened which column in the audit trail. A member sees the mask alone. ## Copying, dumping and leaving - `palbase db:copy --from production --to staging` copies every encrypted value as `•••`, sealed with the target's keys: one environment's values never land readable in another. - A dump (`palbase db:dump`) holds ciphertext. The environment's owner downloads the keys that open it — `palbase fields:keys --out keys.json` writes them into a file readable by its owner alone, and the download is in the audit trail. Keep the file as you keep the database's password. ## Without palbase/backend A server with another way to its database — NestJS with TypeORM or Prisma, plain SQL — seals and opens a column itself with `palbase/server`, in the same format: a column one app writes, the other reads. ```ts // src/patients/fields.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); /** Awaited at startup: after it, the calls below are synchronous. */ export const fieldsLoaded = palbase.fields.load(); /** What an INSERT writes: the sealed value, and the lookup it is found by. */ export function sealNationalId(value: string) { return { national_id: palbase.fields.encrypt("patients.national_id", value), national_id_lookup: palbase.fields.lookup("patients.national_id", value), }; } export const nationalIdLookup = (value: string) => palbase.fields.lookup("patients.national_id", value); export const openNationalId = (sealed: string) => palbase.fields.decrypt("patients.national_id", sealed); ``` ```ts // src/patients/patients.repository.ts import { nationalIdLookup, openNationalId, sealNationalId } from "./fields"; /** Your Postgres client (pg's Pool, postgres.js), down to the one call used here. */ export interface Db { query(text: string, values: unknown[]): Promise<{ rows: { national_id: string }[] }>; } export async function admit(db: Db, id: string, name: string, nationalId: string): Promise { const sealed = sealNationalId(nationalId); await db.query("INSERT INTO patients (id, name, national_id, national_id_lookup) VALUES ($1, $2, $3, $4)", [id, name, sealed.national_id, sealed.national_id_lookup]); } export async function nationalIdOf(db: Db, wanted: string): Promise { const { rows } = await db.query("SELECT national_id FROM patients WHERE national_id_lookup = $1", [nationalIdLookup(wanted)]); return rows[0] && openNationalId(rows[0].national_id); } ``` The first argument is the column's address. A TypeORM column transformer is the same two calls: ```ts @Column({ transformer: { to: (v: string) => palbase.fields.encrypt("patients.national_id", v), from: (v: string) => palbase.fields.decrypt("patients.national_id", v) } }) national_id!: string; ``` `palbase.fields` needs Node's crypto: Node, Bun or Deno. ## Tests `palbase/server/testing`'s `testFields()` gives keys of no project, held in the test's process. Pass them where the project's would go — `createRequestDatabase(sql, identity, { schemas, fields })` from `palbase/backend/engine`, or palgres' `createDatabase({ …, fields })` — for a test that runs an encrypted schema against a database of its own: ```ts // test/fields.test.ts import { expect, it } from "bun:test"; import { testFields } from "palbase/server/testing"; it("opens what it seals, at the same column alone", async () => { const fields = await testFields(); const sealed = fields.encrypt("patients.national_id", "10000000146"); expect(sealed.startsWith("pbe1.")).toBe(true); expect(fields.decrypt("patients.national_id", sealed)).toBe("10000000146"); expect(() => fields.decrypt("patients.phone", sealed)).toThrow(); }); ``` `palbase/backend/test`'s in-memory database keeps an encrypted column as a plain one, and does not refuse what the engine refuses on it. # Encrypted Requests A field your route marks `encrypted()` is sealed by the app before the request leaves the device, and a field your route answers with `encrypt()` is opened by the app when the answer arrives. Your handler and the app's code see the value; everything between them — the network, the edge, a proxy, the logs, a debugging proxy, the browser's network panel — sees ciphertext. It is on every plan, at no cost. It protects a field from whatever sits between the app and your code. It does not protect it from the device that typed it, nor from your backend, which opens it. To keep it sealed in the database too, store it in an [encrypted column](/docs/backend/encryption). ## Quick example The backend: `tc` (a national ID) is encrypted in the request and in the answer. ```ts // server/src/patients/patients.controller.ts import { Body, Controller, encrypt, encrypted, Get, NotFound, Param, Post, z, type Encrypted } from "palbase/backend"; const CreatePatient = z.object({ name: z.string().min(1).max(200), // Sealed by the app; opened before validation, so the handler and the schema see the value. tc: encrypted(z.string().regex(/^\d{11}$/)), }); type CreatePatient = z.infer; interface Patient { id: string; name: string; tc: string; } @Controller("/patients") export class PatientsController { private readonly patients = new Map(); @Post("") create(@Body(CreatePatient) body: CreatePatient): { id: string; name: string; tc: Encrypted } { const patient = { id: crypto.randomUUID(), name: body.name, tc: body.tc }; this.patients.set(patient.id, patient); // Sealed with the request's own key: only the app that asked opens it. return { id: patient.id, name: patient.name, tc: encrypt(patient.tc) }; } @Get("/:id") get(@Param("id") id: string): { id: string; name: string; tc: Encrypted } { const patient = this.patients.get(id); if (!patient) throw new NotFound("no such patient"); return { id: patient.id, name: patient.name, tc: encrypt(patient.tc) }; } } ``` Once it is deployed, `palbase apps:sync` writes the app's client with the fields marked, and the app's calls are what they would be without them: ```ts // src/patients.ts import { pb } from "../palbase/clinic-web"; const patient = await pb.backend.patients.create({ name: "Ada", tc: "10000000146" }); console.log(patient.tc); // "10000000146" ``` What went over the wire: ```text POST /patients Palbase-Encryption: 1.t1.gZlVWWADouVFMPA7e26mzk8OCPWBYEEnrr9VqRsqSgA.z-X3bStZx8D0K24F90qhbcbUTIIHDxZYWwnMIeFjZWcMqHk5nHmy7ZYKGyb8Idvo {"name":"Ada","tc":"pbt1.fCnMiPfNbStaAugZD5anhbAszYy170oluQ_30omSb7lSOGzhJACqpVg"} 200 {"id":"a71f381e-b849-4ccd-9e0d-224a1fc80f2c","name":"Ada","tc":"pbt1.yrbQ_0Qhe_p0R7HXOgHgkXJKaeniFL8N9Jd1xNmZ50C_ckFf5tCPF7o"} ``` Each request has a key of its own, sealed to the project's key in use and bound to the project, the method, the route and its path's values; each field is bound to its place. A value moved to another field, a request replayed against another route or another record, does not open, and the backend answers 400. ## Marking a field **In a request**, `encrypted(schema)` marks a field of the body or of the query, wherever it sits in the body: a nested object's field, every item of an array (`items: z.array(z.object({ tc: encrypted(z.string()) }))`), an optional field. A query's field is one of the query's own names. A path parameter or a header is not marked: it is part of the address the request is routed by. **In an answer**, the handler returns the value through `encrypt(value)`, typed `Encrypted`. The build reads the route's return type and writes the mark into the contract, as it writes every other answer's type: `{ tc: Encrypted }` is a `string` the app opens. An answer the route declares as a named schema holds no mark — the build refuses it, naming the route — and a streamed answer and an upload carry no encrypted field. A request that comes without its key to a route that marks its request is refused before the handler runs. A route that marks only its answer runs its handler and is then refused, since the answer cannot be sealed: keep such a route free of side effects, or mark a field of its request too. ## In the apps The web, iOS and Android clients `palbase apps:sync` writes seal and open the marked fields themselves. The first call that carries one fetches the project's list of keys with the app's publishable key, and checks that the project signed it with the signing key `apps:sync` wrote into the app, and that it answers this call's fresh nonce: a list that does not verify is not sealed to, and the call fails with `encryption_keys_unverified` before anything is sent. A marked field that comes back plain fails with `field_not_encrypted`. The [iOS](/docs/ios/debug-console) and [Android](/docs/android/debug-console) debug consoles record a backend call as it went: an encrypted field is shown sealed, and **Reveal** beside it shows its value. Nothing is recorded while the console is off. ## Keys Each project has its own keys, and apps seal to the newest. An owner or an admin of the organization lists and rotates them: ```bash palbase transit:list # the keys, the one in use, who made and retired each palbase transit:rotate # a new key, in use at once; the older ones still open what is sealed to them palbase transit:rotate --retire-previous # a new key, and every older one refused from now on palbase transit:retire t1 # refuse one older key ``` A key thought exposed is rotated with `--retire-previous`. A request sealed to a retired key is refused at once, before it reaches your backend (which also refuses it on its own once it next fetches the keys, within a minute), and an app that sealed to it is told so: it fetches the list again and sends the call once more, sealed to the new key — the user sees nothing. An app fetches the list again every hour in any case. The secrets audit trail (`palbase secrets:audit`) has every rotation and retirement. ## Errors A request the format refuses is answered `400`, with its code in `error`: | Code | When | `data` | |---|---|---| | `encryption_required` | A marked field came plain, or the request has no `Palbase-Encryption` header. | `field`, `current` | | `encryption_unopenable` | The request's key or a field does not open: changed, moved from another field, sealed for another project, route or record. | `field` | | `encryption_key_retired` | The request is sealed to a key the project retired. The app's client fetches the list again and sends once more. | `current` | | `encryption_key_unknown` | The request is sealed to a key the project has not made. The same. | `current` | ## Trying a route curl and Postman send the field plain, and are refused. `palbase machines:call` calls a route as the app does — its marked fields sealed to the key in use, its answer's opened — and prints the answer: ```bash palbase machines:call backend POST /patients --data '{"name":"Ada","tc":"10000000146"}' ``` Studio's **Try it** on a machine's endpoint seals and opens them the same way. ## Without palbase/backend A server built another way — NestJS, Express, Hono, a route handler — opens a request and seals its answer with `palbase/server`'s `transit`, in the same format. Its contract marks the fields `x-palbase-encrypted: true`; served by its app (a machine made with `--openapi-path`), it is what `palbase apps:sync` writes the apps' clients from. ```ts // src/transit.ts import { createServer } from "palbase/server"; export const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); /** Awaited at startup: the project's keys, fetched again every minute and when a request names one not held. */ export const transitLoaded = palbase.transit.load(); ``` ```ts // src/patients.ts import { TransitRefusal } from "palbase/server"; import { palbase } from "./transit"; const patients = new Map(); /** POST /patients and GET /patients/{id}, as a fetch handler (Hono, Bun.serve, a route handler) takes them. */ export async function handle(request: Request): Promise { const key = request.headers.get("palbase-encryption"); const id = new URL(request.url).pathname.split("/")[2]; try { if (request.method === "POST") { // The route as the contract writes it: a key sealed for another route does not open here. const req = await palbase.transit.request({ header: key, method: "POST", path: "/patients" }); const input = (await req.open(await request.json(), "/body", ["/body/tc"])) as { name: string; tc: string }; const patient = { id: crypto.randomUUID(), name: input.name, tc: input.tc }; patients.set(patient.id, patient); return Response.json(await req.seal(patient, ["/response/tc"])); } // A path's values are part of what the key is sealed for, in order. const req = await palbase.transit.request({ header: key, method: "GET", path: "/patients/{id}", params: [id ?? ""] }); const patient = patients.get(id ?? ""); if (!patient) return Response.json({ error: "not_found" }, { status: 404 }); return Response.json(await req.seal(patient, ["/response/tc"])); } catch (e) { // Answered as palbase/backend answers it, so the apps' clients know a retired key when they meet one. if (e instanceof TransitRefusal) return Response.json({ error: e.code, error_description: e.message, data: e.data }, { status: e.status }); throw e; } } ``` In a NestJS controller the same calls go in its methods: the header through `@Headers("palbase-encryption")`, `open()` on the `@Body()`, the answer returned through `seal()`, and an exception filter that answers a `TransitRefusal` as above. # Schema changes There are no migration files. Your [schema files](/docs/backend/schema) are the declaration, and every release of your machine migrates its database to what they say — **beside the version still serving**, so no request waits for a migration and no request of the old version meets a table it cannot read. You change `src/db/public.ts`, commit and push. You run nothing yourself: the platform runs your new image once to **start** the migration, rolls the new version out next to the old one while both read and write the same tables, and runs it again to **complete** once the old one is gone. A change that both versions cannot run through is refused before anything changes, and the version serving keeps serving. ## The workflow at a glance ```bash # 1. Edit the declaration: add a column, rename one, tighten one. # src/db/public.ts # 2. Regenerate the types, so Database.public.* matches what you wrote. bun run scripts/palbase-env.ts # 3. Commit and push to the machine's branch. That push is the release. git commit -am "notes: pinned, and title is called subject" git push ``` The machine builds the commit, and because it is given its database with **migrations on** (Studio: the machine's *Database* setting, "runs its migrations"), the release goes: 1. **start** — the new image runs once with `PALBASE_DB_MIGRATE=start` and the database owner's address. Everything the new version needs is added next to what the running version uses. The new version is not rolled out until start succeeds. 2. **rollout** — the new version comes up beside the old one. Both read and write the same tables. 3. **complete** — once the new version has served and no copy of the old one is left, the image runs with `PALBASE_DB_MIGRATE=complete`. Whatever only the old version used is removed; a renamed column takes its new name, a retyped one takes its place. A version that is abandoned before complete — its start failed, it did not come up, or a newer push took its place — gets **rollback**, which removes what start added and restores what it loosened. Data written meanwhile stays. A database's migrations are one machine's: a second machine that asks to run them is refused (`409 database_migrated_elsewhere`). Another machine may be given the same database without migrations; it binds to the schema version its own files describe, so it carries the same schema files and is released after the one that migrates. ## The app migrates itself The migration is not the platform's code. It is Palgres — the same library that serves your queries — inside your own image. `PalbaseFactory.create` reads `PALBASE_DB_MIGRATE`: set, it migrates the database to the `schemas` it was given and exits; unset, it serves. ```ts // src/main.ts import { PalbaseFactory } from "palbase/backend"; import { AppModule } from "./app.module"; import schema from "./db/public"; // Serves on $PORT. Run with PALBASE_DB_MIGRATE=start|complete|rollback by a // release, it migrates the database as its owner (PALBASE_DB_OWNER_URL) and exits. await PalbaseFactory.create(AppModule, { db: { schemas: [schema] } }); ``` ```ts // src/app.module.ts import { Module } from "palbase/backend"; // The app's modules, listed here as ever: a migration run builds none of them. @Module({ imports: [] }) export class AppModule {} ``` The schema the engine plans from and the engine that applies it are always the same version, because they are in the same image, and a migration run sees nothing but its own image. The run connects as the database's **owner** (`PALBASE_DB_OWNER_URL`, given to migration runs alone); the version that serves connects as the app's login (`PALBASE_DB_URL`) and cannot alter a table. How a run exits is all the platform reads of it: | Exit | What it says | What happens | |---|---|---| | `0` | done, or nothing to do | the release goes on: rollout after start, and so on | | `1` | the migration's own verdict — it is refused, or the rows do not fit it (two rows where a new unique index wants one). Run again, it would end the same | the release stops. What start began is taken back, the version serving stays, and the build says why | | anything else | it was cut off — the database restarting, a lock not had in the tries it has, the run's node drained, its memory out (`75` when the app says so) | the run is made again, and picks up where it stopped | A run's lines are in the machine's logs, marked as a migration run's, and its last line is the reason the build shows — see [Builds, Logs & Rollback](/docs/machines/builds). A migration the process dies in picks up where it stopped: each step is recorded when it commits, and copying a large table records its place with every batch. A step has a day, its attempts together — not a measure of the work, only the end of a run that would never end. Only one migration is open at a time. A push that comes while one is open waits for its complete — or, when the version it belongs to has not rolled out yet, takes its place: that one is rolled back first. ## What start does, so nothing waits - New tables and columns are added; **indexes are built `CONCURRENTLY`**. - `CHECK`s and foreign keys are added `NOT VALID`, then validated while writes go on. - `NOT NULL` is set through a validated `CHECK`. - A type change adds a second column of the new type that the two versions write through each other. - Each step takes the locks its statements need, waits at most 500 ms for them, then gives way and tries again — so a request queued behind it never waits longer than that. A writer is never the one sacrificed to break a lock cycle. - A table is filled in batches that take only rows no writer holds (`SKIP LOCKED`); a write never waits for a batch longer than its commit. ## Adding things Adding a table, a nullable column, a column with a default, an index, a unique index, a `CHECK` or a foreign key is an ordinary release — declare it and push. Two things to know: - **A new `NOT NULL` column needs `.default(…)` or `.fillWith(sql)`**: the version still serving inserts rows without it. - **Constraints apply at start.** A `CHECK`, unique index or foreign key added by a migration holds for every write once start is done — the old version's writes included. A row the old version writes that a new constraint refuses is refused. ## Renaming a column A diff shown two names for one column cannot tell a rename from a drop and an add — and the second loses every value. Say which it is: ```ts // src/db/public.ts import { defineSchema, defineTable, ownedByUser, policy, text, timestamp, uuid } from "palbase/backend"; const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), title: text(), created_at: timestamp().defaultNow(), }, policies: (p) => [policy("notes_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("public", { tables: [notes] }); ``` The next release renames `title` and adds `pinned`: ```ts // src/db/public.ts import { boolean, defineSchema, defineTable, ownedByUser, policy, text, timestamp, uuid } from "palbase/backend"; const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), subject: text().renamedFrom("title"), created_at: timestamp().defaultNow(), pinned: boolean().default(false), }, policies: (p) => [policy("notes_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("public", { tables: [notes] }); ``` ```ts // src/notes/notes.service.ts import { Database, Injectable } from "palbase/backend"; @Injectable() export class NotesService { // The new version writes `subject`; the old one, still serving, writes `title` — the same column. create(owner: string, subject: string) { return Database.public.notes.insert({ owner, subject }); } } ``` ```ts // src/notes/notes.module.ts import { Module } from "palbase/backend"; import { NotesService } from "./notes.service"; @Module({ providers: [NotesService] }) export class NotesModule {} ``` While both versions run, the table keeps its old names and the new version reads and writes through its version's views, which hold the new names; at complete the column takes its new name. The typed tables work this way already. In the new version's **raw SQL** during that window, name tables bare (`notes`, not `public.notes` — a qualified name reaches the table under its old names), and name an `ON CONFLICT` target by its columns rather than its constraint (a view has none). Tools outside the app — psql, a BI tool — see the table itself: the old names until complete. Once complete has run, `renamedFrom` is inert — the old name is gone — so delete it whenever you next touch the file. ## Changing a column's type A type change is planned as a second column of the new type, which both versions write through each other by Postgres' cast both ways (an enum renamed, by its labels); at complete it takes the old column's place. Each version's own writes are cast across, so neither version's values are lost. What both versions cannot run through is refused — see the table below — and for those the way is a new column. A later release's `src/db/public.ts`: ```ts import { boolean, defineSchema, defineTable, enumType, ownedByUser, policy, text, timestamp, uuid } from "palbase/backend"; const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), subject: text(), created_at: timestamp().defaultNow(), // `pinned` becomes a three-way visibility. Postgres has no cast from a // boolean to an enum, so it is a new column filled from the old one, and // `pinned` is ignored until the next release removes it. pinned: boolean().default(false).ignored(), visibility: enumType("note_visibility", ["normal", "pinned", "hidden"]) .fillWith("(CASE WHEN pinned THEN 'pinned' ELSE 'normal' END)::public.note_visibility"), }, policies: (p) => [policy("notes_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("public", { tables: [notes] }); ``` ## Making a column `NOT NULL` A nullable column with rows cannot just become `NOT NULL`: some rows are `NULL`, and the version still serving goes on writing `NULL`. `.fillWith(sql)` says what such a row gets — the rows already there and every write of the old version while both run — and the column is made `NOT NULL` only after. In this release's `src/db/public.ts`, `summary` was `text().nullable()` until now: ```ts import { defineSchema, defineTable, ownedByUser, policy, text, timestamp, uuid } from "palbase/backend"; const notes = defineTable("notes", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), subject: text(), summary: text().fillWith("left(subject, 80)"), created_at: timestamp().defaultNow(), }, policies: (p) => [policy("notes_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("public", { tables: [notes] }); ``` The fill is for the rows the new code does not write: the ones already there, and the old version's. The new version's own inserts must give the column — in its `palbase-env.d.ts` it is required, like any `NOT NULL` column without a default — so the release that adds the fill also changes the code that inserts. `fillWith` is one SQL scalar expression over the row's own columns. A table, function or type it names is schema-qualified (`public.accounts`): it runs with a pinned `search_path`. It reads past RLS, and what it writes is readable by anyone who can read the row. It goes only on a `NOT NULL` column with no default that is not ignored — a constant is a `.default()` — and the value stays after a rollback, so make it one the old version reads correctly. ## Removing a column or a table The release that drops a column must not be the release that stops reading it: the version still serving reads it until complete. So a column or a table **with data** goes in two releases: 1. Mark it — `.ignored()` on a column, `{ ignored: true }` on a table — and push. Nothing changes in the database; this release's code no longer names it, and the typed tables leave it out of every row and every statement. 2. Remove it from the schema and push. Its data goes at that release's complete. A dropped or ignored `NOT NULL` column with no default needs one in the release that ignores it — the new version inserts rows without it, and the old version reads that value in them. Palgres does not make such a column nullable for you: the old version would then read it empty, and after a rollback go on doing so. ## Changing data once A value a column can be worked out from — the rows already there, and each write of the version still serving — is a fill: `.fillWith(sql)`, run in batches beside both versions with nothing held. Every other change of data that is made once — a catalog's rows, data moved into a new shape, a repair — is a [task](/docs/backend/tasks), not part of the migration: it runs on your machine once the release is out, in your own code, and its progress shows in Studio. ```ts // src/modules/people/split-full-names.task.ts import { Database, Task, z } from "palbase/backend"; // Picks up only what is still to do: run again, it does nothing twice. @Task({ name: "split-full-names", payload: z.object({}) }) export class SplitFullNames { async run(): Promise { await Database.$asService().$query( "update people set first_name = split_part(full_name, ' ', 1) where first_name is null and full_name is not null", ); } } ``` Put it once the release is out, under a key, so a second put is the first one answered: ```sh palbase machines:tasks:enqueue split-full-names --key split-full-names-1 ``` A key holds for a day after the task is over: write the task so a later run finds nothing left to do, as above, rather than counting on the key. A change that is too large for one statement does a share of the rows each run, and puts itself again (`Tasks.enqueue`, with the next key) until none is left. There is no `backfill(name, sql)` here: a bare `UPDATE` run as part of a release would race the writes of the version still serving, and stay in the schema file for good. ## What is refused, and what to do instead A refused migration changes nothing, and the version serving keeps serving. The build says what was refused and the way through: | Change | Instead | |---|---| | A column or table with data is removed from the schema | Mark it `.ignored()` / `{ ignored: true }` and release; remove it in the next release | | A nullable column becomes `NOT NULL` | Give `.fillWith(sql)` | | A new `NOT NULL` column has no default | `.default(…)` or `.fillWith(sql)` | | A dropped or ignored `NOT NULL` column has no default | Give it a default in that release | | The type of a column a `CHECK`, a policy, an expression or partial index, the primary key or another table's foreign key uses | Add a column of the new type with `.fillWith("old::newtype")`, move what uses it, then ignore the old column | | A type Postgres has no cast to, either way (boolean to an enum, a timestamp to a boolean), or an enum to one whose labels differ | Add a column of the new type with `.fillWith(sql)`, move to it, then ignore the old column — refused on an empty table too | | The primary key changes | Add a unique constraint | | An enum loses a value | Add a new enum type and move the column to it | | An identity column is added to a table with rows | Add a `bigint` column with a default, and fill it | | A column of a domain with a `CHECK` is added to a table with rows | Add it as the domain's base type with a `CHECK` | | A column changes type and becomes `NOT NULL` in one release | Change its type in this release, make it `NOT NULL` in the next | | A raw unit that is a trigger, a rule or a view has no `drop` | Give it one: `raw(name, up, { drop })` | | A raw index, domain, type or sequence changes under its name | An index: give the new one its own name. A domain, type or sequence: say the change as an `ALTER …` in a unit of its own | | A policy reads a column (or a table) the same release renames | Rename in this release, change the policy in the next | ## What to know while both versions run - **Constraints and policies apply at start**, for both versions: Postgres keeps one set of policies per table, and a new constraint holds for every write. - **A unique the new version drops holds until complete** — the old version's upserts need it — so while both run, the new version cannot write what it forbids. A unique or index changed under its name is built beside the old one, and takes its name at complete. - **A type change keeps both versions' values.** A value one version wrote is not cast back over when the other updates another column. - **A stored generated column made plain** is computed for the old version's writes until complete; a rollback leaves it plain. - **An enum value start added stays after a rollback** — Postgres cannot drop one — and so does a fill. ## Taking over a database Palgres did not make A database another tool left — Prisma, Drizzle, a team's hand-written SQL — comes over as it is. Palgres writes schema files from it, read only; the address is an environment variable, not an argument, because it holds a password: ```bash PALGRES_DATABASE_URL='postgresql://…' bun node_modules/palgres/src/cli.ts pull src # wrote src/db/public.ts # column public.orders.user_id may hold a user's id: mark it ownedByUser() or userRef() so user.deleted reaches it ``` What the DSL's builders say is written with them; a default, check or policy they cannot say is written as its SQL. An index the typed form cannot say, and what the database holds besides tables — domains, types, sequences, functions, views, triggers — are raw units in Postgres' own words. The command prints what to look at: columns that may hold a user's id, and what it leaves as it is (a partitioned table, a view over one). The first release with those files takes the database over. The declared schema is made in a shadow database — the owner has `CREATEDB` for it — and the two are compared the way Postgres writes them. Held as declared, the database becomes version 1 and nothing in it changes; held otherwise, it becomes version 1 as it is, and the ordinary migration to the declared schema does exactly the difference, with the same refusals. A refused take-over writes nothing. Row-level security forced on the owner is turned off — Palgres migrates as the owner, and the app never connects as it — and a database whose own `auth.uid()`, `auth.jwt()` or `auth.role()` reads its caller another way is refused until you rename yours. ## Related - [Schema](/docs/backend/schema) — the declarations a release migrates to - [Database](/docs/backend/database) — the typed tables, and raw SQL while a rename is open - [Row-Level Security](/docs/backend/row-level-security) — policies, which apply at start - [Builds, Logs & Rollback](/docs/machines/builds) — what a push does, and the migration run's lines - [Databases](/docs/machines/databases) — giving a machine its database, with migrations on # Storage Every project has storage: **buckets** of files, read and written by your backend with the secret key. In a `palbase/backend` app the module is `Palbase.storage`; from any other server it is `createServer({ url, secretKey }).storage` — the same client, generated from the storage contract. A client app never holds the secret key, so it reaches files in two ways only: a **public** bucket's objects, which anyone may read, and **signed URLs** your backend hands out — one to read an object, or one to upload it ([Direct Uploads](/docs/backend/direct-uploads)). An AI chat message holds to the same: a client attaches only a public bucket's file, and a private one is attached by your server ([AI](/docs/backend/ai)). ## Quick example ```ts // src/modules/avatars/avatars.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class AvatarsService { /** Stores the picture as the user's: it goes when they are deleted. */ async save(userId: string, png: Blob): Promise { const path = `${userId}/avatar.png`; await Palbase.storage.putObject({ path: { bucket: "avatars", path }, query: { owner_id: userId }, body: png, contentType: "image/png", }); return path; } /** A link that reads the object for ten minutes, by anyone holding it. */ async link(path: string): Promise { const signed = await Palbase.storage.createSignedUrl({ path: { bucket: "avatars" }, body: { path, expires_in: 600 } }); return signed.data.url; } } ``` ## Buckets A bucket is declared, and declaring it again makes it so. `PUT /storage/buckets/{bucket}` creates the bucket or changes it to what the body says: | Field | Default | What it says | |---|---|---| | `public` | `false` | Anyone may read its objects at `/storage/public/{bucket}/{path}`, with no credential. | | `file_size_limit` | the platform's, 5 GiB | The largest object it takes, in bytes. | | `allowed_mime_types` | any | The content types it takes; `image/*` names a family. 64 at most. | | `variants` | none | The renditions made of every picture put in it (below); 16 at most. | A bucket's name is a lowercase letter, then lowercase letters, digits and `-`, 2 to 63 characters. ```ts // src/modules/avatars/buckets.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class BucketsService { // A provider's onStart() runs at boot, before the app serves: the bucket // is as this code says on every machine that runs it. async onStart(): Promise { await Palbase.storage.putBucket({ path: { bucket: "avatars" }, body: { public: true, file_size_limit: 5 * 1024 * 1024, allowed_mime_types: ["image/*"], variants: [ { name: "thumb", width: 128, height: 128, fit: "cover", format: "webp", quality: 80 }, { name: "large", width: 1024, height: 0, fit: "inside", format: "jpeg", quality: 0 }, ], }, }); } } ``` The write carries an `Idempotency-Key` the client makes, so its own retry never declares twice; a declaration a renderer cannot honour is refused here (`400`), not at the first upload. `GET /storage/buckets` lists the project's buckets, `GET /storage/buckets/{bucket}` reads one, and `DELETE /storage/buckets/{bucket}` deletes an **empty** bucket — one that still holds objects is refused with `409 bucket_not_empty`, so no file goes by surprise. Studio's **Storage › Buckets** screen lists the buckets, deletes them, and uploads and deletes files in one. ## Writing an object `putObject` stores its body at the path, replacing what is there. ```ts // src/modules/reports/reports.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class ReportsService { async publish(month: string, csv: string) { const written = await Palbase.storage.putObject({ path: { bucket: "reports", path: `monthly/${month}.csv` }, headers: { "If-None-Match": "*" }, // create only: an object already there is 412 body: csv, contentType: "text/csv", }); return written.data; // the StorageObject: path, size, content_type, checksum_sha256 … } } ``` - **Its type is sniffed from the bytes**, not taken from `contentType`, which is used only when the bytes cannot tell (HEIC, say). The bucket's ceiling and its types are enforced while the bytes stream in (`413 payload_too_large`, `415 unsupported_media_type`). - **`owner_id`** makes the object a user's: it is deleted when they are. - **`If-None-Match: *`** creates only (`412 object_exists`); **`X-Checksum-Sha256`** (hex) refuses bytes that do not match (`400 checksum_mismatch`), keeping nothing. - **A picture** gets its `width`, `height`, a `thumbhash` placeholder and the bucket's renditions, made inside the upload — the object answered is complete. - **It emits `file.uploaded`** to the project's webhook ([Webhooks](/docs/server/webhooks)). A route of a `palbase/backend` app reads its body as JSON, a megabyte by default, so a client's file does not come through your backend to get here: the client PUTs it straight to storage at a URL your backend signed. See [Direct Uploads](/docs/backend/direct-uploads). ### What an object looks like | Field | What it is | |---|---| | `bucket`, `path` | Where it is. | | `size`, `content_type` | Its bytes and the type they were sniffed as. | | `checksum_sha256` | The bytes' sha256, hex. | | `owner_id` | The user it belongs to, if any. | | `width`, `height`, `thumbhash` | A picture's dimensions and placeholder. | | `variants` | Its renditions, by name: each with its `content_type` and `size`. | | `created_at`, `updated_at` | When an object was first stored at the path, and when its bytes or path last changed. | ## Reading an object `getObject` answers the bytes as the `Response` itself, so a large file is never read into memory unless you do so: ```ts // src/modules/reports/download.service.ts import { Injectable, NotFound, Palbase } from "palbase/backend"; @Injectable() export class DownloadService { async text(path: string): Promise { const reply = await Palbase.storage.getObject({ path: { bucket: "reports", path } }); if (reply.status === 304 || !reply.data) throw new NotFound(`no report at ${path}`); return reply.data.text(); } async thumbnail(path: string): Promise { const reply = await Palbase.storage.getObject({ path: { bucket: "avatars", path }, query: { variant: "thumb" } }); if (reply.status === 304 || !reply.data) throw new NotFound(`no avatar at ${path}`); return reply.data.arrayBuffer(); } } ``` A missing object is a `404` refusal (a `PalbaseError`). `Range` (one byte range, answered `206`) and `If-None-Match` (answered `304`, with no body) are honoured, and `headObject` reads the headers without the bytes. A read with the secret key names the object's owner in `X-Owner-Id`; a public or signed URL never does. What is served is made inert — `nosniff`, a sandboxing CSP, and a download for any type a browser does not render safely — and every read counts toward the project's egress. ## Three ways a client reads a file | URL | Who may read it | Made by | |---|---|---| | `https://.palbase.studio/storage/public/{bucket}/{path}` | Anyone, with no credential — an ``, a CDN. A private bucket's object is `403`. | Nothing to make: the bucket is `public`. | | `https://.palbase.studio/storage/signed/{bucket}/{path}?…&sig=…` | Anyone holding the URL, until it expires (7 days at most). | `createSignedUrl` | | `GET /storage/buckets/{bucket}/objects/{path}` | Your backend, with the secret key. | — | The `{path}` in a URL is **one** percent-encoded segment: `a/b.jpg` is `a%2Fb.jpg`. `?variant=thumb` reads a rendition on all three. ```ts // src/modules/avatars/urls.ts const PROJECT = "https://.palbase.studio"; /** A public bucket's object, as an takes it. */ export function publicUrl(bucket: string, path: string, variant?: string): string { // The path is ONE segment: a/b.jpg goes as a%2Fb.jpg. const [b, p] = [encodeURIComponent(bucket), encodeURIComponent(path)]; const url = new URL(`/storage/public/${b}/${p}`, PROJECT); if (variant) url.searchParams.set("variant", variant); return url.toString(); } ``` A signed URL is absolute, at the project's own address, and opens nothing at another project's. Every parameter of it is covered by its signature: a URL changed by one character, expired, or naming another object is `403`. The object must exist when the URL is signed. ## Image renditions A bucket's `variants` are made from every picture put in it, inside the upload, and kept beside the original: | Field | Values | Default | |---|---|---| | `name` | a lowercase letter, then lowercase letters, digits and `_` (32 at most) | — | | `width`, `height` | pixels; `0` derives one from the other and the picture's aspect | — | | `fit` | `cover` fills the box and crops; `contain` and `inside` keep the whole picture inside it (`inside` never enlarges) | `contain` | | `format` | `jpeg`, `png`, `webp` | `jpeg` | | `quality` | 1 to 100 for jpeg and webp; `0` is the format's default | `0` | A rendition is read by its name (`?variant=`), and served whole — a byte range applies to the original only. A copy carries the original's renditions with it. ## Listing, copying, moving, deleting ```ts // src/modules/reports/archive.service.ts import { Injectable, Palbase } from "palbase/backend"; import { pages } from "palbase/server"; @Injectable() export class ArchiveService { /** Every report of a year, newest first, a page at a time. */ async year(year: string): Promise { const paths: string[] = []; for await (const page of pages((cursor) => Palbase.storage.listObjects({ path: { bucket: "reports" }, query: { prefix: `monthly/${year}-`, limit: 1000, cursor } }), )) { paths.push(...page.objects.map((o) => o.path)); } return paths; } async retire(path: string): Promise { await Palbase.storage.moveObject({ path: { bucket: "reports" }, body: { from: path, to: `retired/${path}` } }); } async duplicate(path: string, to: string): Promise { await Palbase.storage.copyObject({ path: { bucket: "reports" }, body: { from: path, to, overwrite: true } }); } async remove(path: string): Promise { await Palbase.storage.deleteObject({ path: { bucket: "reports", path } }); } } ``` - **A move** gives the object a new path and copies no byte; **a copy** writes a new object with the original's bytes and renditions. Either refuses an object already at `to` (`409 object_exists`) unless `overwrite`. - **A delete** removes the object and its renditions; the bytes are swept from the store after. - They emit `file.moved`, `file.uploaded` and `file.deleted`. ## Reacting to storage events Every write emits an event — `file.uploaded`, `file.deleted`, `file.moved` — with `{ bucket, path, size, content_type, owner_id }`. A `palbase/backend` app handles them with `@On("file.uploaded")` ([Event Hooks](/docs/backend/hooks)); any other server verifies the delivery with `palbase/server` ([Webhooks](/docs/server/webhooks)). ## The `Storage` service A backend reaches a bucket with `Storage`: `Storage.buckets.` for a bucket the project holds (its renditions in the type), `Storage.bucket(name)` for a name made while it runs. Each call answers `{ data, error, status }` and never throws for the platform's sake: a refusal is its `error` (`{ code, message }`), and a platform that could not be reached is `status: 0`. | Call | What it does | |---|---| | `upload(path, file, { contentType?, upsert? })` | Writes the object. Without `upsert` it only creates: an object already there is `409 already_exists`. | | `download(path)` | The object's bytes, as a `Blob` | | `getPublicUrl(path, { variant? })` | Its URL at the project's address — a public bucket's objects are read there by anyone; no call is made | | `createSignedUrl(path, { expiresIn })` | A URL that reads it until it expires: `"90s"`, `"15m"`, `"1h"`, `"7d"`, a week at most | | `list(prefix?, { limit?, offset? })` | Its objects whose path starts with `prefix`, newest first (100 when no `limit`) | | `remove(paths)` | Deletes each, in order; the first refusal ends it | | `move(from, to)`, `copy(from, to)` | Within the bucket | ```ts // palbase/palbase-env.d.ts declare module "palbase/backend/stack" { interface Buckets { avatars: { variants: "thumb" | "card" }; } } export {}; ``` ```ts // src/modules/avatars/avatar-urls.service.ts import { Injectable, Storage } from "palbase/backend"; @Injectable() export class AvatarUrls { async save(userId: string, png: Blob): Promise<{ full: string; thumb: string } | null> { const path = `${userId}/avatar.png`; const { error } = await Storage.buckets.avatars.upload(path, png, { contentType: "image/png", upsert: true }); if (error) return null; return { full: Storage.buckets.avatars.getPublicUrl(path), thumb: Storage.buckets.avatars.getPublicUrl(path, { variant: "thumb" }) }; } } ``` ## From the CLI ```sh palbase storage:buckets:set avatars --public --max-size 5MB --types image/* palbase storage:buckets:list palbase storage:cp ./avatar.png avatars:users/42/avatar.png # up; bucket:path names an object palbase storage:cp avatars:users/42 ./backup -r # down, every object under the prefix palbase storage:ls avatars:users/ palbase storage:url documents:invoices/7.pdf --expires 600 # a signed download URL palbase storage:rm avatars:tmp/ -r ``` What `storage:buckets:set` is not given stays as it was; a new bucket is private, takes any type and has no renditions until `--variants-file` gives them. `storage:buckets:delete` removes an empty bucket only. See the [CLI reference](/docs/cli/reference#storage). - [Direct Uploads](/docs/backend/direct-uploads) — signed upload URLs, and the client's `PUT` - [Web Uploads](/docs/web/uploads) — the browser's side of an upload URL - [Server SDK Overview](/docs/server/overview) — calls, refusals, retries and paging - [Event Hooks](/docs/backend/hooks) — `@On("file.uploaded")` in your backend # Direct Uploads A file a user uploads goes from their device **straight to storage**, never through your backend. Your backend decides what may be uploaded — which bucket and path, how large, of which types, belonging to whom — in one of two ways: - **A signed upload URL.** Your backend signs a URL that grants exactly that, and the client `PUT`s the bytes to it. The signature is the credential: the client needs no key, and the URL opens one object, once its conditions hold, until it expires. This page's quick example. - **An upload route.** A route declared with `@Upload` takes the file in the client's call: storage asks the route where the file goes before it writes a byte, and runs the route's handler once the file is stored. See [Upload routes](#upload-routes). ## Quick example ```ts // src/modules/avatars/dto.ts import { z } from "palbase/backend"; export const UploadRequest = z.object({ contentType: z.enum(["image/png", "image/jpeg", "image/webp"]) }); export type UploadRequest = z.infer; export interface UploadGrant { url: string; expiresAt: string; path: string; } ``` ```ts // src/modules/avatars/avatars.controller.ts import { Body, Controller, Palbase, Post, User, type UserT } from "palbase/backend"; import { UploadRequest, type UploadGrant } from "./dto"; @Controller("/avatars") export class AvatarsController { // The user asks; the backend decides the path, the ceiling and the owner. @Post("/upload-url", { rateLimit: { max: 10, window: 60 } }) async uploadUrl(@Body(UploadRequest) body: UploadRequest, @User() user: UserT): Promise { const ext = body.contentType.split("/")[1]; const path = `${user.id}/avatar-${Date.now()}.${ext}`; const signed = await Palbase.storage.createUploadUrl({ path: { bucket: "avatars" }, body: { path, expires_in: 300, max_bytes: 2 * 1024 * 1024, owner_id: user.id }, }); return { url: signed.data.url, expiresAt: signed.data.expires_at, path }; } } ``` The client then sends the file to that URL: ```ts // upload.ts export async function upload(grant: { url: string }, file: Blob): Promise { const res = await fetch(grant.url, { method: "PUT", headers: { "content-type": file.type, "if-none-match": "*" }, body: file, }); if (!res.ok) throw new Error(`the upload was refused: ${res.status}`); } ``` ## The flow 1. **The client asks your backend** for a grant — a route of your own, with whatever the upload needs to say (the kind of file, which record it belongs to). 2. **Your backend signs it** with `POST /storage/buckets/{bucket}/upload-urls` (`Palbase.storage.createUploadUrl`) and answers the URL. 3. **The client `PUT`s the bytes** to the URL — `PUT /storage/signed/{bucket}/{path}`, with the signature in its query. 4. **Storage checks the grant while the bytes stream**, stores the object, answers it (`200`, or `201` for a new one), and emits `file.uploaded`. Your backend is not on the upload's path, so it learns of the upload from the event: an `@On("file.uploaded")` handler ([Event Hooks](/docs/backend/hooks)), or a route the client calls once its `PUT` is answered. ## What the URL grants `createUploadUrl` takes: | Field | What it pins | |---|---| | `path` | The object's path, chosen by your backend, never by the client. | | `expires_in` | Seconds the URL works: 1 to 604800 (7 days). | | `max_bytes` | A ceiling below the bucket's own `file_size_limit`. | | `owner_id` | The user the object will belong to; it is deleted with them. | The bucket's `allowed_mime_types` are pinned too. It answers `{ url, expires_at }`: an absolute URL at the project's own address, `https://.palbase.studio/storage/signed/{bucket}/{path}?…`. Every one of its query parameters — the bucket, the path, the expiry, the ceiling, the types, the owner — is covered by its signature, so a client that edits one holds a URL that opens nothing. ## The `PUT` | Header | What it does | |---|---| | `Content-Type` | Used only when the bytes cannot say what they are (HEIC): the type is sniffed from the bytes. | | `If-None-Match: *` | Create only: an object already at the path is refused with `412`. | | `X-Checksum-Sha256` | The bytes' sha256, hex: bytes that do not match are refused, and nothing is kept. | | Answer | Why | |---|---| | `200` / `201` | Stored — the object, as JSON: `path`, `size`, `content_type`, `width`, `height`, `thumbhash`, `variants`. | | `400 checksum_mismatch` | The bytes do not have the sha256 `X-Checksum-Sha256` named. | | `403` | The signature is wrong, expired, another project's, or a download signature. | | `412 object_exists` | `If-None-Match: *` and an object is already there. | | `413 payload_too_large` | Larger than the URL's ceiling or the bucket's. | | `415 unsupported_media_type` | A type the bucket, or the URL, does not take. | | `422 unreadable_picture` | A picture the bucket's renditions cannot be made from: truncated, or a format storage does not read. | | `429 quota_exceeded` | The project's storage for the period is used up under its quota (with `Retry-After`). | A picture's renditions are made inside the upload, so the object the `PUT` answers already carries them. > **Note:** A browser page on any site can make this `PUT`: it is preflighted, and the project's address answers the preflight. See [Web Uploads](/docs/web/uploads) for the browser. ## Upload routes An upload route is one method that both decides where a file goes and runs once it is there. The client sends the file with the route's input in one call to the project's storage; storage asks your machine whether this caller may upload and where to, writes the bytes under the bucket's rules, then hands the route's handler the stored object. What the handler answers is the client's answer. Nothing is signed, the client makes no second call to say it is done, and the bytes still never pass through your machine. ```ts // src/modules/profile/profile.controller.ts import { Controller, PalError, Upload, UploadedObject, User, type UserT } from "palbase/backend"; interface Avatar { owner: string; path: string; size: number; contentType: string; thumbhash?: string; } @Controller("/profile") export class ProfileController { // Into the avatars bucket, under a folder of the user's: the client never names the path. @Upload("/avatar", { bucket: "avatars", pathTemplate: "{userId}/{uploadId}-{filename}", rateLimit: { max: 10, window: 60 } }) avatar(@UploadedObject() file: UploadedObject, @User() user: UserT): Avatar { // Runs once the file is stored. A refusal thrown here deletes it. if ((file.width ?? 0) < 128) throw new PalError(422, "picture_too_small", "An avatar is 128 pixels wide at least"); return { owner: user.id, path: file.path, size: file.size, contentType: file.contentType, thumbhash: file.thumbhash }; } } ``` The client calls it through the module `palbase apps:sync` writes, the file after the route's input: `pb.backend.profile.avatar(file, { onProgress })` in a browser ([Web Uploads](/docs/web/uploads#an-upload-route)). `@Upload(subpath, options)` is a `POST` route of its controller, and takes: | Option | What it is | |---|---| | `bucket` | The bucket its files go to: lowercase letters, digits and dashes, starting with a letter, 2 to 63 long. Its rules (the largest file, the types it takes, its renditions) hold every upload as storage writes it. | | `pathTemplate` | The path each file is stored under, made for each upload from the tokens below. Segments separated by `/`, none empty, `.` or `..`, no control character, 1024 bytes at most. | | `auth` | Who may upload, as any route's `auth`: a signed-in user unless `auth: false`. | | `rateLimit` | How many uploads a caller may start per window, as any route's `rateLimit`. Spent as an upload is asked for, before a byte is written. | | Token | What it becomes | |---|---| | `{userId}` | The signed-in user's id; `anonymous` for no one, on a route with `auth: false`. | | `{uploadId}` | The upload's own id, made by storage: `file.uploadId` in the handler. | | `{filename}` | The name the client gave its file, made one path segment: control and invisible characters dropped, `/` and `\` a dash, a run of dots one dot, no leading dot or space, 200 characters at most; `file` when it gave none. | A template that names another token, or is no path storage keeps, stops the app as it starts. Storage writes an upload's object only where none is: put `{uploadId}` in the template, or a second upload to the same path is refused `412 object_exists`. The handler takes the stored object with `@UploadedObject()`, and what any route takes: the input the client sent beside the file with `@Body(schema)`, the caller with `@User()` or `@OptionalUser()`, `@Param` and `@QueryParams`. It runs as the caller does, their row-level security included. The caller is the one storage names, their id and session, not their token: an upload route cannot ask for a verified email, and `@Headers()`, `@Req()` and `@Client()` stop the app as it starts, because storage forwards none of the client's headers. A method is an upload route or another route, never both. | `@UploadedObject()` | What it is | |---|---| | `uploadId` | The upload's id, the same as in its path. | | `bucket`, `path` | Where it was stored, as `pathTemplate` made it. | | `size` | Bytes, as storage counted them. | | `contentType` | The type storage found in the bytes, not the file's name or what the client claimed. | | `checksum` | The stored bytes' SHA-256, hex. | | `width`, `height`, `thumbhash` | A picture's size in pixels, and its placeholder (ThumbHash). Absent for anything else. | | `variants` | The renditions the bucket declares, by name, each its address: a public bucket's. None for a private bucket. | ### What storage does 1. **The client's call.** `POST /storage/upload//` at the project's address, with the publishable key and the user's token when someone is signed in. `` is the route's path as one percent-encoded segment (`profile%2Favatar`), and the request's query is the route's. The body is `multipart/form-data`: a JSON `body` part of 1 MiB at most (the route's input), then the `file`. 2. **The machine authorizes it**, within 30 seconds. The route's `auth` and `rateLimit` are held as a request of the caller's would meet them, its `@Resolve` parameters and guards run, its query and the `body` are checked against their schemas, and `pathTemplate` is made into the path. A refusal is the client's answer, as the app gave it, and no byte is written. 3. **Storage writes the bytes** to the bucket under its rules, and measures them: size, checksum, the type the bytes are, a picture's size, placeholder and renditions. The object belongs to the signed-in user, and is deleted with them. 4. **The handler runs** with the stored object, within two minutes. Its answer (`200` with JSON, `204`, a `Response` it built, a refusal) is relayed to the client as it gave it. A refusal, or no answer in two minutes, deletes the object. A kept one is told as `file.uploaded` ([Event Hooks](/docs/backend/hooks)). What the client can be answered besides the route's own: | Status | Why | |---|---| | `400` | Not an upload: not multipart, no `file` part, a `body` that is not JSON or is over 1 MiB, a route or machine name that is none; or a file name that makes the path longer than storage keeps. | | `401` | The route needs a signed-in user, and no one is. | | `404` | No such machine, no upload route at that path, or the bucket the route names is none. | | `412 object_exists` | An object is already at the path the template made. | | `413` | Larger than the bucket takes. | | `415` | A type the bucket does not take. | | `429` | The route's `rateLimit` for this caller (`Retry-After`), the project's storage used up under its quota, or its machine calls all under way. | | `502` | The app did not answer, or its authorize named no bucket and path an object can have. | | `503` | The machine is waking or building. Sending again is safe. | ## Why the backend asks first There is no client-side object API: the publishable key opens nothing in storage, so a client cannot write a file anywhere your code did not name. Putting the decision in your backend gives it the whole say: - **The path is yours.** A user cannot overwrite another's file, because they never choose the path. - **The size and type are yours,** per upload, below the bucket's. - **The owner is yours.** `owner_id` ties the object to the user it is for, and deleting the user deletes it. - **The rate is yours.** The grant is a route of your own: `rateLimit` on it holds how often a user may upload. ## Uploading from your backend When the bytes are already on your server — a generated report, a file fetched from elsewhere — write them with `putObject` and skip the URL: see [Storage](/docs/backend/storage). ## Related - [Storage](/docs/backend/storage) — buckets, objects, renditions and signed download URLs - [Web Uploads](/docs/web/uploads) — the browser's side, for a signed URL and for an upload route - [Event Hooks](/docs/backend/hooks) — acting on `file.uploaded` - [Controllers & Routing](/docs/backend/controllers) — the grant's route and its `rateLimit` # Secrets A project keeps its application secrets — a payment provider's key, a signing secret, the project's own secret key for its backend — in its **secrets**, sealed with the project's own key, versioned, and audited on every read and write. They are kept in two sets, `production` and `development`. A machine is started with one set: every secret in it is in the machine's environment by name, so your code reads `process.env.STRIPE_KEY` and nothing else. A program that wants a value while it runs — or one that is not a machine — reads it with the secret key through the secrets module, `Palbase.secrets`. ## Quick example In Studio, open the environment's **Secrets** screen and add `STRIPE_KEY`. In the machine's settings (**Backend › Machines › the machine › Edit settings**), choose the set its **Secrets** start with. The machine rolls out with the value, and the code reads it: ```ts // src/modules/billing/billing.service.ts import { Injectable } from "palbase/backend"; @Injectable() export class BillingService { // Read once, at boot: a secret that changes rolls the machine out with the new value. private readonly stripeKey = required("STRIPE_KEY"); authorization(): string { return `Bearer ${this.stripeKey}`; } } function required(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is not set: add it to the project's secrets and start the machine with them`); return value; } ``` ## The two sets | Set | What it is for | |---|---| | `production` | What the machines that serve your users start with. | | `development` | A second set, for machines and programs that must not see production's values. | A machine names the set it starts with (`secrets`: `production`, `development`, or none) — in Studio on the machine's settings, or through the machines module (`PATCH /machines/{name}`). Studio's **Secrets** screen manages the set the environment's machines start with: `production` when none names one yet, and it refuses to choose for you when machines start with different sets. ## How a machine reads them - **By name, in its environment.** Each secret of the set is an environment variable of the machine. A name the machine's own `env` also sets wins over the secret — keep a value in one place. - **A change rolls the machine out.** Writing, rolling back or deleting a secret of the set starts the machine's new version with the new values beside the version that serves; nothing restarts in place, and no request meets a gap. - **A build can wait for them.** A machine's `.env.example` names what it needs: a build whose names the machine's env and secrets do not hold waits (`waiting_config`, with `missing_env`) and rolls out once they are there. See [Builds, Logs & Rollback](/docs/machines/builds). A `palbase/backend` app on a machine needs none of its own keys here: the platform starts every machine with its own secret key (`PALBASE_SECRET_KEY`, listed under **Settings › API keys** as `machine: `) and its webhook secret (`PALBASE_WEBHOOK_SECRET`), beside `PALBASE_URL` and `PALBASE_PROJECT_ID`. A secret of the same name set by hand gives way to them. A value missing or malformed stops the boot, naming it. ## Names and sizes | Rule | Limit | |---|---| | Name | An environment-variable name: `A-Z`, `0-9` and `_`, starting with a letter, 128 characters at most. | | Value | 64 KiB. | | Secrets per set | 1000. | ## Reading a value while it runs `Palbase.secrets` (or `createServer(…).secrets`) reads and writes the secrets with the project's secret key: ```ts // src/modules/billing/rotation.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class RotationService { /** The current value, read now rather than at boot. */ async current(name: string): Promise { return (await Palbase.secrets.getSecret({ path: { environment: "production", name } })).data.value; } /** Every value of the set, in one call: what a program outside a machine reads at startup. */ async all(): Promise> { return (await Palbase.secrets.getEnvironmentValues({ path: { environment: "production" } })).data.values; } } ``` | Call | What it does | |---|---| | `listSecrets({ path: { environment } })` | The set's secrets by name, with their version — never their values. | | `getSecret({ path: { environment, name } })` | One secret's current value and version. | | `getEnvironmentValues({ path: { environment } })` | Every value of the set, and every version, in one call. | | `putSecret({ path: { environment, name }, body: { value } })` | Writes the value as the secret's next version, creating it if it is new. | | `listSecretVersions({ path: { environment, name } })` | Its versions, newest first, with the key that wrote each — never their values. | | `rollbackSecret({ path: { environment, name }, body: { version } })` | Makes an earlier version's value the next version; the history stays whole. | | `deleteSecret({ path: { environment, name } })` | Forgets the secret and every version of it. | | `listSecretAudit({ query: { environment?, name? } })` | Every read and write, newest first, with the key that made it and the request id. | Every write is a new version, so a rotation that goes wrong is a rollback away. A write, a rollback and a delete each emit `secret.changed` — the set, the name, the version and the change, **never the value** — to the project's webhook, so a receiver learns what changed and reads it with its own key ([Webhooks](/docs/server/webhooks)). ## Who can read them Only the secret key. A publishable key ships in apps and is refused by every secrets route; a user's token opens none of them. Every read — a list, one value, the whole set — is in the audit trail with the id of the key that made it, which is one more reason to give each server its own secret key: the audit then says which one read what, and revoking one shuts out only that server. ## The `Secrets` service A backend reads a secret with `await Secrets.get("NAME")`: the value or `null`. It reads the machine's environment — the set the machine starts with — so a changed secret arrives with the machine rolled out again. A name the platform puts there itself (`PALBASE_URL`, `PALBASE_SECRET_KEY`, `PALBASE_DB_URL` …) is not the project's secret, and answers `null`. The names it takes are the ones the project holds: the CLI's `types:pull` writes them into `palbase/palbase-env.d.ts`, and a name the project does not hold does not compile. ```ts // palbase/palbase-env.d.ts declare module "palbase/backend/stack" { interface Secrets { STRIPE_KEY: true; } } export {}; ``` ```ts // src/modules/billing/stripe-key.service.ts import { Injectable, Secrets } from "palbase/backend"; @Injectable() export class StripeKey { async get(): Promise { const key = await Secrets.get("STRIPE_KEY"); if (!key) throw new Error("STRIPE_KEY is not among the secrets this machine started with"); return key; } } ``` ## From the CLI ```sh printf %s "$STRIPE_KEY" | palbase secrets:set STRIPE_KEY # production: the value is read from stdin printf %s "sk_test_…" | palbase secrets:set STRIPE_KEY --dev palbase secrets:list # names and versions, never values palbase secrets:versions STRIPE_KEY palbase secrets:rollback STRIPE_KEY --version 3 # a new version with version 3's value palbase secrets:audit --name STRIPE_KEY # who read and wrote it, and when ``` `--dev` acts on the development set — what a backend run on your computer with `palbase run` or `palbase env:pull` reads — and its absence on production. A value is never taken from the command line, where the shell's history would keep it. `palbase secrets:get ` prints one value, for an admin; `palbase secrets:unset ` takes a secret away. Every command is in the [CLI reference](/docs/cli/reference#secrets). ## Related - [Machines](/docs/machines/overview) — a machine's environment, and the secret set it starts with - [Builds, Logs & Rollback](/docs/machines/builds) — `.env.example`, and a build that waits for its settings - [Server SDK Overview](/docs/server/overview) — the secret key and `createServer` - [Webhooks](/docs/server/webhooks) — `secret.changed` # Flags Every project has feature flags and remote configuration: typed values, targeted by conditions, given to single users, read by its apps and kept up to date in them as they change. Apps **read** flags with the publishable key ([Web Flags](/docs/web/flags)). Your backend **defines** them, gives users their own values, and reads what any user would see — with the secret key: `Palbase.flags` in a `palbase/backend` app, `createServer(…).flags` anywhere else. ## Quick example ```ts // src/modules/checkout/checkout.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class CheckoutService { /** Turns the new checkout on for one user — a beta tester, a support case. */ async enableNewFlow(userId: string): Promise { await Palbase.flags.putUserValue({ path: { user_id: userId, key: "checkout.new_flow" }, body: { value: true } }); } /** What that user's app reads for the flag now. */ async newFlowFor(userId: string): Promise { const { data } = await Palbase.flags.getUserValues({ path: { user_id: userId } }); return data.values["checkout.new_flow"] === true; } } ``` ## How a value is resolved For each flag, a reader gets, in order: 1. **its own value**, if the user has one (`source: "override"`); 2. else the value of the **first condition that matches**; 3. else the flag's **default**. A reader with no signed-in user has no values of its own and no percentile, so only the conditions that need neither can match it. ## Defining a flag `PUT /flags/system/{key}` (`putDefinition`) defines a flag, or changes one: ```ts // src/modules/checkout/flags.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class FlagsService { async onStart(): Promise { await Palbase.flags.putDefinition({ path: { key: "checkout.new_flow" }, body: { value_type: "bool", value: false, description: "The one-page checkout", conditions: [ { when: "client.platform == 'ios' && client.app_version >= '2.10.0'", value: true }, { when: "percentile <= 10", value: true }, ], }, }); await Palbase.flags.putDefinition({ path: { key: "home.theme" }, body: { value_type: "string", value: "light", variants: ["light", "dark", "contrast"] }, }); } } ``` | Field | What it says | |---|---| | `value_type` | `bool`, `number`, `string` or `object`. | | `value` | The default (8 KB at most). | | `variants` | A string flag's allowed choices: every value — the default, a condition's, a user's — is one of them. | | `conditions` | Targeting, in order, first match wins; 8 at most, each `{ when, value }`. | | `description` | What the flag is for. | A key is dot-separated segments, each a letter then letters, digits and underscores, 128 characters at most. A project defines **200 flags** at most. A write that would change the type under users' values, or leave a user's value outside the choices, is refused; so is a direct write while publishing is on (below). `listDefinitions`, `getDefinition` and `deleteDefinition` read and remove them; deleting a flag removes every user's value of it. Every reader is told of a change at once. Studio's **Feature flags** screens edit the same definitions and users' values. ### Conditions A condition's `when` is a small expression language: comparisons over a fixed set of variables, combined with `&&`, `||` and parentheses — no functions, no arithmetic. 512 characters and 16 comparisons at most. | Variable | Where it comes from | Compared as | |---|---|---| | `client.platform` | the `X-Platform` header the app sends | a string | | `client.locale` | the first language of `Accept-Language` | a string | | `client.app_version` | `X-Palbase-Client-Version` | a version (`'2.10.0'` is above `'2.9.0'`) | | `client.os_version` | `X-OS-Version` | a version | | `percentile` | the signed-in user, hashed with the flag's key: 1 to 100 | a number | | `server.country` | the country of the address the edge saw | a string (`'TR'`) | | `server.now` | the server's clock | a time (`'2026-01-01T00:00:00Z'`) | Operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `in ['ios', 'android']`, and for strings `startsWith`, `endsWith`, `contains`. - **A variable outside the table** (`client.foo`) is refused when the flag is written — it could never match. - **A variable the client did not send** does not match, and the flag falls back to its default. - **`client.*` is what the client says.** Anyone can send any header, so a `client.*` condition chooses an experience, never access. - **`percentile` is per flag:** `percentile <= 10` is a different tenth of users for each flag, so one unlucky user is not outside every experiment. It needs a signed-in user. - **An unresolved country matches nothing**, `!=` included: "not Turkey" is never true of a caller whose country is unknown. - **`server.now`** cannot be moved by a client's clock, which is what makes a scheduled change real. ## Users' own values | Call | What it does | |---|---| | `putUserValue({ path: { user_id, key }, body: { value } })` | Gives one user their own value for one flag. | | `putUserValues({ path: { user_id }, body: { values } })` | Many flags for one user (1000 at most). | | `batchUserValues({ body: { operations: [{ user_id, values }] } })` | Many users at once, 1000 operations at most: all of them, or none. | | `deleteUserValue({ path: { user_id, key } })` | Removes one; the answer is what resolves for them now. | | `deleteUserValues({ path: { user_id } })` | Removes all of theirs; their flags resolve as anyone's again. | Each value is checked against the flag's type and choices. The user's apps are told at once, on the user's own channel (`flags::`); everyone's are told of a definition change on `flags:`. The web SDK side of that is in [Web Flags](/docs/web/flags). ## Reading as a user ```ts // src/modules/checkout/preview.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class PreviewService { /** What an iOS 2.10 user would read — the conditions see this request's headers as the client's. */ async asIosUser(userId: string): Promise> { const { data } = await Palbase.flags.getUserValues({ path: { user_id: userId }, headers: { "X-Platform": "ios", "X-Palbase-Client-Version": "2.10.0" }, }); return data.values; } } ``` `getUserValues` resolves every flag for one user, as their client would read it: their own values and their percentiles, with the client context taken from the call's headers. `getValues` with the secret key resolves for nobody in particular — the defaults and the conditions that need no user. ## Publishing By default a definition changes the moment it is written. A project that wants changes reviewed turns **publishing** on, once and for good: the live flags become version 1, and from then on a definition changes only through a published draft — a direct `putDefinition` is refused with `409 publish_required`. | Step | Call | |---|---| | Turn publishing on | `activateTemplates({ body: {} })` | | Start the shared draft from the active version | `createTemplateDraft({ headers: { "If-Match": "flags-active-" }, body: {} })` | | Write or remove a parameter | `putTemplateParameter` / `removeTemplateParameter`, each with the draft's `If-Match` | | See what publishing would do | `reviewTemplateDraft()` — each change, how many users' values it touches, and the issues that block it | | Publish | `publishTemplate({ headers: { "If-Match": "", "Idempotency-Key": "" }, body: { note } })` | | Go back | `restoreTemplateVersion({ path: { version }, … })` starts a draft from an earlier version | Every step names the revision it read (`If-Match`), so two people editing the one shared draft never overwrite each other. A draft with issues is refused with `409 template_validation_failed` and the issues. The same `Idempotency-Key` answers the first publication again, and `getPublishReceipt` reads a publication's answer back for a client that lost it. ## Palbase-managed flags Flags under `palbase.` are the platform's: their type is fixed and they are not deleted. ## Which builds are in use Apps' reads are counted by installation: `getCurrentInstallations()` answers the builds that read their flags in the last day, by platform and app version, and `getInstallationsDaily({ query: { days } })` the curve, kept 90 days. ## The `Flags` service A backend reads flags with `Flags`, **for the request's user**: the signed-in caller a handler serves, or a gateway's member. Their own value comes first, then the flag's. `isEnabled` is a plain boolean — a flag that cannot be read is off, and why is logged — and the other reads answer `{ data, error, status }`. A context names another user (`{ userId }`), or nobody (`{ userId: null }`): the flag's own value. | Call | What it answers | |---|---| | `isEnabled(key, context?)` | Whether its value is truthy | | `get(key, default?, context?)` | Its value; the default stands in when it is absent or cannot be read | | `getVariant(key, context?)` | `{ name }` of a string value | | `getAll(context?)` | Every flag: `{ name, enabled, variant? }` | | `setOverride(key, value)` | The request's user's own value (400 without one) | | `$asService().setOverrideForUser(userId, key, value)` … | Any user's, by id: `setOverridesForUser`, `clearOverrideForUser`, `clearAllOverridesForUser`, `batchSetOverrides` | ```ts // palbase/palbase-env.d.ts declare module "palbase/backend/stack" { interface Flags { new_checkout: true; } } export {}; ``` ```ts // src/modules/checkout/checkout-flow.controller.ts import { Controller, Flags, Get } from "palbase/backend"; @Controller("/checkout") export class CheckoutFlowController { @Get("/flow") async flow(): Promise<{ flow: "new" | "classic" }> { return { flow: (await Flags.isEnabled("new_checkout")) ? "new" : "classic" }; } } ``` ## Related - [Web Flags](/docs/web/flags) — reading flags in the browser, and their live updates - [Realtime](/docs/backend/realtime) — the socket the updates travel on - [Server SDK Overview](/docs/server/overview) — calls, refusals and retries - [Test Users](/docs/backend/test-users) — users to give values to while you try a flag # Notifications Every project has notify: email, push, SMS and WhatsApp to its users, one-time codes by text, topics users follow, and each user's preferences about what they take. Your backend sends with the secret key — `Palbase.notify` in a `palbase/backend` app, `createServer(…).notify` anywhere else. Apps only register their own devices and set their own user's preferences and topics, with the publishable key and the user's token. ## Quick example ```ts // src/modules/orders/notify.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class OrderNotices { async shipped(userId: string, email: string, orderId: string): Promise { // Through the project's own email provider: a message composed whole. await Palbase.notify.sendEmail({ body: { to: email, user_id: userId, subject: `Order ${orderId} has shipped`, text: "It is on its way." }, }); // To every device of the user's, through the project's own push account. await Palbase.notify.sendPush({ body: { to: { user_ids: [userId] }, title: "Shipped", body: `Order ${orderId} is on its way`, deep_link: `myapp://orders/${orderId}` }, }); } } ``` A send is **queued**, not delivered: it answers `202` with the message's id, and the message ends `sent` or `failed` later — each emits `notification.sent` or `notification.failed` to the project's webhook ([Webhooks](/docs/server/webhooks)). ## Every channel but one needs a provider of yours | Channel | Goes through | |---|---| | Email | The platform's **shared sender**, within the project's limits — for the platform's templates only (the auth emails: verify, reset, magic link, codes …, as the project overrode them). Anything else — a message composed whole, a template of your own — goes through **your own email provider**: Azure Communication Services, Amazon SES, SendGrid or SMTP. | | Push | Your own FCM, APNs or Expo account — a browser's through its own push service, with the project's Web Push key (nothing to set up). | | Inbox, in-app messages | Palbase itself: nothing to set up. | | SMS | Your own SMS providers — Twilio, Vonage or Azure Communication Services — in failover order. | | One-time codes | Your own verification service (Twilio Verify), or your SMS providers. | | WhatsApp | Your own WhatsApp Business account (Meta). | An email the shared sender may not send is refused with `403 own_provider_required`; past the shared sender's limits a send is `429 send_limit_reached`. A project with an active email provider sends all its email through it. Providers are set in Studio (**Notifications › Providers**) or with `putNotifyProvider({ path: { channel, provider }, body: { credentials, priority, active } })`: the credentials are sealed with the project's own key, checked as the sender will use them, and never answered back. `getDeliverability()` says, per channel, whether it can send now, through what, why not and what to do. From the CLI, the credentials are piped in as JSON — never written on the command line, where the shell would keep them: ```sh palbase notify:providers:set email sendgrid < sendgrid.json # {"api_key": "…", "from_domain": "example.com"} palbase notify:providers:set sms twilio --priority 1 < twilio.json palbase notify:deliverability # can each channel send now, and through what palbase notify:send email ada@example.com --template welcome --var name=Ada palbase notify:messages --limit 20 # what was sent, and what became of it ``` **Apple (APNs).** The credential is a team-scoped APNs key — `{ "private_key": "-----BEGIN PRIVATE KEY-----…", "team_id": "…", "key_id": "…" }`, the `.p8` from Apple's Certificates, Identifiers & Profiles → Keys with Apple Push Notifications service on — and one key serves every app of the team, in both of Apple's environments. As it is saved, notify asks Apple about it, with a token no device holds, for each iOS app registered in the environment: a key Apple refuses (another team's, a revoked one, one not allowed for the app) is refused with `400 invalid_credentials`, saying Apple's answer; Apple not answering is `502 apns_unreachable`, and nothing is saved. ```sh palbase notify:providers:set push apns < apns.json # {"private_key": "…", "team_id": "ABCDE12345", "key_id": "3PSQ7G27A3"} ``` With an [Apple ID signed in](/docs/distribution/app-store#keys-made-for-you), Palbase makes the APNs keys for you instead: one for production and one for the sandbox, each scoped to the environment's iOS apps, and notify takes them as its provider — `key_id` signs production's pushes, `sandbox_key_id` (with `sandbox_private_key`) the sandbox's, and `made_by` is `"palbase"`. A key you set yourself is never replaced so. A push to an iOS device goes **to the bundle of the app it was registered from** — the app whose publishable key registered it ([Client Apps](/docs/apps/overview)) — and **through the APNs environment its token is for**, which the iOS SDK says: `sandbox` for an app run from Xcode or signed for development, `production` for TestFlight, the App Store and ad hoc. So one environment reaches the development build (`com.example.app.dev`, sandbox), its TestFlight build (`com.example.app.dev`, production) and the App Store app (`com.example.app`, production), each app registered in it with its own key. A device that did not say (an older SDK) is tried in production when the key says `is_production`, else sandbox, then — Apple calling the token bad — the other; a key made for one environment alone, asked in the other, is asked in its own. The environment that reached a device is kept. A device whose app is not known (registered before its app was) goes to the environment's one iOS app, or to the key's optional `bundle_id`. Each push's delivery says where it went: `route` — `production com.example.app`. **A provider that stops working** — an APNs key revoked, one not allowed for an app — fails every push alike. notify keeps it failing (`GET /notify/providers`: `failing_since`, `last_error`), Studio's **Notifications › Providers** shows it, and the organization's owners get an email — at most one a day a provider — as `notification.push_failing` (`{ project_id, channel, provider, error, failing_since }`) goes to your webhook. A push to the app it failed for that goes through, or the provider saved anew, ends it: a key that reaches one of your apps and not another stays failing for the other. Templates (`notify:templates:*`), topics (`notify:topics:*`) and the message settings (`notify:settings:*`) have their commands too; every one is in the [CLI reference](/docs/cli/reference#notify). ## Email ```ts // src/modules/accounts/welcome.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class WelcomeService { async send(userId: string, email: string, name: string, locale: string): Promise { const queued = await Palbase.notify.sendEmail({ body: { to: email, user_id: userId, // the user's preferences hold, and deleting them deletes the message template: "welcome", // a template of your own: your own provider variables: { name }, locale, category: "news", // only users who did not turn "news" off get it }, }); return queued.data.skipped ? undefined : queued.data.message_id; } } ``` | Field | What it says | |---|---| | `to` | One address. | | `template`, `variables` | A template and what fills it — or `subject` with `html` and/or `text` (own provider only). Without a text part, the html's text is sent as one. | | `locale`, `locales` | The language to render in; `locales` are more it would take, most wanted first: each, then its language alone, then the project's default and English. | | `category` | `transactional` (the default) reaches every user; any other — `marketing`, `news` — only users who did not turn it off on this channel. | | `user_id` | The user the message is for. | | `from`, `reply_to` | The send's own sender (own provider only). | | `priority` | `high` is tried again sooner when it could not leave. | | `metadata` | Your own data about the message (a JSON object, 4 KiB), kept with its report. | A send that the user's preferences turn off answers `{ skipped: true }`: nothing was queued or counted. ### Templates Templates are Handlebars — `{{name}}`, `{{#if}}`, `{{#each}}` …; in an HTML body `{{name}}` is escaped and `{{{name}}}` is not. `listEmailTemplates()` answers every template the project's email renders from — the platform's defaults and your overrides — with the variables each uses. `putEmailTemplate({ path: { slug, locale }, body: { subject, html_body, text_body } })` overrides one in a locale, and `deleteEmailTemplate` brings the default back. SMS templates work the same way (`putSmsTemplate`), and WhatsApp slugs map to templates approved on your Meta account (`putWhatsAppTemplate`). Studio's **Notifications › Templates** screen edits them. ## Push ```ts // src/modules/chat/push.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class ChatPush { async newMessage(to: string[], from: string): Promise { const queued = await Palbase.notify.sendPush({ body: { to: { user_ids: to }, // A text, or one per locale: each device gets the one in its language. title: { en: "New message", tr: "Yeni mesaj" }, body: { en: "{{from}} wrote to you", tr: "{{from}} size yazdı" }, variables: { from }, category: "chat", data: { kind: "message" }, }, }); console.log(`queued ${queued.data.message_ids.length}, skipped ${queued.data.skipped_user_ids.length}`); } } ``` A push is one message per user, delivered to **every device of theirs** — an Expo token through Expo, a VoIP token through APNs, an iOS token through APNs if the project has it (else FCM), Android through FCM, a browser through its own push service (Web Push, below). `to: { topic }` sends one message to a topic's followers instead. Fields beyond the title and body: `data` (4 KiB for the app), `deep_link`, `image` (https), `badge`, `sound`, `silent`, `content_available`, `mutable_content`, `collapse_key`, `ttl`, `priority`, and `push_type: "voip"` for a call ring to `ios_voip` devices. **Devices are the app's to register**, and the iOS and Android SDKs do it for you: `POST /notify/devices` with the token its push SDK gave it (`device_id`, `platform` — `android`, `ios`, `ios_voip` or `web` — `token`, the install's `installation_id`, and an iOS token's `apns_environment`: `sandbox` or `production`). With no one signed in, the publishable key alone registers it as the install's, proven with the install's secret (`X-Palbase-Installation-Secret`): a push that names the install — `to: { installation_ids }` — reaches it, so an app reaches people who never signed up. When someone signs in, their registration takes the device and it is theirs; signed out, the app registers it as the install's again. A user's device is the session's it was registered in: when the session ends — the user signed out or ended it from another device, its refresh token was revoked, an admin revoked it — the device is forgotten, and no push of theirs reaches it. A token another device held moves to this one. Your backend reads a user's devices (never their tokens) with `listUserDevices({ path: { user_id } })`. **An app taken off a phone** shows when its next push goes: APNs answers `410`, FCM `UNREGISTERED`. The device is forgotten, and `notification.uninstalled` (`{ platform, provider, device_id, installation_id, user_id }`) goes into the person's timeline and to your webhook — the uninstall you can count and reach back for by other means. **How a push went on each device** is the app's to say, and the SDKs say it: every push carries a sealed receipt (`pb_receipt` in its data), and the app reports with it, no sign-in needed (`POST /notify/receipts`): it reached the phone (`delivered` — on iOS from the app's Notification Service Extension, even with the app closed; on Android as the app draws the notification), it could not be shown (`not_shown`: the app's notifications or its channel are off), it was `opened`, `dismissed`, or a button tapped (`action`). A push no device reported on by the time it expired (its `ttl`, or the providers' four weeks) is `unconfirmed`: the phone may have been off, or its report lost — not "not delivered". Each stage reached for the first time is an event (`notification.delivered`, `notification.opened` …) in the person's timeline. On Android a push goes to FCM as data only (`pb_title`, `pb_body`, `pb_receipt` and your own keys), and the app's own messaging service draws it ([Android notifications](/docs/android/notifications)): a system-drawn notification runs no app code, and nothing of it could be told. ### Push templates A push written once — a title and a body per language (Handlebars, as a push's own), a picture and a link — and sent by name: `PUT /notify/templates/push/{slug}/{locale}` (Studio: Notifications → Templates → Push), then `template` in place of a title and a body. Each device gets its person's language; one the template does not have gets the project's default language, else the template's first. ```ts // src/modules/orders/shipped.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class OrderShipped { async tell(userId: string, order: string): Promise { await Palbase.notify.sendPush({ body: { to: { user_ids: [userId] }, template: "order.shipped", variables: { order }, category: "transactional" }, }); } } ``` A send that names a template names no title or body of its own (`400 invalid_push`), and one the project has not is refused (`404 template_not_found`). ## Web push A browser takes the project's pushes through **its own push service** (Chrome's, Firefox's, Safari's), with the standard Web Push and a VAPID key of the project's own — made as the first browser asks for it, sealed, nothing for you to set up or pay for. The web app subscribes with `pb.notify.push.subscribe()` (from a tap: browsers allow the prompt only then) and serves the SDK's service worker, `palbase/web/push-sw.js`, which shows each push, opens its link when it is tapped, and reports how it went — `delivered`, `opened`, `dismissed` — as the native SDKs do. A browser is a device like any other (`platform: "web"`): it is the signed-in user's, or the install's while no one is, and a push to the user reaches it with their phones. A subscription that ended — the person blocked the site, cleared it — is forgotten as it is next pushed, and `notification.uninstalled` goes into the timeline. `GET /notify/webpush` (Studio: Notifications → Providers) says how many browsers are subscribed. ## Inbox Each person has an inbox: the messages your backend puts there, and — when a push says so (`inbox: true`), or the project's `inbox.push_copy` setting does — a copy of a push as it is delivered (once, however many devices took it). It is **the same on every device of theirs**: read on one, it is read on each; deleted, it is gone from each. A message stays its days — the project's `inbox.days` (90 unless set), or `expires_in_days` (1 to 365) — and an inbox keeps its newest 250. When the user is deleted, their inbox goes with them. ```ts // src/modules/orders/inbox.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class OrderInbox { async receipt(userId: string, order: string): Promise { await Palbase.notify.sendInbox({ body: { to: { user_ids: [userId] }, title: { en: "Your receipt", tr: "Fişiniz" }, body: { en: "Order {{order}} is paid.", tr: "{{order}} numaralı sipariş ödendi." }, variables: { order }, deep_link: `myapp://orders/${order}`, expires_in_days: 30, }, }); } } ``` A message is a title and a body (a text, or one per language: each reader gets theirs), with a picture, a link and `data` — or a designed card (`card`, a message definition as the in-app messages' below, `presentation: { type: "card" }`), drawn by the SDKs' renderer. The apps read the signed-in person's inbox (`GET /notify/inbox`, how many are unread with it), mark messages read (`POST /notify/inbox/read`, some or all) and delete one (`DELETE /notify/inbox/{id}`); the iOS, Android and web SDKs draw it ready (`InboxView`, `pb.notify.inbox.mount`). Your backend reads anyone's with `listInboxMessages({ query: { user_id } })` (Studio: Notifications → Log → Inbox). ## In-app messages A message shown **in the app** — over it (a modal), across the whole screen, or as a banner at an edge — when something happens there. A campaign is what it shows, what shows it, whom it is for and how often: ```ts // src/modules/growth/cart-reminder.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class CartReminder { async create(audienceId: string): Promise { const { data } = await Palbase.notify.listInAppTemplates(); const welcome = data.templates.find((t) => t.key === "welcome"); if (!welcome) throw new Error("no welcome template"); const { data: campaign } = await Palbase.notify.createInAppCampaign({ body: { name: "Cart reminder", status: "active", definition: welcome.definition, // An event the app records — session_start as it opens — with properties it must have. trigger: { event: "cart_viewed", where: { plan: "pro" } }, // None: everyone. An audience: its people who allowed personalization. audience_id: audienceId, // Beside the project's caps: at most this often for this campaign. limits: { per_day: 1, max_total: 3 }, // The person's event within its hours of seeing it: counted as its conversion. conversion: { event: "purchase", hours: 24 }, }, }); return campaign.id; } } ``` - **What it shows** is a definition, the same kind as a paywall's: a theme and a free layout of components, in every language it has, drawn natively by the iOS, Android and web SDKs with their paywall renderer. Start from the module's designed messages (`listInAppTemplates`; Studio's editor shows each as the apps draw it). Its buttons go to a link, ask for push (the system's prompt, after the message's own ask; allowed, the device registers for pushes), show a paywall, or call the app's own code. - **What shows it** is an event of the app's: one analytics records (`capture`, screens, page views) or the app names (`inApp.trigger`). Triggers are matched **on the device**: no event leaves it for them, whatever the person chose of analytics. - **Whom it is for**: everyone, or an audience — and then only its people who **allowed personalization** (`personalization` consent). A message to everyone needs no consent to be shown. - **How often**: the project's caps hold across every campaign — one a session, three a day, ten minutes apart unless its `in_app` settings say otherwise — and a campaign adds its own (`per_session`, `per_day`, `min_minutes`, `max_total`). The server counts each showing on every device of the person and refuses one past a cap (`409 frequency_capped`): two phones do not show it twice. - **What it did**: each showing, button tapped and conversion is an event in the person's timeline (`notification.in_app_shown`, `notification.in_app_clicked`, `notification.in_app_converted`) and in the campaign's counts (`getInAppCampaignStats`: shown, people, clicked, dismissed, converted). A conversion is reported by the app while the person allows analytics. The apps fetch the person's campaigns as each session starts (`GET /notify/in-app/messages`) and ask before showing one (`POST /notify/in-app/impressions`); the SDKs do both: `.palbaseInAppMessages(pb)` on iOS, `PalbaseInAppMessages(pb) { … }` on Android, `pb.notify.inApp.start()` on the web. ## Topics ```ts // src/modules/teams/topics.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class TeamTopics { async create(teamId: string, members: string[]): Promise { await Palbase.notify.createTopic({ body: { name: `team.${teamId}`, self_subscribe: false } }); await Palbase.notify.addTopicSubscribers({ path: { name: `team.${teamId}` }, body: { user_ids: members } }); } async announce(teamId: string, text: string): Promise { await Palbase.notify.sendPush({ body: { to: { topic: `team.${teamId}` }, title: "Team", body: text } }); } } ``` A topic is **closed** unless `self_subscribe`: only your backend adds its followers, and a user asking is refused (`403 topic_closed`). An open topic is followed from the app — `POST /notify/topics/{name}/subscription` with the user's token — and any user may leave any topic. `changeTopic` opens or closes one, `removeTopicSubscriber` takes a user off, `deleteTopic` deletes it and who follows it. ## Preferences A user's preferences are channel → category → whether they take it. `transactional` is always on and not theirs to turn off; a category they never set is on. The app reads and sets its own user's (`GET` and `PUT /notify/preferences`); your backend reads and sets anyone's with `getUserPreferences` and `setUserPreferences({ path: { user_id }, body: { email: { marketing: false } } })`. Every send with a `user_id` and a `category` honours them. ## When a message goes A send may say when it goes, with `schedule`: `at`, a moment for everyone, or `local_time`, an hour on each recipient's own clock — a date and time with no zone. A recipient's clock is their newest device's time zone, which the iOS and Android SDKs register with the push token (`timezone` on `POST /notify/devices`); for one whose devices say none, it is the project's `default_timezone`. A local time already past on someone's clock goes to them at once. ```json { "to": { "user_ids": ["usr_01a1…", "usr_01a2…"] }, "title": { "en": "Good morning", "tr": "Günaydın" }, "category": "marketing", "schedule": { "local_time": "2026-10-15T10:00" } } ``` That push goes at 10:00 in New York to a user whose phone is in New York, and at 10:00 in Istanbul to one in Istanbul. Until then each message is `scheduled`, with `send_at` saying when. A message that is not transactional also keeps to the project's **send rules**, set with `putNotifySettings` or in Studio (**Notifications › Settings › When marketing goes**). A settings write that names none of the three keeps them as they are, so a client written before them does not wipe them; one named `null` is taken off: | Setting | What it does | |---|---| | `default_timezone` | The clock of a recipient whose devices say none, an IANA name (`Europe/Istanbul`); UTC when unset. | | `quiet_hours` | `{ start, end }`, `HH:MM` on the recipient's clock (`22:00` to `08:00` spans midnight; on the night the clocks change they still end at `08:00`). A message that would arrive in them is `held` until they end, then sent. | | `marketing_cap` | `{ count, hours }`: how many such messages one recipient gets at most in a window. One more is `skipped`, with `skip_reason: "frequency_cap"`. | Transactional messages — codes, receipts, security notices — go at once, whatever the hour and however many. When a waiting message's time comes it is decided again: what the user chose meanwhile holds (a category turned off skips it, `preferences`), and it waits out quiet hours and keeps to the cap like a new send. | Status | What it means | |---|---| | `scheduled` | Waiting for its time (`send_at`). | | `held` | Waiting out its recipient's quiet hours (`send_at`: when they end). | | `skipped` | Not sent, and never will be. `skip_reason` says why: `consent` (they refused this channel's marketing), `frequency_cap`, `preferences`, `iys` (İYS holds no consent for it). | | `canceled` | Taken back while it waited. | `POST /notify/messages/{id}/cancel` takes back a `scheduled` or `held` message; one already on its way answers `409 message_not_waiting`. Studio's **Notifications › Log** shows each waiting message with when it goes, and takes it back. An email, text or WhatsApp message a rule skips answers `{ message_id, skipped: true, skip_reason }` — its message is in the log; one the user's preferences turned off answers `{ skipped: true }` alone, and nothing was queued. A skipped text does not count against its number's one text a minute and ten a day. ## To an audience `to: { audience: "" }` sends a push to each member of an analytics audience once ([Analytics › Audiences](/docs/web/analytics#audiences)): a member who signed in, as their user; one who never did, on each install of theirs. Each member's push keeps to their own clock, quiet hours, the cap and what they allowed, like a send to them alone. The audience's members are taken as the send is worked through, so a push to a large audience goes out over a little while rather than in one burst. A push to an audience or a topic with `schedule.at` waits whole until that moment and is spread then; with `schedule.local_time` it is spread at once, each member's push waiting for their own hour. Either way, taking back the push (`POST /notify/messages/{id}/cancel`, its own id) takes it back whole: its spreading stops, and every member's push still waiting goes with it. ## Consent, İYS and texts that say stop What a person allowed in the app ([consent](/docs/web/analytics#consent)) holds for messages too: a send that is not transactional to someone who refused this channel's marketing — `marketing_push`, `marketing_email`, `marketing_sms`, `marketing_whatsapp` — is skipped (`consent`). **A text that says stop.** Point your Twilio number's "A message comes in" webhook at `https://./notify/webhooks/sms/twilio/inbound`. A text that is `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END` or `QUIT` alone, or `DUR`, `İPTAL`, `IPTAL` or `RET` alone or with one word after it (your brand: `RET ACME`), in any case, is the refusal — "Cancel my order" is a question, not a refusal — of marketing texts of the user whose verified phone number sent it: recorded as their choice (`marketing_sms`, source `sms_stop`), it reaches their apps and your backend as any choice does, and their next marketing text is skipped. Twilio signs each call with your auth token and an unsigned one is refused; nothing is replied to the sender. **İYS.** A brand that sends commercial messages in Turkey must hold each recipient's consent in İYS (İleti Yönetim Sistemi). Give notify your brand's own İYS API user — `putNotifyProvider({ path: { channel: "iys", provider: "iys" }, body: { credentials, priority: 0, active: true } })`, with `credentials` `{ username, password, iys_code, brand_code, recipient_type }` (`recipient_type` is `BIREYSEL` unless your recipients are traders, `TACIR`) — and then: - a text to a Turkish number (`+90…`) or an email that is not transactional goes only where İYS holds an `ONAY`; one it does not is skipped (`iys`), and so is one İYS refuses to answer about as it stands. İYS out of reach, the message waits and is tried again. A number abroad is not İYS's, and is not asked about. - each choice a user makes of `marketing_sms` or `marketing_email` from then on — in the app, by a stop text, through your server — is written to İYS as `ONAY` or `RET`, with where it was made (`HS_MOBIL` in the app, `HS_MESAJ` by a text, `HS_EPOSTA` by an email's link, `HS_CAGRI_MERKEZI` through your support), for their verified Turkish number (`+90…`) or their verified address. The choice holds for your messages at once; İYS is told by a job of your project's, tried again for about three days while İYS is out of reach. Palbase is not an İYS integrator: the account is yours, and İYS's records are your brand's. ## SMS, codes and WhatsApp | Call | What it does | |---|---| | `sendSms({ body: { to, body } })` | A text to one number in E.164 form (`+14155551212`): a body, or a `template` with `variables`. A number gets one text a minute and ten a day. It answers the segments and encoding the text takes. | | `startVerification({ body: { to } })` | Has your verification service generate a one-time code and text it. | | `checkVerification({ body: { to, code } })` | `{ approved }`: whether the code is the one it sent. | | `sendWhatsApp({ body: { to, template_slug, variables } })` | A WhatsApp message: a text inside a conversation the user opened in the last 24 hours, a template approved on your Meta account, or one of your slugs. | The project's message settings say where texts may go: `putNotifySettings` sets the default locale, the sender of email through your own provider, the countries texts may (`sms_countries`) and may not (`sms_blocked_countries`) reach, and the send rules ([When a message goes](#when-a-message-goes)). ## Reports `listNotifyMessages({ query })` answers the project's messages, newest first — their delivery reports, never what they said — filtered by channel, status, user, category or a window. `getNotifyMessage({ path: { id } })` reads one: its status, attempts, provider, and what the provider reported after it left (delivered, bounced, opened …) when its credential lets it report — and, a push, how it went on each device and when it got to each stage. `getMetricsSummary()` counts them per channel, status and day. Studio's **Notifications** screens show the same reports, devices and metrics. ## The `Notifications` service A backend sends with `Notifications`, and each send answers `{ data, error, status }`: - `Notifications.email.send({ to, subject?, html?, text?, templateSlug?, locale?, variables?, from?, reply_to?, category? })` — `to` one address, or several: one message each, in order, the first refusal ending them. - `Notifications.push.send({ to, title?, body?, data?, deep_link?, category?, priority?, … })` — `to` a user's id, several, or `{ topic }`. ```ts // src/modules/invites/invite-mail.service.ts import { Injectable, Log, Notifications } from "palbase/backend"; @Injectable() export class InviteMail { async send(to: string, link: string): Promise { const { error } = await Notifications.email.send({ to, subject: "You're invited", html: `Join`, category: "invite" }); if (error) Log.warn("the invitation was not sent", { code: error.code }); return error === null; } } ``` ## Journeys A journey sends a person messages over time after something they did — a welcome over their first week, a win-back after they stopped buying — with waits, branches and A/B splits between them: [Journeys](/docs/backend/journeys). ## Related - [Webhooks](/docs/server/webhooks) — `notification.sent`, `notification.failed`, `notification.received`, `notification.uninstalled`, `notification.push_failing` - [Analytics](/docs/web/analytics#audiences) — the audiences a push can go to, and each person's consent - [Server SDK Overview](/docs/server/overview) — calls, refusals, retries and `Idempotency-Key` - [Secrets](/docs/backend/secrets) — the values your own code reads (a provider's credentials are kept by notify, sealed) - [Test Users](/docs/backend/test-users) — users to send to while you try a flow # Journeys A journey takes each person who does something — signs up, stops opening the app, buys, joins an audience — through the steps you set: messages, waits, branches, A/B splits, coupons and grants. It is built in Studio's **Journeys** or kept as a JSON file and handled with the CLI, tried on a test user, published, and then reported on: who entered, what each step came to, how many reached its goal. Journeys are the project's server's: their API takes the secret key (or a member's session in Studio), never the publishable key. ## Quick example A journey that welcomes a new user, checks a day later whether they finished setting up, and A/B tests the reminder for those who did not: ```json { "name": "Onboarding", "entry": { "event": { "name": "sign_up" } }, "goal": { "event": { "name": "onboarding_completed" }, "within": "P7D" }, "start": "welcome", "steps": { "welcome": { "inbox": { "title": { "en": "Welcome aboard" }, "body": { "en": "Here is how to get the most out of the app." } }, "next": "wait_1d" }, "wait_1d": { "wait": { "for": "P1D" }, "next": "finished" }, "finished": { "branch": { "if": { "event": { "name": "onboarding_completed" } }, "then": "end", "else": "tip" } }, "tip": { "split": [{ "name": "push", "weight": 50, "next": "tip_push" }, { "name": "inbox", "weight": 50, "next": "tip_inbox" }] }, "tip_push": { "push": { "title": { "en": "One step left" }, "body": { "en": "Finish setting up in under a minute." } }, "next": "end" }, "tip_inbox": { "inbox": { "title": { "en": "One step left" }, "body": { "en": "It is all in one place." } }, "next": "end" } } } ``` ```bash palbase journeys:validate onboarding.json # every problem, at its path, with what to do about it palbase journeys:push onboarding.json --publish # save it as a draft (key: the file's name) and publish it palbase journeys:stats onboarding # who entered, each step, the goal's conversions, each A/B way ``` ## A journey A definition has who enters (`entry`), the steps by id with the first one (`start`), what the journey is for (`goal`), what takes a person out early (`exit`) and whether a person enters again (`reentry`). Each step does one thing and names the step after it in `next`; `end`, or no `next`, ends the person's run. A person is in a journey once at a time. | Who enters (`entry`, one of) | | |---|---| | `event: { name, where }` | A person who does the event — an app's own (`sign_up`), or a module's: `purchases.purchase.completed`, `purchases.coupon.redeemed`, `audience.entered` — with the properties `where` names, each equal. | | `audience: ""` | A person who comes to be in an analytics audience. | | `inactive: { event, for }` | A person who has not done the event for a while (1 hour to 90 days): the moment it passes since their last one. `$session_start` is someone who has not opened the app. | | `members_of: ""` | An audience's members, once, as the journey is published. | `goal: { event, within }` — a run that sees the event within `within` of entering (30 days unless it says, 90 at most) ends **converted**; a run that completed before it is counted converted too. `exit: { events, audience_left }` takes a person out when they do one of the events or leave the audience. `reentry.mode` is `once` (the default: never again), `always` (each time, once their run ended) or `after` (once `after` has passed since they last entered). Whatever it says, a person enters one journey 20 times a day at most, so a journey whose own steps cause its entry event does not loop. Durations are ISO 8601: `PT30M`, `PT2H`, `P2D`, `P1W`. ## Steps | Step | What it does | |---|---| | `push` | A push to the person's devices: `title` and `body` by locale (`en`, `tr`, `pt-BR`), or one of the project's push `template`s; `deep_link`, `image`, `data`, `category`; `inbox: true` keeps a copy in their inbox. | | `inbox` | A message in the person's inbox: `title` and `body` by locale, `deep_link`, `image`, `expires_in_days`. | | `email` | An email from one of the project's email templates, with its `variables`. | | `in_app` | One of the project's in-app campaigns made for journeys, shown the next time it is triggered in their app (waiting up to `days`, 7 unless it says). | | `coupon` | Gives the person their code of one of the project's coupons; `{{coupon}}` in the messages after it is that code. | | `offer` | Gives the person one of the catalog's grant offers: an entitlement for a while. | | `set` | Writes properties of the person's (analytics' people): 1 to 20. | | `event` | Tells your backend: a `journey.event` with the step's `name` and `data` — a webhook, or an `@On`. | | `wait` | Holds the person: `for` a while (1 minute to 90 days), `until` a time of day where they are (`"09:00"`, in their `$timezone`), or until they do an `event`, at most `timeout` — then `next`, or `timeout_next` when it timed out. | | `branch` | Sends the person to `then` when `if` holds of them, else to `else`. A condition asks a `property` of theirs (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `exists`, `missing`), whether they are in an `audience`, whether they did an `event` (since entering, or `within` a while), or `all`, `any` or `not` of others. | | `split` | An A/B split: 2 to 5 ways whose `weight`s add up to 100. Each person takes one way by chance — the same one every time — and the report counts each way's conversions. | A message step is sent by the project's send rules ([Notifications › When a message goes](/docs/backend/notifications#when-a-message-goes)): quiet hours, the marketing cap and what the person allowed. A message they did not allow is skipped and the run goes on. A journey has 100 steps at most, every one reachable from `start`, and no way back to a step already taken but through a wait. ## From your backend A step your backend sees — a document approved, a first upload — moves a journey when your server tells it as the user's event: `POST /analytics/capture` with the secret key and `user_id`. It enters them into a journey of that event, takes them past a wait for it, counts toward a goal, and answers a branch that asks whether they did it, as an event their app sent would. ```bash curl -s -X POST "https://.palbase.studio/analytics/capture" \ -H "apikey: $PALBASE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"event": "crew_verified", "user_id": "usr_123", "properties": {"airline": "TK"}}' ``` ## In Studio **Journeys** lists the environment's journeys with their status and who enters. **New journey** starts one from a template — *Onboarding*, *Win back* (a customer who has not bought for 30 days) or *Didn't come back* (a user who has not opened the app for a week) — or from a blank page. A journey's page draws it top to bottom: who enters at the top, each step under the one before, a branch's or a split's ways side by side until they meet, the goal at the bottom. A step opens its form in the side panel; the **+** between two steps adds one there. The module checks the draft a moment after each change, and each problem shows under the field it is about (`steps.welcome.inbox.title.en` under the welcome step's English title) and in the problems list, which takes you to it. **JSON** shows the definition the CLI pushes; a change there is the flow. **Preview** walks a made-up person through what is on screen — their properties, audiences, time zone and what they do after entering — and shows each step they would take and when; nothing is sent. ## Trying a journey on a test user `POST /journeys/{key}/test` with `{ "user_id": "…" }` runs the saved journey — a draft too — for one of the project's [test users](/docs/backend/test-users) from its first step. Its messages really go to that user, each wait is a minute long, and the try is counted in no report and no month's people; its `journey.*` events carry `test: true`. Studio's **Try on a test user** does the same, and follows the run beside what landed in the person's inbox. A user who is not a test user is refused (`422 not_a_test_user`), and so is a stopped journey (`409 journey_stopped`). A new try of the same journey ends that user's try still running. ## Publishing, stopping and copying `POST /journeys/{key}/publish` makes a draft live: people enter it from then on (a `members_of` journey enrolls the audience's members now). A live journey does not change (`409 journey_published`): copy it into a new draft (`POST /journeys/{key}/copy` with `{ "key": "…", "name": "…" }`), publish that, and stop the old one. `POST /journeys/{key}/stop` lets no one in any more and ends the runs in it; its report stays. `DELETE /journeys/{key}` deletes a journey with its runs and its report. A project keeps 500 journeys and runs 50 at once. An environment without its paid rights — on Free, out of credit, or at its monthly limit — runs 2 at once (`402 live_journeys_limit`); on Pay as you go, the people who enter its journeys are billed by the month ([Billing](/docs/getting-started/billing)): the first 1,000 people a month are free, then $2 for each 1,000 more. ## The report `GET /journeys/{key}/stats` answers who entered (`entered`), how many reached the goal (`converted`), who is in it now (`in_progress`: `active`, `waiting`, `held`), how the runs ended (`ended`: `completed`, `converted`, `exited`, `stopped`, `failed`, `erased`), each step's outcomes from the start (`steps[].outcomes`: `sent`, `skipped`, `waited`, `then`, `else`, a split's way …) and, for a split, each way's conversions (`steps[].converted`). `GET /journeys/{key}/runs` pages each person's run — its state, the step it is at, each step it took and what came of it — filtered by `user_id` or `state`; `GET /journeys/people/{user_id}` is one person's runs in every journey. Studio's **Report** shows the same. People who enter while the organization's monthly people are used up are held (`held`) until it has them again. ## Events Each run tells the project's backend as it goes — a [webhook](/docs/server/webhooks#the-events), or an `@On`: | Type | Sent when | |---|---| | `journey.entered` | A person entered a journey. | | `journey.step` | A person's run took a step. | | `journey.event` | A journey's `event` step told the project's backend. | | `journey.converted` | A person's run reached the journey's goal. | | `journey.ended` | A person's run ended: completed, converted, exited, stopped. | ## The API | Call | What it does | |---|---| | `GET /journeys` | The project's journeys, newest first: `status`, `limit`, `cursor`. | | `POST /journeys` | A new draft: `{ key, definition }`. | | `GET /journeys/{key}` | A journey whole, with its definition. | | `PUT /journeys/{key}` | A draft's definition, written whole: `{ definition }`. | | `DELETE /journeys/{key}` | Deletes it with its runs and its report. | | `POST /journeys/validate` | Every problem of a definition, each at its path: `{ definition }` → `{ valid, problems }`. Nothing is kept. | | `POST /journeys/simulate` | A made-up person's way through a journey — the project's (`key`) or one not saved (`definition`) — with each step and when. Nothing is sent. | | `POST /journeys/{key}/test` | Tries the saved journey on a test user: `{ user_id }`. | | `POST /journeys/{key}/publish` | Makes a draft live. | | `POST /journeys/{key}/stop` | Stops a live journey. | | `POST /journeys/{key}/copy` | Copies a journey into a new draft. | | `GET /journeys/{key}/stats` | Its report. | | `GET /journeys/{key}/runs` | Each person's run. | | `GET /journeys/people/{user_id}` | One person's runs in every journey. | ```bash curl -s -X POST "https://.palbase.studio/journeys/onboarding/test" \ -H "apikey: $PALBASE_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"user_id": "usr_test_1"}' ``` ## From the CLI A journey is kept as a file and handled with the CLI: saved as a draft, published, then followed. ```bash palbase journeys:validate winback.json # every problem, at its path, with what to do about it palbase journeys:simulate winback.json --event purchases.purchase.completed@P1D # walk a made-up person through it; nothing is sent palbase journeys:push winback.json --publish # save it as a draft (key: the file's name) and publish it palbase journeys # the environment's journeys: key, name, status, way in palbase journeys:stats winback # who entered, how their runs ended, the goal's conversions palbase journeys:runs winback # each person's run: its state, the step it is at palbase journeys:stop winback # no one enters it any more, the runs in it end ``` `journeys:get` prints a journey's definition to edit and push again, `journeys:copy` copies one into a new draft, `journeys:publish` publishes a draft, and `journeys:delete` deletes one with its runs and its report. Every command is in the [CLI reference](/docs/cli/reference#journeys). ## Related - [Notifications](/docs/backend/notifications) — the send rules a journey's messages keep to, push templates and inboxes - [Analytics › Audiences](/docs/web/analytics#audiences) — the audiences a journey enters people from, and asks about - [Test Users](/docs/backend/test-users) — the users a journey is tried on - [Webhooks](/docs/server/webhooks) — `journey.entered`, `journey.step`, `journey.event`, `journey.converted`, `journey.ended` # Realtime Every project has realtime: **channels** its clients join over one socket, a **broadcast** on a channel that reaches every subscriber, and a **shared state** per channel — keyed entries every subscriber with state access reads, a snapshot when it joins and the changes after. Clients join and listen ([Web Realtime](/docs/web/realtime)). Your backend publishes as the project's server, with the secret key: `Palbase.realtime` in a `palbase/backend` app, `createServer(…).realtime` anywhere else. Who may join which channel is the project's [channel declaration](/docs/backend/channels). ## Quick example ```ts // src/modules/orders/live.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class OrderLive { /** Tells the order's owner, on their own channel, that its status changed. */ async statusChanged(userId: string, orderId: string, status: string): Promise { await Palbase.realtime.broadcast({ body: { messages: [{ topic: `orders:${userId}`, event: "status", payload: { orderId, status } }] }, }); } } ``` With `orders:{uid}` declared as an `owner` channel ([Channels](/docs/backend/channels)), the signed-in user — and only they — joins `orders:` and hears it as a `broadcast`: ```ts // watch.ts import { createClient } from "palbase/web"; const pb = createClient({ url: "https://.palbase.studio", publishableKey: "pb__c…" }); export async function watch(userId: string, onStatus: (change: unknown) => void): Promise<() => void> { const channel = pb.socket.channel(`orders:${userId}`); const off = channel.on("broadcast", (frame) => { if (frame.event === "status") onStatus(frame.payload); }); await channel.join(); // refused unless the declaration lets this user join return () => { off(); channel.leave(); }; } ``` ## Broadcast `POST /realtime/broadcast` publishes 1 to 100 events in one call, each `{ topic, event, payload }`: | Field | What it is | |---|---| | `topic` | The channel's name, such as `orders:usr_123` — without the `realtime:` prefix the socket carries. | | `event` | The event's name, which the client reads as the frame's `event`. | | `payload` | Any JSON. | It answers `202` with how many were published. A broadcast reaches every subscriber on every replica of realtime, but the one that sent it — and the server is no subscriber, so a server broadcast reaches them all. **A broadcast is delivered at most once.** A client that was not on the channel at that moment — offline, reconnecting, not joined yet — does not get it later, and nothing is kept. That makes a broadcast a *nudge*: "order 42 changed, look again". What a client must not miss belongs in your database, or in the channel's state. ## Revalidate `POST /realtime/revalidate` asks every replica to decide again, now, who may be on channels whose decision may no longer hold, and drops the members who may not. It names one channel (`topic`), or every channel of a pattern whose named parameters have the values given (`pattern` and `params`), and optionally one user's memberships alone (`user`). With a `topic` and no `user`, `end: true` ends the channel instead: every member is dropped without being asked, and what a durable channel kept is forgotten. It answers `202`. A dropped member's client is told the channel closed, and its rejoin is decided on the merits. Without it, a decision holds until the user's token runs out, fifteen minutes at most. ## Shared state Each channel has a keyed state that outlives any one connection. Your backend writes it with `POST /realtime/state`, 1 to 100 operations a call: ```ts // src/modules/boards/board-state.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class BoardState { async setCard(boardId: string, cardId: string, card: { title: string; column: string }): Promise { await Palbase.realtime.writeState({ body: { ops: [{ topic: `board:${boardId}`, key: cardId, value: card }] } }); } async removeCard(boardId: string, cardId: string): Promise { await Palbase.realtime.writeState({ body: { ops: [{ topic: `board:${boardId}`, key: cardId, delete: true }] } }); } } ``` - **Durable.** Entries the server writes stay until they are removed. A subscriber that joins gets the whole state (a `state_snapshot` frame), then what changed as it changes (`state_diff`); a client that lost the thread asks for it all again (`state_resync`). - **Small.** A value is any JSON, 16 KiB at most, and each channel has a budget: a write past it is refused with `422`, and the operations before it in the call stay applied. - **Read by subscribers with state access.** Who has it is the declaration's `state_read` (and who may write from the client, `state_write`) — see [Channels](/docs/backend/channels). Clients may also write **ephemeral** entries — their own presence — which are gone when they leave. ## Limits `getLimits()` reads the limits the project holds its clients to, and the platform's above them; `putLimits({ body })` sets them: connections open at once (`max_connections`), broadcasts and state writes per second (`max_events_per_second`), joins per second (`max_joins_per_second`), and channels on one connection (`max_channels_per_connection`). `null` is the platform's own, and a value above the platform's has no effect beyond it. Studio's **Realtime** screen shows and sets the same limits, and can send a test message. ## The socket, from the client's side The socket is `GET /realtime/v1/websocket` at the project's address — Phoenix Channels v2 frames, one socket per client for every channel it is on. The publishable key opens it; a client says who it is with the access token each join carries, so the user's rights on a channel are decided per join and again when the declaration changes. The web SDK speaks it for you (`pb.socket`): it opens on the first join, sends a heartbeat every 25 s, reconnects with growing pauses, and joins its channels again after a drop. Your backend never subscribes: `palbase/server` has no socket. What a client sends that the server must act on is a call to one of your backend's routes. ## Channel names A channel is `:`-separated segments: `orders:`, `board:7f3a`, `game:42:chat`, 255 bytes at most. The namespaces `roles:`, `flags:`, `messaging:` and `debug:` are the platform's — flags' live updates travel on `flags:` and `flags::`, a user's permission set on `roles::`. A channel the declaration does not match belongs to no one, and no client may join it. ## The `Realtime` service A backend broadcasts with `Realtime`: `Realtime.broadcast(channel, event, payload?)`, and a channel's state with `Realtime.state.set(channel, key, value)` and `Realtime.state.clear(channel, key)`. Each answers `{ data, error, status }`: the platform taking it, not every subscriber receiving it; it never fails the handler. ```ts // src/modules/projects/project-signal.service.ts import { Injectable, Realtime } from "palbase/backend"; @Injectable() export class ProjectSignal { async changed(projectId: string, what: string): Promise { await Realtime.broadcast(`project:${projectId}`, what, {}); } } ``` ## Related - [Gateways](/docs/backend/gateways) — a `palbase/backend` app's own realtime channels - [Channels](/docs/backend/channels) — who may join, publish and write state - [Web Realtime](/docs/web/realtime) — joining and listening in the browser - [Server SDK Overview](/docs/server/overview) — calls, refusals and retries - [Flags](/docs/backend/flags) — the live updates that ride the same socket # Gateways A gateway is a realtime channel of your app: a class marked `@Gateway(pattern, options)` and listed in a module's `providers`. Its pattern names the channels it is: `room:{roomId}` is the gateway of `room:42`, `room:43`, …, each a channel of its own. The class says who may join, the events it sends, the messages its members may send and the ack each one is answered with, and it is told when a channel fills and when it empties. A gateway is the one way an app talks realtime to your backend. `palbase apps:sync` writes each app a typed client of the backend's gateways ([Web Gateways](/docs/web/gateways)): the app joins `RoomGateway` with `{ roomId }`, and writes no channel name, event name or payload type of its own. ## Quick example The rooms, and who is in each: ```ts // src/modules/rooms/rooms.service.ts import { Injectable } from "palbase/backend"; export interface RoomMessage { id: string; from: string; text: string; } // Kept in memory to keep the example small; a real app keeps its rooms in its database. @Injectable() export class RoomsService { private readonly members = new Map>(); add(roomId: string, userId: string): void { this.members.set(roomId, (this.members.get(roomId) ?? new Set()).add(userId)); } isMember(roomId: string, userId: string): boolean { return this.members.get(roomId)?.has(userId) === true; } post(from: string, text: string): RoomMessage { return { id: crypto.randomUUID(), from, text }; } } ``` The gateway: ```ts // src/modules/rooms/room.gateway.ts import { Authorize, Gateway, GatewayRefusal, OnConnect, Subscribe, z } from "palbase/backend"; import type { GatewayAuthorizeContext, GatewayContext, GatewayGrant, GatewayMessageContext } from "palbase/backend"; import { RoomsService } from "./rooms.service"; export const Say = z.object({ text: z.string().min(1).max(2000) }); export const Said = z.object({ id: z.string() }); export const Message = z.object({ id: z.string(), from: z.string(), text: z.string() }); export const Welcome = z.object({ members: z.number() }); // room:, one channel per room. Its members hear `message`; they send `say`. @Gateway("room:{roomId}") export class RoomGateway { static readonly events = { message: Message, welcome: Welcome }; constructor(private readonly rooms: RoomsService) {} // Who may join: the room's members, and no one else. @Authorize() enter(ctx: GatewayAuthorizeContext): GatewayGrant { return this.rooms.isMember(ctx.params.roomId, ctx.user.id); } // Said to the member who joined, alone. @OnConnect() joined(ctx: GatewayContext): void { ctx.emit("welcome", { members: ctx.count }); } // A member's message, checked against Say before this runs. What it returns is the member's ack. @Subscribe("say", Say, { reply: Said }) say(ctx: GatewayMessageContext>): z.infer { if (ctx.payload.text.startsWith("/")) throw new GatewayRefusal("no_commands"); const message = this.rooms.post(ctx.user.id, ctx.payload.text); ctx.emit("message", message); return { id: message.id }; } } ``` A route that makes the user a member, and one that posts to a room from outside it: ```ts // src/modules/rooms/rooms.controller.ts import { Body, Controller, Forbidden, Palbase, Param, Post, User, type UserT } from "palbase/backend"; import { RoomGateway, Say } from "./room.gateway"; import { RoomsService } from "./rooms.service"; @Controller("/rooms") export class RoomsController { constructor(private readonly rooms: RoomsService) {} // From now on the gateway lets this user into room:. @Post("/{roomId}/members") join(@Param("roomId") roomId: string, @User() user: UserT): void { this.rooms.add(roomId, user.id); } // A message posted over HTTP reaches the room's members as one said in it. @Post("/{roomId}/messages") post(@Param("roomId") roomId: string, @Body(Say) body: { text: string }, @User() user: UserT): { id: string } { if (!this.rooms.isMember(roomId, user.id)) throw new Forbidden("not a member of this room"); const message = this.rooms.post(user.id, body.text); Palbase.gateways.to(RoomGateway, { roomId }).emit("message", message); return { id: message.id }; } } ``` ```ts // src/modules/rooms/rooms.module.ts import { Module } from "palbase/backend"; import { RoomGateway } from "./room.gateway"; import { RoomsController } from "./rooms.controller"; import { RoomsService } from "./rooms.service"; @Module({ controllers: [RoomsController], providers: [RoomsService, RoomGateway] }) export class RoomsModule {} ``` A gateway is a provider like any other: its module lists it in `providers`, it takes its dependencies by constructor, and it is built once, so what one method starts another can stop. A `@Gateway` that no module lists stops the boot by name, and so does a class that is both a controller and a gateway. ## Options | Option | What it is | Default | |---|---|---| | `access` | Who may join, with no method of yours: `"owner"`, the signed-in user whose id the pattern's one `{param}` is; `"public"`, any signed-in user. Left out, an `@Authorize` method decides. | none | | `durable` | Keeps what it sends: `true` for a day, or `{ keepSeconds }` from 1 to 604800 (a week). See [Durable gateways](#durable-gateways). | not kept | | `graceMs` | How long the channel is held after its last member left before it is empty (`@OnEmpty`), in milliseconds. | 60000 | | `publish` | Its members may broadcast to one another on the channel. A gateway with no method only: one with methods takes its members' messages itself (`@Subscribe`). | `false` | | `chat` | It carries a chat of the project's agents: its event `chat` is the platform's, and every generated client holds the chat's state. Kept a day unless `durable` says how long. See [AI](/docs/backend/ai#a-chat-in-an-app). | `false` | Who may join is said once: `access` or an `@Authorize` method, never both and never neither. The boot refuses either mistake by name. ## Patterns A pattern is `:`-separated segments, as a channel's name is. A segment written `{name}` takes exactly one non-empty segment of the channel, and the method is given it in `ctx.params`. Any other segment matches itself. | Pattern | Its channels | Not its channels | |---|---|---| | `lobby` | `lobby` | `lobby:2` | | `room:{roomId}` | `room:42` | `room`, `room:42:chat` | | `game:{gameId}:team:{team}` | `game:7:team:red` | `game:7:team` | A pattern is 255 bytes at most, has no `/` and no control character, and names each `{param}` once. The namespaces `roles:`, `flags:`, `messaging:` and `debug:` are the platform's. A pattern that breaks one of these rules is refused when its class is declared. When two of an app's patterns match one channel, the more specific one decides it: a named segment beats a `{param}`, from the left, so `lobby:staff` decides `lobby:staff` and `lobby:{id}` every other lobby. Two gateways of the same channels (`room:{roomId}` and `room:{id}`) are refused at boot. Two of the project's machines that declare the same channels have neither: the channel is refused until one of them gives it up. The class's name is the gateway's name in every generated client (`RoomGateway`), so it is an identifier and one of a kind in the app. ## Who may join Only a signed-in user joins a gateway. The access token the client's socket carries is who they are. With `access: "owner"`, the pattern's one `{param}` must be the joining user's id: `inbox:{userId}` is joined by that user alone. With `access: "public"`, any signed-in user of the project joins. Either way no code of yours decides it. Otherwise its `@Authorize` method decides, at each join. It is given the user and the channel's parameters, and it runs as that user, in a transaction of its own: `Database` is theirs, and [row-level security](/docs/backend/row-level-security) holds. It answers a grant: | Answer | Means | |---|---| | `true` | In, to hear and to send. | | `{ write: false }` | In, to hear. A message they send is refused (`forbidden`). | | `{ read: false }`, `false`, `null`, `undefined` | Not in. | Anything else lets no one in: `{}`, a row, a number, or `{ read: member?.canRead }` when there is no member (`undefined` is no answer). So does a method that throws. Each of these is in the app's log, without the value it answered. A refused join reaches the client as `unauthorized`; a join the app could not be asked about, as `unavailable`, which is worth trying again. A grant holds until the user's token runs out, fifteen minutes at most — a minute for a gateway declared with `chat: true` — and then `@Authorize` is asked again. When a decision stops being true before that (a member taken off a team, a document unshared), say so: `POST /realtime/revalidate` with the secret key, naming the channel (`{ "topic": "board:42" }`) or a pattern's channels by some of their parameters (`{ "pattern": "board:{boardId}:user:{userId}", "params": { "userId": "usr_…" } }`), optionally one user's memberships alone (`"user"`). Every replica asks `@Authorize` again for those memberships at once, and a member it no longer lets in is told the channel closed; their rejoin is refused. A chat gateway needs none of this: the platform revalidates its channel itself when a member is taken out of the chat, and ends it when the chat is deleted. ## Events What a gateway sends is declared on its class: `static readonly events = { name: schema }`, each a [Standard Schema](https://standardschema.dev) of the payload. The generated clients type each event by it. From a gateway's own methods, `ctx.emit(event, payload)` sends to every member of the channel. In `@OnConnect` it sends to the member who joined, and no one else. From anywhere else in the app (a route, a service, a [task](/docs/backend/tasks), an [`@On`](/docs/backend/hooks)) `Palbase.gateways.to(Gateway, params)` names one channel, and its `emit` is typed by the gateway's events: ```ts // src/modules/rooms/room-notices.service.ts import { Injectable, Palbase } from "palbase/backend"; import { RoomGateway } from "./room.gateway"; @Injectable() export class RoomNotices { /** The room hears it as a message from no one in particular. */ closingSoon(roomId: string, minutes: number): void { Palbase.gateways.to(RoomGateway, { roomId }).emit("message", { id: crypto.randomUUID(), from: "", text: `This room closes in ${minutes} minutes.` }); } } ``` - **In order.** Events to one channel are sent in the order they were emitted. Those emitted while one is on its way go together in the next send. - **Never thrown.** `emit` returns at once and does not fail the method. An event the gateway does not declare, or a payload that is not JSON, is not sent, and the app's log says so. So is an event that could not be sent. - **At most once,** unless the gateway is [durable](#durable-gateways): a member that was not on the channel when an event was sent does not get it later. A member hears only what the gateway sends. Members' own messages go to the backend, never to one another: what the others should hear, the method emits. ## Messages and acks `@Subscribe(name, schema, { reply })` marks the method that takes one message its members send. The payload is checked against `schema` before the method runs. One that does not fit never reaches it, and the member is told the schema's issues. The method is given a `GatewayMessageContext`: the channel, its `params`, the member as `ctx.user`, and the payload as the schema made it (`ctx.payload`). It runs as the member, in a transaction of its own, committed when it returns and rolled back when it throws. What it returns is the member's **ack**, which their `send` resolves with. `reply` is the ack's schema, which the generated clients type it by and which the ack is checked against. What a member's `send` is answered: | The method | The member is told | |---|---| | Returns a value | The value, as JSON carries it. Past 64 KiB, `reply_too_large`. | | Returns nothing | `ok`, with no ack. | | Throws `new GatewayRefusal("muted")` | The refusal, with your reason: `muted`. What the method wrote is rolled back. | | Throws anything else | `handler_failed`. The error is in the app's log, never in the member's answer. | | Returns an ack its `reply` refuses | `handler_failed`, and the log says why. | A `GatewayRefusal`'s reason is a word your app acts on: `not_your_turn`, `too_many_messages`, `muted`. It is 256 bytes at most. A message the gateway does not declare is refused with its name. ## Moments A gateway with methods is told of its channels' moments: | Decorator | When | Runs as | |---|---|---| | `@OnFirst()` | The channel has a member and is held by no one. Told once, however many members come at once, and before the first of them is answered. | The app | | `@OnConnect()` | A member joined, after they were answered. What it emits goes to them alone. | The member | | `@OnDisconnect()` | A member left. | The member | | `@OnEmpty()` | Nobody has been on the channel for its `graceMs`. | The app | "The app" is the service role, as a [cron](/docs/backend/cron)'s work is: every row in reach. Each moment's `GatewayContext` carries `channel`, `params`, `count` (its members now), `user` (`id` is `""` in `@OnFirst` and `@OnEmpty`, which no one in particular caused), `emit`, `pipe`, `signal` and `owner`. `ctx.signal` is the channel's own. It aborts when the channel empties, before `@OnEmpty` runs, so what `@OnFirst` started stops on it without a method to remember it. `ctx.owner.hold(work)` runs `work` past the method, until that signal aborts. A failure of it is logged, never the app's crash. `ctx.owner.release()` gives the channel up at once: its signal aborts, and the next member to come finds it held by no one, so `@OnFirst` runs again. ```ts // src/modules/prices/prices.gateway.ts import { Gateway, Log, OnEmpty, OnFirst, z } from "palbase/backend"; import type { GatewayContext } from "palbase/backend"; export const Tick = z.object({ symbol: z.string(), price: z.number() }); // prices:: a price read from the exchange only while someone watches it. @Gateway("prices:{symbol}", { access: "public", graceMs: 30_000 }) export class PricesGateway { static readonly events = { tick: Tick }; // The first watcher came: read the price every second until the channel empties. @OnFirst() open(ctx: GatewayContext): void { const symbol = ctx.params.symbol; ctx.owner.hold(async (signal) => { while (!signal.aborted) { const res = await fetch(`https://quotes.example.com/v1/${encodeURIComponent(symbol)}`, { signal }); const { price } = (await res.json()) as { price: number }; ctx.emit("tick", { symbol, price }); await new Promise((resolve) => setTimeout(resolve, 1000)); } }); } // Thirty seconds with nobody watching: the loop above has stopped already. @OnEmpty() closed(ctx: GatewayContext): void { Log.info("nobody watches", { symbol: ctx.params.symbol }); } } ``` A moment whose method throws is told again 1, 5 and 15 seconds later. A join or a leave told again is run once. ## Durable gateways A durable gateway keeps what it sends: every event is kept and numbered as it is sent, for a day (`durable: true`) or for `{ keepSeconds }`, a week at most. A member that comes back, a phone that walked out of range or a socket that dropped, is sent everything after the last event it had, in order and once. A member that joins is sent what the gateway keeps first, unless it asks for only what comes next ([Web Gateways](/docs/web/gateways#durable-gateways)). ```ts // src/modules/orders/order-updates.gateway.ts import { Gateway, z } from "palbase/backend"; export const Status = z.object({ orderId: z.string(), status: z.enum(["paid", "packed", "shipped", "delivered"]) }); // orders:: a user's own order updates, kept a week. @Gateway("orders:{userId}", { access: "owner", durable: { keepSeconds: 604_800 } }) export class OrderUpdates { static readonly events = { status: Status }; } ``` ```ts // src/modules/orders/order-status.service.ts import { Injectable, Palbase } from "palbase/backend"; import { OrderUpdates } from "./order-updates.gateway"; @Injectable() export class OrderStatus { shipped(userId: string, orderId: string): void { Palbase.gateways.to(OrderUpdates, { userId }).emit("status", { orderId, status: "shipped" }); } } ``` Every event delivered to a member counts toward the project's realtime messages, the ones sent again to a member that came back included. What a project's durable gateways keep is bounded: an event past the bound is not kept or sent, and the app's log says so. Keep a durable gateway for what members must not miss while they are away. What must outlive the conversation belongs in your database. ## Pipes `ctx.pipe(source)` has the platform write `source` into the gateway's channel from then on. `Palbase.gateways.to(Gateway, params).pipe(source)` does the same from anywhere in the app. The backend is not in the stream: after the call, what the source says reaches the channel's members without passing through the machine. The source today is a chat of the project's agents, `Palbase.ai.chats.events(chatId)`, piped into a gateway declared with `chat: true`. See [AI](/docs/backend/ai#a-chat-in-an-app). ## A gateway with no method A gateway that has no method, only its access, its events and perhaps `publish`, never wakes the machine. Realtime decides its joins from the app's contract alone, and `Palbase.gateways.to(…).emit` sends to it from wherever the app runs. `OrderUpdates` above is one: a user's app can hold `orders:` open all day while the machine sleeps. A gateway with a method is the machine's. Its joins, its members' messages and its moments are signed calls the platform makes to the machine, waking it if it sleeps. A join or a message is answered within 8 seconds and a moment within 30, or realtime gives up on it: a join is then `unavailable`, and a moment is told again. A release's gateways are what realtime goes by within 30 seconds of it serving. ## Limits | | | |---|---| | A pattern, a channel, an event's or a message's name | 255 bytes | | An ack | 64 KiB of JSON | | A refusal's reason | 256 bytes | | What a durable gateway keeps | a week | | An `@Authorize` or a message, answered | within 8 seconds | | A moment, answered | within 30 seconds | What the project's clients may use (connections, joins and events per second, channels per connection) is set with `putLimits` ([Realtime](/docs/backend/realtime#limits)). ## On your computer Off the platform (the app started on your computer, or in a test) nothing joins a gateway: realtime is what calls a machine about one. `Palbase.gateways.to(…).emit` sends as the app, to the project its `PALBASE_URL` names. Test a gateway's methods as plain methods of a class ([Testing](/docs/backend/testing)). ## Gateways and the channel declaration A channel a gateway matches is the gateway's, whatever the project's [channel declaration](/docs/backend/channels) says. The declaration (`Palbase.realtime.putChannels`, Studio's **Realtime › Channels**) is the realtime module's own list, for a backend that is not a `palbase/backend` app: a server that broadcasts with the secret key ([Realtime](/docs/backend/realtime)) and channels its clients join by name. A `palbase/backend` app declares its channels as gateways. ## Related - [Web Gateways](/docs/web/gateways): joining a gateway from an app, its events, messages and acks - [AI](/docs/backend/ai): a chat of the project's agents, carried by a gateway - [Realtime](/docs/backend/realtime): the project's realtime from any server - [Modules & Dependency Injection](/docs/backend/modules): providers, and what is refused at boot # Channels > **Note:** A `palbase/backend` app declares its channels as [gateways](/docs/backend/gateways): `@Gateway` classes, typed end to end, which the generated clients join. This page is the realtime module's own declaration, `Palbase.realtime.putChannels`, for a backend that is not a `palbase/backend` app. A channel a gateway matches is the gateway's, whatever this declaration says. A channel's name is a string a client chooses. Proving who you are says nothing about which channels are yours — a chat's `dm:alice:bob` must not be readable by every signed-in user who can spell it — so only the project says what its channel names mean. It **declares** them: a list of patterns, each saying who may join the channels it matches and what they may do there. **A channel no pattern matches belongs to no one**, and no client may join it. ## Quick example ```ts // src/modules/realtime/channels.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class ChannelsService { // Declared at boot, from code: the declaration is what this code says. async onStart(): Promise { await Palbase.realtime.putChannels({ body: { channels: [ // A user's own inbox: only they may join, and they may do everything on it. { pattern: "inbox:{uid}", kind: "owner" }, // A board any signed-in user may watch and edit. { pattern: "board:{id}", kind: "public", publish: true, state_read: true, state_write: true }, // A lobby anyone signed in may listen to, where only the server speaks. { pattern: "lobby", kind: "public" }, ], }, }); } } ``` `PUT /realtime/channels` replaces the whole declaration, in its order; `GET /realtime/channels` reads it back. Studio's **Realtime › Channels** screen edits the same list. ## Patterns A pattern is `:`-separated segments, 255 bytes at most. A segment written `{name}` matches exactly **one** non-empty segment; any other segment matches itself. | Pattern | Matches | Does not match | |---|---|---| | `lobby` | `lobby` | `lobby:2` | | `board:{id}` | `board:7f3a` | `board`, `board:7f3a:cards` | | `game:{id}:chat` | `game:42:chat` | `game:42`, `game::chat` | The segment counts must be equal — so `user:{uid}` never swallows `user:42:secrets` below it — and a capture name appears once in a pattern. ## The two kinds | Kind | Who may join | What they may do | |---|---|---| | `owner` | The signed-in user the pattern's **one** `{name}` names, and no one else. | Everything: listen, publish, read and write the state. | | `public` | Any signed-in user of the project. | Listen; and what the rule grants — `publish` (broadcast on it), `state_read` (receive its state), `state_write` (write its state). | An `owner` pattern has exactly one capture: `inbox:{uid}` is the user `uid`'s, so `inbox:usr_123` is joined by `usr_123` alone. Both kinds need a **signed-in user**: the access token each join carries is who the user is, and a client with no one signed in joins none of the project's declared channels. Your backend is never refused: what it publishes and writes with the secret key ([Realtime](/docs/backend/realtime)) reaches every subscriber of the channel, whatever the declaration lets clients do there. ## First match wins The declaration is matched in order, and the **first** pattern that matches a channel decides it — including deciding no: ```ts // src/modules/realtime/rooms.service.ts import { Injectable, Palbase } from "palbase/backend"; @Injectable() export class RoomsService { async onStart(): Promise { await Palbase.realtime.putChannels({ body: { channels: [ // The announcements room: everyone listens, nobody but the server speaks. { pattern: "room:announcements", kind: "public" }, // Every other room: everyone may talk. { pattern: "room:{name}", kind: "public", publish: true }, ], }, }); } } ``` Put the narrow patterns before the wide ones: written the other way round, `room:{name}` would match `room:announcements` first and let everyone publish there. A channel an `owner` pattern matches for a different user is refused there and then — it does not fall through to a later pattern. ## The platform's channels Four namespaces are the platform's and cannot be declared: `roles:`, `flags:`, `messaging:` and `debug:`. Of them, clients join: | Channel | Who | What it carries | |---|---|---| | `flags:` | Any client of the project, signed in or not. | The notice that the project's flags changed ([Flags](/docs/backend/flags)). | | `flags::` | That user. | The notice that their own values changed. | | `roles::` | That user. | Their permission set. | All three are read-only: only the platform writes them. ## When it changes - **Open sockets are decided again within a heartbeat** of a new declaration: a client whose channel is no longer theirs is put off it. - **A decision is trusted for at most 15 minutes**, or until the user's token expires if that is sooner: a revoked user, or one whose token is no longer valid, is noticed within that time. - **A refused join** answers the client with its reason — `unauthorized` when the declaration says no, `unavailable` when it could not be read just then (worth retrying; `unauthorized` is final). ## Limits The declaration holds **100 patterns** at most. What the project's clients may use — connections, joins and events per second, channels per connection — is set with `putLimits` ([Realtime](/docs/backend/realtime)). ## From the CLI ```sh palbase realtime:channels:get --json > channels.json # the declaration, as the document to edit palbase realtime:channels:set --file channels.json # replace it; open sockets are decided again within a heartbeat palbase realtime:limits:set max_connections=500 palbase realtime:broadcast room:42 message '{"text":"hello"}' ``` Each entry of the file is a channel: `{"pattern": "room:{id}", "kind": "public", "publish": true, "state_read": true, "state_write": false}`, or `{"pattern": "user:{uid}", "kind": "owner"}`. See the [CLI reference](/docs/cli/reference#realtime). ## Related - [Realtime](/docs/backend/realtime) — broadcasting and writing state from your backend - [Web Realtime](/docs/web/realtime) — joining a channel in the browser - [Flags](/docs/backend/flags) — what `flags:` carries - [Authentication](/docs/backend/authentication) — the user a join's token names # AI `Palbase.ai` is the project's models and agents, from your backend. It answers one-shot questions (`ask`, `generate`, `stream`, `embed`, `image`), gives an agent tools that run on your machine, defines agents from each turn's context, and runs **chats**: conversations of the project's agents with the members your backend names. Every call is made with the app's secret key and is recorded with its cost. The models are the platform's, and nothing is configured to use them. A model call is billed to the project, so no app makes one. There is no AI client in `palbase/web` or the mobile SDKs: an app reaches a chat through one of your backend's [gateways](/docs/backend/gateways), and asks your routes for anything else. ## Quick example ```ts // src/modules/reviews/reviews.controller.ts import { Body, Controller, Palbase, Post, z } from "palbase/backend"; const Review = z.object({ text: z.string().min(1).max(4000) }); const Verdict = z.object({ sentiment: z.enum(["positive", "neutral", "negative"]), summary: z.string() }); @Controller("/reviews") export class ReviewsController { // A document of Verdict's shape, checked against it before it is returned. @Post("/verdict") verdict(@Body(Review) body: z.infer): Promise> { return Palbase.ai.generate({ schema: Verdict, instructions: "Classify the customer review and summarize it in one sentence.", prompt: body.text, model: "fast", }); } } ``` ## One-shot answers | Call | What it answers | |---|---| | `ask(prompt, options?)` | The answer as text. | | `generate({ schema, prompt })` | A document of the schema's shape, checked against it. With `background: true`, the response's id at once; its end is the event `ai.response.completed` (or `ai.response.failed`, see [Event Hooks](/docs/backend/hooks)), and `result(id, schema)` reads it. | | `stream({ prompt })` | The answer as the model writes it: `textStream`, then `text` and `response` at the end. With a `schema`, `partialObjectStream` and `object`. | | `embed(text)`, `embed(texts)` | A vector per text, in order. `dimensions` asks for a shorter one. | | `image({ prompt })` | Pictures, waited for. `save(bucket, path)` writes one into the project's storage. | Each takes `instructions`, `model`, `messages` (the conversation so far, in place of `prompt`), `metadata` (labels of your own, returned with the response) and `signal`. A `model` is a role, `"fast"`, `"default"` or `"strong"`, or a catalogue model as `provider:model` (`Palbase.ai.models()`). `images` gives the question up to 4 pictures, each a file of the project's storage (`{ bucket, path }`), an https address (`{ url }`) or its bytes (`{ mediaType, data }`: base64 png, jpeg, webp or gif, up to 5 MiB each and 6 MiB together, kept in the project's storage while the question is answered, then deleted). A tool's pictures reach the model too. Once a response has ended, its `usage` says what it cost: `cost_nano_usd`, `usd`, and `cost_final` once nothing more of it can be settled. The question is asked with the secret key, so the model can be shown any file of the project's: check that a file is the user's own before you name it. `tools` and `maxToolCalls` give a one-shot answer the tools below, except a client tool, which only a chat has an app to answer. `stream(…).toResponse()` is the stream as a route's answer to a browser, as server-sent events: `data: {"text": "…"}` for each fragment, `event: reset` with `{"offset": n}` when the model went back (keep the first `n` characters), and a last `event: done` with the answer. A failure is `event: error` with its code alone. ```ts // src/modules/reviews/replies.controller.ts import { Body, Controller, Palbase, Post, z } from "palbase/backend"; const Draft = z.object({ review: z.string().min(1).max(4000) }); @Controller("/replies") export class RepliesController { // The browser reads the reply as it is written. @Post("/draft") draft(@Body(Draft) body: z.infer): Response { return Palbase.ai.stream({ instructions: "Draft a short, polite reply to this review.", prompt: body.review }).toResponse(); } } ``` `usage()` lists the project's uses of models and what each cost. Each use carries its labels (`metadata`): the caller's, and for a chat's turn, the chat's labels and the chat. `usage({ metadata: { product: "p1" } })` lists only the uses that carry those labels, such as a product's spend or a task's. This works after the chat is deleted too. `connectProvider(provider, key)` sends the project's calls to a provider with the project's own key from then on. ## What it costs The models answer with Palbase's own provider keys, unless the project connected a key of its own for the model's provider (`connectProvider`, or Studio's **AI › Models & keys**). A call under the project's own key is billed to it by the provider, and Palbase charges nothing for it. - **Each month brings $1.00 of Palbase's models free**, to each environment: its own dollar, whatever its plan and its organization's other environments ([Billing](/docs/getting-started/billing)). - **Past it**, an environment on Pay as you go pays from its own credit, at the provider's price and 10%. One on Free, one whose credit ran out, or one at its monthly limit is refused (`402 ai_payment_required`) until the month ends (UTC), or until it is on Pay as you go with credit: a top-up serves it again within a minute. - **A monthly budget.** An owner or an admin of the organization may set the most a project spends on Palbase's models in a month, in Studio's **AI › Settings** or with the CLI. Past it, calls to them are refused (`402 ai_budget_reached`) until the month ends or the budget is raised, and the organization's owners are emailed, once a month and budget. The project's key and its environment tokens cannot set it: what spends the money does not raise its own limit. - A Free project's dollar and a budget hold as each use ends: a response is admitted with what the project has left, and one that would cost more, or is still running when it is reached, stops there with the error `ai_limit_reached` (a project with less than a cent left is at its limit: `402`). Responses running at once may each spend what was left, so the month may pass the limit by a little. - **Pay as you go.** A paying environment's responses are held, the same way, to what it can still pay: its own credit — no more than its monthly limit still allows — and what is left of its free dollar. When less than a cent is left, calls are `402 ai_payment_required` until a top-up; usage of the last moments before it ran out is taken from the next top-up. - **Voice.** A voice session's model runs for as long as the session is open: an environment on Free opens one on Palbase's models no more (`402 ai_payment_required`), one on Pay as you go needs a dollar left of its budget and of its credit for each voice open, and an open one is closed (`ai_limit_reached`) when the environment's free dollar, budget or credit runs out. On the project's own OpenAI key it runs regardless. - **A call cut before its answer** (a turn stopped, a stream given up): its provider says what its tokens were only at the end. A reasoning model's hidden thinking, which the provider bills as output, is then estimated from how long it thought at the model's measured speed, rounded down, and the use is marked `estimated` (Studio: **Estimate**). `GET /ai/spend` says the month's spend at what it is billed, what is left of a Free project's dollar, the budget, and whether Palbase's models serve the project now (and if not, why). Studio shows the same on **AI › Models & keys**. ## Tools An agent is given tools from five sources: | Source | Made with | Runs | |---|---|---| | Your backend | `Palbase.ai.tool({ name, description, input, run })` | On your machine, when the agent calls it. | | The app | `Palbase.ai.clientTool({ name, description, input })` | In the app the chat is open in. The turn waits for the answer, which reaches the chat through your gateway. | | An HTTPS tool | `Palbase.ai.endpoints.put(name, { kind: "https", url, … })`, then `{ source: "https", name }` | At that address: a signed `POST`. | | An MCP server | `Palbase.ai.endpoints.put(name, { kind: "mcp", url })`, then `{ source: "mcp", name, only? }` | At that address, over streamable HTTP. | | The platform | `{ source: "platform", name }`: `web.search`, `web.fetch`, `workspace.fetch_archive` | On the platform. `web.search` searches the web, `web.fetch` reads a page of it, `workspace.fetch_archive` downloads an archive from a URL and unpacks it into the chat's workspace (a workspace chat has it already). They belong to the agent; its subagents use the agent's. | A tool answers within 110 seconds; past that the agent is told it did not answer. A tool whose work takes longer (a review that takes minutes) answers `{ deferred: true, expires_in_seconds? }` at once, and the call waits. Your server gives the answer later with `chats.answer(chatId, callId, output)` (`POST /ai/chats/{id}/tool-outputs/{callId}` with the secret key), within the time the tool named: a minute to a day, an hour by default. A call nobody answers by then is told to the agent as unanswered. No member answers such a call. The tool is told the call's `callId` and the chat in its context. A chat that works in a workspace gives its agent the workspace's own tools as well: | Tool | Does | |---|---| | `palai.workspace.file`, `str_replace_based_edit_tool` | Read, write and edit a file. | | `palai.workspace.glob`, `palai.workspace.grep` | Find files by name, and lines by pattern. | | `palai.workspace.shell`, `palai.workspace.background_status`, `palai.workspace.background_kill` | Run a command — it reaches the internet over HTTP, HTTPS, SSH and git (a package install, a clone, Palbase's own docs), never a private address or a mail server — keep one running in the background, read it and stop it. `xcodebuild`, `xcrun`, `swift` and `simuse` run on the platform's Mac, and what they write under `artifacts/` and `scratch/` comes back into the workspace. | | `palai.workspace.fetch_archive` | Download an archive from a URL and unpack it into the workspace. | | `palai.workspace.commit`, `palai.publish.push` | Commit, as the member the turn is for, and push to the chat's branch. | | `palai.artifact.publish`, `palai.artifact.open`, `palai.artifact.read`, `palai.artifact.list` | Publish a file as the turn's output, and read the ones published. | | `palai.agent.spawn`, `palai.agent.status`, `palai.agent.send`, `palai.agent.stop` | Hand work to a subagent, follow it, message it and stop it. | The table a backend tool reads: ```ts // src/db/public.ts import { defineSchema, defineTable, ownedByUser, policy, text, uuid } from "palbase/backend"; export const orders = defineTable("orders", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), status: text(), }, // A customer reads their own orders, and no one else's. policies: (p) => [policy("orders_own").for("select").to("authenticated").using(p.col("owner").eq(p.auth.uid()))], }); export default defineSchema("public", { tables: [orders] }); ``` ```ts // src/modules/support/support.tools.ts import { Database, Palbase, z } from "palbase/backend"; import { ToolError } from "palbase/ai"; // Run on this machine when the agent calls it, its input checked against the schema first. export const lookupOrder = Palbase.ai.tool({ name: "lookup_order", description: "Tells an order's status by its id.", input: z.object({ orderId: z.string().uuid() }), run: async ({ orderId }) => { // As the chat's member: the orders policy decides what is found. const order = await Database.public.orders.findUnique({ where: { id: orderId } }); if (!order) throw new ToolError(`No order ${orderId} of this customer.`); return { orderId, status: order.status }; }, }); // Every call waits for a person's yes before it runs. export const refundOrder = Palbase.ai.tool({ name: "refund_order", description: "Refunds an order. A person approves each call.", input: z.object({ orderId: z.string().uuid() }), approval: true, run: ({ orderId }) => `Refund started for ${orderId}.`, }); // The app answers it: the customer picks a city on their screen. export const pickCity = Palbase.ai.clientTool({ name: "pick_city", description: "Has the customer pick a city in the app, and answers the city's name.", input: z.object({ reason: z.string() }), }); ``` A tool is made once, at a module's top level: the framework serves every tool its process declared, and the platform calls the machine for one by its name. Its `run` is given the input and a context: `chatId`, `agent`, `callId` (the same for every attempt at one call), the chat's `metadata`, `signal`, and `user`. - **Whose rights.** A chat that has only ever had one member is theirs: the tool runs in a transaction as that member, and `ctx.user` is them. In a chat that has had several members, a tool runs for no member and `ctx.user` is `null`, so no member has the agent act with another's rights. Reach past the caller's rights only by saying so (`Database.$asService()`). - **What it answers.** A string, an object (said to the agent as JSON), pictures (`{ mediaType, bytes }`) or a list of them, 4 MiB at most in all. - **When it fails.** A `ToolError`'s message is what the agent and the chat's members are told. Anything else thrown is told as "The tool failed", and the error is in the app's log. - **`approval: true`.** Every call waits for a person's yes: the member whose message began the turn, or the server. An HTTPS tool is any server's route. The platform calls it with `POST {tool, call_id, input, context}`, signed with the endpoint's secret in `Palbase-Signature`, and it answers `{ output, is_error }`. `endpoints.put` answers that secret once (`signing_secret`), and putting the endpoint again makes a new one. Keep it in the project's [secrets](/docs/backend/secrets): ```ts // src/modules/rates/rates.controller.ts import { Controller, Post, Req } from "palbase/backend"; import { verifySignature, WebhookSignatureError } from "palbase/server"; // put once: Palbase.ai.endpoints.put("convert", { kind: "https", url: "https://.app.palbase.studio/tools/convert", description, parameters }) @Controller("/tools", { auth: false }) export class RatesController { @Post("/convert") async convert(@Req() req: Request): Promise { const secret = process.env.CONVERT_TOOL_SECRET; if (!secret) throw new Error("CONVERT_TOOL_SECRET is not set"); const raw = new Uint8Array(await req.arrayBuffer()); try { await verifySignature({ secret, signature: req.headers.get("palbase-signature"), body: raw }); } catch (e) { if (e instanceof WebhookSignatureError) return Response.json({ error: "invalid_signature" }, { status: 401 }); throw e; } const call = JSON.parse(new TextDecoder().decode(raw)) as { input: { usd: number } }; return Response.json({ output: [{ type: "text", text: `${(call.input.usd * 0.92).toFixed(2)} EUR` }], is_error: false }); } } ``` A project holds 32 endpoints. `endpoints.list()` and `endpoints.delete(name)` read and remove them. ## Agents `Palbase.ai.agent(name, define)` is an agent your backend defines at the start of each turn, from the turn's context: the chat's `chatId`, `metadata` and `user` (the member, in a chat that has only ever had one). ```ts // src/modules/support/support.agent.ts import { Palbase } from "palbase/backend"; import { lookupOrder, pickCity, refundOrder } from "./support.tools"; export const support = Palbase.ai.agent("support", (ctx) => ({ model: "fast", instructions: [ "You are the shop's support assistant. Answer briefly.", ctx.user ? `The customer is ${ctx.user.name}.` : "Several people are in this chat: each message says who wrote it.", ].join(" "), tools: [lookupOrder, refundOrder, pickCity, { source: "mcp", name: "shop-docs" }], subagents: [{ name: "researcher", description: "Looks a question up in the shop's documents, step by step.", tools: [{ source: "mcp", name: "shop-docs" }] }], maxToolCalls: 12, })); ``` The agent is your build's: the build writes it into its contract (`x-palbase-agents`, with its tools in `x-palbase-ai-tools`), and the platform reads it from the build your machine serves. Nothing is registered at start. It is changed in the code and shipped with a deploy. Studio lists it as defined in code, with its tools and what its last turn was defined as, and offers no edit. `Palbase.ai.agent(name, define, { description, machine })` says what the agent is for and, when one code runs as several machines, which of them declares it. | Field | What it is | |---|---| | `instructions` | What the agent is told before the chat. | | `model` | A role or a catalogue model. | | `tools` | 64 at most, an MCP server's tools counted. | | `subagents` | 16 at most: each a `name`, a `description` the agent reads to hand it work, and its own `instructions`, `model` and `tools`. `required: true` fails the turn when its part fails. | | `maxToolCalls` | The turn's tool calls, 1 to 10 000. Past it the agent is told it has no tools left, and answers. | | `thinking` | `visible` shows the model's reasoning in the chat's stream as `thinking` parts. `hidden` does not ask for it. When the model's provider will not give it, the turn says so once: a `thinking` part with `unavailable: true`, the `model` and the provider's `reason`. One cause is an OpenAI organization not verified for reasoning summaries; Anthropic's models (`strong`) show theirs. | ### Agents the project keeps An agent can also be made in Studio (**AI › Agents**), with the CLI (`palbase ai:agents:create`) or with the API, without a deploy. It is changed as a **draft**: the draft is tried (Studio's **Try**) and **published** as a version that never changes. Chats and one-shot answers by its name talk to the version published; `"helper@3"` stays on version 3. An older version is published again with `rollback`. ```ts import { Palbase } from "palbase/backend"; await Palbase.ai.agents.saveDraft("helper", { instructions: "Answer in one sentence.", model: "fast" }); await Palbase.ai.agents.publish("helper"); const answer = await Palbase.ai.ask("What does the return policy say?", { agent: "helper" }); ``` | Call | What it does | |---|---| | `agents.list()`, `agents.get(name)` | The project's agents: those its build declares (`source: "code"`) and those it keeps (`source: "platform"`). | | `agents.saveDraft(name, definition, { revision })` | Saves the draft. A draft saved since the `revision` you read is refused (`409 draft_changed`), so a change made meanwhile is not lost. | | `agents.publish(name, { revision })` | Publishes the draft as the next version. | | `agents.rollback(name, version)`, `agents.versions(name)` | Publishes an older version again, and lists the versions. | | `agents.delete(name)`, `agents.tools()` | Deletes the agent with its versions, and lists the tools an agent can be given. | A kept agent is given the same tools a backend's is. A backend or client tool named by its name alone (`{ source: "backend", name: "lookup_order" }`) is your build's: what it does, what it takes and whether it waits for a person's yes are filled in from the build at every turn, so the agent follows your code. `output` is the shape of the agent's one-shot answers, a JSON Schema of an object: `generate({ agent: "classify" })` returns a document of it, and a call's own `schema` replaces it for that call. `description` says what the agent is for. **One name is one agent's.** The platform refuses a kept agent by a name your build declares (`409 agent_defined_in_code`), and a build that declares a name the project keeps fails its deploy, the version before it serving on. `chats.create({ definition })` gives one chat a definition of its own, with no name kept. **An agent from a description.** Studio's **Create with AI**, or `palbase ai:agents:generate "helps customers who ask where their order is"`, writes a draft: its instructions, model and tools, chosen from the project's own tools. Palbase pays for the model that writes it. A person writes 20 drafts an hour in a project. ## Chats A chat is the project server's. Only your backend starts one (a user's own call is refused `403 server_only`), and it names the chat's members: ```ts // src/modules/support/support.controller.ts import { Body, Controller, Palbase, Post, User, z, type UserT } from "palbase/backend"; import { support } from "./support.agent"; const StartChat = z.object({ title: z.string().max(256).optional() }); @Controller("/support") export class SupportController { // The signed-in user asks for a chat with support, and is its owner. @Post("/chats") async start(@Body(StartChat) body: z.infer, @User() user: UserT): Promise<{ id: string }> { const chat = await Palbase.ai.chats.create({ agent: support, title: body.title, members: [{ userId: user.id, owner: true }] }); return { id: chat.id }; } } ``` | Call | What it does | |---|---| | `chats.create({ agent, members, title, metadata, workspace })` | Starts a chat with a named agent (or `definition`). 64 members at most, each `{ userId, name, owner }`: `name` is how the agent and the others are told who wrote. | | `chats.get(id)`, `chats.list({ member })`, `chats.delete(id)` | Reads a chat, lists them (a member's, or all), deletes one. A turn still running is stopped. | | `chats.members.put(id, userId, { name, owner })`, `chats.members.delete(id, userId)` | Adds or changes a member, removes one. | | `chats.send(id, { text, clientId, delivery, as, budget, images, files })` | A message for the agent: a turn of its own when none runs. | | `chats.messages(id)` | What was written, newest first, a page at a time. | | `chats.stop(id)` | Stops the turn the agent is on. What it wrote stays. Answers the turn's id, or `null` when none ran. | | `chats.approve(id, approvalId, { reason, as })`, `chats.deny(…)` | A call that waits for a person's yes, made or not. Denied, the agent is told the reason and goes on. | | `chats.answer(id, callId, output, { isError, as })` | A client tool's answer: `true` when it is the one taken, `false` when another came first. | | `chats.sendToSubagent(id, runId, text, { as })` | A message for a subagent at work, at its next step. | | `chats.turnCost(id, responseId)` | What a turn cost so far, its subagents counted: tokens, `costNanoUsd` (and `usd`), and `final` once nothing more of it is settled. `runs` breaks it down run by run: the turn's own and each subagent's, by name, model and state. A turn still at work is read as it spends. | | `chats.events(id)` | The chat as a source a gateway pipes into its channel. See [A chat in an app](#a-chat-in-an-app). | `send` keys a message on its `clientId`: the same id sent again, a retry, is the same message, delivered once. One is made when you give none. `delivery` is for a message that comes while the agent works: `"next"` (the default) gives it the message at its next step, `"now"` cuts the step it is in. **Writing as a member.** Without `as`, the server writes as itself, and decides any approval or tool call. With `as: userId`, it writes as that member and is held to their rules. The message is theirs and counts toward their messages a minute (`429 too_many_messages`). A turn another member began is theirs alone to decide or answer (`403 not_your_turn`). A user who is not a member is refused (`403 not_a_member`). A gateway does all of a member's work this way, as the member who sent the message. **Events.** Every turn of a chat is an event of the project's: `ai.response.started` as it begins, whoever opened it (a member's message, the server's, the chat's goal), and `ai.response.completed` or `ai.response.failed` as it ends. A turn's event carries the chat (`chat_id`, its labels as `metadata`), the member it ran for (`user_id`, `null` for the server's), the labels of the message that opened it (`message_metadata`), whether it was the goal's (`goal_turn`, `goal_id`), and how it ended: `completed`, `failed` (its `error`: a `code`, a `message`, and whether asking again may help, `retryable`), `stopped`, or `budget_exceeded`. Take them with `@On("ai.response.completed")` ([Event Hooks](/docs/backend/hooks)). The chat's stream says the same on the turn's last part (`end`, `error`). **Pictures and files.** A message carries files of the project's storage. `images: [{ bucket, path }]` are pictures the model is shown with it: up to 4 (as many as the model is shown at once), up to 5 MiB each, png, jpeg, webp or gif. A picture's type is read from its bytes, not its label, and anything else is `415 unsupported_image`. `files: [{ bucket, path, filename, contentType }]` are put in the chat's workspace before the agent reads the message, at `.palbase/attachments//`: up to 8, up to 15 MiB each. The agent is told what each one is and where. `filename` defaults to the path's last segment, and `contentType` to what storage says. A chat that works in no workspace takes images alone (`409 no_workspace`). Each one is copied for the chat as the message is written, so the agent reads what was sent whatever later happens to the file, and the copy is deleted with the chat. The same bytes are kept once, and an object named twice (a picture that is also a file) is read once. Your server attaches any file of the project's storage, writing `as` a member or not: the decision is yours. A client, a member with their own session, attaches only a public bucket's files; a private bucket's file, even their own (`owner_id`), is attached by your server. Any other is `404 attachment_not_found`, the same answer as for a file that is not there. A message shows what it carries as `attachments` (images first): `kind` (`image` or `file`), `bucket`, `path`, `name`, `contentType`, `size`, `sha256`, and a file's `workspacePath`. The chat's members, and your server, read each one back at `GET /ai/chats/{id}/messages/{messageId}/attachments/{position}`. A fork begins with copies of what its history's messages carried, and its first turn shows the model the history's 4 newest pictures again. **Budgets.** `send(id, { text, budget: { maxCostUsd: 0.5 } })` bounds what the turn that message starts may cost, its subagents' calls counted. Past it the turn stops and ends `budget_exceeded`. A message into a turn already running takes that turn's budget. **Settings.** `Palbase.ai.settings.put({ … })` sets what the project allows its chats: `userMessagesPerMinute` (1 to 600, 20 unless set), and `historyLimitBytes`, the chat history the project keeps: a free project at most 500 MiB, less if it says so; a project with paid rights keeps all of it (`historyLimitBytes` is `null`) unless it says a limit of its own, up to 1 TiB. Past it, a message is refused (`409 history_full`) until chats are deleted. `settings.get()` also says how much is kept now. `clients`, off unless set, lets a user's own session read and write the chats they are in at the project's address. The app SDKs read through it what a gateway does not carry — a chat's older history (`loadOlder`), a tool call's whole answer (`chatToolCall`), a workspace file or an attachment — and a chat's live messages still come through its gateway. ## Voice A chat can be talked to out loud. The app opens a WebRTC peer connection, sends its offer to `POST /ai/chats/{id}/voice`, and sets the answer it gets back; the audio then goes between the app and the platform's voice provider, whose key never leaves the platform. The voice speaks as the chat's agent, with its instructions. A voice is its member's: the user who starts it, or the member your server starts it for (`as`). They and your server read it (`GET …/voice/{voiceId}`: live or closed, how many seconds, why it ended), tell it (`…/commands`: `speak`, `tell`, `know`, `instruct`, your server's alone) and close it (`DELETE`); no other member does (`403 not_your_voice`). A member starts at most 5 voices an hour, the ones your server starts for them counted with their own (`429 too_many_voices`). One voice talks in a chat at a time: a member's new voice ends the one they left open, and another member's live voice is refused (`409 voice_active`). A voice closes when its member is taken out of the chat (`member_removed`) or the chat is deleted (`chat_deleted`). Your server can give the voice, with its start, how it speaks (`instructions`, after the chat's agent's own) and what it knows as the talk opens (`context`). Both are your server's alone, as `know` and `instruct` are (`403 server_only` from an app), and are at most 48 KiB together. Given with the start, the voice speaks with them from its first second. Told after the start, piece by piece, the voice talks without them until the last piece is in, which for a long opening is many seconds. `speak` says its `text` exactly and nothing else, with no words of the voice's own before or after it. It waits until the voice finishes what it is saying, so a speak never cuts into the voice's answer, and speaks are said one at a time in the order they came. With `interrupt: true` it cuts what the voice is saying and is said at once. `tell` gives the voice something to say in its own words, `know` gives it something to keep in mind, and `instruct` changes how it behaves. Up to 16 speaks can wait at a time. Past that, a speak is refused with `429 voice_busy`. What the person asks for out loud is handed over, as the voice's `delegation` says: | `delegation` | What happens | |---|---| | `"chat"` (the default) | The request is a message of the voice's member to the chat's agent — a turn of its own, or into the one at work — with what was said before it given to the agent as context. When that turn ends, the voice says its answer back. No code of yours is needed. | | `"events"` | Your backend is told: `ai.voice.delegated` carries the request's `delegation_id`, its `text` and what was said before it (`earlier`). Have it done, and answer with a `speak` command naming the `delegation_id`. | What was said either way is the chat's: a `transcript` part of its stream for each line, and the same line in its history (`messages`, of `kind: "voice"`, with `voice: { id, role }`). The stream's `voice` part says when it began and when it ended. The end is told once, with its `close_reason` and `seconds`, whatever ended the voice: the person's connection was lost, the voice was closed, or the platform lost it (`lost`). A chat whose voice is live has its pipe written whether or not a turn is at work. A voice's start and end are the events `ai.voice.started` and `ai.voice.closed` (its `seconds` and `close_reason`). ## Workspaces A chat can work in GitHub repositories: its turns run with them checked out, and what its agent commits is pushed to the branch. The tokens are your backend's to give, at each clone and each push. ```ts // src/modules/builds/app.repo.ts import { Palbase } from "palbase/backend"; // A token for one clone or one push, asked of this machine each time and kept nowhere: here a // fine-grained token for this repository's contents, from the machine's secrets. export const shopApp = Palbase.ai.repo({ name: "shop-app", repository: "acme/shop-app", defaultBranch: "main", token: () => { const token = process.env.SHOP_APP_GITHUB_TOKEN; if (!token) throw new Error("SHOP_APP_GITHUB_TOKEN is not set"); return token; }, }); ``` ```ts // src/modules/builds/builds.service.ts import { Injectable, Palbase } from "palbase/backend"; import { shopApp } from "./app.repo"; @Injectable() export class BuildChats { async onStart(): Promise { await Palbase.ai.repos.put(shopApp); } /** A chat that works on a branch of the app; each push waits for its owner's yes. */ async start(userId: string, branch: string): Promise { const chat = await Palbase.ai.chats.create({ definition: { instructions: "You work on the shop's app. Make small commits.", model: "strong" }, members: [{ userId, owner: true }], workspace: { repos: [{ repo: shopApp, branch }], autoPush: false }, }); return chat.id; } /** What a turn published: a build's product, a screenshot, a report. */ async firstArtifact(chatId: string, responseId: string): Promise { const [first] = await Palbase.ai.chats.artifacts(chatId, responseId); return first ? Palbase.ai.chats.artifact(chatId, responseId, first.id) : null; } } ``` With `autoPush: false` a push waits for a member's approval, as an `approval: true` tool does. With `autoPush: true` the agent pushes without one, and what the chat's members write then reaches the repository. A project puts 10,000 repositories, and a chat works in 8 of them at most. `repos.list()` and `repos.delete(name)` read and remove them. **A repository for a chat at work.** `chats.workspace.addRepo(chatId, { repo, branch? })` (`POST /ai/chats/{id}/workspace/repos`) puts one more of the project's repositories in an open chat's workspace, beside its others. A repository born while the work goes on is put first with `repos.put`. The engine clones it into the workspace before the chat's next turn, and every chat working in the workspace (a fork, too) has it. Your server alone does this. **Whose commits, and what they carry.** Each commit the agent makes is written as the member its turn is for: their name in the chat and their address, as an author and as a committer. This holds for the platform's commit tool and for a plain `git commit` the agent runs in the shell. Each turn writes its member into the repositories' own git config and installs a `commit-msg` hook that adds the chat's trailers. A hook, or a hooks path, the repository already has is left alone. Give the address with the member (`members: [{ userId, name, email }]`, or `PUT /ai/chats/{id}/members/{userId} { email }`). A member's own session gives it again, verified, whenever they write. It is never served to the chat's other members. A member with no address commits under one of the platform's that names them and reaches no one. A turn that is no member's (your server's own) commits as the platform. `workspace: { trailers: [{ key, value }] }` are git trailers every commit of the chat carries at the end of its message: a task (`PalCore-Task: #12`), a link back to the conversation, a co-author (`Co-authored-by: …`). Up to 16, each one line. The agent's work is pushed only to the chat's branch. On a protected or default branch (`main`, `master`, the repository's default) it goes to a branch of its own, `agent//`. **What the workspace did, as events.** The workspace's machine publishes its tree whenever the files are quiet for a moment, so mid-turn, as a build writes its product. It publishes once more as it lets the workspace go. Each tree is `ai.workspace.published`, with the files it changed against the tree it replaced (`changed: [{ path, change: added|modified|removed, size_bytes }]`, 1000 at most). Read one of its files, as that tree left it, at `GET /ai/chats/{id}/workspace/files?path=&manifest=` (without `manifest`, from the tree the workspace is at now). Files are served up to 64 MiB (`413 file_too_large` past it), with their digest in `Content-Digest`; publish a larger one as the turn's artifact. A project reads 4 files at once (`429 too_many_reads`, with `Retry-After`), and a file is written for no longer than its bytes take at 256 KiB/s plus a minute. A tree's changes are named 1000 at a time in the event (`changed_truncated` past that). A push is `ai.workspace.pushed`, with the commits it brought (`commits: [{ sha, subject, author_name, author_email }]`, 100 at most), the repository by your name for it, the branch and the member. The chat's members see the push in the stream with each commit's author by name, never by address. Each event is told once, from the turn's start until 20 minutes after its end, whoever is watching the chat. ## A chat in an app A chat reaches its members through a gateway declared with `chat: true`. When its first member joins, the gateway pipes the chat into its channel. From then on the **platform** writes the chat there itself: the members' messages, each answer as it is written, the tool calls, the approvals and the subagents' work. The backend is not in the stream. The channel keeps the chat a day (or what `durable` says), so a member who joins later, or was away, has all of it. What a member does in the chat (a message, a yes or a no, a client tool's answer, stop) comes to the gateway as one of its messages, and the backend does it **as them**. The channel keeps a day of the chat. A chat opened later is read back at `GET /ai/chats/{id}/parts?limit=&cursor=`, a page at a time from its newest end back: the same parts the stream told, in their order, each under the id the channel told it (`part_id`). A client that holds the channel's parts and pages of these keeps each one once. An answer's text is placed by its offset, as in the stream. The last page reaches the chat's start and begins where a fork began. A client that already holds the channel's day passes the `id` of the oldest part it holds as `before=`, and the first page starts just before that part, so the day is not read twice. Each page after it is read by its `next_cursor`. A call with both `cursor` and `before` is refused (`400`). A voice's end is in the pages under the id the channel told it (`vclosed:`). For a voice the engine lost, whose end its journal never says, it comes right after the voice's last part. Reading a page changes nothing. A client's `loadOlder()` reads the page before the oldest it holds. A tool call's answer is in the stream by its end: its last 16 KiB, with `omitted_bytes` saying what was left out. A build's log ends with what failed. A member who opens the call reads it whole at `GET /ai/chats/{id}/tool-calls/{callId}`, with secrets masked as the stream masks them. Your server reads a turn's tool calls as the engine committed them, arguments and results as they were, a page at a time, at `GET /ai/chats/{id}/tool-calls?turn=&limit=&cursor=`. That is what a diff of the files the turn wrote is made of. A subagent's calls are read by its response (an agent part's `response_id`). A project reads two of these, or of a chat's past pages, at once (`429 too_many_reads`). ```ts // src/modules/support/support.gateway.ts import { Authorize, Gateway, GatewayRefusal, OnFirst, Palbase, PalbaseError, Subscribe, z } from "palbase/backend"; import type { GatewayAuthorizeContext, GatewayContext, GatewayGrant, GatewayMessageContext } from "palbase/backend"; export const Say = z.object({ text: z.string().min(1).max(8000), clientId: z.string().max(64).optional(), delivery: z.enum(["next", "now"]).optional() }); export const Said = z.object({ clientId: z.string() }); export const Decide = z.object({ approvalId: z.string(), approve: z.boolean(), reason: z.string().max(1024).optional() }); export const Answer = z.object({ callId: z.string(), output: z.string().max(16_000) }); export const Stopped = z.object({ stopped: z.string().nullable() }); // The ai module's refusals that are the member's own to act on, said to them by their code. async function asMember(work: () => Promise): Promise { try { return await work(); } catch (e) { if (e instanceof PalbaseError && [403, 404, 409, 429].includes(e.status)) throw new GatewayRefusal(e.code); throw e; } } @Gateway("support:{chatId}", { chat: true }) export class SupportGateway { // The chat's members, and no one else. A chat that is not there lets no one in. @Authorize() async enter(ctx: GatewayAuthorizeContext): Promise { const chat = await Palbase.ai.chats.get(ctx.params.chatId).catch((e: unknown) => { if (e instanceof PalbaseError && e.status === 404) return null; throw e; }); return chat?.members.some((m) => m.user_id === ctx.user.id) === true; } // The chat into the channel, from its start. @OnFirst() async open(ctx: GatewayContext): Promise { await ctx.pipe(Palbase.ai.chats.events(ctx.params.chatId)); } @Subscribe("say", Say, { reply: Said }) async say(ctx: GatewayMessageContext>): Promise> { const { text, clientId, delivery } = ctx.payload; const m = await asMember(() => Palbase.ai.chats.send(ctx.params.chatId, { text, clientId, delivery, as: ctx.user.id })); return { clientId: m.client_id }; } // A call that waits for a person's yes: the member whose message began the turn says it. @Subscribe("decide", Decide) async decide(ctx: GatewayMessageContext>): Promise { const { approvalId, approve, reason } = ctx.payload; const as = ctx.user.id; await asMember(() => (approve ? Palbase.ai.chats.approve(ctx.params.chatId, approvalId, { reason, as }) : Palbase.ai.chats.deny(ctx.params.chatId, approvalId, { reason, as }))); } // A client tool's answer, from the app of the member whose turn it is. @Subscribe("answer", Answer) async answer(ctx: GatewayMessageContext>): Promise { await asMember(() => Palbase.ai.chats.answer(ctx.params.chatId, ctx.payload.callId, ctx.payload.output, { as: ctx.user.id })); } // Any member stops the agent. What it wrote stays. @Subscribe("stop", z.object({}), { reply: Stopped }) async stop(ctx: GatewayMessageContext>): Promise> { return { stopped: await asMember(() => Palbase.ai.chats.stop(ctx.params.chatId)) }; } } ``` ```ts // src/modules/support/support.module.ts import { Module } from "palbase/backend"; import { SupportController } from "./support.controller"; import { SupportGateway } from "./support.gateway"; // The agent and its tools are values, declared as their files load: the controller's import brings them. @Module({ controllers: [SupportController], providers: [SupportGateway] }) export class SupportModule {} ``` In the app, the member joins `SupportGateway` with the chat's id, and the handle holds the chat's state (`handle.chat`), which the generated client folds from what the platform writes: the messages and answers in order, the last turn, and what waits for this user. The member sends `say`, `decide`, `answer` and `stop` through the gateway, and a refusal arrives as its reason: `not_your_turn`, `too_many_messages`, `history_full`. See [Web Gateways](/docs/web/gateways#chat-gateways). ## Refusals | Status | `error` | When | |---|---|---| | 403 | `server_only` | A user started a chat, or asked for what only the server reads (a turn's cost, a chat's pipe). | | 403 | `not_a_member` | `as` names a user who is not a member of the chat. | | 403 | `not_your_turn` | `as` decides or answers a turn another member began. | | 404 | `attachment_not_found` | A message attaches a file that is not there, or one its writer may not attach. | | 402 | `ai_payment_required` | The environment used this month's free AI and has no credit to pay past it — it is on Free, its credit ran out or its month is at its limit — and it has no key of its own for the model's provider ([What it costs](#what-it-costs)). | | 402 | `ai_budget_reached` | The project's monthly AI budget is spent, and it has no key of its own for the model's provider. | | 403 | `forbidden` | A key or a token named `monthly_budget_usd` in the settings: an owner or an admin sets it, in Studio or with the CLI. | | 409 | `history_full` | The project keeps as much chat history as it may. Delete chats to write again. | | 409 | `no_workspace` | A message carries `files` into a chat that works in no workspace. | | 409 | `attachment_changed` | A file changed while it was attached. Send the message again. | | 409 | `too_many_members` | A chat holds 64 members. | | 409 | `too_many_endpoints`, `too_many_repos` | A project holds 32 of each. | | 413 | `attachment_too_large` | A message's picture is over 5 MiB, or its file over 15 MiB. | | 415 | `unsupported_image` | A message's picture is not a png, jpeg, webp or gif by its bytes. | | 429 | `too_many_messages` | The member sent as many messages this minute as the project allows. `Retry-After` says when to write again. | | 503 | `billing_unavailable` | Palbase could not check the project's use of its models just now. `Retry-After` says when to try again. | Each arrives as a `PalbaseError` with its `status` and `code`, its message said in words the caller can act on. A gateway turns the ones a member can act on into a `GatewayRefusal` with the same code, as `asMember` does above. ## Related - [Gateways](/docs/backend/gateways): the channel a chat reaches its members on - [Web Gateways](/docs/web/gateways): a chat's state in the browser - [Event Hooks](/docs/backend/hooks): `ai.response.started`, `ai.response.completed` and `ai.response.failed` (a background response's end, and every chat turn's start and end), `ai.goal.updated`, `ai.voice.started`, `ai.voice.closed`, `ai.voice.delegated` - [Secrets](/docs/backend/secrets): an HTTPS tool's signing secret, a repository's token - [Row-Level Security](/docs/backend/row-level-security): what a tool finds as the chat's member # Webhooks Stripe, GitHub, Slack and the rest tell your app what happened by POSTing to a URL you give them. A `@Webhook` class receives those deliveries at `POST /webhooks/` on your machine: the framework checks each one's signature against the bytes **as they arrived**, under a secret it reads from the machine's environment, and hands it to the `@On` method of its event type. A delivery that is not signed with that secret never reaches your code. This page is about a **third party's** deliveries. The platform's own events (`user.deleted`, `file.uploaded`, …) arrive at `/webhooks/palbase` and are handled by `@On` on an ordinary provider class: see [Event Hooks](/docs/backend/hooks). A server that is not a `palbase/backend` app receives the platform's events with `verifyWebhook`: see [Server SDK: Webhooks](/docs/server/webhooks). ## Quick example ```ts // src/modules/billing/stripe.webhook.ts import { Database, Log, On, Webhook, type WebhookMeta } from "palbase/backend"; interface StripeEvent { id: string; type: string; data: { object: T }; } interface Invoice { id: string; customer: string; amount_paid: number; } @Webhook({ name: "stripe", provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } }) export class StripeWebhook { @On("invoice.paid") async invoicePaid(event: StripeEvent, meta: WebhookMeta): Promise { const invoice = event.data.object; // A delivery runs as no user: what it writes, it writes as the service. await Database.$asService().$query("update accounts set paid_until = now() + interval '1 month' where stripe_customer = $1", [invoice.customer]); Log.info("invoice paid", { invoice: invoice.id, delivery: meta.requestId }); } @On("customer.subscription.deleted") async cancelled(event: StripeEvent<{ customer: string }>): Promise { await Database.$asService().$query("update accounts set paid_until = null where stripe_customer = $1", [event.data.object.customer]); } } ``` ```ts // src/modules/billing/billing.module.ts import { Module } from "palbase/backend"; import { StripeWebhook } from "./stripe.webhook"; @Module({ providers: [StripeWebhook] }) export class BillingModule {} ``` Then, in Stripe's dashboard, add an endpoint at `https://.app.palbase.studio/webhooks/stripe` for `invoice.paid` and `customer.subscription.deleted`, and copy its signing secret (`whsec_…`) into the environment's secrets as `STRIPE_WEBHOOK_SECRET` ([The secret](#the-secret)). From the next deploy on, a paid invoice runs `invoicePaid`. ## `@Webhook(options)` | Option | What it is | |---|---| | `name` | The last segment of the URL, `/webhooks/`: what the sender is configured with. Lowercase letters, digits and dashes, starting and ending with a letter or digit. Not `palbase`, which is the platform's own. Two `@Webhook` classes of one app cannot share a name. | | `provider` | A sender whose signature scheme the framework knows: `stripe`, `github`, `slack`, `discord`, `twilio`, `sendgrid` or `livekit` ([Providers](#providers)). | | `signature` | A scheme spelled out, for any other sender ([A signature of your own](#a-signature-of-your-own)). | | `secret` | `{ env: "VAR_NAME" }`: the environment variable that holds the signing secret. Its name, never its value. | A webhook takes exactly one of `provider` and `signature`: one with neither would take forged deliveries, and one with both would verify with one and leave the other unread. It needs at least one `@On`, and an event type is taken by one method. Every one of these mistakes throws where the class is declared. The class is a provider: its module lists it in `providers`, and it takes its dependencies by constructor, as a controller does ([Modules & Dependency Injection](/docs/backend/modules)). It needs no `@Injectable()`. ## Providers Each provider's scheme is the one its own documentation describes. What it is checked with, what the framework reads as the event's type (the name an `@On` takes), and what it remembers a delivery by ([Duplicates and replays](#duplicates-and-replays)): | `provider` | Signature | The secret is | Event type | Remembered by | Sender's largest body | |---|---|---|---|---|---| | `stripe` | `Stripe-Signature`: HMAC-SHA256 of `"."`, any of its `v1`s; `t` within five minutes | the endpoint's signing secret (`whsec_…`) | the body's `type`: `invoice.paid` | the body's `id` | 512 KiB | | `github` | `X-Hub-Signature-256`: `sha256=` HMAC-SHA256 of the body | the webhook's secret | `X-GitHub-Event` (unsigned, see below) and the body's `action`: `pull_request.opened`; the event alone when there is no action: `push`, `ping` | `X-GitHub-Delivery` with the body's SHA-256 | 25 MB | | `slack` | `X-Slack-Signature`: `v0=` HMAC-SHA256 of `"v0::"`; `X-Slack-Request-Timestamp` within five minutes | the app's signing secret | the body's `type`: `url_verification`, `event_callback`; an interactivity form's `payload.type`: `block_actions`, `view_submission`; `""` for a slash command | the body's `event_id` | 64 KiB | | `discord` | `X-Signature-Ed25519`: Ed25519 of `""`; the timestamp within five minutes | the application's **public key** (64 hex characters) | `PING` for type 1, `INTERACTION_CREATE` for type 2, `type_` for any other | the body's `id` | 64 KiB | | `twilio` | `X-Twilio-Signature`: base64 HMAC-SHA1 of the URL Twilio called and its signed parameters; no time | the account's auth token | the `EventType` parameter; `""` when there is none (a plain incoming message or call) | `I-Twilio-Idempotency-Token` with the SHA-256 of the URL and parameters signed | 64 KiB | | `sendgrid` | `X-Twilio-Email-Event-Webhook-Signature`: ECDSA P-256 of `""`; the timestamp within five minutes | the Event Webhook's **verification key** (the base64 public key SendGrid shows) | always `event_batch` | each event's `sg_event_id` | 1 MiB | | `livekit` | `Authorization`: a JWT signed HS256 with the API secret, unexpired (with a minute's leeway), whose `sha256` claim is the body's hash | the API secret | the body's `event`: `room_started` | the body's `id` | 512 KiB | **A delivery's body is capped** at the smaller of its sender's largest (the last column) and the app's own `bodyLimit` (`PalbaseFactory.create(…, { bodyLimit })`, 1 MiB unless you set it). With the default, a Stripe, LiveKit, Slack, Discord or Twilio delivery is held to what that sender sends, and a GitHub or SendGrid one to 1 MiB: an app that takes GitHub's larger pushes raises its `bodyLimit`, which raises it for every route too. A body over the cap is refused `413 payload_too_large`, before more of it is read than the cap. The secrets of `discord` and `sendgrid` are public keys, which cannot forge a delivery. One that is not a key of the right kind stops the boot, naming the variable. **Twilio signs the URL it called.** The framework rebuilds it as `https://` with the host the request came to, its path and its query, so give Twilio the machine's address exactly as it serves it. It tries the URL with and without the port, as Twilio's own library does. A form body's parameters, decoded and sorted, are part of what is signed, and the handler is given those same parameters: `{ From: "+1555…", Body: "hi", … }`, a name sent twice as an array. A JSON body is signed through the `bodySHA256` parameter of the URL instead. Twilio's signed string has nothing between a name and its value, or between one parameter and the next. So a captured request can be split again into other parameters that verify just the same (`Body=HiFrom+1555` for `Body=Hi&From=+1555`). Twilio's own libraries accept such a request too, and no check can tell it from the original. A Twilio handler checks what it acts on, such as a `MessageSid` or `CallSid` it can look up, before trusting the parameters' split. **GitHub's event name is not signed.** `X-GitHub-Event` is a header, outside what GitHub's signature covers, so whoever holds a GitHub delivery can send it again under another event's name, to that event's `@On`. GitHub signs nothing else that names the event, and every receiver routes on that header, Octokit's included. A GitHub handler checks that the payload is the one it takes: a `pull_request` has a `pull_request`, a `push` has its `ref` and `commits`. **Slack's form or JSON is read from the signed bytes.** A body that is not JSON but is a form with a `payload` field is an interactivity payload, whatever its `Content-Type`, because no signature covers that header. ## A signature of your own A sender with no preset is described by its scheme: an HMAC over a template of the body and, optionally, a timestamp. ```ts // src/modules/shipping/carrier.webhook.ts import { Log, On, Webhook } from "palbase/backend"; interface Shipment { type: string; tracking_number: string; status: string; } @Webhook({ name: "carrier", signature: { header: "x-carrier-signature", prefix: "sha256=", algo: "hmac-sha256", encoding: "hex", // What the HMAC covers: {body} is the bytes as they arrived, {ts} the timestamp header. signs: "{ts}.{body}", timestampHeader: "x-carrier-timestamp", }, secret: { env: "CARRIER_WEBHOOK_SECRET" }, }) export class CarrierWebhook { @On("shipment.delivered") delivered(event: Shipment): void { Log.info("delivered", { tracking: event.tracking_number }); } } ``` | `signature` field | What it is | |---|---| | `header` | The header the signature arrives in. A delivery without it is refused before its body is read. | | `prefix` | Text before the signature in that header (`sha256=`): stripped before comparing. A header without it is refused. | | `algo` | `hmac-sha256` or `hmac-sha1`. | | `encoding` | `hex` or `base64`. | | `signs` | The template the HMAC covers. It must contain `{body}`; `{ts}` puts in the timestamp header's value; everything else is literal text. | | `timestampHeader` | Where `{ts}` is read. With `{ts}` in the template, the timestamp is whole seconds and must be within five minutes of the machine's clock. | The event's type is the body's `type`. A body may be as large as the app's own `bodyLimit` (1 MiB unless you set it): there is no sender's largest to hold it to. A spelled-out scheme names no delivery id, so the framework cannot recognise a delivery sent again: every one runs. Without `{ts}` in `signs`, a captured delivery also verifies whenever it is sent again, at any time ([Duplicates and replays](#duplicates-and-replays)). Use the sender's timestamp when it sends one. ## The secret `secret: { env }` names an environment variable, and the framework reads it when the app is built. Keep it with the project's other secrets: 1. **Environment › Variables**, **Add secret**: the name you gave `env` (`STRIPE_WEBHOOK_SECRET`), and the sender's secret as its value. 2. **Backend › Machines**, the machine, **Edit settings**: start it with the set that holds it (**Secrets: Production secrets**). A machine already started with it rolls out with the new value. 3. List the name in the machine's `.env.example`. A build whose variables the machine does not have waits for them instead of rolling out an app that cannot start ([Builds, Logs & Rollback](/docs/machines/builds)). An app whose webhook's variable is unset, or does not hold a key its provider can use, does not start, and says which: ```text [palbase] the app cannot start: - @Webhook "stripe" (StripeWebhook) reads its signing secret from STRIPE_WEBHOOK_SECRET, which is not set ``` This is not `PALBASE_WEBHOOK_SECRET`. That one is the platform's endpoint secret for [its own events](/docs/backend/hooks); an app with `@Webhook` classes and no platform `@On` does not need it. ## Events and `@On` Inside a `@Webhook` class, `@On("")` takes that sender's events, under whatever name the sender gives them: `invoice.paid`, `pull_request.opened`, `PING`, `block_actions`. (Outside one, `@On` takes the platform's events, whose types are dotted lowercase words; [Event Hooks](/docs/backend/hooks).) A handler is called as `(event, meta)`: | Argument | What it is | |---|---| | `event` | The body's JSON. A body that is not JSON is given as its text. For Slack's interactivity (a form of one field, `payload=`), the parsed `payload`. For Twilio's form, the signed parameters. | | `meta.requestId` | The delivery's request id: the app's log lines of it carry it. | | `meta.env` | The app's environment as it started, without `PALBASE_SECRET_KEY`, `PALBASE_WEBHOOK_SECRET` and `PALBASE_PLATFORM_SECRET`. | | `meta.environmentId` | The project's id. | ```ts // src/modules/slack/slack.webhook.ts import { Log, On, Webhook } from "palbase/backend"; @Webhook({ name: "slack", provider: "slack", secret: { env: "SLACK_SIGNING_SECRET" } }) export class SlackWebhook { // Slack checks the URL once when it is set, and expects the challenge back. @On("url_verification") verify(event: { challenge: string }): { challenge: string } { return { challenge: event.challenge }; } @On("event_callback") message(event: { event: { type: string; user?: string; text?: string } }): void { Log.info("slack event", { type: event.event.type, user: event.event.user }); } // A button: Slack's interactivity form, its payload already parsed. @On("block_actions") clicked(event: { user: { id: string }; actions: { action_id: string }[] }): void { Log.info("clicked", { user: event.user.id, action: event.actions[0]?.action_id }); } } ``` A delivery whose type no `@On` takes is acknowledged with `200 { ok: true, handled: false }` and runs nothing: answering an error would make the sender retry it, and some senders turn off an endpoint that keeps failing. **`@On("")` takes the deliveries that name no type.** Two senders' deliveries name none: - Twilio's plain incoming message or call: a form with no `EventType`. The handler is given its signed parameters. - A Slack slash command: a form with no `payload`. The handler is given the form's text, which `new URLSearchParams(event)` reads. `@On("")` is a `@Webhook`'s alone. On any other class, `@On` takes the platform's events, which always name their type, and an empty one stops the boot. A SendGrid delivery is a batch: its one event type is `event_batch`, and the handler is given the array of events. The event is the sender's data: the database takes values from it, never a filter's structure ([Database](/docs/backend/database)). A delivery runs inside one database transaction, as no user (`anon`), as the platform's events do: what it writes, it writes through `Database.$asService()`. All of it commits when the handler returns, or none of it when it throws. ## What a handler returns What the handler returns is what the sender gets: | The handler returns | The sender gets | |---|---| | nothing (`undefined` or `null`) | `200 { ok: true, handled: true }` | | a `Response` | that response, as it is: its status, headers and body | | a string | `200` with it as the body: `text/xml` for `twilio` (TwiML), `text/plain` for any other | | anything else | `200` with it as JSON: Slack's `{ challenge }`, Discord's `{ type: 1 }` to a `PING` | ```ts // src/modules/discord/discord.webhook.ts import { On, Webhook } from "palbase/backend"; @Webhook({ name: "discord", provider: "discord", secret: { env: "DISCORD_PUBLIC_KEY" } }) export class DiscordWebhook { // Discord pings the URL when it is set, and keeps it only if it answers a PONG. @On("PING") ping(): { type: number } { return { type: 1 }; } @On("INTERACTION_CREATE") interaction(event: { data?: { name?: string } }): { type: number; data: { content: string } } { return { type: 4, data: { content: `You ran /${event.data?.name ?? "?"}` } }; } } ``` A Twilio number's incoming messages carry no `EventType`, so `@On("")` takes them, and a string it returns goes back to Twilio as TwiML (`text/xml`): ```ts // src/modules/sms/sms.webhook.ts import { Log, On, Webhook } from "palbase/backend"; interface IncomingSms { MessageSid: string; From: string; Body: string; } @Webhook({ name: "sms", provider: "twilio", secret: { env: "TWILIO_AUTH_TOKEN" } }) export class SmsWebhook { // A plain incoming message names no event type: "" takes it. @On("") incoming(event: IncomingSms): string { Log.info("sms in", { sid: event.MessageSid, from: event.From }); return 'Thanks, we got it.'; } } ``` Give Twilio `https://.app.palbase.studio/webhooks/sms` as the number's **A message comes in** webhook, method `POST`. ## What the sender sees The sender decides from the status whether to deliver again, so every answer says what happened: | Status | When | The sender | |---|---|---| | `200` | Handled; or no `@On` takes its type (`handled: false`); or a delivery already run (`handled: false, duplicate: true`) | stops | | `401 invalid_signature` | The signature does not check: its `error_description` ends with `missing_signature`, `malformed_signature`, `stale_timestamp` or `signature_mismatch` | retries, and a forger gets nowhere | | `405 method_not_allowed` | Not a `POST` | — | | `409 in_progress` | The same delivery (by its id) is being handled right now; `Retry-After: 5` | delivers it again | | `413 payload_too_large` | The body is over its cap: the smaller of what that sender sends and the app's `bodyLimit` ([Providers](#providers)) | — | | `500 handler_failed` | The handler threw | delivers it again, later | | `503` | What the handler needed of the platform did not answer: `auth_unavailable`, `service_unavailable` (a module call that answered 429, 502, 503 or 504, or never arrived), or the database's `database_unavailable`; with `Retry-After` | delivers it again, after the wait | A delivery needs no API key and no user token: the signature is its only credential, and nothing else is checked. A missing signature header is refused before a byte of the body is read. The reason for a failure stays in the app's log, under the request id: the `500` says only `The event could not be handled`, because a sender may show its answers to anyone who can open its dashboard. Refusals are counted, and the counts logged at most once a minute per webhook, so a stream of forged deliveries cannot fill the log. ## Duplicates and replays Senders deliver **at least once**: a delivery whose answer was lost is sent again. The framework remembers the deliveries it ran, up to the last 10,000 per webhook, and acknowledges one sent again with `duplicate: true` instead of running it twice. What it remembers a delivery by comes from **what the sender signed**, never from a header alone: | Sender | Remembered by | A captured delivery sent again by someone else | |---|---|---| | Stripe, Slack, Discord, LiveKit, SendGrid | the id inside the signed body (`id`, `event_id`, each event's `sg_event_id`), when it has one: a Slack interactivity payload or `url_verification` has none | is not run again within five minutes, and is refused after: they sign a time | | GitHub | `X-GitHub-Delivery` with the SHA-256 of the body | is **run again** under a fresh `X-GitHub-Delivery`: GitHub signs neither a time nor an id | | Twilio | `I-Twilio-Idempotency-Token` with the SHA-256 of the URL and parameters signed | is **run again** under a fresh token: Twilio signs neither a time nor an id | | a spelled-out `signature` | nothing | is run again every time, and without `{ts}` it verifies whenever it is sent | So the sender's own retry is run once: GitHub keeps the delivery's GUID on a redelivery, and Twilio's token is there to tell its retries apart. A delivery replayed under another id by whoever holds a copy of it, from a log or a proxy, is run again for GitHub, Twilio and a template without `{ts}`. For a SendGrid batch the framework keeps the events it has not run and hands the handler those alone. That memory is one copy's, in its memory. Another copy of the machine, or the same one after a restart, runs a delivery again. So: - **Write handlers to be idempotent.** Key what they write on the event's own ids (`invoice.id`, a `MessageSid`, a payload's ids) with a unique constraint or an upsert, so a second run changes nothing. - **Check what a handler acts on.** For GitHub and Twilio, a delivery's authenticity does not say it is new, and their event name or parameters are not wholly signed ([Providers](#providers)). Look up the object the event names before you act on it. ## The contract A `@Webhook` is part of the app's contract — written by its build, kept by the platform with it — which Studio's backend screens read: `POST /webhooks/`, with `x-palbase-webhook` saying who sends it (`provider`, or the `signatureHeader` of a spelled-out scheme), the variable its secret is read from (`secretEnv`, the name alone) and the event types its `@On` take (`events`). ## The `webhooks` path segment `webhooks` is the platform's first path segment, never a controller's. `@Controller` refuses a base path that starts with it, the app does not start with a route whose path begins with it, and a request to a `/webhooks/` no `@Webhook` has answers `404`. `/webhooks/palbase` is the platform's own events ([Event Hooks](/docs/backend/hooks)); every other `/webhooks/` belongs to the `@Webhook` of that name. ## Testing A delivery is a signed `POST`, so a test signs one the way the sender does and hands it to the app. With the webhook's secret in the app's `env`: ```ts // src/modules/billing/stripe.webhook.test.ts import { createHmac } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { Module, On, PalbaseFactory, Webhook, type PalbaseApplication } from "palbase/backend"; const seen: string[] = []; @Webhook({ name: "stripe", provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } }) class TestStripeWebhook { @On("invoice.paid") paid(event: { id: string }): void { seen.push(event.id); } } @Module({ providers: [TestStripeWebhook] }) class TestModule {} const secret = "whsec_test_0123456789"; const env = { PALBASE_URL: "https://abcd1234.palbase.studio", PALBASE_PROJECT_ID: "11111111-2222-3333-4444-555555555555", PALBASE_SECRET_KEY: "pb_abcd1234_s0123456789abcdef0123456789abcdef", STRIPE_WEBHOOK_SECRET: secret, }; /** A delivery as Stripe signs one: HMAC-SHA256 of ".". */ function stripeDelivery(event: { id: string; type: string }, key = secret): Request { const body = JSON.stringify(event); const t = Math.floor(Date.now() / 1000); const v1 = createHmac("sha256", key).update(`${t}.${body}`).digest("hex"); return new Request("http://app.test/webhooks/stripe", { method: "POST", body, headers: { "stripe-signature": `t=${t},v1=${v1}` } }); } let app: PalbaseApplication; beforeAll(async () => { app = await PalbaseFactory.create(TestModule, { env, port: 0, host: "127.0.0.1", exitOnFatal: false }); }); afterAll(() => app.close()); describe("the Stripe webhook", () => { it("runs a paid invoice once, however often it is delivered", async () => { expect((await app.handle(stripeDelivery({ id: "evt_1", type: "invoice.paid" }))).status).toBe(200); const again = await app.handle(stripeDelivery({ id: "evt_1", type: "invoice.paid" })); expect(await again.json()).toMatchObject({ handled: false, duplicate: true }); expect(seen).toEqual(["evt_1"]); }); it("refuses a delivery signed with another secret", async () => { const res = await app.handle(stripeDelivery({ id: "evt_2", type: "invoice.paid" }, "whsec_someone_else")); expect(res.status).toBe(401); expect(await res.json()).toMatchObject({ error: "invalid_signature" }); }); }); ``` `getWebhookManifest(StripeWebhook)` reads what a class declares (its `name`, `provider`, `secretEnv` and `events`) without building it, for a test that pins the wiring. See [Testing](/docs/backend/testing) for the rest of a backend's tests. ## Related - [Event Hooks](/docs/backend/hooks) — the platform's own events, at `/webhooks/palbase` - [Server SDK: Webhooks](/docs/server/webhooks) — the platform's events delivered to any other server - [Secrets](/docs/backend/secrets) — where a webhook's signing secret is kept - [Database](/docs/backend/database) — `$asService()` and the delivery's transaction - [Controllers & Routing](/docs/backend/controllers) — the paths that are the platform's - [Testing](/docs/backend/testing) — building the app in a test # Event Hooks The project's modules announce what happens in them as **events**: a user deleted, a file uploaded, a secret changed, a message sent. A backend handles one with `@On("")` on a method of a provider class. The platform delivers each event to the project's webhook endpoint as a signed `POST`. `palbase/backend` receives it at `/webhooks/palbase`, checks the signature, and runs every handler of that type once, inside one database transaction. These are the **platform's** events. A third party's — Stripe, GitHub, Slack — arrive at `/webhooks/` and are taken by `@On` inside a `@Webhook` class: see [Webhooks](/docs/backend/webhooks). ## Quick example ```ts // src/modules/accounts/account-events.ts import { Injectable, Log, On, Palbase, type WebhookEvent } from "palbase/backend"; import { pages } from "palbase/server"; @Injectable() export class AccountEvents { @On("user.deleted") async userDeleted(event: WebhookEvent<{ user_id: string }>, meta: { requestId: string }): Promise { // The user's files go with them: every page of their prefix, then each object. const prefix = `users/${event.data.user_id}/`; const paths: string[] = []; for await (const page of pages((cursor) => Palbase.storage.listObjects({ path: { bucket: "uploads" }, query: { prefix, cursor } }))) { paths.push(...page.objects.map((o) => o.path)); } for (const path of paths) await Palbase.storage.deleteObject({ path: { bucket: "uploads", path } }); Log.info("removed a deleted user's uploads", { user: event.data.user_id, files: paths.length, delivery: meta.requestId }); } } ``` ```ts // src/modules/accounts/accounts.module.ts import { Module } from "palbase/backend"; import { AccountEvents } from "./account-events"; @Module({ providers: [AccountEvents] }) export class AccountsModule {} ``` The class is a provider like any other: its module lists it in `providers`, and it takes its dependencies by constructor. `@On` marks it as a handler class, so it may also stand without `@Injectable()`. Add `@Injectable()` whenever it has constructor parameters, because TypeScript emits their types only for a decorated class. ## What a handler is given ```ts // src/modules/files/file-events.ts import { Injectable, Log, On, type WebhookEvent } from "palbase/backend"; @Injectable() export class FileEvents { @On("file.uploaded") uploaded(event: WebhookEvent, meta: { requestId: string }): void { Log.info("a file arrived", { event: event.id, type: event.type, at: event.created_at, delivery: meta.requestId, data: event.data }); } } ``` | Field of `WebhookEvent` | What it is | |---|---| | `id` | The event's id. The same id twice is the same event, delivered again. | | `type` | Dotted lowercase words: `user.deleted` | | `project_id` | The project it happened in | | `created_at` | When it happened | | `data` | What the module says of it, typed `T` when you give one | `meta.requestId` is the delivery's request id: every `Log` line the handler writes carries it. `@On(type)` takes the type as dotted lowercase words (`user.deleted`). Anything else throws when the class is declared. One method handles one type. A type may have handlers in several classes, and they all run. ## Events Each module's contract names what it emits. Among them: | Event | From | |---|---| | `user.deleted` | auth: `data` is `{ user_id }` | | `file.uploaded`, `file.deleted`, `file.moved` | storage | | `secret.changed` | secrets (its name, never its value) | | `notification.sent`, `notification.failed` | notify | | `machine.created`, `machine.updated`, `machine.deleted` | machines | | `database.created`, `database.deleted`, `database.password_changed`, … | machines | Type `data` for the events you handle: `WebhookEvent<{ user_id: string }>`. The framework checks only the envelope (`id`, `type`, `project_id`, `created_at`, `data`). ## How a delivery runs 1. **The signature.** The platform signs each delivery with the endpoint's secret: `Palbase-Signature: t=,v1=.")>`. The app checks it against `PALBASE_WEBHOOK_SECRET` over the body exactly as it arrived, with `t` within five minutes of now. Anything else is `401 invalid_signature`, naming `missing_signature`, `malformed_signature`, `stale_timestamp` or `signature_mismatch`, and no handler runs. A request that is not a `POST` is `405`. 2. **Once.** An event already handled by this process is acknowledged again (`204`) and not run again. One whose first delivery is still running is answered `409 in_progress` with `Retry-After: 5`. 3. **One transaction.** Every handler of the type runs in order, inside **one** database transaction when the app has a database. Either all their work commits, or none of it does. 4. **The answer.** All handlers returned: `204`. One threw: the transaction is rolled back, the error is logged with the handler's name, and the answer is `500 handler_failed`. The platform then delivers the event again, with a growing delay, up to twelve attempts. A type with no handler is acknowledged with `204`. The payload is data, like a request's body: the database takes values from `event.data`, never a filter's structure. > **Note:** "Once" is per process, for the last 10,000 events. A machine with several copies, or one that restarted, can run an event a second time. Make a handler safe to run twice, keyed on `event.id` or on what the event names, as deleting what is already deleted is. ### The database inside a delivery A delivery has no caller, so its transaction is `anon` with no claims, and no policy that reads `auth.uid()` admits it. Work that writes users' rows goes through `Database.$asService()`: ```ts // src/modules/accounts/forget.ts import { Database, Injectable, On, type WebhookEvent } from "palbase/backend"; @Injectable() export class ForgetAudit { @On("user.deleted") async record(event: WebhookEvent<{ user_id: string }>): Promise { await Database.$asService().$query("insert into deletion_log (user_id, event_id) values ($1, $2)", [event.data.user_id, event.id]); } } ``` ### `user.deleted` and the tables that keep users An app whose schema keeps users' ids (columns declared with `ownedByUser()` or `userRef()`) handles `user.deleted` without writing a handler. The framework's own handler runs first, in the same transaction: it deletes the rows the user owned and clears the references to them. Your `@On("user.deleted")` handlers run after it. See [Schema](/docs/backend/schema). ## What the app needs to receive events An app that has an `@On` handler, or whose schema keeps users' ids, must know the endpoint's secret. On a machine the platform gives it as `PALBASE_WEBHOOK_SECRET`; anywhere else the app reads it from there (`whsec_` and 64 hex characters), or from `PalbaseFactory.create(…, { palbase: { webhookSecret } })`. Without it the app does not start: ```text [palbase] the app cannot start: - palbase.webhookSecret / PALBASE_WEBHOOK_SECRET is the project's webhook endpoint secret (whsec_…): the platform delivers this app events — its @On handlers', or user.deleted for the users its tables keep ``` The platform keeps the machine's endpoint itself: the machine's address followed by `/webhooks/palbase`, for example `https://-.app.palbase.studio/webhooks/palbase`, sent exactly the events the app takes — its `@On` types, and `user.deleted` when its schema keeps users' ids. It reads them from the app's contract once a version serves alone, so a push or a rollback that changes what the app takes changes the endpoint with it. The endpoint is listed under **Settings › Webhooks** as the machine's; it cannot be changed or deleted there, and it goes with the machine. A machine whose app takes no events has none. Any other server receives the same deliveries with `palbase/server`'s `verifyWebhook`. See [Webhooks](/docs/server/webhooks). ## Testing a handler A delivery is an ordinary signed request, so a test can make one and hand it to the app. See [Testing](/docs/backend/testing) for building the app in a test. The signature is the HMAC of `"."`: ```ts // test/deliver.ts import { createHmac } from "node:crypto"; export function delivery(secret: string, event: { id: string; type: string; data: unknown }): Request { const body = JSON.stringify({ ...event, project_id: "11111111-2222-3333-4444-555555555555", created_at: new Date().toISOString() }); const t = Math.floor(Date.now() / 1000); const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex"); return new Request("http://app.test/webhooks/palbase", { method: "POST", body, headers: { "content-type": "application/json", "palbase-signature": `t=${t},v1=${v1}` }, }); } ``` ## Related - [Webhooks](/docs/backend/webhooks): a third party's deliveries, with `@Webhook` - [Server SDK: Webhooks](/docs/server/webhooks): the same deliveries, to any server - [Database](/docs/backend/database): `$asService()` and the request's transaction - [Schema](/docs/backend/schema): `ownedByUser()` and `userRef()` - [Modules & Dependency Injection](/docs/backend/modules): providers - [Testing](/docs/backend/testing): building the app in a test # Cron A cron is a class the platform runs on a schedule: a nightly report, an hourly cleanup, a digest every Monday at 9 in the morning, Istanbul time. Mark a provider with `@Cron({ name, schedule })` and give it a `run(meta)` method. When the machine's version comes up, the platform asks it which crons it has. It then calls each one when it is due, waking the machine if it sleeps. Work that comes from something that happened — an order paid, a file uploaded — is not a cron that looks every minute for what to do: put it on the machine's queue as a [task](/docs/backend/tasks), and it runs once, as soon as it is put, with the machine asleep in between. ## Quick example The tables the cron works on: ```ts // src/db/public.ts import { defineSchema, defineTable, integer, ownedByUser, policy, text, timestamp, uuid } from "palbase/backend"; export const profiles = defineTable("profiles", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), name: text(), created_at: timestamp().defaultNow() }, policies: (p) => [policy("profiles_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); export const upload_sessions = defineTable("upload_sessions", { columns: { id: uuid().primaryKey().defaultRandom(), owner: ownedByUser(), expires_at: timestamp() }, policies: (p) => [policy("uploads_own").for("all").using(p.col("owner").eq(p.auth.uid())).withCheck(p.col("owner").eq(p.auth.uid()))], }); // The app's own: no policy lets a user reach it, the service role does. export const daily_reports = defineTable("daily_reports", { columns: { run_id: text().primaryKey(), signups: integer(), made_at: timestamp().defaultNow() }, }); export default defineSchema("public", { tables: [profiles, upload_sessions, daily_reports] }); ``` ```ts // src/modules/reports/daily-signups.cron.ts import { Cron, Database, Log, type CronMeta } from "palbase/backend"; import { ago } from "palgres"; // Every day at 06:00 in Istanbul: the last day's sign-ups, counted into one row. @Cron({ name: "daily-signups", schedule: "0 6 * * *", timeZone: "Europe/Istanbul", timeout: 120, retry: 3 }) export class DailySignups { async run(meta: CronMeta): Promise { const signups = await Database.public.profiles.count({ where: { created_at: { gte: ago({ days: 1 }) } } }); // Keyed on the run: an attempt made again finds its row there, and writes no second one. await Database.public.daily_reports.insertMany([{ run_id: meta.runId, signups }], { action: "ignore", returning: false }); Log.info("daily sign-ups", { signups, run: meta.runId, attempt: meta.attempt }); } } ``` ```ts // src/modules/reports/upload-sweep.cron.ts import { Cron, Database, Log } from "palbase/backend"; import { now } from "palgres"; // Every 15 minutes: the upload sessions that expired, removed. @Cron({ name: "upload-sweep", schedule: "*/15 * * * *" }) export class UploadSweep { async run(): Promise { const gone = await Database.public.upload_sessions.deleteMany({ where: { expires_at: { lt: now() } } }); Log.info("expired upload sessions removed", { gone }); } } ``` ```ts // src/modules/reports/reports.module.ts import { Module } from "palbase/backend"; import { DailySignups } from "./daily-signups.cron"; import { UploadSweep } from "./upload-sweep.cron"; @Module({ providers: [DailySignups, UploadSweep] }) export class ReportsModule {} ``` A cron is a provider like any other: its module lists it in `providers`, and it takes its dependencies by constructor — a service whose method does the work, for one. A class is one cron. A controller that is a `@Cron` is refused at boot, because a controller is not where crons live. ## The schedule Five fields, as cron's, on the clocks of the cron's time zone — **UTC** unless `timeZone` names one: minute, hour, day of the month, month, day of the week. | Field | Values | |---|---| | minute | 0–59 | | hour | 0–23 | | day of the month | 1–31 | | month | 1–12 | | day of the week | 0–7 (0 and 7 are Sunday) | Each field is `*`, a number, a list (`1,15`), a range (`1-5`), or a step (`*/10`, `10/20`, `8-18/2`). When both day fields are given, a day that matches either one counts, as in classic cron. There are no seconds and no `@daily`-style names. A schedule the platform would not run is refused where `@Cron` is written: six fields, `@daily`, `60 * * * *`, or a date that never comes (`0 0 31 2 *`). ### Time zones `timeZone` is a time zone's IANA name — `Europe/Istanbul`, `America/New_York`, `Asia/Kolkata` — and the schedule is read on its clocks. Where they change for summer time, the platform keeps to one rule, so a cron is never run twice for one time: - A time the clocks **skip** (02:30 on the night they go from 02:00 to 03:00) is not run that day. - A time the clocks **show twice** (02:30 on the night they go back from 03:00 to 02:00) is run the first time only. A cron that must run every so often, whatever the clocks do, is written in UTC. ## Options | Option | What it is | Default | |---|---|---| | `name` | What the platform calls the cron and keeps its runs under. Letters, digits, `.`, `_` and `-`, at most 64. | required | | `schedule` | Five fields of cron's | required | | `timeZone` | The IANA time zone whose clocks the schedule is on | `UTC` | | `timeout` | Seconds one run may take before it is a failed attempt: 1–900 | 30 | | `retry` | Attempts after a failed one: 0–10 | 5 | Renaming the cron starts a new one: the old name's runs stay under it. Two crons of one name are refused at boot, naming both. A machine runs at most 50 crons. ## How a run goes 1. **Due.** The platform calls the cron at its minute, waking the machine if it is asleep, on one of its copies. One run of a cron is open at a time: a cron due again while its last run is still going is skipped for that minute. While the machine is held at the call, it is not put to sleep. 2. **Signed.** The call is `POST /_palbase/crons/`, signed with the machine's `PALBASE_PLATFORM_SECRET` in `Palbase-Signature`, as the platform's other calls into a machine are. Anything not signed by the platform is `401`, and nothing runs. A request for these paths from the internet never reaches the machine: its edge answers `404`. 3. **One transaction, as the service role.** `run` runs in a database transaction of its own, given its `CronMeta`. A cron has no caller, so its transaction is the **service role's**: every row is in reach and no policy filters it. That differs from a [delivery](/docs/backend/hooks), which runs as `anon`. 4. **Done or failed.** `run` returned: the run succeeded (`204`). It threw — anything, a `NotFound` too: the transaction is rolled back, the error is logged with the cron's name, the run and the attempt, and the attempt failed (`500`). It ran past its `timeout`: the platform hangs up, and what the attempt wrote is rolled back even if it returns later. A failed attempt is tried again after 10 s, 20 s, 40 s, … five minutes at most, until `retry` is spent. 5. **Once.** An attempt that ended is answered `204` if the same call comes again; one still running is answered `409` and is not run beside itself. A cron this version no longer has is answered `404`: the platform skips the run and does not try it again. A project whose machine time for the period is used up runs no cron: its runs are skipped until the time comes back. ## Run now, pause and resume From the machine's **Crons** tab in Studio, or the CLI — an admin's, as every change is: | | Studio | CLI | |---|---|---| | Run a cron now, apart from its schedule | Run now | `palbase machines:crons:run ` | | Pause it: it is not run until resumed, whatever version of the machine comes up meanwhile | Pause | `palbase machines:crons:pause ` | | Resume it: due at its next time from now — the runs it missed are not made up | Resume | `palbase machines:crons:resume ` | A run asked for now is marked as such among the runs, and waits its turn behind a run still going (`409` while one is). A pause is the project's, not the program's: a deploy does not undo it. ## What a cron is given | Field of `CronMeta` | What it is | |---|---| | `env` | The app's environment variables as it started, the platform's keys left out | | `environmentId` | The project's id | | `cron` | The cron's name | | `runId` | The run's id. It is the same for every attempt of one run. | | `scheduledAt` | The minute it was due (or was asked to run now), as a `Date` | | `attempt` | 1 for the first, then one more for each attempt made again | > **Note:** An attempt can run and still be counted as failed: the machine finished, but its answer never reached the platform. The next attempt runs it again. Make a cron safe to run twice, keyed on `runId` or on what it works on, as the report above is. ## Seeing the runs The crons a machine's running version declared, and each one's runs, are on its **Crons** tab in Studio and in the CLI (`palbase machines:crons`, `palbase machines:crons:runs `). They are also in the API: `GET /machines//crons` and `GET /machines//crons//runs`, with the project's secret key. ## On your computer Off the platform there is no `PALBASE_PLATFORM_SECRET`, so `/_palbase/crons` is not served and nothing calls a cron. The app says how many crons it has when it starts. To run one, call its `run` from a test: ```ts // src/modules/reports/upload-sweep.cron.test.ts import { describe, expect, it } from "vitest"; import { UploadSweep } from "./upload-sweep.cron"; describe("UploadSweep", () => { it("is a class like any other", () => { expect(typeof new UploadSweep().run).toBe("function"); }); }); ``` The database work in a cron needs a request's database, as a handler's does: see [Testing](/docs/backend/testing). ## Related - [Tasks](/docs/backend/tasks): work put on the machine's queue, run once, now or later - [Event Hooks](/docs/backend/hooks): the platform's events, delivered as they happen - [Database](/docs/backend/database): the transaction a cron runs in - [Modules & Dependency Injection](/docs/backend/modules): providers and their modules # Tasks A task is work your backend puts on its machine's queue to be done once, now or later: a receipt mailed when an order is paid, a reminder a day after sign-up, a report built in the background. Mark a provider with `@Task({ name })` and give it a `run(payload, meta)` method; put a task with `Tasks.enqueue(TheClass, payload)`. The platform keeps the queue. When a task is due it wakes the machine and calls `run`; an attempt that fails is made again later, and a task with no attempt left is kept, to be tried again from Studio or the CLI. Nothing polls: a machine with no task due sleeps. A [cron](/docs/backend/cron) that wakes every minute to look in a table for work keeps it awake for nothing, and takes up to a minute to notice. ## Quick example The table the task works on: ```ts // src/db/public.ts import { defineSchema, defineTable, text, timestamp, uuid } from "palbase/backend"; // The app's own: no policy lets a user reach it, the service role does. export const orders = defineTable("orders", { columns: { id: uuid().primaryKey().defaultRandom(), email: text(), receipt_sent_at: timestamp().nullable() }, }); export default defineSchema("public", { tables: [orders] }); ``` ```ts // src/modules/orders/send-receipt.task.ts import { Database, Log, Notifications, Task, TaskFailure, z, type TaskMeta } from "palbase/backend"; import { now } from "palgres"; const Receipt = z.object({ orderId: z.string().uuid() }); // Mail an order's receipt. A failed attempt is made again: up to five more, 10 s, 20 s, 40 s… apart. @Task({ name: "send-receipt", payload: Receipt, retry: 5, concurrency: 10 }) export class SendReceipt { async run(payload: z.infer, meta: TaskMeta): Promise { const order = await Database.public.orders.findUnique({ where: { id: payload.orderId } }); // The order is gone: no attempt will ever find it. if (!order) throw new TaskFailure(`order ${payload.orderId} is gone`); if (order.receipt_sent_at) return; // an attempt made again after one that finished await Notifications.email.send({ to: order.email, templateSlug: "receipt", variables: { order: order.id } }); await Database.public.orders.update({ where: { id: order.id }, set: { receipt_sent_at: now() } }); Log.info("receipt sent", { order: order.id, task: meta.id, attempt: meta.attempt }); } } ``` ```ts // src/modules/orders/orders.controller.ts import { Controller, Param, Post, Tasks } from "palbase/backend"; import { SendReceipt } from "./send-receipt.task"; @Controller("/orders") export class OrdersController { @Post("/{id}/paid") async paid(@Param("id") id: string) { // Put once per order: the same key again answers the task already put. const task = await Tasks.enqueue(SendReceipt, { orderId: id }, { key: `receipt:${id}` }); return { receipt: task.id }; } } ``` ```ts // src/modules/orders/orders.module.ts import { Module } from "palbase/backend"; import { OrdersController } from "./orders.controller"; import { SendReceipt } from "./send-receipt.task"; @Module({ controllers: [OrdersController], providers: [SendReceipt] }) export class OrdersModule {} ``` A task type is a provider like any other: its module lists it in `providers`, and it takes its dependencies by constructor. A class is one task type. ## Putting a task ```ts // src/modules/users/reminders.ts import { Task, Tasks, type TaskMeta } from "palbase/backend"; import { SendReceipt } from "../orders/send-receipt.task"; @Task({ name: "remind" }) export class Remind { async run(payload: { userId: string }, meta: TaskMeta): Promise { console.log(`remind ${payload.userId} (task ${meta.id})`); } } export async function put(orderId: string, userId: string, trialEnds: string): Promise { await Tasks.enqueue(SendReceipt, { orderId }); // due now await Tasks.enqueue(Remind, { userId }, { delay: 24 * 3600 }); // in a day await Tasks.enqueue(Remind, { userId }, { runAt: new Date(trialEnds) }); await Tasks.enqueue(SendReceipt, { orderId }, { key: `receipt:${orderId}` }); } ``` What `Tasks.enqueue` takes is checked as it is written: the payload is what the class's `run` takes, and anything else does not compile. It answers the task as it was put — `id`, `state`, `runAt` — and `duplicate: true` when a task of the type was put already under the same key. | Option | What it is | |---|---| | `delay` | Due this many seconds from now: 30 days at most | | `runAt` | Due at this time: 30 days from now at most; a time past is now | | `key` | Put once: another task of the type under the same key, until a day after the first is over, is not put — the first is answered | A task carries 64 KiB of JSON at most, kept and handed to `run` as the bytes it was given: name what it works on — an id — and read it where it runs. A project's queues hold 100,000 tasks not yet done, or 256 MiB of what the tasks kept carry; past that, `Tasks.enqueue` is refused (`429`, `queue_full`) until some are done. `Tasks.cancel(id)` takes a task that is waiting off the queue; `Tasks.get(id)` reads one as it is now. A task of another of the project's machines is put by its type's name: `Tasks.enqueue("rebuild-index", payload, { machine: "search" })`. ## Options | Option | What it is | Default | |---|---|---| | `name` | The task type: what its tasks are put under, kept under and called by. Letters, digits, `.`, `_` and `-`, at most 64. | required | | `payload` | A schema (zod, valibot, arktype — any Standard Schema) what a task carries is checked against before `run`; `run` is given what it gives | none | | `timeout` | Seconds one attempt may take before it is a failed one: 1–900 | 60 | | `retry` | Attempts after a failed one: 0–20 | 5 | | `concurrency` | How many of its tasks the machine runs at once, however many copies it has: 1–50 | 5 | A machine has at most 50 task types; two of one name are refused at boot. ## How a task goes 1. **Due.** When its time comes, the platform calls the machine for it — waking it if it sleeps — once the type has room under its `concurrency` (and the project under 50 at once). Tasks are taken type by type, oldest first: one type's backlog holds no other's back. 2. **Signed.** The call is `POST /_palbase/tasks/`, signed with the machine's `PALBASE_PLATFORM_SECRET`, as the platform's other calls into a machine are. Nothing from the internet reaches it. 3. **Checked, then run in one transaction, as the service role.** What it carries is checked against `payload`, then `run` runs in a database transaction of its own, given its `TaskMeta`, with every row in reach as a [cron](/docs/backend/cron)'s is. 4. **Done, failed for good, or made again.** - `run` returned: the task succeeded. - What it carries is not what `payload` takes, or `run` threw a `TaskFailure`: the task **failed** at once and is not tried again — no attempt would do better. - `run` threw anything else, or ran past its `timeout`: the attempt failed and its transaction is rolled back. The next one is made after 10 s, then 20 s, 40 s, … an hour apart at most, until `retry` is spent; then the task **failed**. A task that succeeded lets go of what it carried; only a task that may be tried again keeps it. A task is delivered **at least once**: its `id` is the same through every attempt, and an attempt can run and still be counted as failed (the machine finished, its answer was lost). Key the work on the task's `id`, or on what it works on, as the receipt above does with `receipt_sent_at`. A project whose machine time for the period is used up runs no task: a task due then fails ("its machine time for the period is used up"), and none is put meanwhile (`429`, `quota_exceeded`). Once the time comes back, the failed ones are tried again by hand — not run in a heap the moment it does. ## What a task is given | Field of `TaskMeta` | What it is | |---|---| | `env` | The app's environment variables as it started, the platform's keys left out | | `environmentId` | The project's id | | `task` | The task type's name | | `id` | The task's id: the same for every attempt of it | | `attempt` | 1 for the first, then one more for each attempt made again | | `createdAt` | When it was put on the queue | ## Failed tasks A task that succeeded or was cancelled is kept a day; one that failed, 30 — with why its last attempt failed. On the machine's **Tasks** tab in Studio, or with the CLI: | | Studio | CLI | |---|---|---| | The types, and what each one's queue holds | Task types | `palbase machines:tasks:types` | | The tasks, of one state or type | Tasks, with Show | `palbase machines:tasks --state failed` | | A task, with what it carries | | `palbase machines:tasks:get ` | | Try a failed task again: due now, with as many attempts again as its type gives — its attempts counted on, not from zero | Retry | `palbase machines:tasks:retry ` | | Take a waiting task off the queue: it is not run, nor tried again, and what it carried is let go | Cancel | `palbase machines:tasks:cancel ` | | Put a task by hand | | `palbase machines:tasks:enqueue --payload ''` | Trying again and cancelling are an admin's, as every change is; so is reading what a task carries — a member sees the queue, not what is in it. ## On your computer Off the platform — the app started on your computer, or in a test — it runs its own tasks, in its own process, as the platform would: each when it is due, its type's `concurrency` kept, a failed attempt made again later, a key held a day. Nothing of that queue outlives the process. `Tasks.enqueue` of another machine's type is refused there. ## Without palbase/backend Any program a machine runs takes tasks by answering two signed calls, as it takes [crons](/docs/machines/overview#crons-and-tasks); anything with the project's secret key puts them: `POST /machines//tasks` with `{"task", "payload", "delay" | "run_at", "key"}`. ## Related - [Cron](/docs/backend/cron): work on a schedule - [Event Hooks](/docs/backend/hooks): the platform's events, delivered as they happen - [Database](/docs/backend/database): the transaction a task runs in # Cache Every project has a cache: short-lived entries, each a value a server worked out and wants again, kept by the platform rather than in your machine's memory. Every copy of a machine reads the same entries, a push does not empty them, and a machine that slept finds them when it wakes. In a `palbase/backend` app it is `Cache`; from any other server it is `createServer({ url, secretKey }).cache`, the cache module's calls as its contract states them. Only the secret key reaches it: an app never reads or writes the cache. A cache is not a database. Every entry runs out, after thirty days at most, and an entry may be gone before its time, dropped to make room. What must not be lost belongs in a [database](/docs/backend/database). ## Quick example ```ts // src/modules/products/products.service.ts import { Cache, Database, Injectable } from "palbase/backend"; type Product = { id: string; name: string; price_cents: number }; @Injectable() export class ProductsService { /** The catalog, read from the database at most once every five minutes. */ async catalog(): Promise { return Cache.getOrSet("catalog", 300, () => Database.$query("select id, name, price_cents from products order by name")); } /** After a change, the next read goes to the database again. */ async changed(): Promise { await Cache.del("catalog"); } } ``` ## The `Cache` service | Call | What it does | |---|---| | `get(key)` | The value, or `null` when there is none, or when the cache could not be reached | | `set(key, value, ttl?)` | Writes the value for `ttl` seconds, replacing what was there and its time left. Without a `ttl` it lasts thirty days | | `getOrSet(key, ttl, fn)` | Reads through: on a hit the value; on a miss `fn`'s, kept for `ttl` seconds | | `incr(key)` | Adds one to the key's count and answers the new count | | `del(key)` | Deletes the entry. Deleting what is not there is not an error | - **A key** is UTF-8 text, 1 to 512 bytes. Every project has keys of its own: no key reaches another project's entries, so a key needs no prefix for that. - **A value** is JSON, 512 KiB at most. What is kept is what `JSON.stringify` makes of it: a `Date` comes back as its ISO string, a class instance as its fields, `undefined` as `null`. A value written as `null` reads as `null` from `get`, the same as a miss; `getOrSet` counts it as a hit. - **A `ttl`** is in seconds. A fraction is rounded up; none, `0` or less is thirty days, and more than thirty days is thirty days (`CACHE_MAX_TTL`, which `palbase/backend` exports). ### When the cache cannot be reached The app is not stopped by a cache that is away. A call that cannot reach it, or that is refused for now (`429`, `503`), is treated by what it is for: | Call | When the cache is away | |---|---| | `get` | A miss: `null`, and a warning in the machine's log | | `set` | The write is let go, with a warning | | `getOrSet` | `fn` runs and its value is answered, not kept | | `del` | Throws: the old value would come back after the delete said it was done | | `incr` | Throws: there is no number to make up | A read is sent again first, as every read of the server SDK is, waiting the `Retry-After` the cache asks for. Any other refusal, such as a key longer than 512 bytes or a value larger than 512 KiB, throws a `PalbaseError` from every call ([Server SDK](/docs/server/overview)). ### Reading through: `getOrSet` On a miss, `fn` is called and its value kept for `ttl` seconds. Misses on the same key that arrive while `fn` runs, in the same process, wait for that one call: a hundred requests that miss at once work the value out once. The sharing is a process's own, so two copies of a machine that miss at the same moment each call `fn`. When `fn` throws, every caller waiting on it gets that error, and nothing is kept. ### Counting: `incr` `incr` is atomic across every copy of the machine: two callers never get the same number. ```ts // src/modules/exports/exports.service.ts import { Cache, Injectable, TooManyRequests } from "palbase/backend"; @Injectable() export class ExportsService { /** A user's exports today, the fifth the last. */ async allow(userId: string): Promise { const day = new Date().toISOString().slice(0, 10); const n = await Cache.incr(`exports:${userId}:${day}`); if (n > 5) throw new TooManyRequests({ retryAfter: 3600 }, "five exports a day"); } } ``` - An entry that is not there, or holds something other than a whole number, counts from zero. - A count keeps the time its entry had left; a new count lasts thirty days. A count started with `set(key, 0, ttl)` ends with that `ttl`. - A count stops at fifteen digits: one more is refused (`409 count_too_large`) and nothing is counted. - `incr` is never sent again by the client. When the cache was reached but did not answer, the count may have been taken: read the entry before counting again. For a route's own limit on how often a caller may call it, use the route's `rateLimit` ([Controllers & Routing](/docs/backend/controllers)), not a count of your own. ## Room in the cache An entry weighs what the cache keeps for it: its value plus three times its key, a quarter more, and 320 bytes. A project's entries together weigh at most its quota, 25 MiB. A write past the quota does not fail. The project's own entries nearest their end are dropped to make room, and the write's answer says how many (`evicted`). When the cache as a whole is full, the project that weighs the most gives back first; a project under its share keeps its entries. One entry larger than the whole quota is refused (`413 entry_too_large`). A project reads and writes so many bytes a second (8 MiB read, 2 MiB written; the second's first call is taken whatever its size), and makes at most its requests per second of calls to the cache. Past either, a call is `429 rate_limited` with `Retry-After`. The cache's calls are not counted as the project's API requests, and they are not lines of its request [log](/docs/getting-started/logs). What the project's entries weigh is metered as **Cache memory**, and the bytes its reads carry out as **Cache reads**, on the project's **Settings › Usage** in Studio ([Usage & Limits](/docs/getting-started/usage-and-limits)). A deleted environment's entries are deleted with it. ## From any server `createServer(...).cache` is the cache module's calls, with the secret key: ```ts // server/cache.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); const written = await palbase.cache.putCacheEntry({ query: { key: "greeting" }, body: { value: { text: "hello" }, ttl: 600 } }); console.log(written.data.expires_in, written.data.evicted); const read = await palbase.cache.getCacheEntry({ query: { key: "greeting" } }); if (read.data.found) console.log(read.data.value, read.data.expires_in); const usage = await palbase.cache.getCacheUsage(); console.log(usage.data.bytes, usage.data.limit_bytes, usage.data.entries); ``` | Call | HTTP | Answers | |---|---|---| | `getCacheEntry({ query: { key } })` | `GET /cache/entry?key=` | `{ found, value?, expires_in? }`: a key with no entry is `found: false`, not an error | | `putCacheEntry({ query: { key }, body: { value, ttl? } })` | `PUT /cache/entry?key=` | `{ expires_in, evicted }`. A `ttl` is 1 to 2592000 seconds; outside it, `400` | | `deleteCacheEntry({ query: { key } })` | `DELETE /cache/entry?key=` | `{ deleted }`: whether there was an entry | | `incrementCacheEntry({ query: { key } })` | `POST /cache/entry/increment?key=` | `{ value }`: the new count | | `getCacheUsage()` | `GET /cache/usage` | `{ bytes, limit_bytes, entries }`: what the live entries weigh, the quota, how many there are | The key is a query parameter, which the request's log line does not keep. A publishable key is refused (`403 secret_key_required`), and so is the secret key sent with a user's token beside it. A cache that could not be reached is `503 cache_unavailable`, and one that takes no more at this moment `503 cache_busy`; both carry `Retry-After`. ## In tests `withServices({ Cache: … })` gives the code a cache of the test's own ([Testing](/docs/backend/testing#the-services-the-code-reads-withservices)). A test that does not give one, and runs code that reads `Cache`, is refused by name: the real calls would reach a platform no test serves. ## Related - [Database](/docs/backend/database) — what must be kept - [Machines](/docs/machines/overview) — copies, sleep, and what a copy keeps in its own memory - [Server SDK Overview](/docs/server/overview) — calls, refusals, retries - [Usage & Limits](/docs/getting-started/usage-and-limits) — what is metered # Translations Write the sentences your backend says to people in one language, and `t()` answers each request in the language its caller reads: the first of their languages your app has a translation for, and the language you wrote it in otherwise. ```ts import { Controller, Post, t } from "palbase/backend"; @Controller("/payments") export class PaymentsController { @Post("/charge") charge() { // … return { message: t("Kartınız reddedildi ({{amount}} ₺)", { amount: 50 }) }; } } ``` The sentence itself is the key. `{{name}}` takes a value from the second argument; `{{{name}}}` is left as it is. ## How a request's language is chosen Every request says its user's languages in `Accept-Language`, best first: a browser sends them, and the iOS and Android SDKs send the device's languages in its order. For each sentence, `t()` takes the first of them your app has a translation of, then the language the sentences are written in. Each language is matched as a table names it, from the most specific down: `pt-BR` reads `pt-BR.json`, then `pt.json`; `zh-TW` reads `zh-TW.json`, then `zh-Hant.json`, then `zh.json`. A device's regional preferences (`fr-FR-u-mu-celsius`) are not part of the language. The answer says which: `Content-Language: en` (a list when one answer mixes languages; unless your handler set it). An app with `palbase/strings/` answers every request with `Vary: Accept-Language`, so a cache never hands one user's language to another. `t()` outside a request — a scheduled job, an event hook, a gateway, the app's start — has no caller to ask, and says the sentence as it is written. ## palbase/strings/ The translations live in your repository, next to the code, one file per language: ```text palbase/strings/ _meta.json { "version": 3, "source": "tr" } en.json { "Kartınız reddedildi ({{amount}} ₺)": "Your card was declined ({{amount}} ₺)" } de.json { "Kartınız reddedildi ({{amount}} ₺)": "" } ``` - `_meta.json` names the language the sentences are written in (`source`). It has no file of its own: its text is the key. - Each other language is `.json`, named as the language is written (`en`, `de`, `pt-BR`): `"sentence": "translation"`. - An empty translation is not one yet: that sentence is said in the source language. Correct a translation in the file and deploy, as any change to your app: the machine reads the table when it starts. The build checks it too: a table that breaks a rule — a file named `EN.json`, a translation that is not a string, a file for the source language, a table still in the earlier format below — stops the build before it takes traffic, and the log names the file and the rule. Dockerfiles made by `palbase init` 0.3.1 or later copy `palbase/` into the image; check yours, or one you wrote, for `COPY palbase ./palbase`. An app that started without a table says so, with where it looked, the first time `t()` is called. ## Keep the files with the code Run it in your backend's directory (or below it: it works in the nearest directory up with a `package.json`): ```bash npx palbase strings --source tr --add en # start the table: the sentences are in Turkish; en.json is made, empty npx palbase strings --translate # fill what is empty npx palbase strings # after a change to the code npx palbase strings --add de # one more language ``` `palbase strings` reads every `t("…")` your backend calls — `t` imported from `palbase/backend`, or from a module of your app that re-exports it — with the TypeScript compiler. A sentence new in the code is added to every language's file, empty; a translation is kept. A sentence the code no longer calls is kept too, with its translations, and listed: `--prune` takes it out of every file. Run it again and it changes nothing. What it cannot read is pointed out, with its file and line: a `t()` whose sentence is not written out (`t(message)` — no file can hold its translation: write the sentence itself, its values as `{{name}}`), and a `t` imported from a module it does not follow (a path alias such as `@/lib/i18n`). If it reads no sentence at all while the table has some, it changes nothing. A table in the format of the earlier Palbase (`"version": 2`, a `{"value", "state"}` object for each sentence) is written in this one the first time you run it, every translation kept; until then the app does not start with it. ## Translate with your AI module ```bash npx palbase strings --translate ``` Each empty translation of a sentence the code calls is sent to your environment's AI module — the same models, usage and cost as any other call your project makes, under your own key on this computer — and written into the file as it comes back. On the production environment it asks first: its AI budget is the one your app's own calls use. A translation that does not keep every `{{placeholder}}` of its sentence, or comes back empty, is not taken: it is listed, and stays empty. The command ends with what it cost, from your environment's AI usage (or says when not every call is there yet). A translation is used as it is written: read the files' diff before you commit it, and correct it there. # Test Users A **test user** is a real user of the project — it signs in, gets tokens, owns rows and files — made with the project's secret key for testing, and marked as such everywhere it goes. Test users are created email-verified, skip the breach check and the password's minimum length, are not counted as active users, and only test users can be deleted through the test-user route, so a cleanup that goes wrong cannot take a real account with it. Your backend sees the mark: `user.test` is `true` on a test user's requests. ## Quick example ```ts // scripts/make-test-users.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); // Three users with generated credentials, and an access token each. const made = await palbase.auth.adminCreateTestUsers({ body: { count: 3, with_tokens: true } }); for (const u of made.data.users) { // The password is shown once: keep what you need now. console.log(u.user_id, u.email, u.password, u.access_token?.slice(0, 16)); } ``` ## Making them `POST /admin/test-users` (`auth.adminCreateTestUsers`) creates up to 1000 at a time — **all of them, or none** — and answers their credentials once: a password is never retrievable afterwards. | Body | What it makes | |---|---| | `{ count: n }` | `n` users with generated emails and passwords. | | `{ users: [{ email, password }] }` | Users with exactly these credentials; `users` wins when both are sent. | | `{ users: [{ email, password, phone, otp }] }` | Also a verified phone, and the fixed six-digit code that phone accepts at sign-in — so a phone sign-in can be tested without a text being sent. | | `with_tokens: true` | Also an access token per user, for calls made as them. | Each answered user carries `user_id`, `email`, `password` and, with `with_tokens`, `access_token`. | Refusal | Why | |---|---| | `409 user_exists` | An email is already taken (nothing was made). | | `409 test_user_cap_exceeded` | The batch would take the project past its test-user cap. | | `400 invalid_request` | Neither a positive `count` nor complete `users`; a phone without its six-digit code. | Send an `Idempotency-Key` header to make the creation safe to retry: the same key and the same request answer the first result again instead of creating twice. A fixture made with chosen emails is easy to make *if missing* — treat `409 user_exists` as "already there". `PUT /admin/test-users/phone` (`adminSetTestUserPhone`) gives an existing test user, found by email, a verified phone and its fixed code; setting the same again succeeds, and an email that is not a test user's is `404 test_user_not_found`. ## In a route A test user's token says `test: true`, and `palbase/backend` hands it to the route as `user.test`: ```ts // src/modules/admin/reset.controller.ts import { Controller, Forbidden, Post, User, type UserT } from "palbase/backend"; @Controller("/admin") export class ResetController { // A route only the suite may call: every other user is turned away. @Post("/reset-demo") reset(@User() user: UserT): { reset: true } { if (!user.test) throw new Forbidden("this route serves test users only"); return { reset: true }; } } ``` The flag is the platform's, set when the user was made with the secret key: a user who signs up cannot give it to themselves. ## Calling your backend as one With `with_tokens`, each user's `access_token` goes on a call to your machine as `Authorization: Bearer …`, exactly as a signed-in app's does: ```ts // scripts/call-as-test-user.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); const [user] = (await palbase.auth.adminCreateTestUsers({ body: { count: 1, with_tokens: true } })).data.users; const res = await fetch("https://.app.palbase.studio/notes", { headers: { authorization: `Bearer ${user.access_token}` }, }); console.log(res.status, await res.json()); await palbase.auth.adminDeleteTestUser({ path: { uid: user.user_id } }); ``` They sign in like anyone else, too — with `palbase/web`'s `pb.auth.signIn(email, password)`, or by phone with their fixed code. ## Listing and removing them | Call | What it does | |---|---| | `adminListTestUsers()` | Every test user of the project, newest first, whole: their number is capped, so the list is one answer. Each carries `is_test: true`. | | `adminDeleteTestUser({ path: { uid } })` | Deletes a test user and everything that belongs to it. A user who is not a test user is refused, `403 not_a_test_user`. | Deleting a test user is a user deletion like any other: it emits `user.deleted` (with `is_test: true`), so your backend forgets them as it forgets anyone ([Event Hooks](/docs/backend/hooks)), and storage objects owned by them go with them. ## Made for a test run A test run makes the users its tests act as, and deletes them when it ends: `palbase test --users alice,bob` on your computer, a machine's `test_users` on the platform before each new build takes traffic. Each is a test user like these, at an address of the run's own, with its access token; a test acts as one with `api.signInAs("alice")` ([Testing](/docs/backend/testing#over-http-api-as-test-users)). ## From Studio Studio's **Test accounts** tab under Authentication makes test logins, lists them, and searches them, for the environment you are in. ## Related - [Authentication](/docs/backend/authentication) — `@User()`, `user.test`, and how a token is checked - [Testing](/docs/backend/testing) — testing a backend, with and without a network - [Server SDK Overview](/docs/server/overview) — `createServer`, refusals and `Idempotency-Key` - [Web Auth](/docs/web/auth) — signing in as one from an app # Testing A backend is tested where it runs, and the same tests run in three places: on your computer (`bun test`), against your app as test users made for the run (`palbase test`), and on the platform against **each new build before it takes traffic**. `palbase/backend/test` is the kit they are written with: | Name | What it is for | |---|---| | `isolated()` | a service built with the dependencies the test chooses, nothing else | | `withServices()` | the services the code reads (`Database`, `Cache`, `Storage`, …) given by the test | | `fakeDatabase()` | a database in memory, for `withServices({ Database })` | | `api`, `testRun` | the app over HTTP, as the run's test users (`api.signInAs("alice")`) | | `createTestApi`, `TestApiError` | a client of your own; what a refusal throws | The HTTP half is `palbase/server/testing` — the same names, for a backend written with any framework (NestJS, Express, …). The tests run with **Bun's test runner** (`bun:test`), as the platform runs them: Bun keeps the decorator metadata `isolated()` reads a constructor's dependencies from. `palbase init` starts a backend with its tests in `tests/` and `"test": "bun test ./tests"`. ## Unit tests: `isolated()` A service takes its dependencies by constructor. `isolated()` builds the class the test asks for, with what `.with()` gives in place of a dependency — however deep it is — and every other one built as the app builds it: ```ts // src/modules/mail/mailer.ts import { Injectable, Log } from "palbase/backend"; export abstract class Mailer { abstract send(to: string, subject: string): Promise; } @Injectable() export class LogMailer extends Mailer { async send(to: string, subject: string): Promise { Log.info("mail", { to, subject }); } } ``` ```ts // src/modules/mail/welcome.service.ts import { BadRequest, Injectable } from "palbase/backend"; import { Mailer } from "./mailer"; @Injectable() export class WelcomeService { constructor(private readonly mailer: Mailer) {} async welcome(email: string): Promise { if (!email.includes("@")) throw new BadRequest({ fields: [{ field: "email", message: "not an address" }] }); await this.mailer.send(email, "Welcome"); } } ``` ```ts // tests/welcome.test.ts import { describe, expect, it } from "bun:test"; import { BadRequest } from "palbase/backend"; import { isolated } from "palbase/backend/test"; import { Mailer } from "../src/modules/mail/mailer"; import { WelcomeService } from "../src/modules/mail/welcome.service"; class RecordingMailer extends Mailer { readonly sent: string[] = []; async send(to: string): Promise { this.sent.push(to); } } describe("WelcomeService", () => { it("mails the address it is given", async () => { const mailer = new RecordingMailer(); await isolated().with(Mailer, mailer).get(WelcomeService).welcome("ada@example.com"); expect(mailer.sent).toEqual(["ada@example.com"]); }); it("refuses what is not an address, naming the field", async () => { const welcome = isolated().with(Mailer, new RecordingMailer()).get(WelcomeService); await expect(welcome.welcome("ada")).rejects.toBeInstanceOf(BadRequest); }); }); ``` Each `isolated()` is a container of its own: one instance of each class within it, nothing shared with another test or with the app. Two classes that need each other to be built are refused, naming the circle; `.with()` one of them and there is none. ## The services the code reads: `withServices()` `Database`, [`Cache`](/docs/backend/cache), `Secrets`, `Storage`, `Notifications`, `Flags`, `Realtime` and `Log` are read where the code needs them, not passed in. `withServices(given, fn)` runs `fn` as a request does, with the services given: `Database` is the client given (`fakeDatabase().raw`), each other service the object given. A service the code reads and the test did not give is refused **by name** — its real calls would reach a platform no test serves — and `Log` writes as it always does. ```ts // src/modules/notes/notes.service.ts import { Cache, Database, Injectable } from "palbase/backend"; @Injectable() export class NotesService { async count(owner: string): Promise { const cached = await Cache.get(`notes:${owner}`); if (cached !== null) return cached; const n = await Database.$count("notes", { owner }); await Cache.set(`notes:${owner}`, n, 60); return n; } } ``` ```ts // tests/notes.service.test.ts import { describe, expect, it } from "bun:test"; import { fakeDatabase, isolated, withServices, type RuntimeServices } from "palbase/backend/test"; import { NotesService } from "../src/modules/notes/notes.service"; describe("NotesService", () => { it("counts a user's notes, and keeps the count for a minute", async () => { const db = fakeDatabase(); db.seed("notes", [ { id: "n1", owner: "ada", title: "one" }, { id: "n2", owner: "ada", title: "two" }, { id: "n3", owner: "bob", title: "three" }, ]); const kept = new Map(); const cache = { get: async (key: string) => kept.get(key) ?? null, set: async (key: string, value: unknown) => void kept.set(key, value), } as unknown as RuntimeServices["Cache"]; const n = await withServices({ Database: db.raw, Cache: cache }, () => isolated().get(NotesService).count("ada")); expect(n).toBe(2); expect(kept.get("notes:ada")).toBe(2); }); }); ``` `withServices` also carries a string table for `t()` ([Translations](/docs/backend/translations)): `withServices(given, fn, { strings, acceptLanguage: "en" })`, with `loadStringsTable(root)` reading the project's `palbase/strings`. ### A database in memory: `fakeDatabase()` `fakeDatabase()` answers the engine's operations with the engine's filters and refusals: `seed(table, rows)` puts rows in, `inserted`, `updated` and `deleted` say what the code wrote, and raw SQL is recorded in `queries`, not run. What it cannot answer as Postgres would — a unique key, a relation, a cursor, search, an `$atomic`'s isolation — it refuses by name. It has no schema (no column's default, type or constraint is applied), it reads text of digits as the number it spells, it gives rows in the order they were written unless `orderBy` says otherwise, and there is no row-level security. What depends on those is tested against the app over HTTP, or against a Postgres of your own (below). ## Over HTTP: `api`, as test users `api` calls the app under test where the run says it answers (`PALBASE_TEST_BASE_URL`), as the test users the run made for it (`PALBASE_TEST_IDENTITIES`, each with its access token). `testRun.stack` says whether an app answers: an HTTP test skips itself where none does, so `bun test` passes on a computer with no app started. ```ts // tests/notes.http.test.ts import { describe, expect, it } from "bun:test"; import { api, TestApiError, testRun } from "palbase/backend/test"; describe.skipIf(!testRun.stack)("notes, over HTTP", () => { it("are each user's own", async () => { const title = `a test run's note ${crypto.randomUUID()}`; await api.signInAs("alice"); const note = await api.post<{ id: string }>("/notes", { title }); expect((await api.get<{ title: string }>(`/notes/${note.id}`)).title).toBe(title); await api.signInAs("bob"); const refused = await api.get(`/notes/${note.id}`).catch((e: unknown) => e); expect(refused).toBeInstanceOf(TestApiError); expect((refused as TestApiError).status).toBe(404); }); it("refuses a caller with no token", async () => { api.asAnonymous(); const refused = await api.post("/notes", { title: "x" }).catch((e: unknown) => e); expect((refused as TestApiError).status).toBe(401); }); }); ``` - `api.signInAs(name)` acts as the run's test user of that name; `api.asAnonymous()` as no one. `api.signIn({ email, password })` signs in at the project's auth (it needs a publishable key: `PALBASE_TEST_API_KEY`). - A refusal throws `TestApiError`: its `status`, its code (`error`), its `data` and the whole `body`, the platform's envelope. - `testRun.minted("alice")` says whether the run made that user. `createTestApi({ baseUrl, identities, … })` is a client of your own, for a test that calls two apps. ## Running them | Where | How | What runs | |---|---|---| | Your computer | `bun test` | the unit tests; the HTTP tests skip themselves | | Your computer, against your app | `palbase test --users alice,bob --base-url http://localhost:8080 -- bun test ./tests` | all of them, as test users made for this run in the environment you use, deleted when the command ends — Ctrl-C too | | As a machine runs them | `palbase test --machine backend --base-url http://localhost:8080` | the machine's own test command, as its test users (it shows the command and asks before running it; `-y` says yes) | | The platform | `palbase machines:update backend --test-command "bun test ./tests" --test-users alice,bob` | every new build, against the new version itself, before it takes traffic | On the platform the tests run against the new version **as it will run** — its env, its secrets, its database, after its migration has started: they pass and it takes traffic; one fails and the build fails, naming the test, while the machine keeps serving the version it runs (see [Builds](/docs/machines/builds#tests-before-it-takes-traffic)). The tests write where the app writes, so write them as a real user's session: a note of their own, not a table they expect empty. What a test user owns goes with them — the run deletes its users, and a deleted user's rows are erased (`ownedByUser()`, on `user.deleted`). The image has to carry the tests: `palbase init`'s `Dockerfile` copies `tests/`, and Bun's types are a dev dependency the image does not install. ## Without the network The kit tests the app as it is deployed. The app can also be tested inside the test's own process — its routes, auth, validation and refusals answering a `Request` through `handle()`, no port opened. ### The app in the process: `handle()` `PalbaseFactory.create` answers a `Request` through the app's `handle()`, the same path a request from the network takes. A test builds the app with its own environment, on a free port, without exiting on a crash: ```ts // src/modules/notes/notes.controller.ts import { Body, Controller, Get, Post, User, z, type UserT } from "palbase/backend"; export const CreateNote = z.object({ title: z.string().min(1).max(200) }); export type CreateNote = z.infer; @Controller("/notes") export class NotesController { @Post("") create(@Body(CreateNote) body: CreateNote, @User() user: UserT): { owner: string; title: string } { return { owner: user.id, title: body.title }; } @Get("/health", { auth: false }) health(): { ok: true } { return { ok: true }; } } ``` ```ts // src/modules/notes/notes.module.ts import { Module } from "palbase/backend"; import { NotesController } from "./notes.controller"; @Module({ controllers: [NotesController] }) export class NotesModule {} ``` ```ts // tests/notes.handle.test.ts import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { PalbaseFactory, type PalbaseApplication } from "palbase/backend"; import { NotesModule } from "../src/modules/notes/notes.module"; const env = { PALBASE_URL: "https://abcd1234.palbase.studio", PALBASE_PROJECT_ID: "11111111-2222-3333-4444-555555555555", PALBASE_SECRET_KEY: "pb_abcd1234_s0123456789abcdef0123456789abcdef", }; let app: PalbaseApplication; beforeAll(async () => { app = await PalbaseFactory.create(NotesModule, { env, port: 0, host: "127.0.0.1", exitOnFatal: false }); }); afterAll(() => app.close()); const call = (path: string, init?: RequestInit) => app.handle(new Request(`http://app.test${path}`, init)); describe("notes over HTTP", () => { it("serves a public route", async () => { const res = await call("/notes/health"); expect(res.status).toBe(200); expect(await res.json()).toEqual({ ok: true }); }); it("refuses a caller with no token, in the envelope", async () => { const res = await call("/notes", { method: "POST", body: JSON.stringify({ title: "x" }) }); expect(res.status).toBe(401); expect(await res.json()).toMatchObject({ error: "unauthorized", status: 401 }); }); it("lists the routes it serves", () => { expect(app.routes).toEqual(["POST /notes", "GET /notes/health"]); }); }); ``` - `env` stands in for what the platform and the project's Secrets give a machine. Nothing is called with these values until a route needs the project: a route with no token never asks for the project's keys. - `port: 0` takes a free port, so tests can run side by side. `exitOnFatal: false` leaves a crash to the test runner instead of exiting the process. - `app.close()` stops it and runs every `onShutdown()`. ### Signed-in requests, without the network A signed-in route needs a token the app verifies against a key set. In a test the key set is the test's own: `createApp` (the engine `PalbaseFactory` serves) takes a `TokenVerifier` you build, and `createServer` takes a `fetch` that answers the project's module calls however the test says. A helper that makes all three: ```ts // tests/project.ts import { createServer } from "palbase/server"; import { TokenVerifier } from "palbase/backend"; export const url = "https://abcd1234.palbase.studio"; export const projectId = "11111111-2222-3333-4444-555555555555"; const b64 = (bytes: Uint8Array | string): string => Buffer.from(typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes).toString("base64url"); /** A signing key of the test's own, the key set that publishes it, and tokens it signs. */ export async function testProject(answer: (url: URL, init: RequestInit) => Response = () => Response.json({})) { const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; const jwk = { ...(await crypto.subtle.exportKey("jwk", pair.publicKey)), kid: "test", alg: "ES256", use: "sig" }; const verifier = new TokenVerifier({ issuer: url, projectId, fetch: async () => Response.json({ keys: [jwk] }), // Every session stands; a test of sign-out answers false for its session. standing: async () => true, }); async function token(userId: string, claims: Record = {}): Promise { const now = Math.floor(Date.now() / 1000); const body = { iss: url, aud: [url], sub: userId, exp: now + 900, iat: now, auth_time: now, email: `${userId}@example.com`, email_verified: true, role: "authenticated", session_id: `ses_${userId}`, project_id: projectId, ...claims, }; const head = b64(JSON.stringify({ alg: "ES256", kid: "test", typ: "JWT" })); const payload = b64(JSON.stringify(body)); const signature = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, pair.privateKey, new TextEncoder().encode(`${head}.${payload}`)); return `${head}.${payload}.${b64(new Uint8Array(signature))}`; } // `Palbase.*` inside the app: each call answered by `answer`, sent once. const server = createServer({ url, secretKey: "pb_abcd1234_s0123456789abcdef0123456789abcdef", fetch: async (input, init) => answer(new URL(String(input)), init ?? {}), retry: { attempts: 1, maxWaitSeconds: 0 }, }); return { verifier, token, server }; } ``` ```ts // tests/notes.signed-in.test.ts import { describe, expect, it } from "bun:test"; import { createApp } from "palbase/backend"; import { testProject } from "./project"; import { NotesModule } from "../src/modules/notes/notes.module"; describe("notes for a signed-in user", () => { it("takes the user from the token", async () => { const project = await testProject(); const app = createApp({ modules: [NotesModule], verifier: project.verifier, server: project.server }); await app.start(); const res = await app.handle( new Request("http://app.test/notes", { method: "POST", headers: { authorization: `Bearer ${await project.token("usr_ada")}` }, body: JSON.stringify({ title: "Ship it" }), }), ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ owner: "usr_ada", title: "Ship it" }); // A refusal the schema makes, with the field it names. const bad = await app.handle( new Request("http://app.test/notes", { method: "POST", headers: { authorization: `Bearer ${await project.token("usr_ada")}` }, body: JSON.stringify({ title: "" }), }), ); expect(bad.status).toBe(400); expect(await bad.json()).toMatchObject({ error: "bad_request", data: { fields: [{ field: "title" }] } }); await app.stop(); }); }); ``` `createApp` takes the same settings the bootstrap does (`auth`, `rateLimit`, `maxBodyBytes`, `cors`, `webhookSecret`) and builds the container at once, so a wiring mistake throws right there. `start()` runs every `onStart()`, and `stop()` runs every `onShutdown()`. It opens no port. To test an [`@On` handler](/docs/backend/hooks), give it a `webhookSecret` and hand it a signed delivery. > **Note:** Classes are recorded process-wide when their files are imported. Build each test file's app from modules that own every `@Controller`, `@Injectable()` and `@On` class that file's imports bring in, or the build stops with `unowned class`. ### Against a Postgres of your own `Database` is the request's transaction against a real Postgres, so a test that reaches it needs one: Postgres 18, with the roles the platform's databases have, and the app's schema migrated into it. The roles, made once: ```sql CREATE ROLE anon NOLOGIN; CREATE ROLE authenticated NOLOGIN; CREATE ROLE service_role NOLOGIN BYPASSRLS; CREATE ROLE app_owner LOGIN CREATEDB; CREATE ROLE app_login LOGIN NOINHERIT; GRANT anon, authenticated, service_role TO app_login; ``` Migrate it with the app itself, as a release does: run it once with `PALBASE_DB_MIGRATE=start` and once with `PALBASE_DB_MIGRATE=complete`, each with `PALBASE_DB_OWNER_URL` naming the owner. Then build the app with `db: { schemas }` and `PALBASE_DB_URL` naming `app_login`. A migration run exits the process, so run it before the tests, not inside them. The default database driver is Bun's SQL: tests that run on Node give the app a driver of their own. See [Database](/docs/backend/database) and [Schema changes](/docs/backend/migrations). ## Related - [Builds](/docs/machines/builds#tests-before-it-takes-traffic): tests before a version takes traffic - [Modules & Dependency Injection](/docs/backend/modules): the constructor seam - [Event Hooks](/docs/backend/hooks): signing a delivery in a test - [Test Users](/docs/backend/test-users): users made for tests - [Responses & Errors](/docs/backend/errors): what a refusal looks like # Overview `palbase/server` is Palbase for servers: one client, made with the project's **secret key**, that reaches every module's server calls — auth's user management, storage, notify, secrets, flags, analytics, realtime's broadcast and channel declaration, and machines. Use it from anything that runs on a server you control: a Next.js route handler, a worker, a script, a backend that is not a Palbase machine. A `palbase/backend` app does not construct one: its `Palbase` is this same client, built from the machine's environment (see [Backend SDK Overview](/docs/backend/overview)). ## Quick example ```ts // server.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, // https://.palbase.studio secretKey: process.env.PALBASE_SECRET_KEY!, // pb__s… }); // Every call takes { path, query, headers, body } as its operation needs them, // and answers { status, data, headers }. const { data } = await palbase.storage.listBuckets(); console.log(data.buckets.map((b) => b.name)); // Give one user their own value of a flag; their apps are told at once. await palbase.flags.putUserValue({ path: { user_id: "usr_123", key: "checkout.new_flow" }, body: { value: true } }); ``` ## The secret key An environment has publishable keys (`pb__c…`, for apps — each [registered app](/docs/apps/overview) has its own) and any number of named secret keys (`pb__s…`, for servers). Make a secret key in Studio under **Settings › API keys › Create secret key**: it is shown **once**, and each one can be revoked on its own without touching the others — one per server is the habit that makes a leak cheap to shut out. A secret key opens the whole project: every user, every file, every secret. `createServer` refuses to run where one could reach a browser: | Where | What happens | |---|---| | A bundle built for a browser or React Native | The package's export conditions resolve `palbase/server` to a module that **throws when the bundle loads** — before a key can ship in it. | | A page (`document` exists), a browser worker (`navigator.userAgent` starts `Mozilla/`), or React Native | `createServer` throws a `TypeError` naming `palbase/web` as what apps use. | | A publishable key passed as `secretKey` | `createServer` throws: a publishable key belongs in `palbase/web`. | Node, Bun, Deno, Cloudflare Workers and edge runtimes resolve the real client. ## `createServer(options)` | Option | Default | What it is | |---|---|---| | `url` | — | The project's address, `https://.palbase.studio`. https only (http only for a loopback address); the origin alone, with no path. | | `secretKey` | — | The project's secret key, from the server's environment. | | `retry` | `{ attempts: 3, maxWaitSeconds: 30 }` | How a call that was answered "not now" is sent again (below). | | `fetch` | `globalThis.fetch` | The fetch the calls go through. | | `solveProofOfWork` | `false` | Solve auth's proof-of-work challenge here and send the call again. Left off, a sign-in a server makes for a user answers `403 pow_required` with the challenge, for the user's own device to solve. | It answers a `PalbaseServer`: | Member | Module | Contract | |---|---|---| | `auth` | users, sessions, test users, auth settings (`/admin/…`) | auth | | `storage` | buckets, objects, signed URLs | storage | | `notify` | email, push, SMS, WhatsApp, templates, topics | notify | | `secrets` | the project's secrets, by environment | secrets | | `flags` | definitions, users' values, publishing | flags | | `analytics` | captures, and reading events back | analytics | | `realtime` | broadcast, state, the channel declaration, limits | realtime | | `machines` | machines, builds, logs, databases | machines | | `transport` | the one transport every call goes through | — | Each module's methods are generated from its contract, one per operation, named by its operation id in camelCase: `PUT /storage/buckets/{bucket}` is `storage.putBucket`, `GET /admin/users` is `auth.adminListUsers`. The module pages say what each module does: [Storage](/docs/backend/storage), [Secrets](/docs/backend/secrets), [Flags](/docs/backend/flags), [Notifications](/docs/backend/notifications), [Realtime](/docs/backend/realtime), [Channels](/docs/backend/channels), [Test Users](/docs/backend/test-users), and [Machines](/docs/machines/overview) for machines and databases. ## The shape of a call A call takes one object with the parts its operation has — `path` for the path's parameters, `query`, `headers`, `body` — and answers a `Reply`: the `status` the module answered, its `data` (the JSON body, parsed), and its `headers`. ```ts // users.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); const created = await palbase.auth.adminCreateUser({ body: { email: "grace@example.com", password: "a long passphrase of her own", email_verified: true }, }); console.log(created.status, created.data.id); // 201 usr_… const user = await palbase.auth.adminGetUser({ path: { uid: created.data.id } }); await palbase.auth.adminUpdateUser({ path: { uid: user.data.id }, body: { metadata: { plan: "pro" } } }); ``` A path parameter is always **one** URL segment: the client percent-encodes it, so a storage path `avatars/ada.png` travels as `avatars%2Fada.png`, and an empty value, `.` or `..` is refused before anything is sent (`invalid_path_parameter`). An answer the module sends as bytes — an object's contents — arrives as the `Response` itself, for you to read as you need (`await reply.data.arrayBuffer()`). The types of every request and answer are exported, by module, as `types`: ```ts // report.ts import type { types } from "palbase/server"; export function describe(object: types.storage.StorageObject): string { return `${object.path}: ${object.size} bytes of ${object.content_type}`; } ``` ## Refusals are `PalbaseError` Every answer the contract calls a refusal throws a `PalbaseError` carrying the module's envelope: | Field | What it is | |---|---| | `status` | The HTTP status; `0` when the request never got an answer. | | `code` | The envelope's `error` (`user_exists`, `bucket_not_empty`, `own_provider_required` …). | | `message` | The envelope's `error_description`. | | `requestId` | The request's id, for the module's logs. | | `retryAfter` | Seconds the module asked to wait (`Retry-After`, or the envelope's `retry_after`). | | `body` | The body as sent, for the answers that are not the envelope. | | `op` | The operation, such as `PutBucket`. | ```ts // make-user.ts import { createServer, PalbaseError } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); export async function ensureUser(email: string, password: string): Promise { try { return (await palbase.auth.adminCreateUser({ body: { email, password } })).data.id; } catch (e) { if (e instanceof PalbaseError && e.code === "user_exists") return null; throw e; } } ``` A few codes are the client's own, with `status: 0`: `network_error` (the request did not reach the project), `missing_credential`, `invalid_path` and `invalid_path_parameter`. Two more are about answers the contract does not have: `undeclared_answer` (a 2xx the operation does not declare — the request **may have taken effect**) and `undeclared_redirect`. The client never follows a redirect. The key goes to the project's origin and nowhere else: a request URL that would leave it is refused, and a 3xx is a refusal rather than a hop that would carry the key to whatever host it names. ## Retries A call is sent again only when doing so cannot do the work twice: - **What is retried:** `429`, `502`, `503` and `504`, and a request that never reached the server (`network_error`). - **Which calls:** reads (`GET`, `HEAD`), and the writes whose contract takes an `Idempotency-Key`. For those the client makes one key per call and every attempt carries it, so the module answers a retry with the first answer (`Idempotent-Replayed: true`) instead of doing the work again. Pass your own `Idempotency-Key` header to make a write safe across your own retries too — a job that runs again after a crash, say. - **How long:** what the module's `Retry-After` asks, never more than `maxWaitSeconds`; with no word from the module, 0.5 s, 1 s, 2 s … with a little jitter. A wait longer than `maxWaitSeconds` is not waited for: the error reaches you. Everything else is yours: a `400`, a `409`, a `404` is the module's answer, and sending it again would only get it again. A body that can be read only once (a stream) is not sent twice. ```ts // strict.ts import { createServer } from "palbase/server"; // One attempt, no waiting: the caller decides. export const strict = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY!, retry: { attempts: 1, maxWaitSeconds: 0 }, }); ``` ## Listings, a page at a time Listings answer a page and a `next_cursor`; the next page is the same call with that cursor, until a page carries none. `pages(call)` walks them for you: ```ts // sweep.ts import { createServer, pages } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); let banned = 0; for await (const page of pages((cursor) => palbase.auth.adminListUsers({ query: { limit: 200, cursor, banned: true } }))) { banned += page.users.length; } console.log(`${banned} banned users`); ``` A listing that answers a cursor it has answered before is a loop, and `pages` throws rather than page forever. ## From a Palbase machine A `palbase/backend` app reaches the same client as `Palbase`, imported, never constructed: it is built at boot from `PALBASE_URL` (which the platform sets on every machine) and `PALBASE_SECRET_KEY` (a project secret you add — see [Secrets](/docs/backend/secrets)). Everything on this page holds for it: `Palbase.storage.putBucket(…)` is `createServer(…).storage.putBucket(…)`. ## Related - [Webhooks](/docs/server/webhooks) — the project's events delivered to your server, and `verifyWebhook` - [Backend SDK Overview](/docs/backend/overview) — `Palbase`, `Database` and `Log` inside a machine - [Storage](/docs/backend/storage) · [Notifications](/docs/backend/notifications) · [Flags](/docs/backend/flags) · [Secrets](/docs/backend/secrets) — the modules, from a server - [Web SDK Overview](/docs/web/overview) — the publishable-key client for apps # Webhooks When something happens in a project — a user is deleted, a file is uploaded, a secret changes, an email bounces — the platform POSTs an **event** to each of the project's webhook endpoints that takes its type, signed with that endpoint's secret. `palbase/server` checks the signature: `verifyWebhookRequest(request, secret)` answers the event, or throws. A `palbase/backend` app does not call it at all — its `@On` handlers receive the events already checked (see [Event Hooks](/docs/backend/hooks)). ## Quick example ```ts // server.ts import { verifyWebhookRequest, WebhookSignatureError } from "palbase/server"; const secret = process.env.PALBASE_WEBHOOK_SECRET!; // whsec_…, shown once when the endpoint was made const handled = new Set(); export async function handle(request: Request): Promise { let event; try { event = await verifyWebhookRequest<{ user_id?: string }>(request, secret); } catch (e) { if (e instanceof WebhookSignatureError) return new Response(e.code, { status: 401 }); throw e; } // A delivery may come twice: the event's id says it is the same one. if (handled.has(event.id)) return new Response(null, { status: 204 }); if (event.type === "user.deleted") { console.log(`forget everything about ${event.data.user_id}`); } handled.add(event.id); return new Response(null, { status: 204 }); } ``` Any `2xx` is delivery. Anything else — another status, a timeout, a refused connection — and the platform sends the event again. ## Endpoints An endpoint is an `https` URL on the public internet and the event types it takes — up to 50 of them, each dotted lowercase words such as `user.deleted`; an endpoint that names none takes every type. A project has **5 endpoints at most**, besides the ones the platform keeps for its machines. When an endpoint is made, its secret (`whsec_` and 64 hex characters) is answered **once**; keep it in the receiving server's secrets. Manage a project's endpoints in Studio, under an environment's **Settings › Webhooks**: add an endpoint (its secret is shown once, with a copy button), change its address or events, or delete it. Reading the list is open to the organization's members; adding, changing and deleting are an admin's — a person, or an admin [access token](/docs/getting-started/access-tokens). Each endpoint shows who made it: a person, an access token or an environment token, by the name it had then. An endpoint stays when the token that made it is deleted. Palbase's own environment is shown read-only. A `palbase/backend` app on a machine needs none of this: the platform gives the machine its `PALBASE_WEBHOOK_SECRET` and keeps an endpoint for exactly the events its app takes, listed as the machine's — not changed or deleted from here, and gone with the machine ([Event Hooks](/docs/backend/hooks)). The CLI does the same: ```sh palbase webhooks:create https://api.example.com/palbase --events user.deleted,file.uploaded --secret-to RECEIVER_WEBHOOK_SECRET palbase webhooks:list palbase webhooks:update --events user.deleted palbase webhooks:delete ``` `--secret-to` keeps the new endpoint's secret in the environment's secrets (production, or development with `--dev`) instead of printing it, so a receiver that is itself a machine of the environment reads it by name. See the [CLI reference](/docs/cli/reference#webhooks). ### Failed deliveries A delivery is tried 12 times, the wait growing up to five minutes between tries. When the endpoint still has not taken it, Palbase gives up and keeps the delivery for **30 days** under **Settings › Webhooks › Failed deliveries**: the event, the endpoint, how many times it was tried and why the last try failed. **Redeliver** sends the same event, with the same `Palbase-Event-Id` and body, to that endpoint again within seconds, signed afresh, as Stripe's *Resend* does. Your receiver already handles an event more than once by its id, so a redelivery is safe. Members read the list; an admin redelivers, and the redelivery is written to the organization's audit log. Studio's API does the same: `GET /v1/environments/{ref}/webhooks/deliveries?failed=true` and `POST /v1/environments/{ref}/webhooks/deliveries/{id}/redeliver`. A delivery whose endpoint was deleted is sent nowhere (`409 endpoint_gone`). ## What a delivery is A delivery is a `POST` with a JSON body — the event — and three headers: | Header | What it carries | |---|---| | `Palbase-Signature` | `t=,v1=.")>` | | `Palbase-Event-Id` | The event's id (also in the body): the same id twice is the same event delivered again. | | `Palbase-Event-Type` | The event's type (also in the body). | ```json { "id": "6f2c1c9e-…", "type": "file.uploaded", "project_id": "2b7d…", "created_at": "2026-10-08T09:41:22Z", "data": { "bucket": "avatars", "path": "usr_123/face.png", "size": 48211, "content_type": "image/png", "owner_id": "usr_123" } } ``` The platform waits **10 seconds** for an answer, follows no redirect, and tries an event up to **12 times**, waiting 1 s, 2 s, 4 s … up to five minutes between attempts. One project's slow or silent receiver holds back only that project's events. **Delivery is at least once.** An event that reached your endpoint may be sent again — a retry after a timeout your server answered too late, or the platform's sender starting again — so key the work on the event's `id`, not on its arrival. An event is never dropped because an endpoint was down for a while: it is retried until it is taken or its attempts run out. ## The events | Type | Sent when | `data` | |---|---|---| | `user.deleted` | A user is deleted — by themselves, or with the secret key. | `{ user_id, is_test, deleted_at }` | | `session.ended` | A user's session ends — they signed out, ended it from another device, it was revoked (by an admin, a ban, a password reset, its refresh token used twice), it ran out with no one back to renew it (`ended_at` is then the moment its time was up), or the user was deleted. | `{ user_id, session_id, ended_at }` | | `file.uploaded` | An object is written: a `PUT`, a copy, an upload through a signed URL. | `{ bucket, path, size, content_type, owner_id }` | | `file.deleted` | An object is deleted. | the same fields | | `file.moved` | An object is given another path. | the same fields | | `secret.changed` | A secret is written, rolled back or deleted. The value is never in the event. | `{ environment, name, version, change }` | | `notification.sent` / `notification.failed` | A message ended sent, or failed. | `{ message_id, channel, rail, template, … }` | | `notification.received` | A user sent a WhatsApp message to the project's number. | the message | | `notification.uninstalled` | A push service said no app holds a device's token any more: the app was taken off the phone. The device is forgotten. | `{ platform, provider, device_id, installation_id, user_id }` | | `notification.push_failing` | A push provider started failing for its own fault — its APNs key refused, a topic the key may not send to: every push through it fails until its setting is fixed ([Apple (APNs)](/docs/backend/notifications#every-channel-but-one-needs-a-provider-of-yours)). At most once a day a provider. | `{ project_id, channel, provider, error, failing_since }` | | `journey.entered` / `journey.converted` | A person entered a journey, or their run reached its goal. A try on a test user's events carry `test: true` ([Journeys](/docs/backend/journeys#events)). | `{ journey, journey_id, run_id, user_id, test }` | | `journey.step` | A person's run took a step. | `{ journey, journey_id, run_id, user_id, step, kind, outcome, test }` | | `journey.event` | A journey's `event` step told the project's backend. | `{ journey, journey_id, run_id, user_id, step, name, data, test }` | | `journey.ended` | A person's run ended: `completed`, `converted`, `exited`, `stopped`. | `{ journey, journey_id, run_id, user_id, reason, test }` | | `messaging.report.created` | A person reported a conversation, someone in it or messages of it. Read what it carries with the secret key (`GET /messaging/admin/reports/{report_id}`); an organization that runs its customers' apps passes it on to them from here ([Messaging](/docs/messaging/overview#moderation)). | `{ report_id, conversation_id, reporter_id, reported_user_id, reason, message_count }` | | `messaging.conversation.created` / `messaging.conversation.deleted` | A conversation was made — by a person, or the project's server — or deleted for everyone. | `{ conversation_id, kind, created_by, member_ids }` / `{ conversation_id, deleted_by }` | | `audience.entered` / `audience.exited` | A person came to be in an analytics audience, or left it. | `{ audience_id, audience_name, person_id, user_id }` | | `machine.created` / `machine.updated` / `machine.deleted` | A machine was opened, changed, or taken away. | `{ name, source, size, replicas, port, version }` | | `stores.apple.changed` | The Apple account was connected, checked (and Apple's answer changed) or disconnected. The key is never in the event. | `{ change, issuer_id, key_id, status, capabilities }` | | `stores.app.changed` | An app was linked to its store. | `{ change, app_id, bundle_id, asc_app_id }` | | `stores.package.changed` | A package was made, or its build arrived. | `{ change, package_id, app_id, build_number, version }` | | `stores.delivery.requested` | A delivery to TestFlight was sent, or sent again. | `{ package_id, channel }` | | `stores.delivery.changed` | A delivery moved: `queued`, `signing`, `uploading`, `processing`, `compliance`, `ready` or `failed`. | `{ package_id, channel, status, asc_build_id, error_code, error }` | | `stores.group.changed` | A tester group was made, renamed, deleted, its testers set, or a build given to it. | `{ change, group_id, app_id, name, testers, package_id }` | | `stores.action.changed` | A planned action (Beta App Review, export compliance, expiring a build, an App Store page or version step) was done, declined or failed. | `{ action_id, kind, package_id, app_id, status, summary }` | | `stores.version.changed` | An app's App Store version came to a new state: sent to App Review, in review, approved, rejected, withdrawn or released. `result` is App Review's answer, `approved` or `rejected`, or `null`; `source` who told it: `apple_webhook`, `poll` or `action`. | `{ app_id, bundle_id, name, version, asc_version_id, previous_state, state, result, source }` | | `stores.google.changed` | The Play connection was made, checked (and Play's answer changed) or disconnected. | `{ change, service_account, status, capabilities }` | | `stores.webhook.changed` / `stores.build.changed` / `stores.beta_review.changed` / `stores.feedback.received` / `stores.product.changed` / `stores.play_release.changed` / `stores.vitals.changed` | An app's App Store Connect webhook, its build uploads, Beta App Review, testers' feedback, its in-app purchases' review, its Play releases and its crash data — each one is in [App Health](/docs/distribution/health#the-events). | the event's own | | `database.created` / `database.updated` / `database.deleted` / `database.undeleted` / `database.purged` / `database.password_changed` / `database.restoring` | A database's life. | `{ name, size }` | | `purchases.*` | A purchase, a renewal, a refund, an entitlement or credits changing — each one is in [Purchases › Entitlements](/docs/purchases/entitlements#events). | the event's own | | `ai.response.started` | A chat's turn began: a member's message, the server's, the chat's goal, or the late messages of the turn before opened it. | `{ response_id, status: "running", model, metadata, chat_id, user_id, message_metadata, goal_id, goal_turn }` | | `ai.response.completed` / `ai.response.failed` | A response started with `background: true` ended, or a chat's turn did. A turn's `status` is `completed`, `failed`, `stopped` or `budget_exceeded`, its `metadata` the chat's labels, and one that failed says why in `error`. | `{ response_id, status, model, metadata, cost_nano_usd, error?: { code, message, retryable } }`, a turn's with `chat_id, user_id, message_metadata, goal_id, goal_turn` | | `ai.goal.updated` | A chat's goal was set, its status changed, or it was cleared. | `{ chat_id, goal_id, status, previous_status, stop_code, stop_reason, set_by, metadata }` | | `ai.voice.started` / `ai.voice.closed` | A chat's voice began, or ended (closed, replaced, its member taken out, its chat deleted, lost). | `{ chat_id, voice_id, user_id, delegation, status, seconds, close_reason, metadata }` | | `ai.voice.delegated` | A person asked a voice of `delegation: "events"` for something out loud: have it done, and answer with the voice's `speak`, naming the delegation. | `{ chat_id, voice_id, delegation_id, text, earlier, user_id, metadata }` | | `ai.workspace.published` | A chat's workspace published a tree (mid-turn, as a build writes its product, and once more as its machine lets it go): what changed against the tree it replaced. Read a file of it with `GET /ai/chats/{id}/workspace/files?path=&manifest=`. | `{ chat_id, manifest_id, previous_manifest_id?, changed: [{ path, change, size_bytes }], changed_count, changed_truncated, metadata }` | | `ai.workspace.pushed` | A chat's agent pushed its work to the chat's branch. | `{ chat_id, repo, repository, branch, head_sha, commits: [{ sha, subject, author_name, author_email }], commits_truncated, response_id, user_id, metadata }` | The modules' contracts say which of their operations emit which event; each is noted on the operation (for example, `PUT /storage/buckets/{bucket}/objects/{path}` "Emits file.uploaded"). ## Checking a delivery The signature is an HMAC of the body **exactly as it arrived** — the bytes, before any JSON parsing. A parsed and re-serialised body is a different string and never verifies, so read the raw body first. | Function | Use it for | |---|---| | `verifyWebhookRequest(request, secret, { toleranceSeconds?, now?, maxBytes? })` | A fetch `Request` (Node, Bun, Deno, Workers, a Next.js route handler): reads its body — 1 MiB at most unless `maxBytes` says — and its header. | | `verifyWebhook({ secret, signature, body, toleranceSeconds?, now? })` | A body you already hold, as a string or bytes, and the header's value. | | `verifySignature({ secret, signature, body, … })` | The check alone, for a signed call whose body is not an event (see below). | Each throws a `WebhookSignatureError` whose `code` says why: | `code` | Why | |---|---| | `missing_signature` | No `Palbase-Signature` header. | | `malformed_signature` | The header is not `t=…,v1=…`. | | `stale_timestamp` | `t` is more than `toleranceSeconds` (300 by default) from now: an old delivery sent again is not taken. | | `signature_mismatch` | No `v1` is this body signed with this secret — a changed body, or another endpoint's secret. | | `invalid_body` | The signed body is not JSON, or not an event (`id`, `type`, `project_id`, `created_at`, `data`). | | `body_too_large` | The body is larger than `maxBytes`. | `verifyWebhook` takes only an endpoint secret (`whsec_…`): an API key passed by mistake is a `TypeError`, not a check that quietly fails. ```ts // app/api/palbase/route.ts import { verifyWebhook, WebhookSignatureError } from "palbase/server"; export async function POST(request: Request): Promise { const body = await request.text(); // the raw body, before anything parses it try { const event = await verifyWebhook({ secret: process.env.PALBASE_WEBHOOK_SECRET!, signature: request.headers.get("palbase-signature"), body, }); console.log(event.type, event.id); return new Response(null, { status: 204 }); } catch (e) { if (e instanceof WebhookSignatureError) return new Response(e.code, { status: 401 }); throw e; } } ``` ## The platform's own calls to a machine The platform signs what it asks of a machine the same way, with the key it starts every machine with in `PALBASE_PLATFORM_SECRET`: the crons' and tasks' lists and calls (`GET /_palbase/crons`, `POST /_palbase/crons/`, `GET /_palbase/tasks`, `POST /_palbase/tasks/`). Those paths are reached only by the platform: a request from outside whose path names them never gets to the machine. A program that serves them checks the call with `verifySignature`, whose body is the request's (empty for a `GET`). See [Machines](/docs/machines/overview). ## Related - [Event Hooks](/docs/backend/hooks) — `@On` handlers in a `palbase/backend` app - [Backend Webhooks](/docs/backend/webhooks) — a third party's deliveries (Stripe, GitHub, …) to a `palbase/backend` app, with `@Webhook` - [Server SDK Overview](/docs/server/overview) — `createServer`, refusals and retries - [Storage](/docs/backend/storage) — what emits `file.uploaded`, `file.deleted` and `file.moved` - [Secrets](/docs/backend/secrets) — where a receiving server keeps its endpoint secret # Machines A **machine** is your own code running on Palbase: a small sandboxed computer that runs one image of your project's, built from a branch of a GitHub repository, and answers HTTPS at an address of its own. A [`palbase/backend`](/docs/backend/overview) app is the usual thing to run on one, but a machine runs whatever its build makes — a Next.js site, a Go service, a worker that only serves a health check. You never deploy a machine by hand. You [connect GitHub](/docs/machines/github), open the machine on a repository, a branch and a directory, and from then on **a push to that branch is the deploy**: it is built, rolled out beside the version that serves, and only then takes traffic. See [Builds, Logs & Rollback](/docs/machines/builds). In Studio, machines live under **Backend › Machines**. Everything Studio does there is the machines module's API, which takes the project's secret key — every step on these pages names the call beside the click. ## What a machine is | | | |---|---| | Name | Lowercase letters, digits and dashes, starting with a letter, 32 characters at most. `backend` when left out. Not `github`, `network` or `sizes`, which are the API's own paths. | | Source | A repository, a branch and a directory of it ([Connecting GitHub](/docs/machines/github)) | | Size | How much CPU and memory it has, and whether it sleeps (below) | | Port | The TCP port it listens on, `8080` unless you say otherwise. It also finds it in `PORT`. | | Copies | How many copies run while it is awake, 1 to 5 | | Health check | A path that answers 2xx while it serves | | Environment | Variables of your own, and the project secret set it starts with | | Database | One of the project's [databases](/docs/machines/databases), or none | A project has **5 machines at most**, and a machine's name is that machine's alone. Deleting a machine frees its name at once. ## Its address The machine named `backend` is the project's backend and answers at the project's own name under the app domain; any other machine answers at its name and the project's ref: ```text https://.app.palbase.studio the machine named backend https://-.app.palbase.studio any other machine ``` The `url` field of the machine (Studio shows it as **Address**, with an **Open** button) is the address to use. A request there is your customers' own: no API key is asked for and none is added. Who may call a route is the app's to decide — a `palbase/backend` app checks the caller's access token on every route that needs one ([Authentication](/docs/backend/authentication)). What the platform answers itself, before your program sees anything: | Answer | When | Safe to send again | |---|---|---| | `404 no_such_machine` | The host names no machine (deleted, or never made) | — | | `429 quota_exceeded` | The project has used its machine time for the period; `Retry-After` says until when | at that time | | `429 rate_limited` | The project's machines are over its requests-per-second limit; `Retry-After: 1` | yes | | `503 machine_waking` | The machine was asleep and is still coming up; the request did not reach it; `Retry-After: 5` | yes | | `503 machine_unavailable` | The machine could not be woken or reached; `Retry-After: 2` | yes | Every request to the address is counted where it arrives, whatever program answers it: Studio's machine page draws the machine's **Requests** by status class and its **Response time** (median and p95) over the period chosen — the platform's answers above among them, as your customers saw them. Thirty days are kept; of a request, only its time, status and duration — nothing of who sent it or what it asked. A path whose first segment is `_palbase` — `/_palbase/health`, `/_palbase/crons` — is never sent to a machine from outside. It is the platform's own way in (its health checks, its [crons and tasks](#crons-and-tasks)), and a request for it from the internet is answered `404`. A backend's contract is not asked of it: its build writes it, and the platform keeps it with the build. ## Sizes | Size | CPU | Memory | Sleeps | |---|---|---|---| | `shared-256` | 0.2 vCPU | 256 MiB | yes | | `shared-512` | 0.5 vCPU | 512 MiB | yes | | `shared-1024` | 1 vCPU | 1024 MiB | yes | | `dedicated-1x` | 1 vCPU of its own | 2048 MiB | never | | `dedicated-2x` | 2 vCPU of its own | 4096 MiB | never | A **shared** size has a fraction of a CPU as its ceiling and is **put to sleep when it is idle** — after 15 minutes without a request — and woken by the next request, which waits for it. A request that waits longer than the wake allows is answered `503 machine_waking` with `Retry-After`, and asking again is what brings the answer. A **dedicated** size has whole CPUs of its own and never sleeps. Sleeping has consequences for code: a machine that sleeps runs nothing while it sleeps. A timer, an in-memory cache or a background loop stops with it, and a request that wakes it pays the start-up. Work that must happen on a schedule is a [cron](#crons-and-tasks), and work to be done once, now or later, a [task](#crons-and-tasks): the platform wakes the machine for either. `shared-256` is the size every project may open. The others are sizes that are paid for: open to an environment on Pay as you go with credit ([Billing](/docs/getting-started/billing)); for another, opening a machine at one, or moving one to it, is refused with `403 size_not_available`; `palbase machines:sizes` lists the sizes the environment may open now ([Usage & Limits](/docs/getting-started/usage-and-limits)). What a project's machines and databases reserve between them is held to the project's limit: a size or a number of copies that does not fit is refused with `409 machines_over_limit`. ## Copies A machine runs **one copy** unless you give it more (`replicas`, **Copies** in Studio, 1 to 5). Each copy runs on a node of its own, a request goes to one of them, and a copy or a node that goes leaves the others serving. Every copy is of the machine's size and counts as one against the project's limits and its machine time. What a copy keeps in its own memory or on its own disk, the others do not see — a session, a counter, a cache, a rate-limit window. A machine with more than one copy keeps those where every copy reaches them: its database, or the project's [cache](/docs/backend/cache). A long-lived connection (a WebSocket) stays with the copy that took it. The machine finds its number of copies in `PALBASE_REPLICAS`. ## The health check A machine says it is well at a **health path**: a path on its port that answers 2xx while it serves. While it starts it has 90 seconds to answer there; once it serves, one that stops answering for about 6 seconds is started again. Leave the path empty and the build says it: a directory whose `package.json` depends on `palbase/backend` is asked at `/_palbase/health`, which the framework answers `200` while it serves and `503` while it stops. A machine that says nowhere is asked only whether it listens on its port. ## Its environment The platform starts every machine with these, whatever else it is given: | Variable | What it holds | |---|---| | `PORT` | The port it listens on | | `PALBASE_URL` | The project's address, `https://.palbase.studio` | | `PALBASE_PROJECT_ID` | The project's id | | `PALBASE_REPLICAS` | How many copies of it run | | `PALBASE_PLATFORM_SECRET` | The key the platform signs its own calls to this machine with ([crons and tasks](#crons-and-tasks), a backend's contract) | | `PALBASE_MACHINE` | The machine's own name: what its program puts its tasks on the queue of | | `PALBASE_DB_URL` | Given a database: where it answers, as the application's login | | `PALBASE_DB_CONNECTIONS` | Given a database: how many connections to it one copy may hold | Besides those, a machine has two sources of its own: - **Environment variables** — settings, not secrets: one `NAME=value` a line in Studio's **Edit settings**, `env` in the API. 100 variables and 64 KiB at most. `PORT` is refused there, and a name the platform sets gives way to the platform's. - **Secrets** — the project keeps its secrets in two sets, `production` and `development`, and a machine names the set it starts with (**Secrets** in **Edit settings**: *No secrets*, *Production secrets* or *Development secrets*). Every secret of the set is in the machine's environment by its name; a name in `env` wins over the same secret. When a secret of the set changes, the machine rolls out with the new value beside the version that serves. Studio's **Environment › Variables** screen holds the set the environment's machines start with — see [Secrets](/docs/backend/secrets). A `palbase/backend` app also needs `PALBASE_SECRET_KEY` — the project's secret key, which nothing puts there for you. Make a secret key in **Settings › API keys** (it is shown once), add it as the secret `PALBASE_SECRET_KEY`, and start the machine with that set. Name it in the app's `.env.example` and a build waits for it instead of rolling out an app that cannot start ([Builds](/docs/machines/builds)). A change of size, port, copies, environment, secrets or health path rolls out beside the version that serves; a change of source is built first and rolls out once its build is up. ## Opening one In Studio: **Backend › Machines › New machine**. The sheet asks for the repository (only those the connected accounts can push to), the branch, the directory, the name, the size, the port and, optionally, a database and whether this machine runs its migrations. The branch's head is built at once; the machine answers at its address once that build is up. From a server, with the secret key, it is one call — the same module Studio calls: ```ts // scripts/open-machine.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); const { data: machine } = await palbase.machines.createMachine({ body: { name: "backend", size: "shared-512", port: 8080, source: { repository: "acme/notes", branch: "main", path: "server" }, secrets: "production", }, }); console.log(machine.state, machine.url); // building https://.app.palbase.studio ``` Inside a `palbase/backend` app the same client is `Palbase.machines`. The calls: | Call | HTTP | What it does | |---|---|---| | `createMachine` | `POST /machines` | Opens a machine; its state is `building` | | `listMachines` | `GET /machines` | The project's machines, by name | | `getMachine` | `GET /machines/{name}` | What was asked for, and what runs | | `updateMachine` | `PATCH /machines/{name}` | Changes its source, size, port, copies, env, secrets, health path or database | | `deleteMachine` | `DELETE /machines/{name}` | Takes it away; its address stops answering | A machine's `state` is `building` (no build of it runs yet), `starting`, `awake`, `asleep` or `failed` — `state_reason` says why it failed, or why a new version is not up. `version` counts the changes asked for and `running_version` the one that runs: they are equal once a change has rolled out. ## Crons and tasks A machine that sleeps cannot keep a timer, and a timer in a machine with three copies fires three times. So the schedule — and the queue of work to be done once — are the platform's: the program **says** what crons and task types it has, and the platform **calls** it when a cron is due or a task's time comes — waking the machine for it, and calling one copy. > **Note:** A backend on `palbase/backend` answers these calls itself: mark a provider with `@Cron` — see [Cron](/docs/backend/cron) — or `@Task` — see [Tasks](/docs/backend/tasks). Any other program answers the calls below. The calls, all on the machine's own port, all signed with the key in `PALBASE_PLATFORM_SECRET`: | Call | The program answers | |---|---| | `GET /_palbase/crons` | `{"crons": [{"name", "schedule", "time_zone", "timeout", "retry"}]}` — asked once each version comes up; `404` means it has none | | `POST /_palbase/crons/` | Runs the cron. The body is `{"cron", "run_id", "scheduled_at", "attempt"}`; the call is held open until it is done | | `GET /_palbase/tasks` | `{"tasks": [{"name", "timeout", "retry", "concurrency"}]}` — asked with the crons; `404` means it has none | | `POST /_palbase/tasks/` | Does one attempt of a task. The body is `{"task", "id", "attempt", "payload", "created_at"}`; the call is held open until it is done | A cron's `schedule` is five cron fields — minute, hour, day of month, month, day of week — on the clocks of `time_zone` (an IANA name; UTC when left out). `timeout` is the seconds an attempt has (30 when left out, 900 at most) and `retry` the attempts a run has after its first fails (5 when left out, 10 at most). A task type's `timeout` is 60 when left out (900 at most), its `retry` 5 (20 at most) and its `concurrency` — how many of its tasks are with the machine at once — 5 (50 at most). A name is letters, digits, `.`, `_` and `-`, 64 characters at most; a machine has 50 crons and 50 task types at most. What the platform cannot run (a bad schedule or zone, a value out of range) is left out of the list. A `2xx` answer is done. For a cron, `404` is "no such cron in this version", and the run is skipped; anything else — or no answer within the timeout — is a failed attempt, made again after 10 s, 20 s, … up to five minutes apart. One run of a cron runs at a time: a cron due again while its last run is still going is **skipped**, not queued. For a task, `422` is "this task can never be done" — it fails at once — and anything else that is not `2xx` is a failed attempt, made again after 10 s, 20 s, … up to an hour apart. Every call carries `Palbase-Signature: t=,v1=.")>` — a webhook's signature, with the machine's key. `verifySignature` from `palbase/server` checks it: ```ts // crons/server.ts import { createServer } from "node:http"; import { verifySignature, WebhookSignatureError } from "palbase/server"; const key = process.env.PALBASE_PLATFORM_SECRET ?? ""; const crons = [{ name: "nightly-digest", schedule: "0 3 * * *", time_zone: "Europe/Istanbul", timeout: 120, retry: 3 }]; async function nightlyDigest(runId: string): Promise { console.log(JSON.stringify({ msg: "digest", run_id: runId })); } createServer(async (req, res) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks); const path = new URL(req.url ?? "/", "http://machine").pathname; if (path !== "/_palbase/crons" && !path.startsWith("/_palbase/crons/")) { res.writeHead(200).end("ok"); return; } try { // A GET is signed over "." and an empty body; a POST over its bytes as they arrived. const signature = req.headers["palbase-signature"]; await verifySignature({ secret: key, signature: Array.isArray(signature) ? signature[0] : signature, body }); } catch (e) { res.writeHead(e instanceof WebhookSignatureError ? 401 : 500).end(); return; } if (req.method === "GET" && path === "/_palbase/crons") { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ crons })); return; } if (req.method === "POST" && path === "/_palbase/crons/nightly-digest") { const run = JSON.parse(body.toString("utf8")) as { run_id: string; attempt: number }; await nightlyDigest(run.run_id); res.writeHead(204).end(); return; } res.writeHead(404).end(); }).listen(Number(process.env.PORT ?? 8080)); ``` The same `run_id` comes with every attempt of one run, and the same `id` with every attempt of one task: key the work on it, and an attempt made again after a lost answer does not do it twice. What the platform keeps of them, with the secret key: | Call | HTTP | What it answers | |---|---|---| | `listCrons` | `GET /machines/{name}/crons` | The crons the running version said, each with its zone, when it is next due, whether it is paused, and its latest run | | `listCronRuns` | `GET /machines/{name}/crons/{cron}/runs` | A cron's latest 50 runs, newest first: `running`, `waiting`, `succeeded`, `failed` or `skipped`, with the attempts each took, why one failed, and whether it was asked to run now. Runs are kept 7 days. | | `runCron` | `POST /machines/{name}/crons/{cron}/run` | Runs it now, apart from its schedule (`409` while a run of it is not over) | | `pauseCron` / `resumeCron` | `POST /machines/{name}/crons/{cron}/pause`, `…/resume` | Paused, it is not run until resumed, whatever version comes up; resumed, it is due at its next time | | `enqueueTask` | `POST /machines/{name}/tasks` | Puts a task — `{"task", "payload", "delay" \| "run_at", "key"}` — of a type the running version declares | | `listTasks` | `GET /machines/{name}/tasks` | Its tasks, newest first, of one `state` and `task` type or all; a task that succeeded or was cancelled is kept a day, one that failed 30 | | `getTask` | `GET /machines/{name}/tasks/{task}` | One task, with its payload | | `retryTask` / `cancelTask` | `POST /machines/{name}/tasks/{task}/retry`, `…/cancel` | A failed task back on the queue, its attempts counted on; a waiting one taken off it | | `listTaskTypes` | `GET /machines/{name}/task-types` | The task types the running version declared, with what each one's queue holds | ## Reaching the internet A machine reaches the internet as any server does — a payment provider's API, a webhook of yours, a package registry — on any port but 25: send mail through a provider's API or its submission port (587). It reaches nothing of the platform's own network: not another project's machine and not the platform's services, except at the platform's public address, as any client of it does. What a project's machines send to the internet is counted, in bytes, as **Machine internet traffic** on the project's usage page (`machines.egress`). What they send to the platform at its public address, what goes to their databases, and what they take from the internet is not. What the project's answers send its clients — its machines' answers, its modules' (files storage hands out among them) — is counted as **Response traffic** (`edge.egress`), in bytes on the wire. A free project sends 5 GB a month at full speed: its machines' internet traffic and its response traffic together. Past it, until the month ends (UTC), it is slowed, never stopped — every byte still goes: - its machines send to the internet at about 256 kbit/s between them — each copy of a machine its share; requests still go out, connections open at once and downloads keep going; what they send takes longer; - its machines' answers to their clients go at about 256 kbit/s between them, as do the files its storage hands out (about 256 kbit/s for the project); its other answers — the API's JSON, realtime — are counted but not slowed. Each machine's log says so when the slowing begins (`palbase: this project has sent 5 GB …`). What its machines send the project's databases and the platform at its public address is never slowed, DNS questions to the machine's resolvers keep a pace of their own, and a project that may use paid sizes is not slowed at all. ### Its outbound range Every machine reaches the internet from one small range of addresses, the platform's, written as a CIDR (such as `203.0.113.8/29`). Studio shows it on the **Machines** page, under **Outbound address**. Add the whole range to a provider's allowlist when the provider — a bank, a payment or an SMS provider — lets in only the addresses it knows: a connection may leave from any address in it. **The range is shared by every Palbase machine**, of every project. An allowlist of it lets in all of Palbase, not your project alone, so a provider must still know you by your own credentials, such as an API key. The range stays the same as the platform changes; a change of it is announced before it is made. | Call | HTTP | What it answers | |---|---|---| | `getMachineNetwork` | `GET /machines/network` | `outbound_ips`: where machines reach the internet from, as ranges (CIDR); `shared`: `true`, every project's machines leave from them | ## Limits | | | |---|---| | Machines per project | 5 | | Copies per machine | 1 to 5 | | What machines and databases reserve between them | 2 vCPU and 4096 MiB a project (a shared size reserves a quarter of its CPU ceiling, a dedicated one all of it; memory is reserved whole) | | Builds | 50 a day (UTC) per project; one build at a time; 20 minutes at most each | | Idle before a shared size sleeps | 15 minutes | | Environment variables | 100, 64 KiB in all | | Logs kept | 7 days, up to 1,200 lines a minute per machine | | Traffic, free project | 5 GB a month at full speed, machine internet traffic and response traffic together; then machines' traffic and answers, and storage's files, at about 256 kbit/s until the month ends (UTC); see [Reaching the internet](#reaching-the-internet) | | Connections to the internet open at once | Per copy of a machine, by its memory: 4,096 for 256 MiB, twice that for each doubling — 8,192 (`shared-512`), 16,384 (`shared-1024`), 32,768 (`dedicated-1x` and larger). One more is refused at once (`connection refused` / a reset). Connections to the platform, to your databases and DNS questions to the machine's resolvers are not counted. The cap keeps one machine from taking a server's whole connection table from the machines beside it: close connections you no longer use, and pool those you reuse | ## Related - [Connecting GitHub](/docs/machines/github) — the repository, branch and directory a machine is built from - [Builds, Logs & Rollback](/docs/machines/builds) — what a push does, and how to go back - [Databases](/docs/machines/databases) — Postgres of your own, and giving one to a machine - [Backend SDK](/docs/backend/overview) — the framework most machines run - [Secrets](/docs/backend/secrets) — the secret sets a machine starts with - [Server SDK](/docs/server/overview) — `createServer`, the client every call on this page goes through # Connecting GitHub A machine is built from GitHub: a **repository**, a **branch** of it and a **directory** in it. The platform reaches your repositories through its GitHub App, **Palbase Cloud**, installed on the GitHub account that owns them. Once an installation is connected to the project, every push to a machine's branch that changes what the machine is built from is built and rolled out — there is no other deploy step, and no command to run. ## Connect the App In Studio: **Backend › Machines › New machine › Connect GitHub**. Studio opens GitHub in a new tab: 1. GitHub asks who you are, and where to install **Palbase Cloud** — on your own account or an organization's, on every repository or the ones you choose. 2. GitHub brings you back to Palbase, which shows the accounts Palbase Cloud is installed on that you can push to, and where to install it on another. 3. You connect the one you choose by writing the project's ref. That is the confirmation that this installation is for this project. 4. Back in Studio, **Refresh**: the sheet lists the repositories you can push to. The link Studio opens holds for 15 minutes. Studio is not on GitHub's way back — it hears nothing of it — which is why the sheet asks you to refresh rather than waiting for a reply. With the secret key, the same steps are calls of the machines module (`createServer(...).machines` from [`palbase/server`](/docs/server/overview), or `Palbase.machines` inside a backend): | Call | HTTP | What it does | |---|---|---| | `connectGitHub` | `POST /machines/github/connect` | Answers the address where the project's owner installs and connects Palbase Cloud (`url`, and `expires_at` 15 minutes on) | | `listInstallations` | `GET /machines/github/installations` | The project's installations: the account each is on, and who connected it | | `linkInstallation` | `POST /machines/github/installations` | Connects an installation with the user's own GitHub token for the App — a device flow's way, for a tool that is not a browser | | `unlinkInstallation` | `DELETE /machines/github/installations/{installation}` | Disconnects one; refused while a machine is built from it | | `listRepositories` | `GET /machines/github/repositories` | Every repository the installations reach: what a machine may be built from | The CLI connects it the same way, and from a computer with no browser too: ```sh palbase github:connect # opens GitHub; back from it, you link the account by writing the environment's ref palbase github:connect --device # GitHub's device flow: a code you enter at github.com/login/device on any device palbase github:list # the accounts linked to the environment, and who linked each palbase github:repos # the repositories its machines may be built from ``` `--all-envs` links every environment of the project at once, and `--from ` links the installation another environment of the organization linked, with no GitHub sign-in. See the [CLI reference](/docs/cli/reference#github). ## Whose repositories An installation reaches the repositories **the user who connected it can push to** — read when they connected it. A repository they could only read is not offered; connecting again picks up what they can push to now. Two consequences: - A repository that does not appear in the sheet is either outside the installation (add it on GitHub, under the App's configuration) or one the connector cannot push to. Connect again as someone who can. - A user who takes their authorization of the App back on GitHub disconnects what they connected. Installations are a project's own, and in Studio every environment is a project of its own: connect GitHub in each environment whose machines are built from it. The machines of two environments may be built from one repository — a `staging` environment from the `staging` branch, `main` from `main`. ## Repository, branch, directory A machine's `source`: | Field | What it is | |---|---| | `repository` | The repository's full name, `owner/name` — one `listRepositories` answers | | `branch` | The branch whose pushes are built and rolled out. Studio fills in the repository's default branch. | | `path` | The directory the machine is built from, relative to the repository's root — the root when left out. Its `Dockerfile`, `package.json` and `.env.example` are read here. | | `context` | What the build is given, when that is not `path` itself: a directory above `path`, or `.` for the root | In Studio's **New machine** sheet these are **Repository**, **Branch** and **Directory**; the branch can be changed later in **Edit settings**, and a new branch is built before it rolls out. ### One repository, several machines A monorepo holds several apps, and each can be its own machine — a `backend` built from `server/`, a `web` machine built from `web/`: ```text acme/notes ├── server/ ← machine "backend": path "server" │ ├── Dockerfile │ ├── package.json │ └── src/ ├── web/ ← machine "web": path "web" │ └── … └── packages/shared/ ← read by both ``` An app that needs a sibling package — `server/` importing `packages/shared/` — is built with the repository's root as its context: `path: "server"`, `context: "."`. Its `Dockerfile`, which stays in `server/`, then copies from the root (`COPY packages/shared ./packages/shared`); without a Dockerfile, the build plans the whole context. ## Which pushes build which machines A push to the branch is built for a machine **when it changes a file in what that machine's build is given**: its `context`, else its `path`, matched by whole directory names, case-sensitively. With the layout above: | The push changes | `backend` (`server`) | `web` (`web`) | a machine with `context: "."` | |---|---|---|---| | `server/src/notes.ts` | built | — | built | | `web/app/page.tsx` | — | built | built | | `packages/shared/x.ts` | — | — | built | | `README.md` | — | — | built | | `serverless/x.ts` | — (not `server/`) | — | built | A push that changes nothing a machine is built from leaves it as it runs, and does not count against the project's builds for the day. A machine built from the repository's root is built by every push. Where GitHub's account of a push may not be everything it changed, every machine built from the branch is built: a forced push, a branch's first push, a push of 2,048 commits or more, and a commit that names no files (an empty commit among them) or 3,000 or more. ## What GitHub shows Each build puts a check on its commit, named **Palbase Cloud · ``** for the `backend` machine and **Palbase Cloud · `-`** for another: *Queued*, *Building*, *Waiting for configuration*, then *Rolled out*, *Build failed* or *Did not come up* — or *Left behind* when a newer commit of the branch is built first. The commit's checks are the quickest way to see what a push did; Studio's machine page has the same builds with their logs ([Builds, Logs & Rollback](/docs/machines/builds)). ## Disconnecting Disconnect an installation (`unlinkInstallation`, `DELETE /machines/github/installations/{installation}`) once no machine is built from it — it is refused while one is. Delete or move the machines built from it first. ## Related - [Machines](/docs/machines/overview) — sizes, addresses, environment and jobs - [Builds, Logs & Rollback](/docs/machines/builds) — what a push does once it is built - [Project Structure](/docs/getting-started/project-structure) — what a backend's directory holds - [Quickstart](/docs/getting-started/quickstart) — connect GitHub and push a backend, end to end # Builds, Logs & Rollback Code reaches a machine one way: **a push to its branch**. The push is built into an image, the image is rolled out **beside the version that serves**, and traffic moves to it once it is up — so a request never meets a gap, and a version that does not come up never takes one. There is no deploy command, and nothing to run on your machine but `git push`. This page follows one push from GitHub to traffic, then covers what you read on the way — the build's log, the machine's own lines — and how to go back. ## What a push does ```text git push origin main └─ a build per machine whose directory the push changed (Connecting GitHub) queued → building ├─ failed the build's log says why ├─ waiting_config .env.example names a variable the machine lacks └─ the image is ready └─ (a database's migrations, when this machine runs them: start) ├─ testing its tests run against the new version (test_command) │ └─ failed a test failed: the reason names it; it never takes traffic, │ the old one keeps serving, the migration is taken back └─ the new version starts beside the old one ├─ it answers its health check → traffic moves; the old one stops │ └─ (migrations: complete, once nothing of the old version is left) └─ it does not come up → the old one keeps serving ``` Each commit of the branch is built once for a machine, however often it is pushed. A project builds one thing at a time; a newer commit that arrives while an older one waits takes its place, and the older build ends `cancelled` (*Left behind* on its GitHub check). A machine that deploys only after its commit's checks pass (`deploy_after_checks`, as Render's *After CI Checks Pass*) lets every push wait for its own checks, side by side. The first whose checks pass is built and leaves the older ones behind, so the machine only ever moves to a newer commit: an older push whose checks pass later is not built. A push whose checks fail ends `skipped`; the others keep waiting. ### Tests before it takes traffic A machine that names a **test command** (`test_command`, as Render's pre-deploy command or a CI's test step) has every new build tested before it takes traffic — the build is `testing` meanwhile. Once its database's migration has started, the new version comes up beside the one that serves, **as it will run**: its env, its secrets, its database. The build's own image then runs the command in a shell (`bun test`, `npm test`) against it: - `PALBASE_TEST_BASE_URL` is where the new version answers; `PALBASE_TEST_AUTH_URL` the project's auth; - **test users** the run makes for its tests (`test_users`), each with its access token, are in `PALBASE_TEST_IDENTITIES` — `api.signInAs("alice")` from `palbase/backend/test` (or `palbase/server/testing`, whatever the backend is written with) acts as one; they are deleted when the run ends, however it ends. The tests reach what the machine reaches — its database, the project's storage, mail and the rest — so they write as the machine does: write tests that leave the data as a real user's session would, and that do not depend on an empty database. They pass, and the version takes traffic. One fails, and the build fails: its `reason` names the tests that failed, its GitHub check shows the end of what they printed (what the run was given that is secret written over), the version never takes traffic, the machine keeps the version it runs, and the migration is taken back. A run has 20 minutes. A build is tested as the machine named its tests when the build was released to it. `palbase test --machine ` runs the same command, as the same test users, on your computer. ### How the image is made The build reads the machine's directory (its `path`): - **With a `Dockerfile`** there, the build uses it, with the build context the machine names (its `context`, else its `path`). - **Without one**, [Railpack](https://railpack.com) detects what the directory is — Node, Bun, Go, Python, … — and plans the image from it. A `palbase/backend` app ships a `Dockerfile` (see [Project Structure](/docs/getting-started/project-structure)): its decorators need the TypeScript compiler options that Bun reads from `tsconfig.json`, and a Dockerfile says exactly how it is run. A build has 20 minutes, and a repository is built from at most 1 GiB and 100,000 files. ### `.env.example`: what the program must be given A `.env.example` in the machine's directory is read as **the list of variables the program needs**: every `NAME=` line is a name (comments, blank lines and `PORT` are skipped; a value after `=` is ignored). ```text # .env.example — the names this backend reads; their values live in Palbase. PALBASE_URL= PALBASE_PROJECT_ID= PALBASE_SECRET_KEY= ``` When the build is done, every name is looked for among what the machine will start with: its own environment variables, its secret set, and what the platform gives it (`PALBASE_URL`, `PALBASE_PROJECT_ID`, `PALBASE_REPLICAS`, `PALBASE_PLATFORM_SECRET`, and with a database `PALBASE_DB_URL` and `PALBASE_DB_CONNECTIONS`). A name found nowhere stops the rollout: the build waits as **`waiting_config`** (*Waiting for variables* in Studio, *Waiting for configuration* on GitHub), its `missing_env` lists the names, and it **rolls out by itself once the machine has them** — add the secret, or the variable, and nothing has to be pushed again. Without this file nothing is checked, and an app missing a setting is rolled out to fail at start. The version before it keeps serving either way, but the waiting build says what is wrong by name; the failed start says it in its logs. ## The rollout The new version starts beside the one that serves, and is held to its [health check](/docs/machines/overview#the-health-check): 90 seconds to answer `2xx` at its health path. Up, it takes the traffic and the old version is stopped — a `palbase/backend` app finishes the requests in flight first (20 seconds at most), then runs its `onShutdown()`. Not up, it never takes a request: the old version keeps serving, the build's check on GitHub says *Did not come up*, and the machine's `state_reason` says why. A change that is not a push rolls out the same way: a new size, port, number of copies, environment, secret set or health path, a changed secret of the machine's set, and a rollback. **`SIGTERM` can come more than once.** A machine runs in a sandbox of its own (a small VM), and a version being stopped is sent `SIGTERM` twice, a few milliseconds apart. A `palbase/backend` app handles that. An app of your own should stop on the first and keep its handler for the rest: in Node and Bun `process.on("SIGTERM", …)`, not `process.once`. A process listening once dies of the second signal (exit 143), and its requests still in flight are cut (`503 machine_interrupted`). ### When a release migrates its database A machine given a database with **Run migrations** on (`database.migrations: true`) migrates it as part of every release, with no step of yours: 1. **start** — before the new version starts, its image runs once with `PALBASE_DB_MIGRATE=start` and the owner's address: what the new version needs is added beside what the running version uses. The version rolls out only once start succeeded. 2. **rollout** — both versions run against the same tables. 3. **complete** — once the new version has served and nothing of the old one is left, the image runs once more with `PALBASE_DB_MIGRATE=complete`, and what only the old version used is removed. A start the app itself refuses — a change the schema cannot make while both versions run, or rows that do not fit it — ends the release: the machine stays on the build that serves, and what start began is taken back (`rollback`). A start that was only cut off (the database restarting, a lock not had in time) is run again and picks up where it stopped. What these runs write is in the machine's logs, marked `migration`. The schema side of all this is [Schema changes](/docs/backend/migrations); the database side is [Databases](/docs/machines/databases). One migration is open at a time, and a database's migrations are one machine's: a second machine asking to run them is refused (`409 database_migrated_elsewhere`). ## Builds Studio's machine page (**Backend › Machines › ``**) lists its **Builds**, newest first — every commit of the branch built for it, its state, and a **Running** badge on the one that serves. **Log** opens a build's log; **Roll back** is on every build that succeeded and does not run. | State | What it means | |---|---| | `waiting_checks` | Waiting for its commit's checks on GitHub (`deploy_after_checks`); `reason` names what it waits for | | `queued` | Waiting its turn: one build at a time per project | | `building` | Being built | | `testing` | Released to its machine; its tests (`test_command`) run against it before it takes traffic — they fail and it fails, `reason` naming them, and the machine runs what it ran | | `waiting_config` | Built, waiting for what `.env.example` names (`missing_env`); rolls out once the machine has it | | `succeeded` | Built and rolled out — or a newer build already had | | `failed` | Not built, or not up; `reason` says which | | `cancelled` | A newer commit was built first, the head of the branch was asked for (a deploy, or a change of source), or the machine was deleted | | `skipped` | Its commit's checks did not pass, or did not end in time; nothing of it was built (`reason`) | | Call | HTTP | What it answers | |---|---|---| | `listBuilds` | `GET /machines/{name}/builds` | A page of builds, newest first (20 by default, `next_cursor` for the next) | | `getBuild` | `GET /machines/{name}/builds/{build}` | One build: its commit and message, state, reason, `missing_env`, whether it runs | | `getBuildLog` | `GET /machines/{name}/builds/{build}/log` | The build's log, its last MiB — where a failure is. `404 log_not_ready` before the build has started; `404 no_log` for one that ended before it ran (its `reason` says why) | ## Logs What the machine writes to stdout and stderr is kept — every copy, every version (one that never came up too), and its migration runs — for **7 days**, up to 1,200 lines a minute per machine; past that, the rest of that minute is not kept. Write one JSON object a line and each is one line to read back: `Log` from `palbase/backend` does exactly that, with the request's id on every line ([Backend SDK](/docs/backend/overview)). Studio's machine page shows the last hour, the last 100, 500 or 2,000 lines of it. From a server, `readMachineLogs` (`GET /machines/{name}/logs`) answers NDJSON, one line object a line, oldest first: | Query | | |---|---| | `since` | From when, inclusive (RFC 3339); an hour ago when left out, a week ago at most | | `until` | Until when, exclusive; now when left out. Not with `follow`. | | `lines` | How many lines at most, 1 to 5,000 (500 by default) — for a followed read, before it follows | | `follow` | `true` holds the answer open and sends what the machine writes next | Each line carries `ts` (to the nanosecond), `id` (ids sort as lines do, and the same line read twice has the same id), `stream` (`stdout` or `stderr`), `generation` (the machine's version that wrote it), `source` (`machine`, or `migration` for a migration run), `line` (what was written) and which copy wrote it. ```ts // scripts/tail.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); type LogLine = { ts: string; id: string; stream: "stdout" | "stderr"; generation: number; source: "machine" | "migration"; line: string }; const since = new Date(Date.now() - 10 * 60_000).toISOString(); const { data } = await palbase.machines.readMachineLogs({ path: { name: "backend" }, query: { since, lines: 200 } }); for (const raw of (await data.text()).split("\n")) { if (!raw) continue; const l = JSON.parse(raw) as LogLine; console.log(l.ts, `v${l.generation}`, l.source, l.stream, l.line); } ``` A followed read is held open for 5 minutes at most (and ends when the machine is deleted). A project holds 5 followed reads open at once; a sixth is `429 too_many_follows`. A reader whose connection ends goes on with `since=` the time of the last line it had — a line may then come twice, and its `id` says so. ## Rollback **Roll back** returns the machine to one of its builds that succeeded: a **new version** of the machine, running that build's image, rolled out beside the one that serves like any other. It is the only change of what a machine runs that is not made by a push. The images of a machine's last 10 successful builds are kept; an older build is refused (`409 build_not_deployable`). ```ts // scripts/rollback.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); // The build that ran before the one that serves. const { data: page } = await palbase.machines.listBuilds({ path: { name: "backend" } }); const previous = page.builds.find((b) => b.state === "succeeded" && !b.running); if (previous) { const { data: machine } = await palbase.machines.rollbackMachine({ path: { name: "backend" }, body: { build: previous.id } }); console.log(`rolling out version ${machine.version}: ${previous.commit}`); } ``` The next push to the branch is built and rolled out as usual, so a rollback holds until then. To keep a machine off a bad commit, revert it on the branch — or point the machine at another branch in **Edit settings**. > **Note:** A rollback changes code, never data. Once a release's migration has completed, the build you return to reads the tables as that migration left them — a column it dropped is gone. A migration still open when its version is abandoned is taken back instead. Make a change an older build cannot read in two releases, so the one before keeps working: [Schema changes](/docs/backend/migrations). ## Related - [Connecting GitHub](/docs/machines/github) — which pushes build which machines - [Machines](/docs/machines/overview) — sizes, the health check, environment and secrets - [Databases](/docs/machines/databases) — giving a machine a database, and its migrations - [Schema changes](/docs/backend/migrations) — what start and complete do to your tables - [Project Structure](/docs/getting-started/project-structure) — the Dockerfile and `.env.example` of a backend # Databases A project's **database** is Postgres 18 of its own, with pgvector and PostGIS, reached from anywhere over TLS with a password. Give one to a [machine](/docs/machines/overview) and the machine starts with its address; connect to it from anything else — `psql`, a migration tool, a BI tool, a server of your own — with the credentials Studio shows once. A database is the project's, not a machine's. One may be given to several machines, and one machine — the one that runs its migrations — owns its schema. A [`palbase/backend`](/docs/backend/database) app declares that schema in TypeScript and migrates it on every release; anything else that connects is an ordinary Postgres client. In Studio, databases live under **Database › Databases**. Each step below names the machines module's call beside the click, for a script that holds the secret key. ## Opening one **Database › Databases › New database** asks for: | Field | API | | |---|---|---| | **Name** | `name` | Lowercase letters, digits and dashes, starting with a letter, 32 at most; `main` when left out. It answers at `-.db.palbase.studio`. | | **Size** | `size` | One of the sizes below; `free` when left out. It can be changed later ([below](#changing-and-removing-one)) | | **Allowed addresses** | `allow` | Who may connect, as addresses or CIDRs (`203.0.113.7`, `203.0.113.0/24`), 50 at most. Empty: anyone with the password. | | **Backups kept** | `backup_days` | 1 to 35 days: how far back a [restore](/docs/machines/backups) reaches | The database's volume is made and it starts; it answers once its state is `awake`. **The answer that opens it holds both of its passwords — the owner's and the application's — and no answer holds them again.** Studio shows them on the database's page once; copy them then. A password you did not keep is made new, not read back ([below](#passwords)). ```ts // scripts/open-database.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); const { data: db } = await palbase.machines.createDatabase({ body: { name: "main", size: "free", backup_days: 7 }, }); console.log(db.host, db.state); // main-.db.palbase.studio starting ``` A project has **3 databases** at most — a deleted one counts until it is purged — and **one of a free size** at a time. ## Sizes | Size | CPU | Memory | Storage | Sleeps | |---|---|---|---|---| | `free` | 0.25 vCPU | 512 MiB | 1 GB | yes — and awake 400 hours a month at most | | `hobby` | 0.25 vCPU | 512 MiB | 5 GB | yes | | `shared-1g` | up to 1 vCPU | 1 GiB | 10 GB | never | | `shared-2g` | up to 2 vCPU | 2 GiB | 25 GB | never | | `shared-4g` | up to 2 vCPU | 4 GiB | 50 GB | never | | `dedicated-1x` | 1 vCPU of its own | 4 GiB | 25 GB | never | | `dedicated-2x` | 2 vCPU of its own | 8 GiB | 50 GB | never | `free` is the size every project may open. The others are sizes that are paid for: open to an environment on Pay as you go with credit ([Billing](/docs/getting-started/billing)); another is refused one with `403 size_not_available`. A size that sleeps is put to sleep when it is idle and **woken by the next connection**, in about 10 seconds; the client's connection waits for it. A `free` database awake for its 400 hours sleeps until the month ends, and a client is told so; one asleep for a week is **archived**, and its next connection brings it back from its backup. The other sizes never sleep and live on a disk of their own. The size is chosen when the database is opened and can be changed later ([below](#changing-and-removing-one)). What a project's machines and databases reserve between them is held to its limit, and a size that does not fit is refused with `409 machines_over_limit`. ### When it is full A database holding its size's storage turns **read-only**: a transaction takes no writes unless it asks to, and reads go on. `read_only`, `used_bytes` and `quota_bytes` on the database say where it stands. To make room, write in a transaction that asks for it — `BEGIN READ WRITE`, then `DROP`, `TRUNCATE`, or `DELETE` followed by `VACUUM FULL` (a `DELETE` alone frees no room). Once it holds less than 95% of its quota it takes writes again, within a minute. ## Connecting Every database has two logins: | Login | Who it is | What it is for | |---|---|---| | **Owner** | Owns the database and its tables | Migrations, `psql`, a tool that changes the schema (Prisma, …) | | **App** | What a machine given the database connects as | Your app's queries. It cannot bypass row-level security; it may `SET ROLE anon`, `authenticated` or `service_role`. | and two ways in: | Port | Through | Use it for | |---|---|---| | `5432` | Postgres itself | Anything; the only choice for migrations, `LISTEN`, advisory locks and session settings | | `6543` | PgBouncer, transaction pooling | Clients that open a connection per request (serverless functions). A session's `SET`, `LISTEN` and advisory locks do not hold here. | TLS is required, and the URLs Studio gives check the server's name (`sslmode=verify-full`). A client that sends no TLS server name — libpq before 14, some drivers — connects as the user shown as **User for clients without SNI**, which carries the database's name in it. **How to connect** (`getDatabaseCredentials`, `GET /databases/{name}/credentials`) answers everything but the passwords, ready to paste, for each login: a direct URL, a pooled URL, a `psql` command, a JDBC URL and the two lines of a Prisma schema's env (`DATABASE_URL` pooled for queries, `DIRECT_URL` for migrations). `[YOUR-PASSWORD]` stands where the password goes. ```text postgresql://:[YOUR-PASSWORD]@main-.db.palbase.studio:5432/?sslmode=verify-full ``` ## Giving it to a machine In the machine's **New machine** sheet or **Edit settings**, choose the **Database**, and whether this machine **runs its migrations**. Through the API it is the machine's `database`: ```ts // scripts/give-database.ts import { createServer } from "palbase/server"; const palbase = createServer({ url: process.env.PALBASE_URL!, secretKey: process.env.PALBASE_SECRET_KEY! }); await palbase.machines.updateMachine({ path: { name: "backend" }, body: { database: { name: "main", migrations: true } }, }); ``` The machine is rolled out with two more variables: - **`PALBASE_DB_URL`** — the database's address, as the **app** login. A `palbase/backend` app reads it by itself; any other program connects with it. - **`PALBASE_DB_CONNECTIONS`** — how many connections to it **one copy** may hold. The database's connections are shared out among the copies of every machine given it, counted twice (a new version comes up beside the one that serves) and with a migration run's share apart. A `palbase/backend` app holds itself to it; a program of your own should size its pool by it. Copies the database could not serve are refused (`409 database_connections_short`), and the other machines given it roll out to their new share. A machine's connection is a client like any other: it wakes a database that sleeps and keeps it awake while it holds one. **Run migrations** (`migrations: true`) makes every release of the machine migrate the database before it serves — as the **owner**, with `PALBASE_DB_MIGRATE=start` before the rollout and `=complete` after ([Builds, Logs & Rollback](/docs/machines/builds#when-a-release-migrates-its-database)). A database's migrations are one machine's: a second machine asking for them is refused with `409 database_migrated_elsewhere`. The other machines given the database read and write it as the app login, and must read the schema the migrating machine declares. A change of `database` gives it whole: `migrations` left out is `false`. `{ name: "" }` gives the machine none. A database that is not the project's is refused (`400 database_not_found`). ## Passwords A password is shown once — when the database is opened, and when it is made new. **Make a new password** on the database's page (**New owner password**, **New app password**), or `changeDatabasePassword` (`POST /databases/{name}/password` with `role: "owner"` or `"app"`), answers the credentials with the new one; the other login carries `[YOUR-PASSWORD]`. The new password works once the database has taken it, a few seconds later. - **Owner**: its current password stops working. Migrations, `psql` and anything else that connects as the owner need the new one — the machine that runs migrations is given it by the platform. - **App**: the new password is set on a second app login, and the machines given the database move to it with **none of their connections refused**. The old login keeps working until the next change — so to shut out a leaked app password, make it new **twice**. A change waits for the one before it to reach the database: `409 password_change_pending` with `Retry-After`, and the same while a database first starts. ## The SQL console **Database › SQL** runs read-only SQL against a database — `SELECT`, `EXPLAIN`, `SHOW` — with autocomplete from its tables, a history and saved queries. It is `queryDatabase` (`POST /databases/{name}/sql`), and its rules are the module's: - One statement, 64 KiB at most; two are refused (SQLSTATE `42601`). - It runs as the **owner**, in a transaction that takes no writes and is always rolled back: a write is refused by Postgres (`25006`). - It has 15 seconds (`57014`) and 5 seconds for a lock (`55P03`). - It answers the columns and the first rows (1 to 1,000, `limit`), `truncated` when there were more, and how long it took. Values come as JSON: a timestamp in RFC 3339, `numeric` as a string, `bytea` as `\x` and its hex, `json` and `jsonb` as the JSON they hold. - A statement Postgres refuses is `400 query_failed` with its message and its `sqlstate`. `GET /databases/{name}/schema` answers what the console's autocomplete reads: every table outside Postgres' own schemas and Palgres', its columns in order — type as Postgres writes it, nullable, part of the primary key, the column a foreign key points at — and whether row-level security is on. A sleeping database is woken for the console, the call held a few seconds (`503 database_waking` with `Retry-After` past that). A database being restored, or one that cannot start, is refused with `409`. A database runs a few console calls at once; past that, `429 too_many_queries`. ## Changing and removing one `updateDatabase` (`PATCH /databases/{name}`, **Edit access and backups** in Studio) changes who may connect (`allow`), how many days of backups are kept (`backup_days`) and the database's size (`size`). Deleting one, taking it back and purging it are on [Backups & Restore](/docs/machines/backups#deleting-a-database). A new size is answered at once with `202`: the database moves to it while it serves. Its sessions on the direct port end once, when it moves, and new logins wait a few seconds then. A change of size is refused with `409` when the database is already moving to another size (`database_resizing`), being brought back to a moment (`database_restoring`) or failed (`database_failed`), or when it holds more than the new size's storage (`database_too_large`); a smaller size waits until what the database holds has been measured, within minutes of its start (`database_not_measured`). A size that is paid for is refused with `403 size_not_available` while those sizes are not open. | Call | HTTP | | |---|---|---| | `listDatabases` | `GET /databases` | The project's databases by name — deleted ones that may still be taken back among them | | `getDatabase` | `GET /databases/{name}` | Its host and ports, size, `allow`, `backup_days`, state, how much it holds and how long it has been awake this month | | `createDatabase` | `POST /databases` | Opens one; the answer holds both passwords, once | | `updateDatabase` | `PATCH /databases/{name}` | Who may connect, and its backup days | | `getDatabaseCredentials` | `GET /databases/{name}/credentials` | How to connect, without the passwords | | `changeDatabasePassword` | `POST /databases/{name}/password` | A new owner or app password, shown once | A database's `state` is `starting`, `awake`, `asleep`, `archived`, `restoring` or `failed` (`state_reason` says why). ## From the CLI ```sh palbase databases:create main --size free --backup-days 7 # its passwords are kept on this computer palbase databases:list palbase db:url main --pooled # a connection address for any tool palbase db:shell main # psql on it (it wakes if asleep) palbase db:dump main --file main.dump # pg_dump's custom format, as its owner palbase databases:update main --allow 203.0.113.0/24 # who may connect; --size moves it to another size palbase db:copy --from main --to staging # one environment's data into another's ``` `palbase databases:sizes` lists the sizes, `palbase databases:password:reset --role owner|app` makes a login's password new, and `palbase databases:restore --to