@palbase/backend 13.0.0 → 14.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  # Palbase Backend SDK (`@palbase/backend`)
2
2
 
3
- > TypeScript backend SDK. NestJS-style class controllers: `@Controller` classes with `@Get`/`@Post`/`@Query`/… methods and `@Body`/`@QueryParams`/`@Param`/`@User` parameter decorators. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use parameter decorators, workers `(payload, meta)`, jobs `(meta)`, hooks/webhooks `(event, meta)`. Middleware is the one exception (`ctx`). Not Express, not Supabase Edge Functions.
3
+ > TypeScript backend SDK. NestJS-style class controllers: `@Controller` classes with `@Get`/`@Post`/`@Query`/… methods and `@Body`/`@QueryParams`/`@Param`/`@User` parameter decorators. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use parameter decorators, jobs `(meta)`, hooks/webhooks `(event, meta)`. Middleware is the one exception (`ctx`). There is NO queue and no `defineWorker` — background work is a cron `@Job` under `jobs/`. Not Express, not Supabase Edge Functions.
4
4
 
5
5
 
6
6
 
@@ -33,7 +33,7 @@ db/schema.ts # config-as-code Postgres schema (tables, co
33
33
 
34
34
  The four folders above are the daily surface. These also exist (own docs, linked
35
35
  below): `resources/` (external connections — [resources.md](./resources.md)),
36
- `seeds/` (seed data), `jobs/` + `workers/` (background — [background.md](./background.md)),
36
+ `seeds/` (seed data), `jobs/` (background — [background.md](./background.md)),
37
37
  `webhooks/` + `hooks/` (events — [events.md](./events.md)), `middleware/`.
38
38
 
39
39
  ### The 7 rules (checklist)
@@ -224,14 +224,13 @@ The **only difference** is the trigger argument:
224
224
  | You are writing… | Handler signature | Trigger arg |
225
225
  |------------------|-------------------|-------------|
226
226
  | **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
227
- | **Workers** (`workers/**`) | `(payload, meta)` | typed payload + `WorkerMeta` |
228
227
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
229
228
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
230
229
  | **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
231
230
  | **Middleware** (`middleware/**`) | `(ctx, next)` | `MiddlewareContext` — the **one exception** |
232
231
 
233
232
  `meta` carries non-service data: `env` (Environment variables),
234
- `environmentId`, and for workers/webhooks `requestId`. Services always come from
233
+ `environmentId`, and for webhooks `requestId`. Services always come from
235
234
  the imported singletons — not from `ctx` or any argument.
236
235
 
237
236
  ## Project shape
@@ -249,7 +248,6 @@ my-backend/
249
248
  ├── db/migrations/ # explicit SQL migrations for type changes (optional)
250
249
  ├── resources/ # external connections, set up once at boot (optional)
251
250
  ├── seeds/ # seed data (optional)
252
- ├── workers/ # background job handlers (optional)
253
251
  ├── jobs/ # cron-scheduled jobs (optional)
254
252
  ├── hooks/ # auth/storage/document event hooks (optional)
255
253
  ├── webhooks/ # inbound provider webhooks (optional)
@@ -642,6 +640,147 @@ rest of the chain (other middleware, then the endpoint method).
642
640
 
643
641
 
644
642
 
643
+ <!-- ===== auth.md ===== -->
644
+
645
+ # Authentication
646
+
647
+ Routes are **secure by default**: every endpoint requires a signed-in user
648
+ unless it opts out with `auth: false`. Client SDKs attach the user's token
649
+ automatically, so on the backend you declare what a route needs and inject the
650
+ user.
651
+
652
+ ```ts
653
+ import { Controller, Get, Post, Body, User, OptionalUser } from "@palbase/backend";
654
+ import type { UserT } from "@palbase/backend";
655
+
656
+ @Controller("/todos") // no auth option → every route needs a user
657
+ export default class TodosController {
658
+ @Post("")
659
+ create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT) {
660
+ return todoService.create(user.id, body.title); // user is non-null — guaranteed
661
+ }
662
+
663
+ @Get("/featured", { auth: false }) // one public route
664
+ featured(@OptionalUser() user: UserT | null) {
665
+ return todoService.featured(user?.id ?? null); // may be null — handle it
666
+ }
667
+ }
668
+ ```
669
+
670
+ ## What `@User()` gives you
671
+
672
+ ```ts
673
+ interface User {
674
+ id: string;
675
+ email?: string; // absent for phone-only users
676
+ emailVerified: boolean;
677
+ role: string;
678
+ metadata: Record<string, unknown>;
679
+ device: VerifiedDevice | null;
680
+ }
681
+ ```
682
+
683
+ Every field is **server-resolved** from the verified profile — nothing here is
684
+ client-settable. `metadata` is your own `auth.users.metadata` (set through the
685
+ admin users API); `role` is the **database** role RLS reads and is always
686
+ `"authenticated"` for a signed-in user, so application roles belong in
687
+ `metadata`, not there.
688
+
689
+ `emailVerified` is likewise read from the verified profile rather than a JWT
690
+ claim: a claim is only true as of when the token was minted, so a user who
691
+ verifies mid-session would keep reporting `false` until their token expired.
692
+
693
+ ## Roles
694
+
695
+ `auth: { role: "admin" }` gates on the caller's `metadata.role`:
696
+
697
+ ```ts
698
+ @Controller("/admin", { auth: { role: "admin" } })
699
+ export default class AdminController {
700
+ @Get("/stats")
701
+ stats(): Promise<Stats> { … } // only metadata.role === "admin" reaches here
702
+ }
703
+ ```
704
+
705
+ Not signed in → `401`. Signed in with a different (or missing) role → `403`. A
706
+ role gate implies authentication, so the caller is resolved even on a route with
707
+ no `@User()` parameter. The role is read from the verified profile per request,
708
+ so revoking it takes effect on the next call rather than at token expiry.
709
+
710
+ ## Email verification
711
+
712
+ The platform handles verification end to end. **You do not configure a sender**,
713
+ and `config/notifications.ts` is unrelated — that file declares providers for
714
+ **your app's own** notifications. Auth email goes out through Palbase's own
715
+ notification tenant, not yours.
716
+
717
+ What happens on `POST /auth/signup`:
718
+
719
+ 1. The account is created with `email_verified = false`.
720
+ 2. A verification email is sent — by default a **6-digit code, valid 5 minutes**.
721
+ (An Environment configured for link-based verification instead sends a link
722
+ token valid **24 hours**.)
723
+ 3. The client calls `verifyEmail({ code, email })` — or `verifyEmail({ token })`
724
+ for the link form — then `resendVerification(email)` if it expired.
725
+
726
+ A send failure does **not** fail the signup: the account exists and the user can
727
+ re-trigger delivery. Resend is rate-limited per IP on the same budget as signup.
728
+
729
+ Branding (app name, logo, colours, support address) comes from the Environment's
730
+ auth branding settings, not from your code.
731
+
732
+ ### Requiring a verified email
733
+
734
+ By default a new account **can sign in immediately**, verified or not.
735
+
736
+ To require verification, turn on **confirm email** for the Environment (Studio →
737
+ Auth → Policy, or `confirm_email_required` on the auth settings API). With it on:
738
+
739
+ - signup creates the account and returns the user, but **no tokens** — the
740
+ response carries no `access_token`, so there is no session until the address
741
+ is confirmed;
742
+ - login returns `403 email_not_confirmed` until it is.
743
+
744
+ That is the whole gate, and it sits at the credential layer where it cannot be
745
+ routed around.
746
+
747
+ For finer control — say, letting a user finish a profile before confirming —
748
+ read the flag in your handler. It costs nothing; it is already on the request:
749
+
750
+ ```ts
751
+ @Post("/publish")
752
+ publish(@User() user: UserT) {
753
+ if (!user.emailVerified) {
754
+ throw new Forbidden("Confirm your email address before publishing.");
755
+ }
756
+ return postService.publish(user.id);
757
+ }
758
+ ```
759
+
760
+ Or declare it on the route and let the runtime enforce it:
761
+
762
+ ```ts
763
+ @Controller("/posts", { auth: { verifiedEmail: true } })
764
+ ```
765
+
766
+ Unverified callers get `403 email_not_verified`. Use the route flag to fence a
767
+ whole controller, and the inline check when only part of a handler cares.
768
+
769
+ ## Password reset and magic links
770
+
771
+ Both are client-driven and need no backend code: the client SDK calls the auth
772
+ endpoints, Palbase sends the mail, the user completes the flow, and your next
773
+ request simply arrives with a valid token. Reset tokens and magic links are
774
+ single-use and expire; a used or expired one fails closed with a `400`.
775
+
776
+ ## Related
777
+
778
+ - [Row-Level Security](./schema.md#row-level-security-rls) — pushing per-user
779
+ access rules into Postgres, where `auth.uid()` is this same verified user.
780
+ - [Database](./database.md) — how `Database.asService()` steps outside RLS.
781
+
782
+
783
+
645
784
  <!-- ===== database.md ===== -->
646
785
 
647
786
  # Database
@@ -810,9 +949,17 @@ A plan may carry at most 1000 operations, 5000 rows in one `insertMany`, and
810
949
  ## Bypassing RLS — `Database.asService()`
811
950
 
812
951
  When a table has [Row-Level Security](./schema.md#row-level-security-rls)
813
- policies, every `Database.*` call runs as the request's verified user
814
- (`authenticated`), so the database filters out rows the user's policies don't
815
- allow. That is the secure default.
952
+ policies, every `Database.*` call runs as the request's verified user, so the
953
+ database filters out rows the user's policies don't allow. That is the secure
954
+ default.
955
+
956
+ The Postgres role it connects as is **`backend_authenticated`** (or
957
+ `backend_anon` when there is no signed-in user) — not `authenticated`. You
958
+ rarely need to know that, because a policy declared in `db/schema.ts` is
959
+ deployed targeting both. It matters in exactly one place: **hand-written
960
+ `CREATE POLICY` SQL in a migration must name both roles**, or it applies to
961
+ nothing your code does. See
962
+ [Row-Level Security](./schema.md#row-level-security-rls).
816
963
 
817
964
  Sometimes you need to read or write **across all users** — an admin endpoint, a
818
965
  background job that fans out notifications, a cleanup task. For that, call
@@ -962,8 +1109,8 @@ type Room = Tables["rooms"]["row"];
962
1109
  ## Row-Level Security (RLS)
963
1110
 
964
1111
  RLS pushes per-user access control **into Postgres**: every `Database.*` query
965
- runs as the request's verified user (the `authenticated` role with that user's
966
- claims), and the database itself filters rows your policies don't allow. A
1112
+ runs as the request's verified user (with that user's claims), and the database
1113
+ itself filters rows your policies don't allow. A
967
1114
  missing `WHERE user_id = …` in your handler can no longer leak another user's
968
1115
  rows — the policy enforces it. This is the recommended way to scope data per
969
1116
  user.
@@ -990,11 +1137,39 @@ policy("pb_owner_all")
990
1137
  | Method | Default | Meaning |
991
1138
  |--------|---------|---------|
992
1139
  | `.for(cmd)` | `"all"` | The SQL command the policy governs. |
993
- | `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
1140
+ | `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. The deploy also adds the backend twin — see below. |
994
1141
  | `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
995
1142
  | `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
996
1143
  | `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
997
1144
 
1145
+ #### Which role your policy must target
1146
+
1147
+ The runtime connects to Postgres as **`backend_authenticated`** (or
1148
+ `backend_anon` when anonymous), which is a **separate role from
1149
+ `authenticated`** — not a member of it. A policy addressed only to
1150
+ `authenticated` therefore applies to nothing your backend does, and with RLS on
1151
+ and no applicable policy, Postgres denies everything: reads come back empty and
1152
+ writes are refused, while your code compiles, your tests pass and the deploy
1153
+ reports success.
1154
+
1155
+ You do not have to think about this when you declare policies here. `.to("authenticated")`
1156
+ is deployed as `TO authenticated, backend_authenticated` (and `anon` gains
1157
+ `backend_anon`); `service_role` is left alone because `backend_service_role` has
1158
+ `BYPASSRLS` and policies never apply to it.
1159
+
1160
+ **Hand-written SQL is the case to watch.** A `CREATE POLICY` in a
1161
+ `db/migrations/*.sql` file is applied verbatim, so write both roles yourself:
1162
+
1163
+ ```sql
1164
+ CREATE POLICY owner_all ON notes FOR ALL
1165
+ TO authenticated, backend_authenticated
1166
+ USING (owner = (select auth.uid()));
1167
+ ```
1168
+
1169
+ Deploy repairs an existing policy that names only `authenticated`/`anon` by
1170
+ adding the twin, so a redeploy fixes one you already shipped — but write both
1171
+ and the policy means what it says the moment it is created.
1172
+
998
1173
  **`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
999
1174
  request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
1000
1175
  once per statement (an initPlan) instead of once per row. `auth.role()` and
@@ -1212,6 +1387,36 @@ generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
1212
1387
  See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
1213
1388
  `Database.tables.*` access.
1214
1389
 
1390
+ ### Hand-writing a policy
1391
+
1392
+ Two things the generated path handles for you and raw SQL does not.
1393
+
1394
+ **Name both roles.** The runtime connects as `backend_authenticated` /
1395
+ `backend_anon`, which are *not* members of `authenticated` / `anon`. A policy
1396
+ addressed only to `authenticated` applies to nothing your backend does — and with
1397
+ RLS on and no applicable policy, Postgres denies everything: empty reads, refused
1398
+ writes, no error anywhere that says why.
1399
+
1400
+ **Guard the CREATE.** Postgres has no `CREATE POLICY IF NOT EXISTS`, so a
1401
+ migration that replays — a fresh Environment, a branch, `palbase db reset` —
1402
+ fails on the second run. Drop first:
1403
+
1404
+ ```sql
1405
+ DROP POLICY IF EXISTS owner_all ON notes;
1406
+ CREATE POLICY owner_all ON notes FOR ALL
1407
+ TO authenticated, backend_authenticated
1408
+ USING (owner = (select auth.uid()))
1409
+ WITH CHECK (owner = (select auth.uid()));
1410
+ ```
1411
+
1412
+ `DROP … IF EXISTS` + `CREATE` is the idempotent pair for anything without an
1413
+ `IF NOT EXISTS` form — policies, triggers, and rules. Columns and tables have
1414
+ `IF NOT EXISTS`; use it there instead of dropping, which would lose data.
1415
+
1416
+ Deploy repairs an already-shipped policy that names only `authenticated`/`anon`
1417
+ by adding the twin, so a redeploy rescues one you got wrong. The replay guard has
1418
+ no such safety net — write it the first time.
1419
+
1215
1420
 
1216
1421
 
1217
1422
  <!-- ===== services.md ===== -->
@@ -1254,15 +1459,6 @@ const profile = await Cache.getOrSet("user:42", 300, async () => {
1254
1459
  `getOrSet` caches whatever `fn` returns, including `null` — return a sentinel or
1255
1460
  guard upstream if you don't want misses cached.
1256
1461
 
1257
- ## Queue
1258
-
1259
- Enqueue work for a worker (see [background.md](./background.md)).
1260
-
1261
- ```ts
1262
- import { Queue } from "@palbase/backend";
1263
- const { jobId } = await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
1264
- ```
1265
-
1266
1462
  ## Log
1267
1463
 
1268
1464
  ```ts
@@ -1558,45 +1754,21 @@ HttpError) … }` matches any of them.
1558
1754
 
1559
1755
  <!-- ===== background.md ===== -->
1560
1756
 
1561
- # Workers & Jobs
1562
-
1563
- Workers and jobs use the **singleton model** — the same imported service
1564
- singletons as endpoints (`import { Database, Log } from "@palbase/backend"`).
1565
- They do **not** receive a `req`. Instead, a small `meta` argument carries the
1566
- non-service data (`env`, `user`, correlation ids).
1567
-
1568
- ## Workers (queue consumers)
1569
-
1570
- A worker processes jobs pushed via `Queue.push(name, payload)`. File lives under
1571
- `workers/`.
1757
+ # Background Jobs
1572
1758
 
1573
- ```ts
1574
- // workers/process-order.ts
1575
- import { defineWorker, Database, Log } from "@palbase/backend";
1576
-
1577
- interface OrderPayload { orderId: string; amount: number; }
1578
-
1579
- export default defineWorker<OrderPayload>({
1580
- name: "process-order", // must match the Queue.push() name
1581
- retry: 5, // optional, default 3
1582
- timeout: 60, // optional, seconds
1583
- backoff: "exponential", // "exponential" | "linear" | "fixed", default exponential
1584
- handler: async (payload, meta) => {
1585
- Log.info(`processing ${payload.orderId} (env ${meta.environmentId})`);
1586
- await Database.update("orders", payload.orderId, { status: "processed" });
1587
- },
1588
- });
1589
- ```
1590
-
1591
- `meta` shape: `{ env, user, requestId, environmentId }`. Environment
1592
- variables are in `meta.env`; services come from the imported singletons.
1593
-
1594
- Enqueue from an endpoint:
1595
-
1596
- ```ts
1597
- import { Queue } from "@palbase/backend";
1598
- await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
1599
- ```
1759
+ `jobs/` is the background rail. A job uses the **singleton model** — the same
1760
+ imported service singletons as endpoints (`import { Database, Log } from
1761
+ "@palbase/backend"`). It does **not** receive a `req`; a small `meta` argument
1762
+ carries the non-service data (`env`, correlation ids).
1763
+
1764
+ > **There is no queue.** `Queue.push` and `defineWorker` existed in earlier
1765
+ > versions and never ran: nothing consumed the queue, so a push returned a job id
1766
+ > for work that was never performed. Both are removed, and a `workers/` directory
1767
+ > now fails the deploy rather than deploying green and doing nothing. Model
1768
+ > queue-shaped work as a job that sweeps its own table: write a row with a
1769
+ > `status` column, and let a cron job pick up the pending ones. A job may run for
1770
+ > up to 300 seconds, which is the longest budget available anywhere on the
1771
+ > platform.
1600
1772
 
1601
1773
  ## Jobs (cron-scheduled)
1602
1774
 
@@ -1626,7 +1798,7 @@ system-initiated).
1626
1798
 
1627
1799
  # Hooks & Webhooks
1628
1800
 
1629
- Like workers/jobs, hooks and webhooks use the **singleton model** — the same
1801
+ Like jobs, hooks and webhooks use the **singleton model** — the same
1630
1802
  imported service singletons as endpoints (`import { Database, Log } from
1631
1803
  "@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
1632
1804
  carries the non-service data (`env`, `environmentId`; webhooks also
@@ -1698,3 +1870,155 @@ handlers access Environment variables via `meta.env`. The runtime verifies the
1698
1870
  signature before dispatching to your event handlers.
1699
1871
 
1700
1872
  `meta` shape: `{ env, requestId, environmentId }`.
1873
+
1874
+
1875
+
1876
+ <!-- ===== config.md ===== -->
1877
+
1878
+ # Module Config (config-as-code)
1879
+
1880
+ Beyond `db/schema.ts`, four more module surfaces are git-authoritative: storage
1881
+ buckets, notification providers, feature-flag definitions, and the outbound-HTTP
1882
+ allowlist. You declare them in `config/*.ts` files (typed, imported from
1883
+ `@palbase/backend`) and on `git push` the deploy creates/updates them. Secrets
1884
+ (certs, keys, API tokens) NEVER go in git — they live in a reserved encrypted env
1885
+ namespace, uploaded by the guided CLI.
1886
+
1887
+ You normally author these with `palbase <module> add …` (the CLI writes the
1888
+ config file + uploads any secret); the files below are what it generates.
1889
+
1890
+ ## Storage buckets — `config/storage.ts`
1891
+
1892
+ ```ts
1893
+ import { defineStorage, bucket } from "@palbase/backend";
1894
+
1895
+ export default defineStorage({
1896
+ buckets: {
1897
+ avatars: bucket({
1898
+ public: true, // served without a signed URL
1899
+ fileSizeLimit: "5MB", // "5MB"/"20MB"/"1GB" or a byte number
1900
+ allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
1901
+ }),
1902
+ invoices: bucket({ public: false, fileSizeLimit: "20MB", allowedMimeTypes: ["application/pdf"] }),
1903
+ },
1904
+ });
1905
+ ```
1906
+
1907
+ Author it: `palbase storage buckets add avatars --public --max-size 5MB --mime image/png,image/jpeg`.
1908
+ On deploy, the buckets are created/updated. A bucket REMOVED from the file is
1909
+ **never auto-deleted** (its files would be lost) — drop it explicitly in Studio.
1910
+ The files inside a bucket are runtime state, not config.
1911
+
1912
+ ## Notification providers — `config/notifications.ts`
1913
+
1914
+ Providers carry secrets (APNs `.p8`, FCM service-account JSON, Twilio token).
1915
+ The config file is **structural** — it names the enabled providers + their
1916
+ non-secret fields; the secret is bound by convention to a reserved env key and
1917
+ NEVER appears in git.
1918
+
1919
+ ```ts
1920
+ import { defineNotifications } from "@palbase/backend";
1921
+
1922
+ export default defineNotifications({
1923
+ push: {
1924
+ apns: { enabled: true, teamId: "A1B2C3D4E5", keyId: "XYZ123", bundleId: "net.example.app" },
1925
+ // no p8 key here — it's in the reserved secret PB_NOTIFICATIONS_APNS_P8
1926
+ },
1927
+ sms: {
1928
+ twilio: { enabled: true, accountSid: "AC...", messagingServiceSid: "MG..." },
1929
+ },
1930
+ });
1931
+ ```
1932
+
1933
+ Author it with the guided CLI — it knows each provider's fields and uploads the
1934
+ secret for you, so you never type a secret-name string:
1935
+
1936
+ ```bash
1937
+ palbase notifications providers # list the catalog + what's configured
1938
+ palbase notifications add apns \
1939
+ --team-id A1B2C3D4E5 --key-id XYZ123 --bundle-id net.example.app \
1940
+ --p8-file ./AuthKey_XYZ123.p8 # → uploads PB_NOTIFICATIONS_APNS_P8 (encrypted)
1941
+ palbase notifications add twilio --account-sid AC... --messaging-sid MG...
1942
+ # prompts for the auth token (hidden)
1943
+ ```
1944
+
1945
+ The reserved secret env keys (`PB_NOTIFICATIONS_*`) are managed by these
1946
+ commands — `palbase secret set PB_*` is refused. Your own custom env
1947
+ (`MY_API_KEY` etc.) is unaffected and still flows via `.env.local`. On deploy,
1948
+ each enabled provider's reserved secret is resolved and the provider is
1949
+ configured; a provider whose secret is missing is skipped (warned, not fatal).
1950
+
1951
+ ## Feature flags — `config/flags.ts`
1952
+
1953
+ Flag DEFINITIONS (key, type, default) are config; the value set for a specific
1954
+ user / an A/B assignment is runtime (set via the SDK/Studio, not git).
1955
+
1956
+ ```ts
1957
+ import { defineFlags, flag } from "@palbase/backend";
1958
+
1959
+ export default defineFlags({
1960
+ flags: {
1961
+ new_checkout: flag({ type: "boolean", default: false, description: "Gate the redesigned checkout" }),
1962
+ upload_limit: flag({ type: "number", default: 10 }),
1963
+ theme: flag({ type: "string", default: "system", variants: ["light", "dark", "system"] }),
1964
+ limits: flag({ type: "json", default: { daily: 10, burst: 50 } }),
1965
+ },
1966
+ });
1967
+ ```
1968
+
1969
+ Four types: `boolean`, `number`, `string`, `json`. A `json` flag's default is an
1970
+ object (not an array, not a scalar) and nests at most 3 deep. `variants` is for
1971
+ `string` only — the flags service rejects it on every other type.
1972
+
1973
+ Author it: `palbase flags add new_checkout --type boolean --default false`, or
1974
+ `palbase flags add limits --type json --default '{"daily":10}'`. On
1975
+ deploy, the definitions are upserted to the flags service (idempotent). A flag
1976
+ removed from the file is **not auto-deleted** (orphan definitions are harmless).
1977
+
1978
+ ## Outbound HTTP — `config/egress.ts`
1979
+
1980
+ Your backend has **no ambient network**. A `fetch()` to an external host is
1981
+ refused unless the host is declared here, and with no `config/egress.ts` at all
1982
+ there is no outbound network whatsoever.
1983
+
1984
+ ```ts
1985
+ import { defineEgress } from "@palbase/backend";
1986
+
1987
+ export default defineEgress({
1988
+ hosts: ["api.openai.com", ".example.com"], // leading dot also covers subdomains
1989
+ timeoutMs: 90_000, // per-call ceiling; omitted ⇒ 30_000
1990
+ });
1991
+ ```
1992
+
1993
+ Hosts are bare hostnames — https on :443 only, so no scheme, port, path or
1994
+ wildcard. `timeoutMs` is 1_000–300_000.
1995
+
1996
+ Unlike the three above, this one is **fail-closed**: a malformed host or an
1997
+ out-of-range `timeoutMs` ABORTS the deploy rather than logging a warning. An
1998
+ allowlist that silently dropped an entry would be a broken feature, and one that
1999
+ silently widened would be a hole; an out-of-range timeout is rejected rather than
2000
+ clamped so your config file and the running system never disagree.
2001
+
2002
+ ### How long a call may take
2003
+
2004
+ `timeoutMs` is a ceiling, not a grant — the call still ends when the invocation
2005
+ around it ends, and it covers the whole call including redirects (three hops do
2006
+ not get three budgets).
2007
+
2008
+ | Where the fetch runs | What else bounds it |
2009
+ |---|---|
2010
+ | Job (`jobs/`) | Its own `@Job({ timeout })`, max 300s — the longest budget available. |
2011
+ | Endpoint / webhook | The gateway's request ceiling. Long work belongs in a job. |
2012
+
2013
+ Responses are **buffered whole** (5 MB cap) before your `fetch()` resolves.
2014
+ Requesting a streaming response from an upstream (`stream: true`, SSE) therefore
2015
+ buys nothing: no partial output, no earlier first byte, and the entire stream
2016
+ must still finish inside `timeoutMs`.
2017
+
2018
+ ## How it's applied
2019
+
2020
+ All four are evaluated + applied **on deploy**, the same place `db/schema.ts`
2021
+ migrations run, reaching each module through your project's gateway with a
2022
+ service-role key. Storage/notifications/flags are fail-soft — a config error logs
2023
+ a warning but never aborts the deploy of your code. `config/egress.ts` is the
2024
+ exception and is fail-closed, for the reason above.
package/docs/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Palbase Backend SDK (`@palbase/backend`)
2
2
 
3
- > TypeScript backend SDK. NestJS-style class controllers: `@Controller` classes with `@Get`/`@Post`/`@Query`/… methods and `@Body`/`@QueryParams`/`@Param`/`@User` parameter decorators. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use parameter decorators, workers `(payload, meta)`, jobs `(meta)`, hooks/webhooks `(event, meta)`. Middleware is the one exception (`ctx`). Not Express, not Supabase Edge Functions.
3
+ > TypeScript backend SDK. NestJS-style class controllers: `@Controller` classes with `@Get`/`@Post`/`@Query`/… methods and `@Body`/`@QueryParams`/`@Param`/`@User` parameter decorators. All handler types import service singletons (`Database`, `Cache`, …). Trigger arg differs by type: endpoints use parameter decorators, jobs `(meta)`, hooks/webhooks `(event, meta)`. Middleware is the one exception (`ctx`). There is NO queue and no `defineWorker` — background work is a cron `@Job` under `jobs/`. Not Express, not Supabase Edge Functions.
4
4
 
5
5
  ## Docs
6
6
 
@@ -8,6 +8,7 @@
8
8
  - [getting-started](./getting-started.md)
9
9
  - [routing](./routing.md)
10
10
  - [endpoints](./endpoints.md)
11
+ - [auth](./auth.md)
11
12
  - [database](./database.md)
12
13
  - [schema](./schema.md)
13
14
  - [migrations](./migrations.md)
@@ -16,3 +17,4 @@
16
17
  - [errors](./errors.md)
17
18
  - [background](./background.md)
18
19
  - [events](./events.md)
20
+ - [config](./config.md)
@@ -146,3 +146,33 @@ Add `rls: true` + `policies: [policy(...)]` to a table in `db/schema.ts`; the
146
146
  generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
147
147
  See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
148
148
  `Database.tables.*` access.
149
+
150
+ ### Hand-writing a policy
151
+
152
+ Two things the generated path handles for you and raw SQL does not.
153
+
154
+ **Name both roles.** The runtime connects as `backend_authenticated` /
155
+ `backend_anon`, which are *not* members of `authenticated` / `anon`. A policy
156
+ addressed only to `authenticated` applies to nothing your backend does — and with
157
+ RLS on and no applicable policy, Postgres denies everything: empty reads, refused
158
+ writes, no error anywhere that says why.
159
+
160
+ **Guard the CREATE.** Postgres has no `CREATE POLICY IF NOT EXISTS`, so a
161
+ migration that replays — a fresh Environment, a branch, `palbase db reset` —
162
+ fails on the second run. Drop first:
163
+
164
+ ```sql
165
+ DROP POLICY IF EXISTS owner_all ON notes;
166
+ CREATE POLICY owner_all ON notes FOR ALL
167
+ TO authenticated, backend_authenticated
168
+ USING (owner = (select auth.uid()))
169
+ WITH CHECK (owner = (select auth.uid()));
170
+ ```
171
+
172
+ `DROP … IF EXISTS` + `CREATE` is the idempotent pair for anything without an
173
+ `IF NOT EXISTS` form — policies, triggers, and rules. Columns and tables have
174
+ `IF NOT EXISTS`; use it there instead of dropping, which would lose data.
175
+
176
+ Deploy repairs an already-shipped policy that names only `authenticated`/`anon`
177
+ by adding the twin, so a redeploy rescues one you got wrong. The replay guard has
178
+ no such safety net — write it the first time.
package/docs/schema.md CHANGED
@@ -108,8 +108,8 @@ type Room = Tables["rooms"]["row"];
108
108
  ## Row-Level Security (RLS)
109
109
 
110
110
  RLS pushes per-user access control **into Postgres**: every `Database.*` query
111
- runs as the request's verified user (the `authenticated` role with that user's
112
- claims), and the database itself filters rows your policies don't allow. A
111
+ runs as the request's verified user (with that user's claims), and the database
112
+ itself filters rows your policies don't allow. A
113
113
  missing `WHERE user_id = …` in your handler can no longer leak another user's
114
114
  rows — the policy enforces it. This is the recommended way to scope data per
115
115
  user.
@@ -136,11 +136,39 @@ policy("pb_owner_all")
136
136
  | Method | Default | Meaning |
137
137
  |--------|---------|---------|
138
138
  | `.for(cmd)` | `"all"` | The SQL command the policy governs. |
139
- | `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. |
139
+ | `.to(...roles)` | `["authenticated"]` | DB roles the policy applies to. `.to()` with no args targets PUBLIC. The deploy also adds the backend twin — see below. |
140
140
  | `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
141
141
  | `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
142
142
  | `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
143
143
 
144
+ #### Which role your policy must target
145
+
146
+ The runtime connects to Postgres as **`backend_authenticated`** (or
147
+ `backend_anon` when anonymous), which is a **separate role from
148
+ `authenticated`** — not a member of it. A policy addressed only to
149
+ `authenticated` therefore applies to nothing your backend does, and with RLS on
150
+ and no applicable policy, Postgres denies everything: reads come back empty and
151
+ writes are refused, while your code compiles, your tests pass and the deploy
152
+ reports success.
153
+
154
+ You do not have to think about this when you declare policies here. `.to("authenticated")`
155
+ is deployed as `TO authenticated, backend_authenticated` (and `anon` gains
156
+ `backend_anon`); `service_role` is left alone because `backend_service_role` has
157
+ `BYPASSRLS` and policies never apply to it.
158
+
159
+ **Hand-written SQL is the case to watch.** A `CREATE POLICY` in a
160
+ `db/migrations/*.sql` file is applied verbatim, so write both roles yourself:
161
+
162
+ ```sql
163
+ CREATE POLICY owner_all ON notes FOR ALL
164
+ TO authenticated, backend_authenticated
165
+ USING (owner = (select auth.uid()));
166
+ ```
167
+
168
+ Deploy repairs an existing policy that names only `authenticated`/`anon` by
169
+ adding the twin, so a redeploy fixes one you already shipped — but write both
170
+ and the policy means what it says the moment it is created.
171
+
144
172
  **`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
145
173
  request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
146
174
  once per statement (an initPlan) instead of once per row. `auth.role()` and
package/docs/services.md CHANGED
@@ -36,15 +36,6 @@ const profile = await Cache.getOrSet("user:42", 300, async () => {
36
36
  `getOrSet` caches whatever `fn` returns, including `null` — return a sentinel or
37
37
  guard upstream if you don't want misses cached.
38
38
 
39
- ## Queue
40
-
41
- Enqueue work for a worker (see [background.md](./background.md)).
42
-
43
- ```ts
44
- import { Queue } from "@palbase/backend";
45
- const { jobId } = await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
46
- ```
47
-
48
39
  ## Log
49
40
 
50
41
  ```ts