@palbase/backend 13.0.0 → 14.0.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,122 @@ 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 token — nothing here is
684
+ client-settable. `emailVerified` in particular is read from the user's verified
685
+ profile, not from a JWT claim: a claim is only true as of when the token was
686
+ minted, so a user who verifies mid-session would keep reporting `false` until
687
+ their token expired.
688
+
689
+ ## Email verification
690
+
691
+ The platform handles verification end to end. **You do not configure a sender**,
692
+ and `config/notifications.ts` is unrelated — that file declares providers for
693
+ **your app's own** notifications. Auth email goes out through Palbase's own
694
+ notification tenant, not yours.
695
+
696
+ What happens on `POST /auth/signup`:
697
+
698
+ 1. The account is created with `email_verified = false`.
699
+ 2. A verification email is sent — by default a **6-digit code, valid 5 minutes**.
700
+ (An Environment configured for link-based verification instead sends a link
701
+ token valid **24 hours**.)
702
+ 3. The client calls `verifyEmail({ code, email })` — or `verifyEmail({ token })`
703
+ for the link form — then `resendVerification(email)` if it expired.
704
+
705
+ A send failure does **not** fail the signup: the account exists and the user can
706
+ re-trigger delivery. Resend is rate-limited per IP on the same budget as signup.
707
+
708
+ Branding (app name, logo, colours, support address) comes from the Environment's
709
+ auth branding settings, not from your code.
710
+
711
+ ### Requiring a verified email
712
+
713
+ By default a new account **can sign in immediately**, verified or not.
714
+
715
+ To require verification, turn on **confirm email** for the Environment (Studio →
716
+ Auth → Policy, or `confirm_email_required` on the auth settings API). With it on:
717
+
718
+ - signup creates the account and returns the user, but **no tokens** — the
719
+ response carries no `access_token`, so there is no session until the address
720
+ is confirmed;
721
+ - login returns `403 email_not_confirmed` until it is.
722
+
723
+ That is the whole gate, and it sits at the credential layer where it cannot be
724
+ routed around.
725
+
726
+ For finer control — say, letting a user finish a profile before confirming —
727
+ read the flag in your handler. It costs nothing; it is already on the request:
728
+
729
+ ```ts
730
+ @Post("/publish")
731
+ publish(@User() user: UserT) {
732
+ if (!user.emailVerified) {
733
+ throw new Forbidden("Confirm your email address before publishing.");
734
+ }
735
+ return postService.publish(user.id);
736
+ }
737
+ ```
738
+
739
+ > There is no per-route `requireVerifiedEmail` option. A route-level flag would
740
+ > have to be enforced by the runtime, and the one-line check above is enforced by
741
+ > your own code — visible where it applies, and impossible to declare on a route
742
+ > and have quietly do nothing.
743
+
744
+ ## Password reset and magic links
745
+
746
+ Both are client-driven and need no backend code: the client SDK calls the auth
747
+ endpoints, Palbase sends the mail, the user completes the flow, and your next
748
+ request simply arrives with a valid token. Reset tokens and magic links are
749
+ single-use and expire; a used or expired one fails closed with a `400`.
750
+
751
+ ## Related
752
+
753
+ - [Row-Level Security](./schema.md#row-level-security-rls) — pushing per-user
754
+ access rules into Postgres, where `auth.uid()` is this same verified user.
755
+ - [Database](./database.md) — how `Database.asService()` steps outside RLS.
756
+
757
+
758
+
645
759
  <!-- ===== database.md ===== -->
646
760
 
647
761
  # Database
@@ -810,9 +924,17 @@ A plan may carry at most 1000 operations, 5000 rows in one `insertMany`, and
810
924
  ## Bypassing RLS — `Database.asService()`
811
925
 
812
926
  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.
927
+ policies, every `Database.*` call runs as the request's verified user, so the
928
+ database filters out rows the user's policies don't allow. That is the secure
929
+ default.
930
+
931
+ The Postgres role it connects as is **`backend_authenticated`** (or
932
+ `backend_anon` when there is no signed-in user) — not `authenticated`. You
933
+ rarely need to know that, because a policy declared in `db/schema.ts` is
934
+ deployed targeting both. It matters in exactly one place: **hand-written
935
+ `CREATE POLICY` SQL in a migration must name both roles**, or it applies to
936
+ nothing your code does. See
937
+ [Row-Level Security](./schema.md#row-level-security-rls).
816
938
 
817
939
  Sometimes you need to read or write **across all users** — an admin endpoint, a
818
940
  background job that fans out notifications, a cleanup task. For that, call
@@ -962,8 +1084,8 @@ type Room = Tables["rooms"]["row"];
962
1084
  ## Row-Level Security (RLS)
963
1085
 
964
1086
  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
1087
+ runs as the request's verified user (with that user's claims), and the database
1088
+ itself filters rows your policies don't allow. A
967
1089
  missing `WHERE user_id = …` in your handler can no longer leak another user's
968
1090
  rows — the policy enforces it. This is the recommended way to scope data per
969
1091
  user.
@@ -990,11 +1112,39 @@ policy("pb_owner_all")
990
1112
  | Method | Default | Meaning |
991
1113
  |--------|---------|---------|
992
1114
  | `.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. |
1115
+ | `.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
1116
  | `.using(sql)` | none | `USING (...)` — which existing rows are visible (SELECT/UPDATE/DELETE). |
995
1117
  | `.withCheck(sql)` | none | `WITH CHECK (...)` — which rows may be written (INSERT/UPDATE). |
996
1118
  | `.as(mode)` | `"permissive"` | `"permissive"` (policies OR together) or `"restrictive"` (AND together). |
997
1119
 
1120
+ #### Which role your policy must target
1121
+
1122
+ The runtime connects to Postgres as **`backend_authenticated`** (or
1123
+ `backend_anon` when anonymous), which is a **separate role from
1124
+ `authenticated`** — not a member of it. A policy addressed only to
1125
+ `authenticated` therefore applies to nothing your backend does, and with RLS on
1126
+ and no applicable policy, Postgres denies everything: reads come back empty and
1127
+ writes are refused, while your code compiles, your tests pass and the deploy
1128
+ reports success.
1129
+
1130
+ You do not have to think about this when you declare policies here. `.to("authenticated")`
1131
+ is deployed as `TO authenticated, backend_authenticated` (and `anon` gains
1132
+ `backend_anon`); `service_role` is left alone because `backend_service_role` has
1133
+ `BYPASSRLS` and policies never apply to it.
1134
+
1135
+ **Hand-written SQL is the case to watch.** A `CREATE POLICY` in a
1136
+ `db/migrations/*.sql` file is applied verbatim, so write both roles yourself:
1137
+
1138
+ ```sql
1139
+ CREATE POLICY owner_all ON notes FOR ALL
1140
+ TO authenticated, backend_authenticated
1141
+ USING (owner = (select auth.uid()));
1142
+ ```
1143
+
1144
+ Deploy repairs an existing policy that names only `authenticated`/`anon` by
1145
+ adding the twin, so a redeploy fixes one you already shipped — but write both
1146
+ and the policy means what it says the moment it is created.
1147
+
998
1148
  **`auth.uid()`** returns the verified user's id (palauth user id, TEXT) from the
999
1149
  request's JWT claims. Wrap it as `(select auth.uid())` — Postgres evaluates that
1000
1150
  once per statement (an initPlan) instead of once per row. `auth.role()` and
@@ -1212,6 +1362,36 @@ generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
1212
1362
  See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
1213
1363
  `Database.tables.*` access.
1214
1364
 
1365
+ ### Hand-writing a policy
1366
+
1367
+ Two things the generated path handles for you and raw SQL does not.
1368
+
1369
+ **Name both roles.** The runtime connects as `backend_authenticated` /
1370
+ `backend_anon`, which are *not* members of `authenticated` / `anon`. A policy
1371
+ addressed only to `authenticated` applies to nothing your backend does — and with
1372
+ RLS on and no applicable policy, Postgres denies everything: empty reads, refused
1373
+ writes, no error anywhere that says why.
1374
+
1375
+ **Guard the CREATE.** Postgres has no `CREATE POLICY IF NOT EXISTS`, so a
1376
+ migration that replays — a fresh Environment, a branch, `palbase db reset` —
1377
+ fails on the second run. Drop first:
1378
+
1379
+ ```sql
1380
+ DROP POLICY IF EXISTS owner_all ON notes;
1381
+ CREATE POLICY owner_all ON notes FOR ALL
1382
+ TO authenticated, backend_authenticated
1383
+ USING (owner = (select auth.uid()))
1384
+ WITH CHECK (owner = (select auth.uid()));
1385
+ ```
1386
+
1387
+ `DROP … IF EXISTS` + `CREATE` is the idempotent pair for anything without an
1388
+ `IF NOT EXISTS` form — policies, triggers, and rules. Columns and tables have
1389
+ `IF NOT EXISTS`; use it there instead of dropping, which would lose data.
1390
+
1391
+ Deploy repairs an already-shipped policy that names only `authenticated`/`anon`
1392
+ by adding the twin, so a redeploy rescues one you got wrong. The replay guard has
1393
+ no such safety net — write it the first time.
1394
+
1215
1395
 
1216
1396
 
1217
1397
  <!-- ===== services.md ===== -->
@@ -1254,15 +1434,6 @@ const profile = await Cache.getOrSet("user:42", 300, async () => {
1254
1434
  `getOrSet` caches whatever `fn` returns, including `null` — return a sentinel or
1255
1435
  guard upstream if you don't want misses cached.
1256
1436
 
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
1437
  ## Log
1267
1438
 
1268
1439
  ```ts
@@ -1558,45 +1729,21 @@ HttpError) … }` matches any of them.
1558
1729
 
1559
1730
  <!-- ===== background.md ===== -->
1560
1731
 
1561
- # Workers & Jobs
1732
+ # Background Jobs
1562
1733
 
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/`.
1572
-
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
- ```
1734
+ `jobs/` is the background rail. A job uses the **singleton model** — the same
1735
+ imported service singletons as endpoints (`import { Database, Log } from
1736
+ "@palbase/backend"`). It does **not** receive a `req`; a small `meta` argument
1737
+ carries the non-service data (`env`, correlation ids).
1738
+
1739
+ > **There is no queue.** `Queue.push` and `defineWorker` existed in earlier
1740
+ > versions and never ran: nothing consumed the queue, so a push returned a job id
1741
+ > for work that was never performed. Both are removed, and a `workers/` directory
1742
+ > now fails the deploy rather than deploying green and doing nothing. Model
1743
+ > queue-shaped work as a job that sweeps its own table: write a row with a
1744
+ > `status` column, and let a cron job pick up the pending ones. A job may run for
1745
+ > up to 300 seconds, which is the longest budget available anywhere on the
1746
+ > platform.
1600
1747
 
1601
1748
  ## Jobs (cron-scheduled)
1602
1749
 
@@ -1626,7 +1773,7 @@ system-initiated).
1626
1773
 
1627
1774
  # Hooks & Webhooks
1628
1775
 
1629
- Like workers/jobs, hooks and webhooks use the **singleton model** — the same
1776
+ Like jobs, hooks and webhooks use the **singleton model** — the same
1630
1777
  imported service singletons as endpoints (`import { Database, Log } from
1631
1778
  "@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
1632
1779
  carries the non-service data (`env`, `environmentId`; webhooks also
@@ -1698,3 +1845,155 @@ handlers access Environment variables via `meta.env`. The runtime verifies the
1698
1845
  signature before dispatching to your event handlers.
1699
1846
 
1700
1847
  `meta` shape: `{ env, requestId, environmentId }`.
1848
+
1849
+
1850
+
1851
+ <!-- ===== config.md ===== -->
1852
+
1853
+ # Module Config (config-as-code)
1854
+
1855
+ Beyond `db/schema.ts`, four more module surfaces are git-authoritative: storage
1856
+ buckets, notification providers, feature-flag definitions, and the outbound-HTTP
1857
+ allowlist. You declare them in `config/*.ts` files (typed, imported from
1858
+ `@palbase/backend`) and on `git push` the deploy creates/updates them. Secrets
1859
+ (certs, keys, API tokens) NEVER go in git — they live in a reserved encrypted env
1860
+ namespace, uploaded by the guided CLI.
1861
+
1862
+ You normally author these with `palbase <module> add …` (the CLI writes the
1863
+ config file + uploads any secret); the files below are what it generates.
1864
+
1865
+ ## Storage buckets — `config/storage.ts`
1866
+
1867
+ ```ts
1868
+ import { defineStorage, bucket } from "@palbase/backend";
1869
+
1870
+ export default defineStorage({
1871
+ buckets: {
1872
+ avatars: bucket({
1873
+ public: true, // served without a signed URL
1874
+ fileSizeLimit: "5MB", // "5MB"/"20MB"/"1GB" or a byte number
1875
+ allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
1876
+ }),
1877
+ invoices: bucket({ public: false, fileSizeLimit: "20MB", allowedMimeTypes: ["application/pdf"] }),
1878
+ },
1879
+ });
1880
+ ```
1881
+
1882
+ Author it: `palbase storage buckets add avatars --public --max-size 5MB --mime image/png,image/jpeg`.
1883
+ On deploy, the buckets are created/updated. A bucket REMOVED from the file is
1884
+ **never auto-deleted** (its files would be lost) — drop it explicitly in Studio.
1885
+ The files inside a bucket are runtime state, not config.
1886
+
1887
+ ## Notification providers — `config/notifications.ts`
1888
+
1889
+ Providers carry secrets (APNs `.p8`, FCM service-account JSON, Twilio token).
1890
+ The config file is **structural** — it names the enabled providers + their
1891
+ non-secret fields; the secret is bound by convention to a reserved env key and
1892
+ NEVER appears in git.
1893
+
1894
+ ```ts
1895
+ import { defineNotifications } from "@palbase/backend";
1896
+
1897
+ export default defineNotifications({
1898
+ push: {
1899
+ apns: { enabled: true, teamId: "A1B2C3D4E5", keyId: "XYZ123", bundleId: "net.example.app" },
1900
+ // no p8 key here — it's in the reserved secret PB_NOTIFICATIONS_APNS_P8
1901
+ },
1902
+ sms: {
1903
+ twilio: { enabled: true, accountSid: "AC...", messagingServiceSid: "MG..." },
1904
+ },
1905
+ });
1906
+ ```
1907
+
1908
+ Author it with the guided CLI — it knows each provider's fields and uploads the
1909
+ secret for you, so you never type a secret-name string:
1910
+
1911
+ ```bash
1912
+ palbase notifications providers # list the catalog + what's configured
1913
+ palbase notifications add apns \
1914
+ --team-id A1B2C3D4E5 --key-id XYZ123 --bundle-id net.example.app \
1915
+ --p8-file ./AuthKey_XYZ123.p8 # → uploads PB_NOTIFICATIONS_APNS_P8 (encrypted)
1916
+ palbase notifications add twilio --account-sid AC... --messaging-sid MG...
1917
+ # prompts for the auth token (hidden)
1918
+ ```
1919
+
1920
+ The reserved secret env keys (`PB_NOTIFICATIONS_*`) are managed by these
1921
+ commands — `palbase secret set PB_*` is refused. Your own custom env
1922
+ (`MY_API_KEY` etc.) is unaffected and still flows via `.env.local`. On deploy,
1923
+ each enabled provider's reserved secret is resolved and the provider is
1924
+ configured; a provider whose secret is missing is skipped (warned, not fatal).
1925
+
1926
+ ## Feature flags — `config/flags.ts`
1927
+
1928
+ Flag DEFINITIONS (key, type, default) are config; the value set for a specific
1929
+ user / an A/B assignment is runtime (set via the SDK/Studio, not git).
1930
+
1931
+ ```ts
1932
+ import { defineFlags, flag } from "@palbase/backend";
1933
+
1934
+ export default defineFlags({
1935
+ flags: {
1936
+ new_checkout: flag({ type: "boolean", default: false, description: "Gate the redesigned checkout" }),
1937
+ upload_limit: flag({ type: "number", default: 10 }),
1938
+ theme: flag({ type: "string", default: "system", variants: ["light", "dark", "system"] }),
1939
+ limits: flag({ type: "json", default: { daily: 10, burst: 50 } }),
1940
+ },
1941
+ });
1942
+ ```
1943
+
1944
+ Four types: `boolean`, `number`, `string`, `json`. A `json` flag's default is an
1945
+ object (not an array, not a scalar) and nests at most 3 deep. `variants` is for
1946
+ `string` only — the flags service rejects it on every other type.
1947
+
1948
+ Author it: `palbase flags add new_checkout --type boolean --default false`, or
1949
+ `palbase flags add limits --type json --default '{"daily":10}'`. On
1950
+ deploy, the definitions are upserted to the flags service (idempotent). A flag
1951
+ removed from the file is **not auto-deleted** (orphan definitions are harmless).
1952
+
1953
+ ## Outbound HTTP — `config/egress.ts`
1954
+
1955
+ Your backend has **no ambient network**. A `fetch()` to an external host is
1956
+ refused unless the host is declared here, and with no `config/egress.ts` at all
1957
+ there is no outbound network whatsoever.
1958
+
1959
+ ```ts
1960
+ import { defineEgress } from "@palbase/backend";
1961
+
1962
+ export default defineEgress({
1963
+ hosts: ["api.openai.com", ".example.com"], // leading dot also covers subdomains
1964
+ timeoutMs: 90_000, // per-call ceiling; omitted ⇒ 30_000
1965
+ });
1966
+ ```
1967
+
1968
+ Hosts are bare hostnames — https on :443 only, so no scheme, port, path or
1969
+ wildcard. `timeoutMs` is 1_000–300_000.
1970
+
1971
+ Unlike the three above, this one is **fail-closed**: a malformed host or an
1972
+ out-of-range `timeoutMs` ABORTS the deploy rather than logging a warning. An
1973
+ allowlist that silently dropped an entry would be a broken feature, and one that
1974
+ silently widened would be a hole; an out-of-range timeout is rejected rather than
1975
+ clamped so your config file and the running system never disagree.
1976
+
1977
+ ### How long a call may take
1978
+
1979
+ `timeoutMs` is a ceiling, not a grant — the call still ends when the invocation
1980
+ around it ends, and it covers the whole call including redirects (three hops do
1981
+ not get three budgets).
1982
+
1983
+ | Where the fetch runs | What else bounds it |
1984
+ |---|---|
1985
+ | Job (`jobs/`) | Its own `@Job({ timeout })`, max 300s — the longest budget available. |
1986
+ | Endpoint / webhook | The gateway's request ceiling. Long work belongs in a job. |
1987
+
1988
+ Responses are **buffered whole** (5 MB cap) before your `fetch()` resolves.
1989
+ Requesting a streaming response from an upstream (`stream: true`, SSE) therefore
1990
+ buys nothing: no partial output, no earlier first byte, and the entire stream
1991
+ must still finish inside `timeoutMs`.
1992
+
1993
+ ## How it's applied
1994
+
1995
+ All four are evaluated + applied **on deploy**, the same place `db/schema.ts`
1996
+ migrations run, reaching each module through your project's gateway with a
1997
+ service-role key. Storage/notifications/flags are fail-soft — a config error logs
1998
+ a warning but never aborts the deploy of your code. `config/egress.ts` is the
1999
+ 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "13.0.0",
3
+ "version": "14.0.0",
4
4
  "description": "Palbase Backend SDK — class controllers (@Controller/@Get/@Post + @Body/@QueryParams/@Param), error classes, schema DSL",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -90,6 +90,8 @@
90
90
  "test:watch": "vitest",
91
91
  "typecheck": "tsc --noEmit",
92
92
  "docs:build": "node scripts/build-llms.mjs",
93
- "emit:openapi-fixture": "tsx scripts/emit-openapi-fixture.mjs"
93
+ "emit:openapi-fixture": "tsx scripts/emit-openapi-fixture.mjs",
94
+ "check:api": "node scripts/api-surface.mjs",
95
+ "api:update": "node scripts/api-surface.mjs --update"
94
96
  }
95
97
  }