@palbase/backend 38.0.4 → 38.0.5
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/docs/auth.md +10 -6
- package/docs/database.md +5 -3
- package/docs/endpoints.md +19 -5
- package/docs/errors.md +12 -4
- package/docs/llms-full.txt +66 -24
- package/docs/migrations.md +5 -3
- package/docs/schema.md +15 -3
- package/package.json +1 -1
package/docs/auth.md
CHANGED
|
@@ -40,17 +40,21 @@ interface UserT { // exported as `UserT`; the value name `User` is the
|
|
|
40
40
|
}
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Every field is **server-resolved**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
Every field is **server-resolved** — nothing here is client-settable. `role` is
|
|
44
|
+
the **database** role RLS reads and is always `"authenticated"` for a signed-in
|
|
45
|
+
user, so `user.role === "admin"` is always false and reads like a working check.
|
|
46
|
+
|
|
47
|
+
**`metadata` is always `{}`.** Its only source is a `metadata` claim, and the
|
|
48
|
+
access token has none — so an `auth.users.metadata` row you filled through the
|
|
49
|
+
admin users API never reaches `user.metadata`, and `user.metadata.plan` is
|
|
50
|
+
`undefined` for every caller with nothing raised anywhere to say so. Keep your
|
|
51
|
+
own per-user attributes in a table of your own and read them with `Database`.
|
|
48
52
|
|
|
49
53
|
Application roles are **`user.roles`** — a `string[]` resolved per request from
|
|
50
54
|
`auth.user_roles`, the table `palbase roles` writes. They were once kept in
|
|
51
55
|
`metadata`; they are not any more, and a role written there gates nothing.
|
|
52
56
|
|
|
53
|
-
`emailVerified`, `email
|
|
57
|
+
`emailVerified`, `email` and `role` come from the **verified token**,
|
|
54
58
|
so they say what was true when it was minted: a user who confirms their address
|
|
55
59
|
mid-session keeps reporting `false` until their next token. `roles` is the
|
|
56
60
|
exception, and deliberately so — it is read from the table on every request, so
|
package/docs/database.md
CHANGED
|
@@ -714,10 +714,12 @@ policies, every `Database.*` call runs as the request's verified user, so the
|
|
|
714
714
|
database filters out rows the user's policies don't allow. That is the secure
|
|
715
715
|
default.
|
|
716
716
|
|
|
717
|
-
The Postgres role it connects as is **`backend_authenticated`**
|
|
718
|
-
|
|
717
|
+
The Postgres role it connects as is **`backend_authenticated`** — always, for
|
|
718
|
+
every request, whether or not somebody is signed in — not `authenticated`. You
|
|
719
719
|
rarely need to know that, because a policy declared in `db/*.ts` is
|
|
720
|
-
deployed targeting both.
|
|
720
|
+
deployed targeting both. What it does mean is that the role never tells a
|
|
721
|
+
policy whether the caller is signed in: put that in the `using` expression,
|
|
722
|
+
never in the role. It matters in exactly one place: **hand-written
|
|
721
723
|
`CREATE POLICY` SQL in a migration must name both roles**, or it applies to
|
|
722
724
|
nothing your code does. See
|
|
723
725
|
[Row-Level Security](./schema.md#row-level-security-rls).
|
package/docs/endpoints.md
CHANGED
|
@@ -103,9 +103,13 @@ query (RFC semantics), so a QUERY route carries its whole input there.
|
|
|
103
103
|
|
|
104
104
|
`@Body`/`@QueryParams`/`@Headers` take a zod schema (validation + codegen source); the
|
|
105
105
|
developer writes the matching type annotation (same name). `UserT` is the
|
|
106
|
-
authenticated-user type (`{ id: string; email?: string;
|
|
107
|
-
Record<string, unknown
|
|
108
|
-
|
|
106
|
+
authenticated-user type (`{ id: string; email?: string; emailVerified: boolean;
|
|
107
|
+
role: string; roles: string[]; metadata: Record<string, unknown>; device:
|
|
108
|
+
VerifiedDevice | null }`) — exported under the alias `UserT` because the value
|
|
109
|
+
name `User` is the `@User()` decorator. **`roles` is the one to reach for**:
|
|
110
|
+
`role` is the DATABASE role and is always `"authenticated"` for a signed-in
|
|
111
|
+
caller, so `user.role === "admin"` is a check that never fires. See
|
|
112
|
+
[Authentication](./auth.md#what-user-gives-you).
|
|
109
113
|
|
|
110
114
|
## Output = return type
|
|
111
115
|
|
|
@@ -118,12 +122,22 @@ error. A method that returns nothing annotates `: void` (or `: Promise<void>`).
|
|
|
118
122
|
## Auth — controller default + route override cascade
|
|
119
123
|
|
|
120
124
|
**Secure by default:** a route requires authentication UNLESS it explicitly opts
|
|
121
|
-
out. Resolution order (most specific wins)
|
|
125
|
+
out. Resolution order (most specific wins) — **four links, not three**:
|
|
122
126
|
|
|
123
127
|
1. Route-level `@Post("", { auth })` — wins if present.
|
|
124
128
|
2. Controller-level `@Controller("/x", { auth })` — applies to routes without
|
|
125
129
|
their own `auth`.
|
|
126
|
-
3.
|
|
130
|
+
3. **Application-level `defineDefaultAuth(...)`** — one declaration, every
|
|
131
|
+
controller that did not speak for itself. This is the link to know about,
|
|
132
|
+
because it can TIGHTEN what a route you are reading looks like it does:
|
|
133
|
+
`defineDefaultAuth({ verifiedEmail: true })` makes every undeclared route
|
|
134
|
+
demand a confirmed address, and nothing at the route says so.
|
|
135
|
+
4. Default `true` (secure-by-default).
|
|
136
|
+
|
|
137
|
+
The whole chain is one function — `resolveEffectiveAuth(routeAuth,
|
|
138
|
+
controllerAuth)` — and both the router and the spec emitter ask it rather than
|
|
139
|
+
spelling the cascade out, so the build-time answer and the runtime answer cannot
|
|
140
|
+
drift apart about who may call an endpoint.
|
|
127
141
|
|
|
128
142
|
```ts
|
|
129
143
|
@Controller("/public", { auth: false }) // all routes default public
|
package/docs/errors.md
CHANGED
|
@@ -26,14 +26,15 @@ import { BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequest
|
|
|
26
26
|
|
|
27
27
|
throw new Conflict("title taken"); // → 409 ("conflict" code by default)
|
|
28
28
|
throw new NotFound(); // → 404 ("not_found", "Not found")
|
|
29
|
-
throw new BadRequest(
|
|
29
|
+
throw new BadRequest({ fields: [] }); // → 400 (data-first, see below)
|
|
30
30
|
throw new Unauthorized(); // → 401
|
|
31
31
|
throw new Forbidden(); // → 403
|
|
32
|
-
throw new TooManyRequests();
|
|
32
|
+
throw new TooManyRequests({ retryAfter: 30 }); // → 429 (data-first)
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Each class fixes its HTTP status.
|
|
36
|
-
`new <Class>(message?, code?, data?)
|
|
35
|
+
Each class fixes its HTTP status. Four of them take
|
|
36
|
+
`new <Class>(message?, code?, data?)` — `Unauthorized`, `Forbidden`, `NotFound`,
|
|
37
|
+
`Conflict`:
|
|
37
38
|
|
|
38
39
|
- `message` overrides the human-readable `error_description` (defaults to a label
|
|
39
40
|
derived from the class name).
|
|
@@ -41,6 +42,13 @@ Each class fixes its HTTP status. The constructor is
|
|
|
41
42
|
code, e.g. `not_found`).
|
|
42
43
|
- `data` rides along under the envelope's `data` field for structured context.
|
|
43
44
|
|
|
45
|
+
**`BadRequest` and `TooManyRequests` are DATA-FIRST, and their data is
|
|
46
|
+
required** — `new BadRequest(data, message?)`, `new TooManyRequests(data,
|
|
47
|
+
message?)`. Both carry a fixed typed payload so codegen can surface it on the
|
|
48
|
+
client: `BadRequestData` is `{ fields: FieldError[] }` and
|
|
49
|
+
`TooManyRequestsData` is `{ retryAfter: number }`. Passing a string, or nothing,
|
|
50
|
+
does not compile.
|
|
51
|
+
|
|
44
52
|
```ts
|
|
45
53
|
throw new NotFound("Room does not exist", "room_not_found");
|
|
46
54
|
throw new Conflict("locked", "title_locked", { retryAfter: 30 });
|
package/docs/llms-full.txt
CHANGED
|
@@ -702,9 +702,13 @@ query (RFC semantics), so a QUERY route carries its whole input there.
|
|
|
702
702
|
|
|
703
703
|
`@Body`/`@QueryParams`/`@Headers` take a zod schema (validation + codegen source); the
|
|
704
704
|
developer writes the matching type annotation (same name). `UserT` is the
|
|
705
|
-
authenticated-user type (`{ id: string; email?: string;
|
|
706
|
-
Record<string, unknown
|
|
707
|
-
|
|
705
|
+
authenticated-user type (`{ id: string; email?: string; emailVerified: boolean;
|
|
706
|
+
role: string; roles: string[]; metadata: Record<string, unknown>; device:
|
|
707
|
+
VerifiedDevice | null }`) — exported under the alias `UserT` because the value
|
|
708
|
+
name `User` is the `@User()` decorator. **`roles` is the one to reach for**:
|
|
709
|
+
`role` is the DATABASE role and is always `"authenticated"` for a signed-in
|
|
710
|
+
caller, so `user.role === "admin"` is a check that never fires. See
|
|
711
|
+
[Authentication](./auth.md#what-user-gives-you).
|
|
708
712
|
|
|
709
713
|
## Output = return type
|
|
710
714
|
|
|
@@ -717,12 +721,22 @@ error. A method that returns nothing annotates `: void` (or `: Promise<void>`).
|
|
|
717
721
|
## Auth — controller default + route override cascade
|
|
718
722
|
|
|
719
723
|
**Secure by default:** a route requires authentication UNLESS it explicitly opts
|
|
720
|
-
out. Resolution order (most specific wins)
|
|
724
|
+
out. Resolution order (most specific wins) — **four links, not three**:
|
|
721
725
|
|
|
722
726
|
1. Route-level `@Post("", { auth })` — wins if present.
|
|
723
727
|
2. Controller-level `@Controller("/x", { auth })` — applies to routes without
|
|
724
728
|
their own `auth`.
|
|
725
|
-
3.
|
|
729
|
+
3. **Application-level `defineDefaultAuth(...)`** — one declaration, every
|
|
730
|
+
controller that did not speak for itself. This is the link to know about,
|
|
731
|
+
because it can TIGHTEN what a route you are reading looks like it does:
|
|
732
|
+
`defineDefaultAuth({ verifiedEmail: true })` makes every undeclared route
|
|
733
|
+
demand a confirmed address, and nothing at the route says so.
|
|
734
|
+
4. Default `true` (secure-by-default).
|
|
735
|
+
|
|
736
|
+
The whole chain is one function — `resolveEffectiveAuth(routeAuth,
|
|
737
|
+
controllerAuth)` — and both the router and the spec emitter ask it rather than
|
|
738
|
+
spelling the cascade out, so the build-time answer and the runtime answer cannot
|
|
739
|
+
drift apart about who may call an endpoint.
|
|
726
740
|
|
|
727
741
|
```ts
|
|
728
742
|
@Controller("/public", { auth: false }) // all routes default public
|
|
@@ -816,17 +830,21 @@ interface UserT { // exported as `UserT`; the value name `User` is the
|
|
|
816
830
|
}
|
|
817
831
|
```
|
|
818
832
|
|
|
819
|
-
Every field is **server-resolved**
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
833
|
+
Every field is **server-resolved** — nothing here is client-settable. `role` is
|
|
834
|
+
the **database** role RLS reads and is always `"authenticated"` for a signed-in
|
|
835
|
+
user, so `user.role === "admin"` is always false and reads like a working check.
|
|
836
|
+
|
|
837
|
+
**`metadata` is always `{}`.** Its only source is a `metadata` claim, and the
|
|
838
|
+
access token has none — so an `auth.users.metadata` row you filled through the
|
|
839
|
+
admin users API never reaches `user.metadata`, and `user.metadata.plan` is
|
|
840
|
+
`undefined` for every caller with nothing raised anywhere to say so. Keep your
|
|
841
|
+
own per-user attributes in a table of your own and read them with `Database`.
|
|
824
842
|
|
|
825
843
|
Application roles are **`user.roles`** — a `string[]` resolved per request from
|
|
826
844
|
`auth.user_roles`, the table `palbase roles` writes. They were once kept in
|
|
827
845
|
`metadata`; they are not any more, and a role written there gates nothing.
|
|
828
846
|
|
|
829
|
-
`emailVerified`, `email
|
|
847
|
+
`emailVerified`, `email` and `role` come from the **verified token**,
|
|
830
848
|
so they say what was true when it was minted: a user who confirms their address
|
|
831
849
|
mid-session keeps reporting `false` until their next token. `roles` is the
|
|
832
850
|
exception, and deliberately so — it is read from the table on every request, so
|
|
@@ -1830,10 +1848,12 @@ policies, every `Database.*` call runs as the request's verified user, so the
|
|
|
1830
1848
|
database filters out rows the user's policies don't allow. That is the secure
|
|
1831
1849
|
default.
|
|
1832
1850
|
|
|
1833
|
-
The Postgres role it connects as is **`backend_authenticated`**
|
|
1834
|
-
|
|
1851
|
+
The Postgres role it connects as is **`backend_authenticated`** — always, for
|
|
1852
|
+
every request, whether or not somebody is signed in — not `authenticated`. You
|
|
1835
1853
|
rarely need to know that, because a policy declared in `db/*.ts` is
|
|
1836
|
-
deployed targeting both.
|
|
1854
|
+
deployed targeting both. What it does mean is that the role never tells a
|
|
1855
|
+
policy whether the caller is signed in: put that in the `using` expression,
|
|
1856
|
+
never in the role. It matters in exactly one place: **hand-written
|
|
1837
1857
|
`CREATE POLICY` SQL in a migration must name both roles**, or it applies to
|
|
1838
1858
|
nothing your code does. See
|
|
1839
1859
|
[Row-Level Security](./schema.md#row-level-security-rls).
|
|
@@ -2724,19 +2744,31 @@ policy("pb_owner_all")
|
|
|
2724
2744
|
|
|
2725
2745
|
#### Which role your policy must target
|
|
2726
2746
|
|
|
2727
|
-
The runtime connects to Postgres as **`backend_authenticated`**
|
|
2728
|
-
|
|
2729
|
-
`authenticated`**
|
|
2747
|
+
The runtime connects to Postgres as **`backend_authenticated`** — always, for
|
|
2748
|
+
every request, signed in or not — which is a **separate role from
|
|
2749
|
+
`authenticated`** and not a member of it. A policy addressed only to
|
|
2730
2750
|
`authenticated` therefore applies to nothing your backend does, and with RLS on
|
|
2731
2751
|
and no applicable policy, Postgres denies everything: reads come back empty and
|
|
2732
2752
|
writes are refused, while your code compiles, your tests pass and the deploy
|
|
2733
2753
|
reports success.
|
|
2734
2754
|
|
|
2755
|
+
`backend_anon` is provisioned too, but your backend never connects as it — so a
|
|
2756
|
+
policy written only for `anon` changes nothing about what your controllers can
|
|
2757
|
+
read.
|
|
2758
|
+
|
|
2735
2759
|
You do not have to think about this when you declare policies here. `.to("authenticated")`
|
|
2736
2760
|
is deployed as `TO authenticated, backend_authenticated` (and `anon` gains
|
|
2737
2761
|
`backend_anon`); `service_role` is left alone because `backend_service_role` has
|
|
2738
2762
|
`BYPASSRLS` and policies never apply to it.
|
|
2739
2763
|
|
|
2764
|
+
**And that is why `.to("authenticated")` does not mean "signed in".** Since
|
|
2765
|
+
every request arrives under the same role, an anonymous caller reaches an
|
|
2766
|
+
`authenticated` policy too. The role decides nothing about identity — the
|
|
2767
|
+
`using` expression does. If a policy is meant for signed-in users, say so in the
|
|
2768
|
+
expression (`auth.uid() is not null`, or a predicate that dereferences it); a
|
|
2769
|
+
policy whose `using` never mentions the caller applies to anonymous traffic as
|
|
2770
|
+
well.
|
|
2771
|
+
|
|
2740
2772
|
**Hand-written SQL is the case to watch.** A `CREATE POLICY` in a
|
|
2741
2773
|
`db/migrations/*.sql` file is applied verbatim, so write both roles yourself:
|
|
2742
2774
|
|
|
@@ -3038,11 +3070,13 @@ column builders, the policy DSL, and typed `Database.public.*` access.
|
|
|
3038
3070
|
|
|
3039
3071
|
Two things the generated path handles for you and raw SQL does not.
|
|
3040
3072
|
|
|
3041
|
-
**Name both roles.** The runtime connects as `backend_authenticated`
|
|
3042
|
-
|
|
3073
|
+
**Name both roles.** The runtime connects as `backend_authenticated` — every
|
|
3074
|
+
request, signed in or not — and it is *not* a member of `authenticated`. A policy
|
|
3043
3075
|
addressed only to `authenticated` applies to nothing your backend does — and with
|
|
3044
3076
|
RLS on and no applicable policy, Postgres denies everything: empty reads, refused
|
|
3045
|
-
writes, no error anywhere that says why.
|
|
3077
|
+
writes, no error anywhere that says why. (`backend_anon` is provisioned and gets
|
|
3078
|
+
the same twin treatment, but your backend never connects as it, so naming it
|
|
3079
|
+
does not fence anonymous callers — the `using` expression is what does that.)
|
|
3046
3080
|
|
|
3047
3081
|
**Guard the CREATE.** Postgres has no `CREATE POLICY IF NOT EXISTS`, so a
|
|
3048
3082
|
migration that replays — a fresh Environment, or `palbase start --reset`, which
|
|
@@ -3534,14 +3568,15 @@ import { BadRequest, Unauthorized, Forbidden, NotFound, Conflict, TooManyRequest
|
|
|
3534
3568
|
|
|
3535
3569
|
throw new Conflict("title taken"); // → 409 ("conflict" code by default)
|
|
3536
3570
|
throw new NotFound(); // → 404 ("not_found", "Not found")
|
|
3537
|
-
throw new BadRequest(
|
|
3571
|
+
throw new BadRequest({ fields: [] }); // → 400 (data-first, see below)
|
|
3538
3572
|
throw new Unauthorized(); // → 401
|
|
3539
3573
|
throw new Forbidden(); // → 403
|
|
3540
|
-
throw new TooManyRequests();
|
|
3574
|
+
throw new TooManyRequests({ retryAfter: 30 }); // → 429 (data-first)
|
|
3541
3575
|
```
|
|
3542
3576
|
|
|
3543
|
-
Each class fixes its HTTP status.
|
|
3544
|
-
`new <Class>(message?, code?, data?)
|
|
3577
|
+
Each class fixes its HTTP status. Four of them take
|
|
3578
|
+
`new <Class>(message?, code?, data?)` — `Unauthorized`, `Forbidden`, `NotFound`,
|
|
3579
|
+
`Conflict`:
|
|
3545
3580
|
|
|
3546
3581
|
- `message` overrides the human-readable `error_description` (defaults to a label
|
|
3547
3582
|
derived from the class name).
|
|
@@ -3549,6 +3584,13 @@ Each class fixes its HTTP status. The constructor is
|
|
|
3549
3584
|
code, e.g. `not_found`).
|
|
3550
3585
|
- `data` rides along under the envelope's `data` field for structured context.
|
|
3551
3586
|
|
|
3587
|
+
**`BadRequest` and `TooManyRequests` are DATA-FIRST, and their data is
|
|
3588
|
+
required** — `new BadRequest(data, message?)`, `new TooManyRequests(data,
|
|
3589
|
+
message?)`. Both carry a fixed typed payload so codegen can surface it on the
|
|
3590
|
+
client: `BadRequestData` is `{ fields: FieldError[] }` and
|
|
3591
|
+
`TooManyRequestsData` is `{ retryAfter: number }`. Passing a string, or nothing,
|
|
3592
|
+
does not compile.
|
|
3593
|
+
|
|
3552
3594
|
```ts
|
|
3553
3595
|
throw new NotFound("Room does not exist", "room_not_found");
|
|
3554
3596
|
throw new Conflict("locked", "title_locked", { retryAfter: 30 });
|
package/docs/migrations.md
CHANGED
|
@@ -175,11 +175,13 @@ column builders, the policy DSL, and typed `Database.public.*` access.
|
|
|
175
175
|
|
|
176
176
|
Two things the generated path handles for you and raw SQL does not.
|
|
177
177
|
|
|
178
|
-
**Name both roles.** The runtime connects as `backend_authenticated`
|
|
179
|
-
|
|
178
|
+
**Name both roles.** The runtime connects as `backend_authenticated` — every
|
|
179
|
+
request, signed in or not — and it is *not* a member of `authenticated`. A policy
|
|
180
180
|
addressed only to `authenticated` applies to nothing your backend does — and with
|
|
181
181
|
RLS on and no applicable policy, Postgres denies everything: empty reads, refused
|
|
182
|
-
writes, no error anywhere that says why.
|
|
182
|
+
writes, no error anywhere that says why. (`backend_anon` is provisioned and gets
|
|
183
|
+
the same twin treatment, but your backend never connects as it, so naming it
|
|
184
|
+
does not fence anonymous callers — the `using` expression is what does that.)
|
|
183
185
|
|
|
184
186
|
**Guard the CREATE.** Postgres has no `CREATE POLICY IF NOT EXISTS`, so a
|
|
185
187
|
migration that replays — a fresh Environment, or `palbase start --reset`, which
|
package/docs/schema.md
CHANGED
|
@@ -570,19 +570,31 @@ policy("pb_owner_all")
|
|
|
570
570
|
|
|
571
571
|
#### Which role your policy must target
|
|
572
572
|
|
|
573
|
-
The runtime connects to Postgres as **`backend_authenticated`**
|
|
574
|
-
|
|
575
|
-
`authenticated`**
|
|
573
|
+
The runtime connects to Postgres as **`backend_authenticated`** — always, for
|
|
574
|
+
every request, signed in or not — which is a **separate role from
|
|
575
|
+
`authenticated`** and not a member of it. A policy addressed only to
|
|
576
576
|
`authenticated` therefore applies to nothing your backend does, and with RLS on
|
|
577
577
|
and no applicable policy, Postgres denies everything: reads come back empty and
|
|
578
578
|
writes are refused, while your code compiles, your tests pass and the deploy
|
|
579
579
|
reports success.
|
|
580
580
|
|
|
581
|
+
`backend_anon` is provisioned too, but your backend never connects as it — so a
|
|
582
|
+
policy written only for `anon` changes nothing about what your controllers can
|
|
583
|
+
read.
|
|
584
|
+
|
|
581
585
|
You do not have to think about this when you declare policies here. `.to("authenticated")`
|
|
582
586
|
is deployed as `TO authenticated, backend_authenticated` (and `anon` gains
|
|
583
587
|
`backend_anon`); `service_role` is left alone because `backend_service_role` has
|
|
584
588
|
`BYPASSRLS` and policies never apply to it.
|
|
585
589
|
|
|
590
|
+
**And that is why `.to("authenticated")` does not mean "signed in".** Since
|
|
591
|
+
every request arrives under the same role, an anonymous caller reaches an
|
|
592
|
+
`authenticated` policy too. The role decides nothing about identity — the
|
|
593
|
+
`using` expression does. If a policy is meant for signed-in users, say so in the
|
|
594
|
+
expression (`auth.uid() is not null`, or a predicate that dereferences it); a
|
|
595
|
+
policy whose `using` never mentions the caller applies to anonymous traffic as
|
|
596
|
+
well.
|
|
597
|
+
|
|
586
598
|
**Hand-written SQL is the case to watch.** A `CREATE POLICY` in a
|
|
587
599
|
`db/migrations/*.sql` file is applied verbatim, so write both roles yourself:
|
|
588
600
|
|
package/package.json
CHANGED