@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.
- package/dist/{chunk-XATG7BRC.js → chunk-I72YYSEI.js} +1 -3
- package/dist/chunk-I72YYSEI.js.map +1 -0
- package/dist/db/index.d.cts +2 -2
- package/dist/db/index.d.ts +2 -2
- package/dist/{endpoint-Ck4hER_7.d.cts → endpoint-CAWdScEH.d.cts} +14 -8
- package/dist/{endpoint-Ck4hER_7.d.ts → endpoint-CAWdScEH.d.ts} +14 -8
- package/dist/{index-BJAf1uPC.d.cts → index-CaRM7qe_.d.cts} +1 -1
- package/dist/{index-l7DhBDtn.d.ts → index-EAlQyErf.d.ts} +1 -1
- package/dist/index.cjs +22 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -71
- package/dist/index.d.ts +40 -71
- package/dist/index.js +20 -39
- package/dist/index.js.map +1 -1
- package/dist/test/index.cjs +0 -6
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -2
- package/dist/test/index.d.ts +1 -2
- package/dist/test/index.js +1 -6
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +2 -4
- package/docs/auth.md +111 -0
- package/docs/background.md +15 -39
- package/docs/config.md +51 -10
- package/docs/database.md +11 -3
- package/docs/events.md +1 -1
- package/docs/llms-full.txt +358 -59
- package/docs/llms.txt +3 -1
- package/docs/migrations.md +30 -0
- package/docs/schema.md +31 -3
- package/docs/services.md +0 -9
- package/package.json +4 -2
- package/dist/chunk-XATG7BRC.js.map +0 -1
package/docs/llms-full.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,
|
|
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/`
|
|
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
|
|
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
|
-
|
|
815
|
-
|
|
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 (
|
|
966
|
-
|
|
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
|
-
#
|
|
1732
|
+
# Background Jobs
|
|
1562
1733
|
|
|
1563
|
-
|
|
1564
|
-
singletons as endpoints (`import { Database, Log } from
|
|
1565
|
-
|
|
1566
|
-
non-service data (`env`,
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
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
|
|
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,
|
|
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)
|
package/docs/migrations.md
CHANGED
|
@@ -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 (
|
|
112
|
-
|
|
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": "
|
|
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
|
}
|