@palbase/backend 32.0.0 → 33.0.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 (63) hide show
  1. package/dist/bin/palbase-backend.cjs +79 -26
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +5 -5
  4. package/dist/{chunk-AZJIKCOR.js → chunk-IKDONZ5D.js} +39 -23
  5. package/dist/{chunk-AZJIKCOR.js.map → chunk-IKDONZ5D.js.map} +1 -1
  6. package/dist/{chunk-IXAX5CON.js → chunk-SI4KGEM3.js} +1 -1
  7. package/dist/{chunk-IXAX5CON.js.map → chunk-SI4KGEM3.js.map} +1 -1
  8. package/dist/{chunk-XEGZ3S2Q.js → chunk-TVCCR6SO.js} +32 -7
  9. package/dist/chunk-TVCCR6SO.js.map +1 -0
  10. package/dist/{chunk-5C5UCILO.js → chunk-WWUG2QXF.js} +3 -3
  11. package/dist/{chunk-KGP6ALIU.js → chunk-XOX6RFPZ.js} +30 -8
  12. package/dist/chunk-XOX6RFPZ.js.map +1 -0
  13. package/dist/{chunk-Z4CZRMNF.js → chunk-YIQ4RS4F.js} +2 -2
  14. package/dist/db/index.cjs +14 -4
  15. package/dist/db/index.cjs.map +1 -1
  16. package/dist/db/index.d.cts +1 -1
  17. package/dist/db/index.d.ts +1 -1
  18. package/dist/db/index.js +3 -3
  19. package/dist/engine/index.cjs +79 -26
  20. package/dist/engine/index.cjs.map +1 -1
  21. package/dist/engine/index.d.cts +3 -3
  22. package/dist/engine/index.d.ts +3 -3
  23. package/dist/engine/index.js +5 -5
  24. package/dist/{index-BILC9WwS.d.ts → index-D-3duy8Y.d.ts} +2 -2
  25. package/dist/{index-DgYkdNT9.d.cts → index-DB_nW-AV.d.cts} +87 -30
  26. package/dist/{index-CWGiJ2Up.d.cts → index-DLveQoOf.d.cts} +2 -2
  27. package/dist/{index-BgWnP07a.d.ts → index-DMZG3kpo.d.ts} +87 -30
  28. package/dist/index.cjs +59 -12
  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 +5 -5
  33. package/dist/openapi/index.d.cts +2 -2
  34. package/dist/openapi/index.d.ts +2 -2
  35. package/dist/{registry-BcRYIQ-R.d.cts → registry-DSTThhKf.d.cts} +1 -1
  36. package/dist/{registry-ClzjxIWy.d.ts → registry-JjF5lcj4.d.ts} +1 -1
  37. package/dist/test/index.cjs +1155 -12
  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 +1126 -8
  42. package/dist/test/index.js.map +1 -1
  43. package/docs/README.md +124 -80
  44. package/docs/auth.md +112 -26
  45. package/docs/background.md +16 -7
  46. package/docs/database.md +22 -18
  47. package/docs/endpoints.md +26 -20
  48. package/docs/errors.md +2 -3
  49. package/docs/events.md +63 -29
  50. package/docs/getting-started.md +8 -7
  51. package/docs/llms-full.txt +519 -244
  52. package/docs/migrations.md +6 -5
  53. package/docs/routing.md +34 -11
  54. package/docs/schema.md +78 -27
  55. package/docs/services.md +30 -13
  56. package/package.json +2 -1
  57. package/stack-images.json +24 -0
  58. package/template/db/public.ts +23 -0
  59. package/template/package.json +1 -1
  60. package/dist/chunk-KGP6ALIU.js.map +0 -1
  61. package/dist/chunk-XEGZ3S2Q.js.map +0 -1
  62. /package/dist/{chunk-5C5UCILO.js.map → chunk-WWUG2QXF.js.map} +0 -0
  63. /package/dist/{chunk-Z4CZRMNF.js.map → chunk-YIQ4RS4F.js.map} +0 -0
package/docs/README.md CHANGED
@@ -16,15 +16,24 @@ deploy gate or the TypeScript compile.
16
16
 
17
17
  ### Folder layout
18
18
 
19
+ Every domain is a FOLDER under `modules/`, carrying its own controller, service,
20
+ schemas, and the one module that lists them:
21
+
19
22
  ```
20
- controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
21
- models/<controller>/<endpoint>.ts # zod schemas one folder per controller, one file per endpoint (+ shared.ts)
22
- services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
23
- db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
23
+ modules/<domain>/<domain>.module.ts # @Module: controllers · providers · exports · imports
24
+ modules/<domain>/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
25
+ modules/<domain>/<name>.service.ts # @Injectable() class — the real logic (controllers stay thin)
26
+ modules/<domain>/dto/<endpoint>.ts # zod schemas — one file per endpoint
27
+ db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
24
28
  ```
25
29
 
26
- The four folders above are the daily surface. Three more are discovered by name,
27
- one class per file, `export default` required: `jobs/` (background
30
+ There is no root `controllers/`, `services/` or `models/` directory, and no
31
+ `app.module.ts` to mount anything into: a `*.module.ts` is found wherever it
32
+ lives, so adding a domain means adding a folder — never a line in a file every
33
+ branch edits.
34
+
35
+ The schema stays at the root, in `db/`. Three more directories are discovered by
36
+ name, one class per file, `export default` required: `jobs/` (background —
28
37
  [background.md](./background.md)), `webhooks/` and `hooks/` (events —
29
38
  [events.md](./events.md)).
30
39
 
@@ -58,7 +67,7 @@ service the controllers call.
58
67
  + the runtime read that named type to bind the 200 response zod schema. There
59
68
  is no `@Returns` decorator. A body route with no named return type is a build
60
69
  error; a method with no response body annotates `: Promise<void>` / `: void`.
61
- 5. **Schemas live in `models/<controller>/<endpoint>.ts`,** exported as a zod
70
+ 5. **Schemas live in `modules/<domain>/dto/<endpoint>.ts`,** exported as a zod
62
71
  VALUE and a same-named `z.infer` TYPE. The controller imports them and writes
63
72
  `@Body(CreateTodoBody) body: CreateTodoBody` (value in the decorator, same name
64
73
  as the type annotation).
@@ -73,12 +82,12 @@ service the controllers call.
73
82
 
74
83
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
75
84
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
76
- > and will not compile against `@palbase/backend` 32.
85
+ > and will not compile against `@palbase/backend` 33.
77
86
 
78
87
  ### Complete CRUD example (copy-pasteable, compiles)
79
88
 
80
89
  ```ts
81
- // models/todos/shared.ts — the response shape, reused across endpoints.
90
+ // modules/todos/dto/shared.ts — the response shape, reused across endpoints.
82
91
  import { z } from "@palbase/backend";
83
92
  export const TodoSchema = z.object({
84
93
  id: z.string(),
@@ -89,41 +98,63 @@ export type TodoSchema = z.infer<typeof TodoSchema>;
89
98
  ```
90
99
 
91
100
  ```ts
92
- // models/todos/create.ts — the POST body.
101
+ // modules/todos/dto/create.ts — the POST body.
93
102
  import { z } from "@palbase/backend";
94
103
  export const CreateTodoBody = z.object({ title: z.string().min(1) });
95
104
  export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
96
105
  ```
97
106
 
98
107
  ```ts
99
- // services/todo.service.ts — plain class + singleton. The real work.
108
+ // modules/todos/todo.service.ts — @Injectable() classes. The real work.
100
109
  import { Database, Injectable, NotFound } from "@palbase/backend";
101
- import type { TodoSchema } from "../models/todos/shared.js";
110
+ import type { Tables } from "@palbase/backend/env";
111
+
112
+ /** One row of `todos`, exactly as `db/public.ts` declares it. */
113
+ export type Todo = Tables["todos"]["row"];
114
+
115
+ /** The one table this vertical touches, behind a class the container can build.
116
+ * `Database.public.todos` is a VALUE, and a dependency is named by its
117
+ * parameter's TYPE — so the seam is an `abstract class`, and it stays ONE table
118
+ * wide: a test fake implements four methods, not the whole `Database`. */
119
+ export abstract class TodoRepo {
120
+ // Mirror the table's own call shape: `findMany` takes `{ where, orderBy, … }`,
121
+ // not a bare filter.
122
+ abstract findMany(q: { where: { user_id: string } }): Promise<Todo[]>;
123
+ abstract insert(row: { user_id: string; title: string }): Promise<Todo>;
124
+ abstract findById(id: string): Promise<Todo | null>;
125
+ abstract delete(id: string): Promise<void>;
126
+ }
102
127
 
103
- /** The typed surface of ONE table. Naming it keeps the seam one table wide:
104
- * a test fake implements five methods, not the whole `Database`. */
105
- type TodosTable = typeof Database.public.todos;
128
+ @Injectable()
129
+ export class DbTodoRepo extends TodoRepo {
130
+ findMany(q: { where: { user_id: string } }): Promise<Todo[]> {
131
+ return Database.public.todos.findMany(q);
132
+ }
133
+ insert(row: { user_id: string; title: string }): Promise<Todo> {
134
+ return Database.public.todos.insert(row);
135
+ }
136
+ findById(id: string): Promise<Todo | null> {
137
+ return Database.public.todos.findById(id);
138
+ }
139
+ delete(id: string): Promise<void> {
140
+ return Database.public.todos.delete(id);
141
+ }
142
+ }
106
143
 
107
144
  @Injectable()
108
145
  export class TodoService {
109
- private readonly todos: TodosTable;
110
-
111
- // THE SEAM IS THE CONSTRUCTOR: the class is handed the table rather than
112
- // reaching for the singleton, so a test constructs it with a stand-in and
113
- // never needs a database. Assign in the BODY — a parameter property
114
- // (`constructor(private todos: …)`) is refused by Node's type-stripping test
115
- // runner, and refused for the whole FILE.
116
- constructor(todos: TodosTable) {
117
- this.todos = todos;
118
- }
146
+ // THE SEAM IS THE CONSTRUCTOR: the container supplies this, so a test hands in
147
+ // a stand-in and never needs a database. Nothing registers it and nothing
148
+ // wires it `todos.module.ts` lists the class, and that list is the ownership.
149
+ constructor(private readonly todos: TodoRepo) {}
119
150
 
120
- list(userId: string): Promise<TodoSchema[]> {
151
+ list(userId: string): Promise<Todo[]> {
121
152
  return this.todos.findMany({ where: { user_id: userId } });
122
153
  }
123
- create(userId: string, title: string): Promise<TodoSchema> {
154
+ create(userId: string, title: string): Promise<Todo> {
124
155
  return this.todos.insert({ user_id: userId, title });
125
156
  }
126
- async get(userId: string, id: string): Promise<TodoSchema> {
157
+ async get(userId: string, id: string): Promise<Todo> {
127
158
  const t = await this.todos.findById(id);
128
159
  if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
129
160
  return t;
@@ -133,68 +164,74 @@ export class TodoService {
133
164
  await this.todos.delete(id);
134
165
  }
135
166
  }
136
-
137
167
  ```
138
168
 
139
169
  ```ts
140
- // todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
170
+ // modules/todos/todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
141
171
  import { Module, type Token } from "@palbase/backend";
142
- import { TodosController } from "./controllers/todos.controller.ts";
143
- import { TodoService } from "./services/todo.service.ts";
172
+ import { TodosController } from "./todos.controller.ts";
173
+ import { DbTodoRepo, TodoService } from "./todo.service.ts";
144
174
 
145
175
  @Module({
146
176
  controllers: [TodosController as Token],
147
- providers: [TodoService as Token],
177
+ providers: [TodoService as Token, DbTodoRepo as Token],
178
+ exports: [], // which of its own classes ANOTHER module may reach
179
+ imports: [], // whose exports THIS module may reach
148
180
  })
149
181
  export class TodosModule {}
150
182
  ```
151
183
 
152
- A class no module lists does not exist: it is refused at build, by name, and it
153
- never reaches the route table or the OpenAPI document. Nothing is wired by hand
154
- a controller names `TodoService` as a constructor parameter and the container
155
- supplies it. See [services.md](./services.md#your-own-service-layer).
184
+ The abstraction is the TOKEN and the implementation is the PROVIDER: `DbTodoRepo`
185
+ is listed, `TodoRepo` is not the container resolves an abstraction to the single
186
+ class that `extends` it. A class no module lists does not exist: it is refused at
187
+ build, by name, and it never reaches the route table or the OpenAPI document.
188
+ Nothing is wired by hand — a controller names `TodoService` as a constructor
189
+ parameter and the container supplies it. See
190
+ [services.md](./services.md#your-own-service-layer).
156
191
 
157
192
  ```ts
158
- // controllers/todos.controller.ts — class controller. Thin: delegates to the service.
193
+ // modules/todos/todos.controller.ts — class controller. Thin: delegates to the service.
159
194
  import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
160
195
  import type { UserT } from "@palbase/backend"; // the user TYPE (@User is the value)
161
- import { todoService } from "../services/todo.service.js";
162
- import { TodoSchema } from "../models/todos/shared.js";
163
- import { CreateTodoBody } from "../models/todos/create.js";
196
+ import { TodoService } from "./todo.service";
197
+ import { TodoSchema } from "./dto/shared";
198
+ import { CreateTodoBody } from "./dto/create";
164
199
 
165
200
  @Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
166
- export default class TodosController {
167
- // The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
168
- // the runtime constructs this class with no arguments, and one that declares a
169
- // parameter is refused when the route table is built at boot, with the class named.
201
+ export class TodosController {
202
+ // The service arrives through the CONSTRUCTOR and the container supplies it.
203
+ // Nothing is imported as an instance and nothing is wired by hand.
204
+ constructor(private readonly todos: TodoService) {}
170
205
 
171
206
  @Get("") // GET /todos → operationId todos.list
172
- async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
173
- return todoService.list(user.id);
207
+ list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
208
+ return this.todos.list(user.id);
174
209
  }
175
210
 
176
211
  @Post("") // POST /todos → todos.create
177
- async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
178
- return todoService.create(user.id, body.title);
212
+ create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
213
+ return this.todos.create(user.id, body.title);
179
214
  }
180
215
 
181
216
  @Get("/{id}") // GET /todos/{id} → todos.get
182
- async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
183
- return todoService.get(user.id, id);
217
+ get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
218
+ return this.todos.get(user.id, id);
184
219
  }
185
220
 
186
221
  @Delete("/{id}") // DELETE /todos/{id} → todos.remove; no body → : Promise<void>
187
- async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
188
- await todoService.remove(user.id, id);
222
+ remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
223
+ return this.todos.remove(user.id, id);
189
224
  }
190
225
  }
191
- // No export is needed at all @Controller registered the class as it decorated
192
- // it. `export default` here is style, not a requirement.
226
+ // Exported by NAME, because `todos.module.ts` imports it to list it in
227
+ // `controllers` that list IS the registration. Nothing here is
228
+ // default-exported.
193
229
  ```
194
230
 
195
231
  ```ts
196
232
  // db/public.ts — config-as-code; the deploy auto-migrates additive changes.
197
233
  // ONE SCHEMA PER FILE: this is `public`; a sibling `db/billing.ts` declares its own.
234
+ // The schema stays at the ROOT — it is not part of any module.
198
235
  import { defineSchema, defineTable, uuid, text, boolean, timestamp, policy, ownedByUser } from "@palbase/backend";
199
236
 
200
237
  export const todos = defineTable("todos", {
@@ -205,8 +242,8 @@ export const todos = defineTable("todos", {
205
242
  completed: boolean().default(false),
206
243
  created_at: timestamp().defaultNow(),
207
244
  },
208
- rls: true, // every Database.* query runs as the request user
209
- policies: [
245
+ // RLS is ON by default, and `policies` is a CALLBACK not an array.
246
+ policies: () => [
210
247
  policy("pb_owner_all")
211
248
  .for("all")
212
249
  .to("authenticated")
@@ -262,54 +299,63 @@ generated client surface) changes; the verb/path do not affect it.
262
299
 
263
300
  ## Mental model
264
301
 
265
- Every handler type imports service singletons the same way:
302
+ Every handler type reaches the PLATFORM services through the same import:
266
303
 
267
304
  ```ts
268
- import { Database, Log, Cache, Queue } from "@palbase/backend";
305
+ import { Database, Log, Cache } from "@palbase/backend";
269
306
  ```
270
307
 
308
+ (Your OWN services are different: a controller names one as a constructor
309
+ parameter and the container supplies it — see the example above.)
310
+
271
311
  The **only difference** is the trigger argument:
272
312
 
273
313
  | You are writing… | Handler signature | Trigger arg |
274
314
  |------------------|-------------------|-------------|
275
- | **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
315
+ | **Endpoints** (`modules/<domain>/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
276
316
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
277
317
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
278
318
  | **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
279
319
 
280
320
  `defineMiddleware(fn)` **throws when called**: no bundler reads a `middleware/`
281
321
  directory and the engine never calls one, so a handler written against it would
282
- deploy and never run. There is no `ctx` anywhere on a path that runs — every
283
- handler above imports its services.
322
+ deploy and never run. There is no `ctx` anywhere on a path that runs — a handler
323
+ imports the platform services it needs.
284
324
 
285
325
  `meta` carries non-service data: `env` (Environment variables),
286
- `environmentId`, and for webhooks `requestId`. Services always come from
287
- the imported singletons — not from `ctx` or any argument.
326
+ `environmentId`, and for webhooks `requestId`. Platform services always come
327
+ from the import — not from `ctx` or any argument.
288
328
 
289
329
  ## Project shape
290
330
 
291
331
  ```
292
332
  my-backend/
293
- ├── package.json # depends on @palbase/backend
294
- ├── tsconfig.json # experimentalDecorators: true (required for the decorators)
295
- ├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
296
- └── hello.controller.ts # @Controller("/hello") + @Get example
297
- ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
298
- │ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
299
- ├── services/ # plain classes/singletons your controllers call
300
- ├── db/public.ts # the database itself: tables, columns, RLS policies
301
- ├── jobs/ # cron-scheduled jobs (optional)
302
- ├── hooks/ # auth/storage/document event hooks (optional)
303
- └── webhooks/ # inbound provider webhooks (optional)
333
+ ├── package.json # depends on @palbase/backend
334
+ ├── tsconfig.json # experimentalDecorators + emitDecoratorMetadata (both required)
335
+ ├── modules/ # ONE FOLDER PER DOMAIN there is no root module
336
+ ├── health/
337
+ │ │ ├── health.controller.ts # @Controller("/health", { auth: false }) + @Get
338
+ └── health.module.ts # the four lists that make the class exist
339
+ │ └── notes/
340
+ ├── notes.controller.ts # HTTP only never imports `Database`
341
+ ├── note.service.ts # @Injectable(); the logic worth testing
342
+ ├── note.service.test.ts # the service, through the container, no database
343
+ │ ├── notes.module.ts # controllers · providers · exports · imports
344
+ │ └── dto/create.ts # zod schemas (value + same-named z.infer type)
345
+ ├── db/public.ts # the database itself: tables, columns, RLS policies
346
+ ├── jobs/ # cron-scheduled jobs (optional)
347
+ ├── hooks/ # auth/storage/document event hooks (optional)
348
+ └── webhooks/ # inbound provider webhooks (optional)
304
349
  ```
305
350
 
306
351
  There is **no `db/migrations/`**. Nothing generates a migration file, nothing
307
352
  commits one and nothing replays one: `db/*.ts` is diffed against the live
308
353
  database and applied — see [migrations.md](./migrations.md).
309
354
 
310
- HTTP endpoints are **not** file-path routed. You author a class controller
311
- (`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
312
- `controllers/` mounts it. See [routing.md](./routing.md).
355
+ HTTP endpoints are **not** file-path routed, and no directory mounts them. You
356
+ author a class controller (`@Controller("/base")` with `@Get`/`@Post`/… methods)
357
+ and a `@Module` lists it in `controllers` that list is the registration. See
358
+ [routing.md](./routing.md).
313
359
 
314
360
  ## Documentation
315
361
 
@@ -328,5 +374,3 @@ HTTP endpoints are **not** file-path routed. You author a class controller
328
374
 
329
375
  For AI coding tools: a single concatenated corpus is generated at
330
376
  [`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
331
- </content>
332
- </invoke>
package/docs/auth.md CHANGED
@@ -8,17 +8,20 @@ user.
8
8
  ```ts
9
9
  import { Controller, Get, Post, Body, User, OptionalUser } from "@palbase/backend";
10
10
  import type { UserT } from "@palbase/backend";
11
+ import { TodoService } from "./todo.service";
11
12
 
12
13
  @Controller("/todos") // no auth option → every route needs a user
13
- export default class TodosController {
14
+ export class TodosController {
15
+ constructor(private readonly todos: TodoService) {}
16
+
14
17
  @Post("")
15
18
  create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT) {
16
- return todoService.create(user.id, body.title); // user is non-null — guaranteed
19
+ return this.todos.create(user.id, body.title); // user is non-null — guaranteed
17
20
  }
18
21
 
19
22
  @Get("/featured", { auth: false }) // one public route
20
23
  featured(@OptionalUser() user: UserT | null) {
21
- return todoService.featured(user?.id ?? null); // may be null — handle it
24
+ return this.todos.featured(user?.id ?? null); // may be null — handle it
22
25
  }
23
26
  }
24
27
  ```
@@ -26,11 +29,12 @@ export default class TodosController {
26
29
  ## What `@User()` gives you
27
30
 
28
31
  ```ts
29
- interface User {
32
+ interface UserT { // exported as `UserT`; the value name `User` is the decorator
30
33
  id: string;
31
34
  email?: string; // absent for phone-only users
32
35
  emailVerified: boolean;
33
- role: string;
36
+ role: string; // the DATABASE role — always "authenticated" when signed in
37
+ roles: string[]; // your APPLICATION roles, read per request
34
38
  metadata: Record<string, unknown>;
35
39
  device: VerifiedDevice | null;
36
40
  }
@@ -39,35 +43,117 @@ interface User {
39
43
  Every field is **server-resolved** from the verified profile — nothing here is
40
44
  client-settable. `metadata` is your own `auth.users.metadata` (set through the
41
45
  admin users API); `role` is the **database** role RLS reads and is always
42
- `"authenticated"` for a signed-in user, so application roles belong in
43
- `metadata`, not there.
46
+ `"authenticated"` for a signed-in user, so `user.role === "admin"` is always
47
+ false and reads like a working check.
48
+
49
+ Application roles are **`user.roles`** — a `string[]` resolved per request from
50
+ `auth.user_roles`, the table `palbase roles` writes. They were once kept in
51
+ `metadata`; they are not any more, and a role written there gates nothing.
52
+
53
+ `emailVerified`, `email`, `role` and `metadata` come from the **verified token**,
54
+ so they say what was true when it was minted: a user who confirms their address
55
+ mid-session keeps reporting `false` until their next token. `roles` is the
56
+ exception, and deliberately so — it is read from the table on every request, so
57
+ a grant or a revocation lands immediately.
44
58
 
45
- `emailVerified` is likewise read from the verified profile rather than a JWT
46
- claim: a claim is only true as of when the token was minted, so a user who
47
- verifies mid-session would keep reporting `false` until their token expired.
48
- Profile reads are cached for ~30 seconds, so a change shows up within that
49
- window, not on the very next request.
59
+ ## Roles and permissions
50
60
 
51
- ## Roles
61
+ Roles are **yours**. There is no built-in "admin": you declare the roles your
62
+ app has, and what each one is allowed to do, from the command line —
52
63
 
53
- `auth: { role: "admin" }` gates on the caller's `metadata.role`:
64
+ ```bash
65
+ palbase roles create member --default --permissions notes.create
66
+ palbase roles create moderator --permissions notes.create,notes.delete_any
67
+ palbase roles assign usr_01a0… moderator
68
+ ```
69
+
70
+ — and `palbase spec` brings them back as constants, so a misspelled permission
71
+ is a compile error rather than a silent `403`:
54
72
 
55
73
  ```ts
56
- @Controller("/admin", { auth: { role: "admin" } })
57
- export default class AdminController {
58
- @Get("/stats")
59
- stats(): Promise<Stats> { … } // only metadata.role === "admin" reaches here
74
+ import { Roles, Permissions } from "./palbe.gen";
75
+ Roles.moderator // 'moderator'
76
+ Permissions.notesDeleteAny // 'notes.delete_any'
77
+ ```
78
+
79
+ `--default` marks the one role every new sign-up receives, written in the same
80
+ transaction as the user row.
81
+
82
+ ### The endpoint gate
83
+
84
+ ```ts
85
+ @Controller("/notes")
86
+ export class NotesController {
87
+ // Anyone signed in.
88
+ @Get("") list(@User() user: UserT) { … }
89
+
90
+ // Only a caller holding the permission — the handler never runs otherwise.
91
+ @Delete("/{id}", { auth: { permission: "notes.delete_any" } })
92
+ remove(@Param("id") id: string) { … }
93
+
94
+ // Or by role, when the check really is "is this caller a moderator".
95
+ @Get("/queue", { auth: { role: "moderator" } })
96
+ queue() { … }
60
97
  }
61
98
  ```
62
99
 
63
- Not signed in → `401`. Signed in with a different (or missing) role `403`. A
64
- role gate implies authentication, so the caller is resolved even on a route with
65
- no `@User()` parameter.
100
+ Not signed in → `401`: declaring a permission or a role implies authentication,
101
+ so the caller is resolved even on a route with no `@User()` parameter. Signed in
102
+ without it → `403`, and the body names what was missing:
103
+
104
+ ```json
105
+ { "error": "forbidden",
106
+ "error_description": "This endpoint requires the \"notes.delete_any\" permission" }
107
+ ```
108
+
109
+ If you deliberately open a route with `auth: { required: false, permission: … }`,
110
+ an unauthenticated caller reaches the gate and is refused with `403` — no
111
+ identity means no roles, and the gate answers that rather than waving them
112
+ through.
113
+
114
+ **Authority is in no claim.** The gate reads `auth.user_roles` inside the
115
+ transaction your handler is about to use — never a JWT claim — so
116
+ `palbase roles revoke` closes the door on the **very next request**, with the
117
+ same access token, no refresh and no re-login. Nothing is cached, and there is
118
+ no epoch to bump.
119
+
120
+ ### The RLS half
121
+
122
+ The gate decides whether the handler runs. It cannot decide which **rows** the
123
+ handler sees — that is Postgres's job, and the same permissions are available
124
+ there:
125
+
126
+ ```ts
127
+ policies: () => [
128
+ policy("notes_owner").for("all").to("authenticated")
129
+ .using("user_id = (select auth.uid())")
130
+ .withCheck("user_id = (select auth.uid())"),
131
+
132
+ // Moderation. Permissive policies are OR'd, so these widen only.
133
+ policy("notes_moderate_read").for("select").to("authenticated")
134
+ .using("(select auth.has_permission('notes.delete_any'))"),
135
+ policy("notes_delete_any").for("delete").to("authenticated")
136
+ .using("(select auth.has_permission('notes.delete_any'))"),
137
+ ]
138
+ ```
139
+
140
+ **The read policy is not optional, and leaving it out is the trap.**
141
+ `DELETE … WHERE id = $1` has to FIND the row first, and that read is governed by
142
+ the SELECT policies. With only the DELETE policy, a moderator holding the
143
+ permission deletes **nothing**: the row is invisible to them, the `WHERE`
144
+ matches nothing, and the statement answers `DELETE 0` — a silent no-op, not an
145
+ error. The rule: **if a permission writes to somebody else's row, a SELECT
146
+ policy has to show them that row.**
147
+
148
+ Two helpers are available in every policy body:
149
+
150
+ | helper | answers |
151
+ |---|---|
152
+ | `auth.has_permission(text)` | does the caller hold this permission, through any role they carry |
153
+ | `auth.app_roles()` | the caller's roles as `text[]`, sorted, `{}` when none |
66
154
 
67
- The role is read from the verified profile, not from a claim baked into the
68
- token, so revoking it takes effect **within ~30 seconds** — the runtime caches a
69
- verified identity for that long — rather than whenever the token happens to
70
- expire.
155
+ Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
156
+ ask about somebody else.
71
157
 
72
158
  ## Email verification
73
159
 
@@ -115,7 +201,7 @@ publish(@User() user: UserT) {
115
201
  if (!user.emailVerified) {
116
202
  throw new Forbidden("Confirm your email address before publishing.");
117
203
  }
118
- return postService.publish(user.id);
204
+ return this.posts.publish(user.id);
119
205
  }
120
206
  ```
121
207
 
@@ -1,9 +1,10 @@
1
1
  # Background Jobs
2
2
 
3
- `jobs/` is the background rail. A job uses the **singleton model** the same
4
- imported service singletons as endpoints (`import { Database, Log } from
5
- "@palbase/backend"`). It does **not** receive a `req`; a small `meta` argument
6
- carries the non-service data (`env`, correlation ids).
3
+ `jobs/` is the background rail, and it stays at the ROOTa job is not part of
4
+ any module. It reaches the platform services through the same import as everything
5
+ else (`import { Database, Log } from "@palbase/backend"`). It does **not** receive
6
+ a `req`; a small `meta` argument carries the non-service data (`env`, correlation
7
+ ids).
7
8
 
8
9
  > **There is no queue.** `Queue.push` and `defineWorker` existed in earlier
9
10
  > versions and never ran: nothing consumed the queue, so a push returned a job id
@@ -16,14 +17,22 @@ carries the non-service data (`env`, correlation ids).
16
17
 
17
18
  ## Jobs (cron-scheduled)
18
19
 
19
- A job runs on a cron schedule. File lives under `jobs/` the job's name is
20
- the file name, there is no `name` option.
20
+ A job runs on a cron schedule. The file lives under `jobs/`, one class per file,
21
+ `export default` required.
22
+
23
+ **`name` is required and DECLARED.** It is the job's identity — the row the
24
+ scheduler holds it under — so it is not taken from the file name any more:
25
+ renaming the file would otherwise start a different job. Lowercase letters,
26
+ digits and dashes.
21
27
 
22
28
  ```ts
23
29
  // jobs/cleanup.ts
24
30
  import { Database, Job, Log, type JobMeta } from "@palbase/backend";
25
31
 
26
- @Job({ schedule: "0 3 * * *", timeout: 120 }) // schedule: standard cron; timeout: optional, seconds
32
+ // name: the scheduler's identity; schedule: standard 5-field cron;
33
+ // timeout: optional seconds (default 30, ceiling 300);
34
+ // retry: optional, how many times a FAILED run is retried (default 5, ceiling 10).
35
+ @Job({ name: "cleanup", schedule: "0 3 * * *", timeout: 120 })
27
36
  export default class CleanupJob {
28
37
  async run(meta: JobMeta) {
29
38
  const expired = await Database.$findMany("sessions", { expired: true });