@palbase/backend 12.0.1 → 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
@@ -1401,9 +1572,8 @@ to drive live chat, presence, dashboards, and other push features.
1401
1572
 
1402
1573
  A `Resource` models one external connection — a pooled datastore, a stateless
1403
1574
  API client, or a per-user factory. You put it in `resources/`, export an
1404
- instance, and **do not register it**: the framework discovers it, sets it up
1405
- once at boot, and drains it on shutdown. On top of that lifecycle you expose
1406
- your own clean facade.
1575
+ instance, and **do not register it**: the framework discovers it and sets it up
1576
+ once at boot. On top of that lifecycle you expose your own clean facade.
1407
1577
 
1408
1578
  ```ts
1409
1579
  import { Resource } from "@palbase/backend";
@@ -1413,13 +1583,20 @@ import { Resource } from "@palbase/backend";
1413
1583
 
1414
1584
  A resource is created once at process boot — NOT per request. The framework:
1415
1585
 
1416
- 1. calls `init(env)` **once**, with only the secrets the resource declared;
1417
- 2. (optionally) calls `shutdown()` on SIGTERM, in reverse boot order.
1586
+ 1. calls `init(env)` **once**, with only the secrets the resource declared.
1587
+
1588
+ That is the whole lifecycle — there is no teardown counterpart. The runtime
1589
+ recycles an environment by disposing its isolate outright, with no signal
1590
+ delivered into your code first, so a `shutdown()` you declare is **never
1591
+ called**. Do not buffer in memory intending to flush on the way out; treat a
1592
+ write as durable when the call that made it returns. The optional `shutdown()`
1593
+ member survives in the type because the older process-based runtime did invoke
1594
+ it on SIGTERM.
1418
1595
 
1419
1596
  The instance lives for the whole process; your facade methods are called
1420
1597
  per-request. This makes "reconnect on every request" structurally impossible.
1421
1598
 
1422
- ## Pooled datastore — `init` + `shutdown`
1599
+ ## Pooled datastore
1423
1600
 
1424
1601
  ```ts
1425
1602
  import { Resource } from "@palbase/backend";
@@ -1431,9 +1608,6 @@ export class Neo4jResource extends Resource {
1431
1608
  async init(env: { NEO4J_URL: string; NEO4J_USER: string; NEO4J_PASSWORD: string }) {
1432
1609
  this.driver = neo4j.driver(env.NEO4J_URL, neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD));
1433
1610
  }
1434
- async shutdown() {
1435
- await this.driver.close();
1436
- }
1437
1611
  session(): Session {
1438
1612
  return this.driver.session();
1439
1613
  }
@@ -1555,45 +1729,21 @@ HttpError) … }` matches any of them.
1555
1729
 
1556
1730
  <!-- ===== background.md ===== -->
1557
1731
 
1558
- # Workers & Jobs
1559
-
1560
- Workers and jobs use the **singleton model** — the same imported service
1561
- singletons as endpoints (`import { Database, Log } from "@palbase/backend"`).
1562
- They do **not** receive a `req`. Instead, a small `meta` argument carries the
1563
- non-service data (`env`, `user`, correlation ids).
1564
-
1565
- ## Workers (queue consumers)
1566
-
1567
- A worker processes jobs pushed via `Queue.push(name, payload)`. File lives under
1568
- `workers/`.
1569
-
1570
- ```ts
1571
- // workers/process-order.ts
1572
- import { defineWorker, Database, Log } from "@palbase/backend";
1573
-
1574
- interface OrderPayload { orderId: string; amount: number; }
1575
-
1576
- export default defineWorker<OrderPayload>({
1577
- name: "process-order", // must match the Queue.push() name
1578
- retry: 5, // optional, default 3
1579
- timeout: 60, // optional, seconds
1580
- backoff: "exponential", // "exponential" | "linear" | "fixed", default exponential
1581
- handler: async (payload, meta) => {
1582
- Log.info(`processing ${payload.orderId} (env ${meta.environmentId})`);
1583
- await Database.update("orders", payload.orderId, { status: "processed" });
1584
- },
1585
- });
1586
- ```
1732
+ # Background Jobs
1587
1733
 
1588
- `meta` shape: `{ env, user, requestId, environmentId }`. Environment
1589
- variables are in `meta.env`; services come from the imported singletons.
1590
-
1591
- Enqueue from an endpoint:
1592
-
1593
- ```ts
1594
- import { Queue } from "@palbase/backend";
1595
- await Queue.push("process-order", { orderId: "ord_1", amount: 1000 });
1596
- ```
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.
1597
1747
 
1598
1748
  ## Jobs (cron-scheduled)
1599
1749
 
@@ -1623,7 +1773,7 @@ system-initiated).
1623
1773
 
1624
1774
  # Hooks & Webhooks
1625
1775
 
1626
- Like workers/jobs, hooks and webhooks use the **singleton model** — the same
1776
+ Like jobs, hooks and webhooks use the **singleton model** — the same
1627
1777
  imported service singletons as endpoints (`import { Database, Log } from
1628
1778
  "@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
1629
1779
  carries the non-service data (`env`, `environmentId`; webhooks also
@@ -1685,9 +1835,165 @@ export default class StripeWebhook {
1685
1835
  ```
1686
1836
 
1687
1837
  `provider` selects a preset signature scheme; a service with no preset spells
1688
- one out with `signature` instead — one of the two is required. The signing
1838
+ one out with `signature` instead — EXACTLY one of the two is required (neither
1839
+ is an unverifiable endpoint; both is ambiguous, and only `provider` would be
1840
+ used, so both are refused at build). `/webhooks` is a reserved path: a
1841
+ controller route resolving under it is refused too, because the platform matches
1842
+ that path before controller dispatch. The signing
1689
1843
  secret is resolved by the runtime from `secret: { env: "NAME" }`; your
1690
1844
  handlers access Environment variables via `meta.env`. The runtime verifies the
1691
1845
  signature before dispatching to your event handlers.
1692
1846
 
1693
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/resources.md CHANGED
@@ -2,9 +2,8 @@
2
2
 
3
3
  A `Resource` models one external connection — a pooled datastore, a stateless
4
4
  API client, or a per-user factory. You put it in `resources/`, export an
5
- instance, and **do not register it**: the framework discovers it, sets it up
6
- once at boot, and drains it on shutdown. On top of that lifecycle you expose
7
- your own clean facade.
5
+ instance, and **do not register it**: the framework discovers it and sets it up
6
+ once at boot. On top of that lifecycle you expose your own clean facade.
8
7
 
9
8
  ```ts
10
9
  import { Resource } from "@palbase/backend";
@@ -14,13 +13,20 @@ import { Resource } from "@palbase/backend";
14
13
 
15
14
  A resource is created once at process boot — NOT per request. The framework:
16
15
 
17
- 1. calls `init(env)` **once**, with only the secrets the resource declared;
18
- 2. (optionally) calls `shutdown()` on SIGTERM, in reverse boot order.
16
+ 1. calls `init(env)` **once**, with only the secrets the resource declared.
17
+
18
+ That is the whole lifecycle — there is no teardown counterpart. The runtime
19
+ recycles an environment by disposing its isolate outright, with no signal
20
+ delivered into your code first, so a `shutdown()` you declare is **never
21
+ called**. Do not buffer in memory intending to flush on the way out; treat a
22
+ write as durable when the call that made it returns. The optional `shutdown()`
23
+ member survives in the type because the older process-based runtime did invoke
24
+ it on SIGTERM.
19
25
 
20
26
  The instance lives for the whole process; your facade methods are called
21
27
  per-request. This makes "reconnect on every request" structurally impossible.
22
28
 
23
- ## Pooled datastore — `init` + `shutdown`
29
+ ## Pooled datastore
24
30
 
25
31
  ```ts
26
32
  import { Resource } from "@palbase/backend";
@@ -32,9 +38,6 @@ export class Neo4jResource extends Resource {
32
38
  async init(env: { NEO4J_URL: string; NEO4J_USER: string; NEO4J_PASSWORD: string }) {
33
39
  this.driver = neo4j.driver(env.NEO4J_URL, neo4j.auth.basic(env.NEO4J_USER, env.NEO4J_PASSWORD));
34
40
  }
35
- async shutdown() {
36
- await this.driver.close();
37
- }
38
41
  session(): Session {
39
42
  return this.driver.session();
40
43
  }