@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.
- 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 +75 -72
- 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 +73 -71
- 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 +6 -2
- package/docs/llms-full.txt +375 -69
- package/docs/llms.txt +3 -1
- package/docs/migrations.md +30 -0
- package/docs/resources.md +12 -9
- 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
|
|
@@ -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
|
|
1405
|
-
once at boot
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
`
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
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
|
|
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
|
|
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,
|
|
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/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
|
|
6
|
-
once at boot
|
|
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
|
-
|
|
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
|
|
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
|
}
|