@palbase/backend 34.0.0 → 34.1.1

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bin/palbase-backend.cjs +275 -30
  3. package/dist/bin/palbase-backend.cjs.map +1 -1
  4. package/dist/bin/palbase-backend.js +4 -4
  5. package/dist/{chunk-SEM36BWY.js → chunk-5UGPXKMJ.js} +60 -12
  6. package/dist/chunk-5UGPXKMJ.js.map +1 -0
  7. package/dist/{chunk-FOWA7PF4.js → chunk-P7MGSAFT.js} +6 -1
  8. package/dist/{chunk-FOWA7PF4.js.map → chunk-P7MGSAFT.js.map} +1 -1
  9. package/dist/{chunk-YX7UA7VC.js → chunk-X2UYIXDS.js} +8 -2
  10. package/dist/chunk-X2UYIXDS.js.map +1 -0
  11. package/dist/{chunk-MNGZE2MM.js → chunk-XZPDPYVY.js} +279 -34
  12. package/dist/chunk-XZPDPYVY.js.map +1 -0
  13. package/dist/{chunk-LOGL2ZHD.js → chunk-YO5GYM73.js} +2 -2
  14. package/dist/db/index.cjs +34 -10
  15. package/dist/db/index.cjs.map +1 -1
  16. package/dist/db/index.d.cts +2 -2
  17. package/dist/db/index.d.ts +2 -2
  18. package/dist/db/index.js +2 -2
  19. package/dist/{endpoint-DpNUx-ON.d.ts → endpoint-BXPdsTA5.d.ts} +56 -2
  20. package/dist/{endpoint-kYgEHrrV.d.cts → endpoint-DqhUP7RT.d.cts} +56 -2
  21. package/dist/engine/index.cjs +275 -30
  22. package/dist/engine/index.cjs.map +1 -1
  23. package/dist/engine/index.d.cts +3 -3
  24. package/dist/engine/index.d.ts +3 -3
  25. package/dist/engine/index.js +4 -4
  26. package/dist/{index-pnDsEfxJ.d.cts → index-Co0Hj_aO.d.cts} +2 -2
  27. package/dist/{index-JrHqNsjQ.d.ts → index-MhCL7ll4.d.ts} +2 -2
  28. package/dist/index.cjs +79 -21
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +6 -6
  31. package/dist/index.d.ts +6 -6
  32. package/dist/index.js +6 -4
  33. package/dist/index.js.map +1 -1
  34. package/dist/openapi/index.d.cts +2 -2
  35. package/dist/openapi/index.d.ts +2 -2
  36. package/dist/{registry-BcOd8Zhf.d.cts → registry-BkIVHPzB.d.cts} +1 -1
  37. package/dist/{registry-BoX5iNyY.d.ts → registry-CvGLR5j1.d.ts} +1 -1
  38. package/dist/test/index.cjs.map +1 -1
  39. package/dist/test/index.d.cts +1 -1
  40. package/dist/test/index.d.ts +1 -1
  41. package/dist/test/index.js +2 -2
  42. package/dist/test/index.js.map +1 -1
  43. package/docs/auth.md +58 -0
  44. package/docs/llms-full.txt +73 -0
  45. package/docs/schema.md +15 -0
  46. package/package.json +15 -15
  47. package/template/db/public.ts +12 -0
  48. package/template/scripts/test.sh +0 -0
  49. package/dist/chunk-MNGZE2MM.js.map +0 -1
  50. package/dist/chunk-SEM36BWY.js.map +0 -1
  51. package/dist/chunk-YX7UA7VC.js.map +0 -1
  52. /package/dist/{chunk-LOGL2ZHD.js.map → chunk-YO5GYM73.js.map} +0 -0
package/docs/auth.md CHANGED
@@ -155,6 +155,64 @@ Two helpers are available in every policy body:
155
155
  Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
156
156
  ask about somebody else.
157
157
 
158
+ ### `can` — what a row says you may do
159
+
160
+ Every row a table hands back carries `can: string[]`: the names of the table's
161
+ named **update** policies whose `using` expression is true for THIS row and THIS
162
+ caller. It is computed in the same `SELECT` that fetched the row — the policy is
163
+ the only copy of the rule, and `can` is its echo.
164
+
165
+ ```ts
166
+ const transfer = await Database.public.transfers.findById(id);
167
+ transfer?.can; // e.g. ["transfers_approve"] — typed from db/public.ts
168
+ ```
169
+
170
+ To put it on the wire, name it in the response schema — the enum comes from the
171
+ policy declaration, never from a hand-typed list:
172
+
173
+ ```ts
174
+ import { z, can } from "@palbase/backend";
175
+ import publicSchema from "../../db/public";
176
+
177
+ export const TransferSchema = z.object({
178
+ id: z.string(),
179
+ status: z.string(),
180
+ can: can(publicSchema.tables.transfers),
181
+ });
182
+ ```
183
+
184
+ The contract, word for word: **`can` is for the interface; it cannot be used for
185
+ an authorization decision; it may be incomplete; the server decides again on
186
+ every request.** A policy whose `using` the engine cannot select as a column is
187
+ dropped from `can` with a `warn` line — it never becomes a silent `true`. In a
188
+ raw-string `using`, write column names unqualified (`status`, not
189
+ `transfers.status` or `public.transfers.status`) and wrap every
190
+ `auth.has_permission(...)` in `(select …)`; a comment, an unbalanced parenthesis
191
+ or an unterminated quote drops the policy the same way.
192
+
193
+ Rows read under `Database.$asService()` carry `can: []`: no policy applies to
194
+ the service role and every `TO` gate is false for it, so a row fetched there and
195
+ handed to a user says nothing about what that user may do — read it as the user.
196
+
197
+ Only `.for("update")` policies are echoed. A `.for("all")` policy such as
198
+ `notes_owner` above governs UPDATE too, but it is not a named update policy and
199
+ never appears in `can` — a table whose only policy is `for("all")` gets
200
+ `can: []` on every row, and `can(table)` refuses it by name. To get an
201
+ affordance, declare a separate `.for("update")` policy beside it (the template's
202
+ `notes_edit_own` next to `notes_owner`).
203
+
204
+ The name `can` is reserved for this echo. A table that declares a column named
205
+ `can` keeps its column and gets no echo (with a `warn`); a **relation** named
206
+ `can` — a table named `can`, a `can_id` foreign key, `as: "can"` or
207
+ `reverseAs: "can"` — is refused by `defineTable` itself, because `row.can` and
208
+ `relations.can` cannot both be true of one row.
209
+
210
+ When the server refuses, the 403 body names the cure: `required: "transfers.approve"`.
211
+ `pb.auth.onPermissionDenied { required in … }` (iOS) / `pb.auth.onPermissionDenied(required => …)`
212
+ (web) fires on it, so a screen drawn from a stale `can` refetches and repairs itself.
213
+ `pb.auth.permissions.holds("transfers.approve")` is the live, observable set of the
214
+ caller's permissions — pushed over realtime the moment a role changes.
215
+
158
216
  ## Email verification
159
217
 
160
218
  The platform handles verification end to end. **You do not configure a sender**,
@@ -931,6 +931,64 @@ Two helpers are available in every policy body:
931
931
  Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
932
932
  ask about somebody else.
933
933
 
934
+ ### `can` — what a row says you may do
935
+
936
+ Every row a table hands back carries `can: string[]`: the names of the table's
937
+ named **update** policies whose `using` expression is true for THIS row and THIS
938
+ caller. It is computed in the same `SELECT` that fetched the row — the policy is
939
+ the only copy of the rule, and `can` is its echo.
940
+
941
+ ```ts
942
+ const transfer = await Database.public.transfers.findById(id);
943
+ transfer?.can; // e.g. ["transfers_approve"] — typed from db/public.ts
944
+ ```
945
+
946
+ To put it on the wire, name it in the response schema — the enum comes from the
947
+ policy declaration, never from a hand-typed list:
948
+
949
+ ```ts
950
+ import { z, can } from "@palbase/backend";
951
+ import publicSchema from "../../db/public";
952
+
953
+ export const TransferSchema = z.object({
954
+ id: z.string(),
955
+ status: z.string(),
956
+ can: can(publicSchema.tables.transfers),
957
+ });
958
+ ```
959
+
960
+ The contract, word for word: **`can` is for the interface; it cannot be used for
961
+ an authorization decision; it may be incomplete; the server decides again on
962
+ every request.** A policy whose `using` the engine cannot select as a column is
963
+ dropped from `can` with a `warn` line — it never becomes a silent `true`. In a
964
+ raw-string `using`, write column names unqualified (`status`, not
965
+ `transfers.status` or `public.transfers.status`) and wrap every
966
+ `auth.has_permission(...)` in `(select …)`; a comment, an unbalanced parenthesis
967
+ or an unterminated quote drops the policy the same way.
968
+
969
+ Rows read under `Database.$asService()` carry `can: []`: no policy applies to
970
+ the service role and every `TO` gate is false for it, so a row fetched there and
971
+ handed to a user says nothing about what that user may do — read it as the user.
972
+
973
+ Only `.for("update")` policies are echoed. A `.for("all")` policy such as
974
+ `notes_owner` above governs UPDATE too, but it is not a named update policy and
975
+ never appears in `can` — a table whose only policy is `for("all")` gets
976
+ `can: []` on every row, and `can(table)` refuses it by name. To get an
977
+ affordance, declare a separate `.for("update")` policy beside it (the template's
978
+ `notes_edit_own` next to `notes_owner`).
979
+
980
+ The name `can` is reserved for this echo. A table that declares a column named
981
+ `can` keeps its column and gets no echo (with a `warn`); a **relation** named
982
+ `can` — a table named `can`, a `can_id` foreign key, `as: "can"` or
983
+ `reverseAs: "can"` — is refused by `defineTable` itself, because `row.can` and
984
+ `relations.can` cannot both be true of one row.
985
+
986
+ When the server refuses, the 403 body names the cure: `required: "transfers.approve"`.
987
+ `pb.auth.onPermissionDenied { required in … }` (iOS) / `pb.auth.onPermissionDenied(required => …)`
988
+ (web) fires on it, so a screen drawn from a stale `can` refetches and repairs itself.
989
+ `pb.auth.permissions.holds("transfers.approve")` is the live, observable set of the
990
+ caller's permissions — pushed over realtime the moment a role changes.
991
+
934
992
  ## Email verification
935
993
 
936
994
  The platform handles verification end to end. **You do not configure a sender**,
@@ -2743,6 +2801,21 @@ calling user's rows — no `WHERE owner = …` needed in the handler. To read or
2743
2801
  write across all users (e.g. an admin job), use the explicit bypass:
2744
2802
  `Database.$asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
2745
2803
 
2804
+ Name your **update** policies deliberately: each name becomes a value in the row's
2805
+ `can` array (`row.can: ("transfers_approve" | "transfers_cancel")[]` in
2806
+ `palbase-env.d.ts`), and `can(table)` puts the same names into a response schema
2807
+ as an enum. Only `.for("update")` counts: a `.for("all")` policy like the
2808
+ owner policies above governs UPDATE but is never echoed, so a table with only
2809
+ `for("all")` policies carries `can: []`. In a raw-string `using`, write column
2810
+ names UNQUALIFIED (`status = 'pending_approval'`, not `transfers.status` or
2811
+ `public.transfers.status`) and wrap `auth.has_permission(...)` in `(select …)`:
2812
+ the engine selects the expression under a row alias, and a `using` it cannot
2813
+ carry there — a table qualifier, a comment, an unwrapped `has_permission`, a
2814
+ policy name longer than 59 bytes — is dropped from `can` with a `warn`, never
2815
+ spliced. A column or relation named `can` is reserved (see [auth.md](./auth.md)).
2816
+ `can` is for the interface; it cannot be used for an authorization decision; it
2817
+ may be incomplete; the server decides again on every request.
2818
+
2746
2819
  ### How policies are applied
2747
2820
 
2748
2821
  On deploy, Palbase diffs your declared schema against the live database and
package/docs/schema.md CHANGED
@@ -676,6 +676,21 @@ calling user's rows — no `WHERE owner = …` needed in the handler. To read or
676
676
  write across all users (e.g. an admin job), use the explicit bypass:
677
677
  `Database.$asService()` (see [database.md](./database.md#bypassing-rls--databaseasservice)).
678
678
 
679
+ Name your **update** policies deliberately: each name becomes a value in the row's
680
+ `can` array (`row.can: ("transfers_approve" | "transfers_cancel")[]` in
681
+ `palbase-env.d.ts`), and `can(table)` puts the same names into a response schema
682
+ as an enum. Only `.for("update")` counts: a `.for("all")` policy like the
683
+ owner policies above governs UPDATE but is never echoed, so a table with only
684
+ `for("all")` policies carries `can: []`. In a raw-string `using`, write column
685
+ names UNQUALIFIED (`status = 'pending_approval'`, not `transfers.status` or
686
+ `public.transfers.status`) and wrap `auth.has_permission(...)` in `(select …)`:
687
+ the engine selects the expression under a row alias, and a `using` it cannot
688
+ carry there — a table qualifier, a comment, an unwrapped `has_permission`, a
689
+ policy name longer than 59 bytes — is dropped from `can` with a `warn`, never
690
+ spliced. A column or relation named `can` is reserved (see [auth.md](./auth.md)).
691
+ `can` is for the interface; it cannot be used for an authorization decision; it
692
+ may be incomplete; the server decides again on every request.
693
+
679
694
  ### How policies are applied
680
695
 
681
696
  On deploy, Palbase diffs your declared schema against the live database and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@palbase/backend",
3
- "version": "34.0.0",
3
+ "version": "34.1.1",
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": {
@@ -94,24 +94,12 @@
94
94
  "stager",
95
95
  "template"
96
96
  ],
97
- "scripts": {
98
- "build": "tsup && npm run build:stager",
99
- "test": "vitest run",
100
- "test:watch": "vitest",
101
- "typecheck": "tsc --noEmit",
102
- "docs:build": "node scripts/build-llms.mjs",
103
- "emit:openapi-fixture": "tsx scripts/emit-openapi-fixture.mjs",
104
- "check:api": "node scripts/api-surface.mjs",
105
- "api:update": "node scripts/api-surface.mjs --update",
106
- "build:stager": "mkdir -p stager && for f in src/stager/*.js; do case \"$f\" in *.test.js) ;; *) cp \"$f\" stager/ ;; esac; done && cp src/stager/package.json stager/"
107
- },
108
97
  "dependencies": {
109
98
  "@asteasolutions/zod-to-openapi": "^7.3.4",
110
99
  "reflect-metadata": "^0.2.2",
111
100
  "zod": "^3.24.0"
112
101
  },
113
102
  "devDependencies": {
114
- "@palbase/core": "workspace:^",
115
103
  "@swc/core": "^1.16.1",
116
104
  "@types/node": "^25.5.2",
117
105
  "bun-types": "^1.4.0",
@@ -121,12 +109,24 @@
121
109
  "tsx": "^4.22.4",
122
110
  "typescript": "^5.7.0",
123
111
  "unplugin-swc": "^1.5.11",
124
- "vitest": "^3.0.0"
112
+ "vitest": "^3.0.0",
113
+ "@palbase/core": "^2.4.0"
125
114
  },
126
115
  "publishConfig": {
127
116
  "access": "public"
128
117
  },
129
118
  "bin": {
130
119
  "palbase-backend": "./dist/bin/palbase-backend.js"
120
+ },
121
+ "scripts": {
122
+ "build": "tsup && npm run build:stager",
123
+ "test": "vitest run",
124
+ "test:watch": "vitest",
125
+ "typecheck": "tsc --noEmit",
126
+ "docs:build": "node scripts/build-llms.mjs",
127
+ "emit:openapi-fixture": "tsx scripts/emit-openapi-fixture.mjs",
128
+ "check:api": "node scripts/api-surface.mjs",
129
+ "api:update": "node scripts/api-surface.mjs --update",
130
+ "build:stager": "mkdir -p stager && for f in src/stager/*.js; do case \"$f\" in *.test.js) ;; *) cp \"$f\" stager/ ;; esac; done && cp src/stager/package.json stager/"
131
131
  }
132
- }
132
+ }
@@ -31,6 +31,18 @@ const notes = defineTable("notes", {
31
31
  .using("user_id = (select auth.uid())")
32
32
  .withCheck("user_id = (select auth.uid())"),
33
33
 
34
+ // A NAMED update policy is also what the row's `can` array echoes back:
35
+ // `note.can` contains "notes_edit_own" when THIS caller may edit THIS row
36
+ // (typed from this file). Put it on the wire with
37
+ // `can(publicSchema.tables.notes)` in the response schema — the schema's
38
+ // table definition, not this handle. `can` is for the interface; it cannot be used for an
39
+ // authorization decision; it may be incomplete; the server decides again
40
+ // on every request.
41
+ policy("notes_edit_own")
42
+ .for("update")
43
+ .to("authenticated")
44
+ .using("user_id = (select auth.uid())"),
45
+
34
46
  // MODERATION, when you want it — delete these two if you do not.
35
47
  //
36
48
  // Roles are yours and there is no built-in "admin": you declare them from
File without changes