@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
@@ -24,15 +24,24 @@ deploy gate or the TypeScript compile.
24
24
 
25
25
  ### Folder layout
26
26
 
27
+ Every domain is a FOLDER under `modules/`, carrying its own controller, service,
28
+ schemas, and the one module that lists them:
29
+
27
30
  ```
28
- controllers/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
29
- models/<controller>/<endpoint>.ts # zod schemas one folder per controller, one file per endpoint (+ shared.ts)
30
- services/<name>.service.ts # plain class + singleton — the real logic (controllers stay thin)
31
- db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
31
+ modules/<domain>/<domain>.module.ts # @Module: controllers · providers · exports · imports
32
+ modules/<domain>/<name>.controller.ts # @Controller class + @Get/@Post/… route methods (the API surface)
33
+ modules/<domain>/<name>.service.ts # @Injectable() class — the real logic (controllers stay thin)
34
+ modules/<domain>/dto/<endpoint>.ts # zod schemas — one file per endpoint
35
+ db/<schema>.ts # one file per Postgres schema (db/public.ts in a fresh scaffold)
32
36
  ```
33
37
 
34
- The four folders above are the daily surface. Three more are discovered by name,
35
- one class per file, `export default` required: `jobs/` (background
38
+ There is no root `controllers/`, `services/` or `models/` directory, and no
39
+ `app.module.ts` to mount anything into: a `*.module.ts` is found wherever it
40
+ lives, so adding a domain means adding a folder — never a line in a file every
41
+ branch edits.
42
+
43
+ The schema stays at the root, in `db/`. Three more directories are discovered by
44
+ name, one class per file, `export default` required: `jobs/` (background —
36
45
  [background.md](./background.md)), `webhooks/` and `hooks/` (events —
37
46
  [events.md](./events.md)).
38
47
 
@@ -66,7 +75,7 @@ service the controllers call.
66
75
  + the runtime read that named type to bind the 200 response zod schema. There
67
76
  is no `@Returns` decorator. A body route with no named return type is a build
68
77
  error; a method with no response body annotates `: Promise<void>` / `: void`.
69
- 5. **Schemas live in `models/<controller>/<endpoint>.ts`,** exported as a zod
78
+ 5. **Schemas live in `modules/<domain>/dto/<endpoint>.ts`,** exported as a zod
70
79
  VALUE and a same-named `z.infer` TYPE. The controller imports them and writes
71
80
  `@Body(CreateTodoBody) body: CreateTodoBody` (value in the decorator, same name
72
81
  as the type annotation).
@@ -81,12 +90,12 @@ service the controllers call.
81
90
 
82
91
  > **Never** emit `defineController`, `defineHandler`, `defineEndpoint`, `route.get(...)`,
83
92
  > `req.input`, `req.params`, or `req.errors` — those are the removed legacy model
84
- > and will not compile against `@palbase/backend` 32.
93
+ > and will not compile against `@palbase/backend` 33.
85
94
 
86
95
  ### Complete CRUD example (copy-pasteable, compiles)
87
96
 
88
97
  ```ts
89
- // models/todos/shared.ts — the response shape, reused across endpoints.
98
+ // modules/todos/dto/shared.ts — the response shape, reused across endpoints.
90
99
  import { z } from "@palbase/backend";
91
100
  export const TodoSchema = z.object({
92
101
  id: z.string(),
@@ -97,41 +106,63 @@ export type TodoSchema = z.infer<typeof TodoSchema>;
97
106
  ```
98
107
 
99
108
  ```ts
100
- // models/todos/create.ts — the POST body.
109
+ // modules/todos/dto/create.ts — the POST body.
101
110
  import { z } from "@palbase/backend";
102
111
  export const CreateTodoBody = z.object({ title: z.string().min(1) });
103
112
  export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
104
113
  ```
105
114
 
106
115
  ```ts
107
- // services/todo.service.ts — plain class + singleton. The real work.
116
+ // modules/todos/todo.service.ts — @Injectable() classes. The real work.
108
117
  import { Database, Injectable, NotFound } from "@palbase/backend";
109
- import type { TodoSchema } from "../models/todos/shared.js";
118
+ import type { Tables } from "@palbase/backend/env";
110
119
 
111
- /** The typed surface of ONE table. Naming it keeps the seam one table wide:
112
- * a test fake implements five methods, not the whole `Database`. */
113
- type TodosTable = typeof Database.public.todos;
120
+ /** One row of `todos`, exactly as `db/public.ts` declares it. */
121
+ export type Todo = Tables["todos"]["row"];
122
+
123
+ /** The one table this vertical touches, behind a class the container can build.
124
+ * `Database.public.todos` is a VALUE, and a dependency is named by its
125
+ * parameter's TYPE — so the seam is an `abstract class`, and it stays ONE table
126
+ * wide: a test fake implements four methods, not the whole `Database`. */
127
+ export abstract class TodoRepo {
128
+ // Mirror the table's own call shape: `findMany` takes `{ where, orderBy, … }`,
129
+ // not a bare filter.
130
+ abstract findMany(q: { where: { user_id: string } }): Promise<Todo[]>;
131
+ abstract insert(row: { user_id: string; title: string }): Promise<Todo>;
132
+ abstract findById(id: string): Promise<Todo | null>;
133
+ abstract delete(id: string): Promise<void>;
134
+ }
114
135
 
115
136
  @Injectable()
116
- export class TodoService {
117
- private readonly todos: TodosTable;
118
-
119
- // THE SEAM IS THE CONSTRUCTOR: the class is handed the table rather than
120
- // reaching for the singleton, so a test constructs it with a stand-in and
121
- // never needs a database. Assign in the BODY — a parameter property
122
- // (`constructor(private todos: …)`) is refused by Node's type-stripping test
123
- // runner, and refused for the whole FILE.
124
- constructor(todos: TodosTable) {
125
- this.todos = todos;
137
+ export class DbTodoRepo extends TodoRepo {
138
+ findMany(q: { where: { user_id: string } }): Promise<Todo[]> {
139
+ return Database.public.todos.findMany(q);
140
+ }
141
+ insert(row: { user_id: string; title: string }): Promise<Todo> {
142
+ return Database.public.todos.insert(row);
126
143
  }
144
+ findById(id: string): Promise<Todo | null> {
145
+ return Database.public.todos.findById(id);
146
+ }
147
+ delete(id: string): Promise<void> {
148
+ return Database.public.todos.delete(id);
149
+ }
150
+ }
151
+
152
+ @Injectable()
153
+ export class TodoService {
154
+ // THE SEAM IS THE CONSTRUCTOR: the container supplies this, so a test hands in
155
+ // a stand-in and never needs a database. Nothing registers it and nothing
156
+ // wires it — `todos.module.ts` lists the class, and that list is the ownership.
157
+ constructor(private readonly todos: TodoRepo) {}
127
158
 
128
- list(userId: string): Promise<TodoSchema[]> {
159
+ list(userId: string): Promise<Todo[]> {
129
160
  return this.todos.findMany({ where: { user_id: userId } });
130
161
  }
131
- create(userId: string, title: string): Promise<TodoSchema> {
162
+ create(userId: string, title: string): Promise<Todo> {
132
163
  return this.todos.insert({ user_id: userId, title });
133
164
  }
134
- async get(userId: string, id: string): Promise<TodoSchema> {
165
+ async get(userId: string, id: string): Promise<Todo> {
135
166
  const t = await this.todos.findById(id);
136
167
  if (!t || t.user_id !== userId) throw new NotFound("No todo with that id");
137
168
  return t;
@@ -141,68 +172,74 @@ export class TodoService {
141
172
  await this.todos.delete(id);
142
173
  }
143
174
  }
144
-
145
175
  ```
146
176
 
147
177
  ```ts
148
- // todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
178
+ // modules/todos/todos.module.ts — ONE module says what exists, who owns it, and what it may reach.
149
179
  import { Module, type Token } from "@palbase/backend";
150
- import { TodosController } from "./controllers/todos.controller.ts";
151
- import { TodoService } from "./services/todo.service.ts";
180
+ import { TodosController } from "./todos.controller.ts";
181
+ import { DbTodoRepo, TodoService } from "./todo.service.ts";
152
182
 
153
183
  @Module({
154
184
  controllers: [TodosController as Token],
155
- providers: [TodoService as Token],
185
+ providers: [TodoService as Token, DbTodoRepo as Token],
186
+ exports: [], // which of its own classes ANOTHER module may reach
187
+ imports: [], // whose exports THIS module may reach
156
188
  })
157
189
  export class TodosModule {}
158
190
  ```
159
191
 
160
- A class no module lists does not exist: it is refused at build, by name, and it
161
- never reaches the route table or the OpenAPI document. Nothing is wired by hand
162
- a controller names `TodoService` as a constructor parameter and the container
163
- supplies it. See [services.md](./services.md#your-own-service-layer).
192
+ The abstraction is the TOKEN and the implementation is the PROVIDER: `DbTodoRepo`
193
+ is listed, `TodoRepo` is not the container resolves an abstraction to the single
194
+ class that `extends` it. A class no module lists does not exist: it is refused at
195
+ build, by name, and it never reaches the route table or the OpenAPI document.
196
+ Nothing is wired by hand — a controller names `TodoService` as a constructor
197
+ parameter and the container supplies it. See
198
+ [services.md](./services.md#your-own-service-layer).
164
199
 
165
200
  ```ts
166
- // controllers/todos.controller.ts — class controller. Thin: delegates to the service.
201
+ // modules/todos/todos.controller.ts — class controller. Thin: delegates to the service.
167
202
  import { Controller, Get, Post, Delete, Body, Param, User } from "@palbase/backend";
168
203
  import type { UserT } from "@palbase/backend"; // the user TYPE (@User is the value)
169
- import { todoService } from "../services/todo.service.js";
170
- import { TodoSchema } from "../models/todos/shared.js";
171
- import { CreateTodoBody } from "../models/todos/create.js";
204
+ import { TodoService } from "./todo.service";
205
+ import { TodoSchema } from "./dto/shared";
206
+ import { CreateTodoBody } from "./dto/create";
172
207
 
173
208
  @Controller("/todos") // secure-by-default; { auth: false } opts the whole controller out
174
- export default class TodosController {
175
- // The service arrives as an IMPORTED SINGLETON, not a constructor parameter:
176
- // the runtime constructs this class with no arguments, and one that declares a
177
- // parameter is refused when the route table is built at boot, with the class named.
209
+ export class TodosController {
210
+ // The service arrives through the CONSTRUCTOR and the container supplies it.
211
+ // Nothing is imported as an instance and nothing is wired by hand.
212
+ constructor(private readonly todos: TodoService) {}
178
213
 
179
214
  @Get("") // GET /todos → operationId todos.list
180
- async list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
181
- return todoService.list(user.id);
215
+ list(@User() user: UserT): Promise<TodoSchema[]> { // return type → 200 response schema
216
+ return this.todos.list(user.id);
182
217
  }
183
218
 
184
219
  @Post("") // POST /todos → todos.create
185
- async create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
186
- return todoService.create(user.id, body.title);
220
+ create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
221
+ return this.todos.create(user.id, body.title);
187
222
  }
188
223
 
189
224
  @Get("/{id}") // GET /todos/{id} → todos.get
190
- async get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
191
- return todoService.get(user.id, id);
225
+ get(@Param("id") id: string, @User() user: UserT): Promise<TodoSchema> {
226
+ return this.todos.get(user.id, id);
192
227
  }
193
228
 
194
229
  @Delete("/{id}") // DELETE /todos/{id} → todos.remove; no body → : Promise<void>
195
- async remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
196
- await todoService.remove(user.id, id);
230
+ remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
231
+ return this.todos.remove(user.id, id);
197
232
  }
198
233
  }
199
- // No export is needed at all @Controller registered the class as it decorated
200
- // it. `export default` here is style, not a requirement.
234
+ // Exported by NAME, because `todos.module.ts` imports it to list it in
235
+ // `controllers` that list IS the registration. Nothing here is
236
+ // default-exported.
201
237
  ```
202
238
 
203
239
  ```ts
204
240
  // db/public.ts — config-as-code; the deploy auto-migrates additive changes.
205
241
  // ONE SCHEMA PER FILE: this is `public`; a sibling `db/billing.ts` declares its own.
242
+ // The schema stays at the ROOT — it is not part of any module.
206
243
  import { defineSchema, defineTable, uuid, text, boolean, timestamp, policy, ownedByUser } from "@palbase/backend";
207
244
 
208
245
  export const todos = defineTable("todos", {
@@ -213,8 +250,8 @@ export const todos = defineTable("todos", {
213
250
  completed: boolean().default(false),
214
251
  created_at: timestamp().defaultNow(),
215
252
  },
216
- rls: true, // every Database.* query runs as the request user
217
- policies: [
253
+ // RLS is ON by default, and `policies` is a CALLBACK not an array.
254
+ policies: () => [
218
255
  policy("pb_owner_all")
219
256
  .for("all")
220
257
  .to("authenticated")
@@ -270,54 +307,63 @@ generated client surface) changes; the verb/path do not affect it.
270
307
 
271
308
  ## Mental model
272
309
 
273
- Every handler type imports service singletons the same way:
310
+ Every handler type reaches the PLATFORM services through the same import:
274
311
 
275
312
  ```ts
276
- import { Database, Log, Cache, Queue } from "@palbase/backend";
313
+ import { Database, Log, Cache } from "@palbase/backend";
277
314
  ```
278
315
 
316
+ (Your OWN services are different: a controller names one as a constructor
317
+ parameter and the container supplies it — see the example above.)
318
+
279
319
  The **only difference** is the trigger argument:
280
320
 
281
321
  | You are writing… | Handler signature | Trigger arg |
282
322
  |------------------|-------------------|-------------|
283
- | **Endpoints** (`controllers/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
323
+ | **Endpoints** (`modules/<domain>/` class controllers) | method `(…params)` | parameter decorators `@Body`/`@QueryParams`/`@Param`/`@User`/… — [endpoints.md](./endpoints.md) |
284
324
  | **Jobs** (`jobs/**`) | `(meta)` | `JobMeta` |
285
325
  | **Hooks** (`hooks/**`) | `(event, meta)` | typed event + `HookMeta` |
286
326
  | **Webhooks** (`webhooks/**`) | `(event, meta)` | typed event + `WebhookMeta` |
287
327
 
288
328
  `defineMiddleware(fn)` **throws when called**: no bundler reads a `middleware/`
289
329
  directory and the engine never calls one, so a handler written against it would
290
- deploy and never run. There is no `ctx` anywhere on a path that runs — every
291
- handler above imports its services.
330
+ deploy and never run. There is no `ctx` anywhere on a path that runs — a handler
331
+ imports the platform services it needs.
292
332
 
293
333
  `meta` carries non-service data: `env` (Environment variables),
294
- `environmentId`, and for webhooks `requestId`. Services always come from
295
- the imported singletons — not from `ctx` or any argument.
334
+ `environmentId`, and for webhooks `requestId`. Platform services always come
335
+ from the import — not from `ctx` or any argument.
296
336
 
297
337
  ## Project shape
298
338
 
299
339
  ```
300
340
  my-backend/
301
- ├── package.json # depends on @palbase/backend
302
- ├── tsconfig.json # experimentalDecorators: true (required for the decorators)
303
- ├── controllers/ # @Controller classes: @Get/@Post methods (mount the API)
304
- └── hello.controller.ts # @Controller("/hello") + @Get example
305
- ├── models/<ctrl>/<ep>.ts # zod schemas, folder per controller, file per endpoint
306
- │ └── hello/greet.ts # GreetQuery + HelloResponse (zod value + z.infer type)
307
- ├── services/ # plain classes/singletons your controllers call
308
- ├── db/public.ts # the database itself: tables, columns, RLS policies
309
- ├── jobs/ # cron-scheduled jobs (optional)
310
- ├── hooks/ # auth/storage/document event hooks (optional)
311
- └── webhooks/ # inbound provider webhooks (optional)
341
+ ├── package.json # depends on @palbase/backend
342
+ ├── tsconfig.json # experimentalDecorators + emitDecoratorMetadata (both required)
343
+ ├── modules/ # ONE FOLDER PER DOMAIN there is no root module
344
+ ├── health/
345
+ │ │ ├── health.controller.ts # @Controller("/health", { auth: false }) + @Get
346
+ └── health.module.ts # the four lists that make the class exist
347
+ │ └── notes/
348
+ ├── notes.controller.ts # HTTP only never imports `Database`
349
+ ├── note.service.ts # @Injectable(); the logic worth testing
350
+ ├── note.service.test.ts # the service, through the container, no database
351
+ │ ├── notes.module.ts # controllers · providers · exports · imports
352
+ │ └── dto/create.ts # zod schemas (value + same-named z.infer type)
353
+ ├── db/public.ts # the database itself: tables, columns, RLS policies
354
+ ├── jobs/ # cron-scheduled jobs (optional)
355
+ ├── hooks/ # auth/storage/document event hooks (optional)
356
+ └── webhooks/ # inbound provider webhooks (optional)
312
357
  ```
313
358
 
314
359
  There is **no `db/migrations/`**. Nothing generates a migration file, nothing
315
360
  commits one and nothing replays one: `db/*.ts` is diffed against the live
316
361
  database and applied — see [migrations.md](./migrations.md).
317
362
 
318
- HTTP endpoints are **not** file-path routed. You author a class controller
319
- (`@Controller("/base")` with `@Get`/`@Post`/… methods); putting it under
320
- `controllers/` mounts it. See [routing.md](./routing.md).
363
+ HTTP endpoints are **not** file-path routed, and no directory mounts them. You
364
+ author a class controller (`@Controller("/base")` with `@Get`/`@Post`/… methods)
365
+ and a `@Module` lists it in `controllers` that list is the registration. See
366
+ [routing.md](./routing.md).
321
367
 
322
368
  ## Documentation
323
369
 
@@ -336,8 +382,6 @@ HTTP endpoints are **not** file-path routed. You author a class controller
336
382
 
337
383
  For AI coding tools: a single concatenated corpus is generated at
338
384
  [`llms-full.txt`](./llms-full.txt) (and an index at [`llms.txt`](./llms.txt)).
339
- </content>
340
- </invoke>
341
385
 
342
386
 
343
387
 
@@ -378,7 +422,8 @@ The controllers use **decorators**, so the `tsconfig.json` must set
378
422
  "experimentalDecorators": true,
379
423
  "noEmit": true
380
424
  },
381
- "include": ["controllers/**/*.ts", "models/**/*.ts", "services/**/*.ts", "db/**/*.ts", "*.d.ts"]
425
+ "include": ["**/*.ts"],
426
+ "exclude": ["node_modules", ".palbase"]
382
427
  }
383
428
  ```
384
429
 
@@ -388,7 +433,7 @@ Your backend runs on Palbase, not on your laptop — there is no local runtime t
388
433
  start. The loop is: edit, validate, push to a dev Environment.
389
434
 
390
435
  - `palbase build` — validate the tree the way the deploy will. It stages and
391
- bundles your `controllers/` exactly as the deploy does and runs the deploy's
436
+ bundles your `modules/` exactly as the deploy does and runs the deploy's
392
437
  own metadata extractor over the result, so a bad decorator, an illegal return
393
438
  type or an SDK major skew fails here rather than shipping a deploy that
394
439
  serves zero endpoints. It is wired into a `pre-push` git hook for you. It also
@@ -402,10 +447,10 @@ start. The loop is: edit, validate, push to a dev Environment.
402
447
 
403
448
  ## Your first endpoint
404
449
 
405
- An endpoint is a method on a class controller. Declare the schemas in `models/`:
450
+ An endpoint is a method on a class controller. Everything for one domain lives in `modules/<domain>/`; declare the schemas in its `dto/`:
406
451
 
407
452
  ```ts
408
- // models/hello/greet.ts
453
+ // modules/hello/dto/greet.ts
409
454
  import { z } from "@palbase/backend";
410
455
 
411
456
  export const GreetQuery = z.object({ name: z.string().optional() });
@@ -415,13 +460,13 @@ export const HelloResponse = z.object({ message: z.string(), user: z.string().nu
415
460
  export type HelloResponse = z.infer<typeof HelloResponse>;
416
461
  ```
417
462
 
418
- Then write the controller in `controllers/hello.controller.ts`:
463
+ Then write the controller in `modules/hello/hello.controller.ts`:
419
464
 
420
465
  ```ts
421
466
  import { Controller, Get, QueryParams, OptionalUser } from "@palbase/backend";
422
467
  import type { UserT } from "@palbase/backend";
423
- import { GreetQuery } from "../models/hello/greet.js";
424
- import type { HelloResponse } from "../models/hello/greet.js"; // the return TYPE names the 200 schema
468
+ import { GreetQuery } from "./dto/greet.js";
469
+ import type { HelloResponse } from "./dto/greet.js"; // the return TYPE names the 200 schema
425
470
 
426
471
  @Controller("/hello", { auth: false })
427
472
  export default class HelloController {
@@ -461,8 +506,11 @@ Routes are declared in code with **class controllers**. A controller is a class
461
506
  decorated with `@Controller(basePath)`; each route is a method decorated with
462
507
  `@Get`/`@Post`/`@Put`/`@Patch`/`@Delete`/`@Query`. Request input + context are
463
508
  injected into the method via **parameter decorators** (`@Body`/`@QueryParams`/
464
- `@Param`/`@User`/…). Putting a controller file under `controllers/` mounts it
465
- there is no central router and no manual registration.
509
+ `@Param`/`@User`/…). A controller is mounted by being LISTED: its module names
510
+ it in `controllers`, and that list is the only place ownership is decided. A
511
+ class no module lists does not exist — the build refuses it by name, and it
512
+ never reaches the route table or the OpenAPI document. Being in a particular
513
+ folder grants nothing.
466
514
 
467
515
  ```ts
468
516
  import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/backend";
@@ -471,31 +519,51 @@ import { Controller, Get, Post, Body, QueryParams, Param, User } from "@palbase/
471
519
  ## Controllers — class + method decorators
472
520
 
473
521
  `@Controller(basePath)` marks the class and sets the mount path. Each route
474
- method declares its verb + subpath; the real work lives in a `services/` class
475
- (the controller method is thin).
522
+ method declares its verb + subpath; the real work lives in an `@Injectable()`
523
+ service that arrives through the CONSTRUCTOR (the controller method is thin).
476
524
 
477
525
  ```ts
478
- // controllers/places.controller.ts
526
+ // modules/places/places.controller.ts
479
527
  import { Controller, Get, Post, Body, User } from "@palbase/backend";
480
528
  import type { UserT } from "@palbase/backend";
481
- import { placeService } from "../services/place.service.js";
482
- import { ImportNearbyBody } from "../models/places/import.js";
483
- import type { PlaceSchema } from "../models/places/shared.js"; // the return TYPE names the 200 schema
529
+ import { PlaceService } from "./place.service.js";
530
+ import { ImportNearbyBody } from "./dto/import.js";
531
+ import { PlaceSchema } from "./dto/shared.js"; // the return TYPE names the 200 schema
484
532
 
485
533
  @Controller("/places")
486
- export default class PlacesController {
534
+ export class PlacesController {
535
+ constructor(private readonly places: PlaceService) {}
536
+
487
537
  @Post("/import")
488
538
  importNearby(@Body(ImportNearbyBody) body: ImportNearbyBody, @User() user: UserT): PlaceSchema {
489
- return placeService.importNearby(body.lat, body.lng, user.id);
539
+ return this.places.importNearby(body.lat, body.lng, user.id);
490
540
  }
491
541
 
492
542
  @Get("/favorites", { auth: false })
493
543
  listFavorites(): PlaceSchema[] {
494
- return placeService.listFavorites();
544
+ return this.places.listFavorites();
495
545
  }
496
546
  }
497
547
  ```
498
548
 
549
+ The class is mounted by being LISTED, and nothing else mounts it:
550
+
551
+ ```ts
552
+ // modules/places/places.module.ts
553
+ import { Module, type Token } from "@palbase/backend";
554
+
555
+ import { PlacesController } from "./places.controller.js";
556
+ import { PlaceService } from "./place.service.js";
557
+
558
+ @Module({
559
+ controllers: [PlacesController as Token],
560
+ providers: [PlaceService as Token],
561
+ exports: [],
562
+ imports: [],
563
+ })
564
+ export class PlacesModule {}
565
+ ```
566
+
499
567
  | Method name | Verb | Full path | operationId (dotted) |
500
568
  |---|---|---|---|
501
569
  | `importNearby` | POST | `/places/import` | `places.importNearby` |
@@ -533,36 +601,41 @@ options, the parameter decorators, auth cascade, and error classes).
533
601
 
534
602
  An endpoint is a **method on a class controller** — a class decorated with
535
603
  `@Controller(basePath)` whose methods are decorated with `@Get`/`@Post`/`@Put`/
536
- `@Patch`/`@Delete`/`@Query`. Controller files live under `controllers/` and are
537
- mounted automatically (see [routing.md](./routing.md)). Request input + context
538
- are injected into the method via **parameter decorators** (`@Body`/`@QueryParams`/
539
- `@Param`/`@User`/…), each piece direct no `req` god-object. Services are reached via the
540
- imported singletons (see [services.md](./services.md)).
604
+ `@Patch`/`@Delete`/`@Query`. A controller lives in its domain's folder
605
+ (`modules/<domain>/<name>.controller.ts`) and a `@Module` lists it in
606
+ `controllers` that list is the registration, not the directory (see
607
+ [routing.md](./routing.md)). Request input + context are injected into the method
608
+ via **parameter decorators** (`@Body`/`@QueryParams`/`@Param`/`@User`/…), each
609
+ piece direct — no `req` god-object. Your own services arrive through the
610
+ CONSTRUCTOR and the container supplies them (see [services.md](./services.md)).
541
611
 
542
612
  ## A controller
543
613
 
544
614
  ```ts
545
- // controllers/rooms.controller.ts
615
+ // modules/rooms/rooms.controller.ts
546
616
  import { Controller, Get, Post, Body, Param, User } from "@palbase/backend";
547
617
  import type { UserT } from "@palbase/backend";
548
- import { roomService } from "../services/room.service.js";
549
- import { CreateRoomBody } from "../models/rooms/create.js";
550
- import type { RoomSchema } from "../models/rooms/shared.js"; // the return TYPE names the 200 schema
618
+ import { RoomService } from "./room.service";
619
+ import { CreateRoomBody } from "./dto/create";
620
+ import { RoomSchema } from "./dto/shared"; // the return TYPE names the 200 schema
551
621
 
552
622
  // A controller does not import `Database`. Everything here is HTTP: validate the
553
623
  // body through a named schema, name the 200 shape as the return type, delegate.
554
624
  // Which rows, whose, in what order is the service's job — and the service is the
555
625
  // thing worth testing, because it is the thing that can be wrong.
556
626
  @Controller("/rooms")
557
- export default class RoomsController {
627
+ export class RoomsController {
628
+ // The service arrives through the constructor; `rooms.module.ts` lists both.
629
+ constructor(private readonly rooms: RoomService) {}
630
+
558
631
  @Post("")
559
632
  create(@Body(CreateRoomBody) body: CreateRoomBody, @User() user: UserT): Promise<RoomSchema> {
560
- return roomService.create(user.id, body);
633
+ return this.rooms.create(user.id, body);
561
634
  }
562
635
 
563
636
  @Get("/{id}")
564
637
  getOne(@Param("id") id: string): Promise<RoomSchema> {
565
- return roomService.get(id);
638
+ return this.rooms.get(id);
566
639
  }
567
640
  }
568
641
  ```
@@ -572,13 +645,14 @@ export default class RoomsController {
572
645
  1. **The controller does not touch the database.** It delegates to a service, as
573
646
  above. A method here that reaches for `Database` has moved the logic into the
574
647
  layer that is hardest to test. (Export the class by NAME so its module can
575
- list it in `controllers`; a class no module lists is refused at build with
576
- `unowned class`. Nothing here is default-exported.)
648
+ import it and list it in `controllers`; a class no module lists is refused at
649
+ build with `unowned class`. Nothing here is default-exported.)
577
650
  2. **A method that awaits a service is `async` + `Promise<T>`.** `Database`
578
651
  returns promises, so a body that `await`s it cannot have a sync return type
579
- (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above are
580
- `async` and return `Promise<RoomSchema>`. (A pure method that returns a literal
581
- with no `await` may stay synchronous.)
652
+ (`: RoomSchema` on an `async` body is a `tsc` error). Both methods above hand
653
+ the service's promise straight back, so they need no `await` — but they still
654
+ return `Promise<RoomSchema>`. (A pure method that returns a literal and touches
655
+ no promise may stay synchronous.)
582
656
 
583
657
  ## Method decorators
584
658
 
@@ -604,8 +678,8 @@ a query string — complex search/filter endpoints that would otherwise be a
604
678
 
605
679
  ```ts
606
680
  @Query("/search")
607
- async search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
608
- return todoService.search(body);
681
+ search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
682
+ return this.todos.search(body);
609
683
  }
610
684
  ```
611
685
 
@@ -652,7 +726,7 @@ out. Resolution order (most specific wins):
652
726
 
653
727
  ```ts
654
728
  @Controller("/public", { auth: false }) // all routes default public
655
- export default class PublicController {
729
+ export class PublicController {
656
730
  @Get("/open") open(): Info { ... } // inherits → public
657
731
  @Get("/secret", { auth: true }) // OVERRIDES → authed
658
732
  secret(@User() u: UserT): Secret { ... }
@@ -710,17 +784,20 @@ user.
710
784
  ```ts
711
785
  import { Controller, Get, Post, Body, User, OptionalUser } from "@palbase/backend";
712
786
  import type { UserT } from "@palbase/backend";
787
+ import { TodoService } from "./todo.service";
713
788
 
714
789
  @Controller("/todos") // no auth option → every route needs a user
715
- export default class TodosController {
790
+ export class TodosController {
791
+ constructor(private readonly todos: TodoService) {}
792
+
716
793
  @Post("")
717
794
  create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT) {
718
- return todoService.create(user.id, body.title); // user is non-null — guaranteed
795
+ return this.todos.create(user.id, body.title); // user is non-null — guaranteed
719
796
  }
720
797
 
721
798
  @Get("/featured", { auth: false }) // one public route
722
799
  featured(@OptionalUser() user: UserT | null) {
723
- return todoService.featured(user?.id ?? null); // may be null — handle it
800
+ return this.todos.featured(user?.id ?? null); // may be null — handle it
724
801
  }
725
802
  }
726
803
  ```
@@ -728,11 +805,12 @@ export default class TodosController {
728
805
  ## What `@User()` gives you
729
806
 
730
807
  ```ts
731
- interface User {
808
+ interface UserT { // exported as `UserT`; the value name `User` is the decorator
732
809
  id: string;
733
810
  email?: string; // absent for phone-only users
734
811
  emailVerified: boolean;
735
- role: string;
812
+ role: string; // the DATABASE role — always "authenticated" when signed in
813
+ roles: string[]; // your APPLICATION roles, read per request
736
814
  metadata: Record<string, unknown>;
737
815
  device: VerifiedDevice | null;
738
816
  }
@@ -741,35 +819,117 @@ interface User {
741
819
  Every field is **server-resolved** from the verified profile — nothing here is
742
820
  client-settable. `metadata` is your own `auth.users.metadata` (set through the
743
821
  admin users API); `role` is the **database** role RLS reads and is always
744
- `"authenticated"` for a signed-in user, so application roles belong in
745
- `metadata`, not there.
822
+ `"authenticated"` for a signed-in user, so `user.role === "admin"` is always
823
+ false and reads like a working check.
824
+
825
+ Application roles are **`user.roles`** — a `string[]` resolved per request from
826
+ `auth.user_roles`, the table `palbase roles` writes. They were once kept in
827
+ `metadata`; they are not any more, and a role written there gates nothing.
828
+
829
+ `emailVerified`, `email`, `role` and `metadata` come from the **verified token**,
830
+ so they say what was true when it was minted: a user who confirms their address
831
+ mid-session keeps reporting `false` until their next token. `roles` is the
832
+ exception, and deliberately so — it is read from the table on every request, so
833
+ a grant or a revocation lands immediately.
834
+
835
+ ## Roles and permissions
836
+
837
+ Roles are **yours**. There is no built-in "admin": you declare the roles your
838
+ app has, and what each one is allowed to do, from the command line —
746
839
 
747
- `emailVerified` is likewise read from the verified profile rather than a JWT
748
- claim: a claim is only true as of when the token was minted, so a user who
749
- verifies mid-session would keep reporting `false` until their token expired.
750
- Profile reads are cached for ~30 seconds, so a change shows up within that
751
- window, not on the very next request.
840
+ ```bash
841
+ palbase roles create member --default --permissions notes.create
842
+ palbase roles create moderator --permissions notes.create,notes.delete_any
843
+ palbase roles assign usr_01a0… moderator
844
+ ```
845
+
846
+ — and `palbase spec` brings them back as constants, so a misspelled permission
847
+ is a compile error rather than a silent `403`:
848
+
849
+ ```ts
850
+ import { Roles, Permissions } from "./palbe.gen";
851
+ Roles.moderator // 'moderator'
852
+ Permissions.notesDeleteAny // 'notes.delete_any'
853
+ ```
752
854
 
753
- ## Roles
855
+ `--default` marks the one role every new sign-up receives, written in the same
856
+ transaction as the user row.
754
857
 
755
- `auth: { role: "admin" }` gates on the caller's `metadata.role`:
858
+ ### The endpoint gate
756
859
 
757
860
  ```ts
758
- @Controller("/admin", { auth: { role: "admin" } })
759
- export default class AdminController {
760
- @Get("/stats")
761
- stats(): Promise<Stats> { … } // only metadata.role === "admin" reaches here
861
+ @Controller("/notes")
862
+ export class NotesController {
863
+ // Anyone signed in.
864
+ @Get("") list(@User() user: UserT) { … }
865
+
866
+ // Only a caller holding the permission — the handler never runs otherwise.
867
+ @Delete("/{id}", { auth: { permission: "notes.delete_any" } })
868
+ remove(@Param("id") id: string) { … }
869
+
870
+ // Or by role, when the check really is "is this caller a moderator".
871
+ @Get("/queue", { auth: { role: "moderator" } })
872
+ queue() { … }
762
873
  }
763
874
  ```
764
875
 
765
- Not signed in → `401`. Signed in with a different (or missing) role `403`. A
766
- role gate implies authentication, so the caller is resolved even on a route with
767
- no `@User()` parameter.
876
+ Not signed in → `401`: declaring a permission or a role implies authentication,
877
+ so the caller is resolved even on a route with no `@User()` parameter. Signed in
878
+ without it → `403`, and the body names what was missing:
768
879
 
769
- The role is read from the verified profile, not from a claim baked into the
770
- token, so revoking it takes effect **within ~30 seconds** — the runtime caches a
771
- verified identity for that long — rather than whenever the token happens to
772
- expire.
880
+ ```json
881
+ { "error": "forbidden",
882
+ "error_description": "This endpoint requires the \"notes.delete_any\" permission" }
883
+ ```
884
+
885
+ If you deliberately open a route with `auth: { required: false, permission: … }`,
886
+ an unauthenticated caller reaches the gate and is refused with `403` — no
887
+ identity means no roles, and the gate answers that rather than waving them
888
+ through.
889
+
890
+ **Authority is in no claim.** The gate reads `auth.user_roles` inside the
891
+ transaction your handler is about to use — never a JWT claim — so
892
+ `palbase roles revoke` closes the door on the **very next request**, with the
893
+ same access token, no refresh and no re-login. Nothing is cached, and there is
894
+ no epoch to bump.
895
+
896
+ ### The RLS half
897
+
898
+ The gate decides whether the handler runs. It cannot decide which **rows** the
899
+ handler sees — that is Postgres's job, and the same permissions are available
900
+ there:
901
+
902
+ ```ts
903
+ policies: () => [
904
+ policy("notes_owner").for("all").to("authenticated")
905
+ .using("user_id = (select auth.uid())")
906
+ .withCheck("user_id = (select auth.uid())"),
907
+
908
+ // Moderation. Permissive policies are OR'd, so these widen only.
909
+ policy("notes_moderate_read").for("select").to("authenticated")
910
+ .using("(select auth.has_permission('notes.delete_any'))"),
911
+ policy("notes_delete_any").for("delete").to("authenticated")
912
+ .using("(select auth.has_permission('notes.delete_any'))"),
913
+ ]
914
+ ```
915
+
916
+ **The read policy is not optional, and leaving it out is the trap.**
917
+ `DELETE … WHERE id = $1` has to FIND the row first, and that read is governed by
918
+ the SELECT policies. With only the DELETE policy, a moderator holding the
919
+ permission deletes **nothing**: the row is invisible to them, the `WHERE`
920
+ matches nothing, and the statement answers `DELETE 0` — a silent no-op, not an
921
+ error. The rule: **if a permission writes to somebody else's row, a SELECT
922
+ policy has to show them that row.**
923
+
924
+ Two helpers are available in every policy body:
925
+
926
+ | helper | answers |
927
+ |---|---|
928
+ | `auth.has_permission(text)` | does the caller hold this permission, through any role they carry |
929
+ | `auth.app_roles()` | the caller's roles as `text[]`, sorted, `{}` when none |
930
+
931
+ Both take the caller from `auth.uid()` and accept no user id, so a policy cannot
932
+ ask about somebody else.
773
933
 
774
934
  ## Email verification
775
935
 
@@ -817,7 +977,7 @@ publish(@User() user: UserT) {
817
977
  if (!user.emailVerified) {
818
978
  throw new Forbidden("Confirm your email address before publishing.");
819
979
  }
820
- return postService.publish(user.id);
980
+ return this.posts.publish(user.id);
821
981
  }
822
982
  ```
823
983
 
@@ -893,8 +1053,9 @@ jobs, hooks, and webhooks all use the same import:
893
1053
  import { Database } from "@palbase/backend";
894
1054
  ```
895
1055
 
896
- Only **middleware** still uses `ctx.db` (see [background.md](./background.md)
897
- and [events.md](./events.md) for worker/job/hook/webhook examples).
1056
+ There is no `ctx` and no `ctx.db` anywhere on a path that runs — every handler
1057
+ imports `Database` (see [background.md](./background.md) and
1058
+ [events.md](./events.md) for the job/hook/webhook examples).
898
1059
 
899
1060
  ## Typed by default — `Database.<şema>.<tablo>`
900
1061
 
@@ -962,7 +1123,9 @@ await Database.public.crew_presence.findMany({
962
1123
  AND: [sqlFragment`expires_at > now()`],
963
1124
  },
964
1125
  });
965
- ``` The same filter language works for `updateMany`, `deleteMany`,
1126
+ ```
1127
+
1128
+ The same filter language works for `updateMany`, `deleteMany`,
966
1129
  `count` and `search` — one language, so two spellings cannot drift apart. An
967
1130
  empty `in` list means NO rows (written into the SQL as `false`), never a silent
968
1131
  full scan.
@@ -1258,7 +1421,7 @@ filter object built from request input that happened to come back empty — is
1258
1421
  exactly the shape that must not silently succeed. `count()` with no filter IS
1259
1422
  allowed: counting is a read.
1260
1423
 
1261
- ### `Database.query` — raw SQL
1424
+ ### `Database.$query` — raw SQL
1262
1425
 
1263
1426
  Use it for shapes the helpers do not cover (joins, aggregates, window functions).
1264
1427
  It runs in **the request's own transaction**, not a read-only one: a write inside
@@ -1283,7 +1446,7 @@ per row with a `23505` caught around each:
1283
1446
 
1284
1447
  ```ts
1285
1448
  await Database.$transaction((tx) => {
1286
- tx.tables.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
1449
+ tx.public.merchants.insertMany(rows, { onConflict: ["name"], action: "ignore" });
1287
1450
  return null;
1288
1451
  });
1289
1452
  ```
@@ -1340,11 +1503,11 @@ middle, so nothing holds a database connection open while your code thinks.
1340
1503
  import { Database, NotFound } from "@palbase/backend";
1341
1504
 
1342
1505
  const { orderId } = await Database.$transaction((tx) => {
1343
- const order = tx.tables.orders
1506
+ const order = tx.public.orders
1344
1507
  .insert({ amount: 1000, status: "pending" })
1345
1508
  .expectOne(new NotFound("order could not be created"));
1346
1509
 
1347
- tx.tables.order_items.insertMany(
1510
+ tx.public.order_items.insertMany(
1348
1511
  cart.map((line) => ({ order_id: order.id, sku: line.sku })),
1349
1512
  );
1350
1513
 
@@ -1400,7 +1563,7 @@ filter plus an expectation — which is also stronger, because the check and the
1400
1563
  write are now the same statement and nothing can slip between them:
1401
1564
 
1402
1565
  ```ts
1403
- tx.tables.invites
1566
+ tx.public.invites
1404
1567
  .updateWhere({ token, accepted_at: null }, { accepted_at: now() })
1405
1568
  .expectOne(new Conflict("invite already used", "invite_used"));
1406
1569
  ```
@@ -1440,7 +1603,7 @@ await Database.public.accounts.updateMany({
1440
1603
 
1441
1604
  // Inside a plan: the same expression, the same meaning.
1442
1605
  await Database.$transaction((tx) => {
1443
- tx.tables.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
1606
+ tx.public.accounts.updateWhere({ id: accountId }, { balance: decrement("5.00") });
1444
1607
  return null;
1445
1608
  });
1446
1609
  ```
@@ -1546,7 +1709,8 @@ Sometimes you need to read or write **across all users** — an admin endpoint,
1546
1709
  background job that fans out notifications, a cleanup task. For that, call
1547
1710
  `Database.$asService()`. It returns a sibling client that runs as
1548
1711
  **`backend_service_role`** (the role that carries `BYPASSRLS`), exposing the
1549
- exact same surface — `tables`, the raw string ops, and `transaction`:
1712
+ exact same surface — `public` (and every other schema), the `$`-prefixed raw ops,
1713
+ and `$transaction`:
1550
1714
 
1551
1715
  ```ts
1552
1716
  import { Database } from "@palbase/backend";
@@ -1555,32 +1719,32 @@ import { Database } from "@palbase/backend";
1555
1719
  const mine = await Database.public.todos.findMany({});
1556
1720
 
1557
1721
  // Service-role bypass: every user's rows. Explicit and greppable.
1558
- const all = await Database.$asService().tables.todos.findMany({});
1559
- const rows = await Database.$asService().query("SELECT count(*) FROM todos");
1722
+ const all = await Database.$asService().public.todos.findMany({});
1723
+ const rows = await Database.$asService().$query("SELECT count(*) FROM todos");
1560
1724
 
1561
1725
  // A service-role transaction (the role is fixed for the whole plan):
1562
- await Database.$asService().transaction((tx) => {
1563
- tx.tables.todos.updateWhere({ id }, { done: true });
1726
+ await Database.$asService().$transaction((tx) => {
1727
+ tx.public.todos.updateWhere({ id }, { done: true });
1564
1728
  return null;
1565
1729
  });
1566
1730
  ```
1567
1731
 
1568
1732
  Guidelines:
1569
1733
 
1570
- - **Be explicit.** Prefer the default `Database.*` and reach for `asService()`
1734
+ - **Be explicit.** Prefer the default `Database.*` and reach for `$asService()`
1571
1735
  only where you genuinely need cross-user access. It is intentionally easy to
1572
1736
  grep for in review.
1573
1737
  - **No double-bypass / no nesting.** The sibling does not re-expose
1574
- `asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
1738
+ `$asService()`, and `tx` never exposes it — a plan's role is fixed for the whole
1575
1739
  transaction. Use `Database.$transaction(...)` for an authenticated one and
1576
- `Database.$asService().transaction(...)` for a service-role one; you cannot mix
1740
+ `Database.$asService().$transaction(...)` for a service-role one; you cannot mix
1577
1741
  enforced and bypassed operations inside a single plan.
1578
1742
  - **It is a second transaction, on a second connection.** It has to be: the
1579
1743
  Postgres role is bound once, when the transaction opens, so a sibling sharing
1580
1744
  the request's transaction would run as `backend_authenticated` and silently
1581
1745
  keep filtering. Three consequences worth knowing:
1582
1746
  - The request opens it at most once, however many times you call
1583
- `asService()`, and never at all if you don't use it.
1747
+ `$asService()`, and never at all if you don't use it.
1584
1748
  - Both settle with the request — commit when your handler returns, roll back
1585
1749
  when it throws — but they settle as two transactions. A row written through
1586
1750
  one surface is **not visible** to the other until the request commits.
@@ -1614,9 +1778,10 @@ those values — never a dictionary, because a name in a dictionary key is a sec
1614
1778
  place the name is written, and a table built under a key does not yet know what
1615
1779
  to call itself when a sibling references it.
1616
1780
 
1617
- Each table's only required field is `columns`; `rls` and `policies` enable
1618
- [Row-Level Security](#row-level-security-rls), and `indexes` declares plain
1619
- btree [indexes](#indexes).
1781
+ Each table's only required field is `columns`; `policies` (a callback — see
1782
+ [Row-Level Security](#row-level-security-rls)) declares the RLS rules, `rls`
1783
+ toggles enforcement (default `true`), and `indexes` declares plain btree
1784
+ [indexes](#indexes).
1620
1785
 
1621
1786
  ```ts
1622
1787
  import {
@@ -2042,14 +2207,28 @@ You do **not** wire anything per endpoint. Saving a file under `db/` regenerates
2042
2207
  of the schema, no generic, no cast:
2043
2208
 
2044
2209
  ```ts
2045
- // services/room.service.ts — the layer that touches the database.
2046
- import { Database } from "@palbase/backend";
2210
+ // modules/rooms/room.service.ts — the layer that touches the database.
2211
+ import { Database, Injectable } from "@palbase/backend";
2212
+ import type { Tables } from "@palbase/backend/env";
2213
+
2214
+ type Room = Tables["rooms"]["row"]; // typed from your db/*.ts
2047
2215
 
2048
- type RoomsTable = typeof Database.public.rooms; // typed from your db/*.ts
2216
+ /** `Database.public.rooms` is a VALUE, and a dependency is named by its
2217
+ * parameter's TYPE — so the seam is an `abstract class`, one table wide. */
2218
+ export abstract class RoomRepo {
2219
+ abstract insert(row: { name: string }): Promise<Room>;
2220
+ }
2049
2221
 
2222
+ @Injectable()
2223
+ export class DbRoomRepo extends RoomRepo {
2224
+ insert(row: { name: string }): Promise<Room> {
2225
+ return Database.public.rooms.insert(row); // typed: rooms.nope ← compile error
2226
+ }
2227
+ }
2228
+
2229
+ @Injectable()
2050
2230
  export class RoomService {
2051
- private readonly rooms: RoomsTable;
2052
- constructor(rooms: RoomsTable) { this.rooms = rooms; }
2231
+ constructor(private readonly rooms: RoomRepo) {}
2053
2232
 
2054
2233
  async create(name: string) {
2055
2234
  const room = await this.rooms.insert({ name });
@@ -2057,41 +2236,48 @@ export class RoomService {
2057
2236
  // room.nope ← compile error
2058
2237
  }
2059
2238
  }
2060
-
2061
2239
  ```
2062
2240
 
2063
2241
  ```ts
2064
- // rooms.module.ts
2242
+ // modules/rooms/rooms.module.ts — the four lists that make these classes exist.
2065
2243
  import { Module, type Token } from "@palbase/backend";
2066
- import { RoomsController } from "./controllers/rooms.controller.ts";
2067
- import { RoomService } from "./services/room.service.ts";
2244
+ import { RoomsController } from "./rooms.controller.ts";
2245
+ import { DbRoomRepo, RoomService } from "./room.service.ts";
2068
2246
 
2069
- @Module({ controllers: [RoomsController as Token], providers: [RoomService as Token] })
2247
+ @Module({
2248
+ controllers: [RoomsController as Token],
2249
+ providers: [RoomService as Token, DbRoomRepo as Token],
2250
+ exports: [],
2251
+ imports: [],
2252
+ })
2070
2253
  export class RoomsModule {}
2071
2254
  ```
2072
2255
 
2073
2256
  ```ts
2074
- // controllers/rooms.controller.ts — HTTP only; no `Database` import here.
2257
+ // modules/rooms/rooms.controller.ts — HTTP only; no `Database` import here.
2075
2258
  import { Controller, Post, Body, z } from "@palbase/backend";
2076
- import { RoomService } from "../services/room.service.js";
2259
+ import { RoomService } from "./room.service";
2077
2260
 
2078
2261
  const CreateRoomBody = z.object({ name: z.string() });
2079
2262
  const RoomOut = z.object({ id: z.string(), name: z.string() });
2080
2263
 
2081
2264
  @Controller("/rooms")
2082
- export default class RoomsController {
2265
+ export class RoomsController {
2266
+ constructor(private readonly rooms: RoomService) {}
2267
+
2083
2268
  @Post("")
2084
2269
  // The return type names the 200 schema — `z.infer<typeof RoomOut>` works
2085
2270
  // inline, no separate `export type` needed.
2086
2271
  create(@Body(CreateRoomBody) body: z.infer<typeof CreateRoomBody>): Promise<z.infer<typeof RoomOut>> {
2087
- return roomService.create(body.name);
2272
+ return this.rooms.create(body.name);
2088
2273
  }
2089
2274
  }
2090
2275
  ```
2091
2276
 
2092
- `Database.public.<name>` exposes `insert`, `update(id, data)`, `delete(id)`,
2093
- `findById(id)`, `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
2094
- whose `tx.tables.<name>` is typed from the same schema, but carries plan
2277
+ `Database.public.<name>` exposes `insert(values)`,
2278
+ `update({ where: { id }, set })`, `delete(id)`, `findById(id)`,
2279
+ `findMany(query?)`. `Database.$transaction(fn)` yields a `tx`
2280
+ whose `tx.public.<name>` is typed from the same schema, but carries plan
2095
2281
  operations (`insert`/`insertMany`/`updateWhere`/`deleteWhere`/`select`) rather
2096
2282
  than awaited calls — see [database.md](./database.md#transactions). The raw
2097
2283
  string-keyed ops (`Database.$insert("rooms", …)`, `Database.$query(…)`) are still
@@ -2113,10 +2299,11 @@ missing `WHERE user_id = …` in your handler can no longer leak another user's
2113
2299
  rows — the policy enforces it. This is the recommended way to scope data per
2114
2300
  user.
2115
2301
 
2116
- Add `policies` (and optionally `rls`) to a table. `policies` being non-empty
2117
- implies `rls: true` automatically (a table with policies must have RLS enabled
2118
- or the policies are inert). Set `rls: true` with no policies only as a
2119
- deliberate deny-all intermediate step.
2302
+ Add `policies` to a table a **callback** returning the list, `policies: () =>
2303
+ […]`, not a bare array. RLS defaults to `true`, and a non-empty `policies` forces
2304
+ it on regardless (a table with policies must have RLS enabled or the policies are
2305
+ inert). Write `rls: true` with no policies only as a deliberate deny-all
2306
+ intermediate step.
2120
2307
 
2121
2308
  ### The `policy()` builder
2122
2309
 
@@ -2175,6 +2362,34 @@ once per statement (an initPlan) instead of once per row. `auth.role()` and
2175
2362
  `auth.uid()` is `NULL`, so an `owner = (select auth.uid())` policy matches no
2176
2363
  rows.
2177
2364
 
2365
+ **`auth.has_permission('resource.action')`** answers whether the caller holds
2366
+ that permission through any role they carry, and **`auth.app_roles()`** returns
2367
+ their roles as a sorted `text[]` (`{}` when they hold none). Both read
2368
+ `auth.user_roles` — the table `palbase roles` writes — and both take the caller
2369
+ from `auth.uid()`, so a policy cannot ask about somebody else. A revoked role is
2370
+ gone from the very next statement; there is nothing cached.
2371
+
2372
+ ```ts
2373
+ policies: () => [
2374
+ policy("notes_owner").for("all")
2375
+ .using("user_id = (select auth.uid())")
2376
+ .withCheck("user_id = (select auth.uid())"),
2377
+
2378
+ // Moderation. Permissive policies are OR'd, so these widen only.
2379
+ policy("notes_moderate_read").for("select")
2380
+ .using("(select auth.has_permission('notes.delete_any'))"),
2381
+ policy("notes_delete_any").for("delete")
2382
+ .using("(select auth.has_permission('notes.delete_any'))"),
2383
+ ],
2384
+ ```
2385
+
2386
+ > **The read policy is not optional.** `DELETE … WHERE id = $1` has to FIND the
2387
+ > row first, and that read is governed by the SELECT policies. With only the
2388
+ > DELETE policy above, a moderator holding the permission deletes **nothing** —
2389
+ > the row is invisible to them, so the `WHERE` matches nothing and the statement
2390
+ > answers `DELETE 0`. No error, no log. If a permission writes to somebody
2391
+ > else's row, a SELECT policy has to show them that row.
2392
+
2178
2393
  > Name policies with a `pb_` prefix. Palbase reconciliation only manages
2179
2394
  > policies it authored (`pb_`-prefixed) and never touches policies created by
2180
2395
  > other modules (storage, cron, …).
@@ -2195,8 +2410,8 @@ export const todos = defineTable("todos", {
2195
2410
  done: boolean().default(false),
2196
2411
  created_at: timestamp().defaultNow(),
2197
2412
  },
2198
- // `policies` non-empty ⇒ RLS is enabled + FORCEd automatically.
2199
- policies: [
2413
+ // `policies` is a CALLBACK. Non-empty ⇒ RLS is enabled + FORCEd automatically.
2414
+ policies: () => [
2200
2415
  // Read: a user sees only their own todos.
2201
2416
  policy("pb_todos_owner_select")
2202
2417
  .for("select")
@@ -2328,7 +2543,7 @@ Code and schema move at different speeds, so three things keep them together:
2328
2543
  ## Your schema change is not live anywhere you have not applied it
2329
2544
 
2330
2545
  The local stack and every Environment hold their own database, and editing
2331
- Editing `db/*.ts` changes none of them: `palbase db apply` is what moves the local
2546
+ `db/*.ts` changes none of them: `palbase db apply` is what moves the local
2332
2547
  one, `palbase push` is what moves an Environment's. Until then the declaration is
2333
2548
  ahead of the tables.
2334
2549
 
@@ -2403,10 +2618,11 @@ existing data gets there.
2403
2618
 
2404
2619
  ## Row-Level Security
2405
2620
 
2406
- Add `rls: true` + `policies: [policy(...)]` to a table in `db/*.ts`; the
2407
- generated migration emits the `ENABLE ROW LEVEL SECURITY` + `CREATE POLICY` DDL.
2408
- See [schema.md](./schema.md) for the column builders, the policy DSL, and typed
2409
- `Database.public.*` access.
2621
+ Add `policies: () => [policy(...)]` to a table in `db/*.ts` — a CALLBACK, not an
2622
+ array — and the generated migration emits the `ENABLE ROW LEVEL SECURITY` +
2623
+ `CREATE POLICY` DDL. RLS is on by default, so `rls: true` is only worth writing
2624
+ as a deliberate deny-all with no policies. See [schema.md](./schema.md) for the
2625
+ column builders, the policy DSL, and typed `Database.public.*` access.
2410
2626
 
2411
2627
  ### Hand-writing a policy
2412
2628
 
@@ -2448,7 +2664,7 @@ no such safety net — write it the first time.
2448
2664
  **Two different things are called "services" here, and this page is the second
2449
2665
  one.** Read the first paragraph before searching this page for a layout rule.
2450
2666
 
2451
- - **YOUR service layer** — `services/<name>.service.ts`, the plain classes your
2667
+ - **YOUR service layer** — `modules/<domain>/<name>.service.ts`, the `@Injectable()` classes your
2452
2668
  own business logic lives in. The scaffold ships one, and the contract is at
2453
2669
  the bottom of this page: [Your own service layer](#your-own-service-layer).
2454
2670
  - **THE service singletons** — the platform objects you import from
@@ -2471,9 +2687,26 @@ turns out to be empty — it fails at the import.
2471
2687
  a channel, but cannot subscribe (a stateless request can't hold a socket).
2472
2688
  Subscription lives on the client SDK (`pb.realtime`).
2473
2689
 
2690
+ `Auth` is available here for exactly one thing: **granting and revoking roles**.
2691
+ Signing in, signing up and session handling run on the client SDK — a backend
2692
+ handler never holds a user's credentials — but assignment is server work, so
2693
+ `assignRole`, `revokeRole` and `rolesOf` are on this side:
2694
+
2695
+ ```ts
2696
+ import { Auth } from "@palbase/backend";
2697
+
2698
+ await Auth.assignRole(userId, "moderator"); // takes effect on the next request
2699
+ await Auth.revokeRole(userId, "moderator"); // and so does this
2700
+ const held = await Auth.rolesOf(userId); // string[]
2701
+ ```
2702
+
2703
+ A role that does not exist throws `RoleNotDefined` naming it — declare it first
2704
+ with `palbase roles create`. A failed read throws rather than answering `[]`:
2705
+ "nobody could say" and "holds nothing" are different claims, and a handler that
2706
+ confuses them denies a user everything they hold.
2707
+
2474
2708
  **Not available to backend handlers** (do not import them here): Functions, CMS,
2475
- Links, Analytics, and Auth. Auth runs on the client SDK; the others are out of
2476
- scope for backend endpoints.
2709
+ Links and Analytics out of scope for backend endpoints.
2477
2710
 
2478
2711
  ## Cache
2479
2712
 
@@ -2603,7 +2836,7 @@ Writes mirror the `Database` / `Database.$asService()` model:
2603
2836
  - `Flags.setOverride(key, value)` (default) writes an override for the **current
2604
2837
  request user** — no `userId` argument, no admin power. It errors on an
2605
2838
  anonymous request (no signed-in user).
2606
- - `Flags.asService()` returns the cross-user admin surface
2839
+ - `Flags.$asService()` returns the cross-user admin surface
2607
2840
  (`setOverrideForUser`, `setOverridesForUser`, `clearOverrideForUser`,
2608
2841
  `clearAllOverridesForUser`, `batchSetOverrides`) for writing overrides for an
2609
2842
  **arbitrary** user. Explicit and greppable, just like `Database.$asService()`.
@@ -2612,8 +2845,8 @@ Writes mirror the `Database` / `Database.$asService()` model:
2612
2845
  // Current request user — no userId needed:
2613
2846
  await Flags.setOverride("new-checkout", true);
2614
2847
 
2615
- // Cross-user admin write — explicit target, via asService():
2616
- await Flags.asService().setOverrideForUser("user_123", "new-checkout", true);
2848
+ // Cross-user admin write — explicit target, via $asService():
2849
+ await Flags.$asService().setOverrideForUser("user_123", "new-checkout", true);
2617
2850
  ```
2618
2851
 
2619
2852
  ## Realtime
@@ -2664,8 +2897,8 @@ to drive live chat, presence, dashboards, and other push features.
2664
2897
  ## Your own service layer
2665
2898
 
2666
2899
  The singletons above are what a service CALLS. This section is the layer that
2667
- calls them — `services/<name>.service.ts`, which the scaffold ships a working
2668
- example of (`services/note.service.ts` and the controller that uses it).
2900
+ calls them — `modules/<domain>/<name>.service.ts`, which the scaffold ships a working
2901
+ example of (`modules/notes/note.service.ts` and the controller that uses it).
2669
2902
 
2670
2903
  The contract is three rules, and the scaffold's own test enforces all three:
2671
2904
 
@@ -2680,7 +2913,7 @@ it.** Mark the class `@Injectable()` and name what it needs as ordinary
2680
2913
  constructor parameters:
2681
2914
 
2682
2915
  ```ts
2683
- // services/note.service.ts
2916
+ // modules/notes/note.service.ts
2684
2917
  import { Database, Injectable } from "@palbase/backend";
2685
2918
 
2686
2919
  type NotesTable = typeof Database.public.notes;
@@ -2696,7 +2929,7 @@ export class NoteService {
2696
2929
  ```
2697
2930
 
2698
2931
  ```ts
2699
- // services/report.service.ts — a service that depends on another service
2932
+ // modules/reports/report.service.ts — a service that depends on another service
2700
2933
  import { Injectable } from "@palbase/backend";
2701
2934
  import { NoteService } from "./note.service.ts";
2702
2935
 
@@ -2713,7 +2946,7 @@ export class ReportService {
2713
2946
  A controller asks the same way, and nothing wires it by hand:
2714
2947
 
2715
2948
  ```ts
2716
- // controllers/notes.controller.ts
2949
+ // modules/notes/notes.controller.ts
2717
2950
  import { Controller, Get } from "@palbase/backend";
2718
2951
  import { NoteService } from "../services/note.service.ts";
2719
2952
 
@@ -2755,7 +2988,7 @@ never reaches the route table, the dispatcher or the OpenAPI document.
2755
2988
  ```ts
2756
2989
  // notes.module.ts — beside the domain it owns, not in a directory we name
2757
2990
  import { Module, type Token } from "@palbase/backend";
2758
- import { NotesController } from "./controllers/notes.controller.ts";
2991
+ import { NotesController } from "./modules/notes/notes.controller.ts";
2759
2992
  import { NoteService } from "./services/note.service.ts";
2760
2993
  import { ReportService } from "./services/report.service.ts";
2761
2994
 
@@ -2808,7 +3041,7 @@ NoteService())` is ordinary TypeScript, and the container is not required for it
2808
3041
  to work.
2809
3042
 
2810
3043
  ```ts
2811
- // services/note.service.test.ts — `npm test`, no database
3044
+ // modules/notes/note.service.test.ts — `npm test`, no database
2812
3045
  import { test } from "node:test";
2813
3046
  import assert from "node:assert/strict";
2814
3047
 
@@ -2872,9 +3105,8 @@ Palbase error envelope:
2872
3105
  { "error": "todo_not_found", "error_description": "No such todo", "status": 404, "request_id": "req_…" }
2873
3106
  ```
2874
3107
 
2875
- Throw anywhere — in a controller method OR in a `services/` class. No `req`, no
2876
- per-route error map: the runtime catches any thrown error class and emits the
2877
- envelope.
3108
+ Throw anywhere — in a controller method OR in a service. No `req`, no per-route
3109
+ error map: the runtime catches any thrown error class and emits the envelope.
2878
3110
 
2879
3111
  The runtime refuses some requests before a handler runs, and those refusals use
2880
3112
  the same envelope and the same codes as the classes below — so a client decodes
@@ -2933,10 +3165,11 @@ HttpError) … }` matches any of them.
2933
3165
 
2934
3166
  # Background Jobs
2935
3167
 
2936
- `jobs/` is the background rail. A job uses the **singleton model** the same
2937
- imported service singletons as endpoints (`import { Database, Log } from
2938
- "@palbase/backend"`). It does **not** receive a `req`; a small `meta` argument
2939
- carries the non-service data (`env`, correlation ids).
3168
+ `jobs/` is the background rail, and it stays at the ROOTa job is not part of
3169
+ any module. It reaches the platform services through the same import as everything
3170
+ else (`import { Database, Log } from "@palbase/backend"`). It does **not** receive
3171
+ a `req`; a small `meta` argument carries the non-service data (`env`, correlation
3172
+ ids).
2940
3173
 
2941
3174
  > **There is no queue.** `Queue.push` and `defineWorker` existed in earlier
2942
3175
  > versions and never ran: nothing consumed the queue, so a push returned a job id
@@ -2949,14 +3182,22 @@ carries the non-service data (`env`, correlation ids).
2949
3182
 
2950
3183
  ## Jobs (cron-scheduled)
2951
3184
 
2952
- A job runs on a cron schedule. File lives under `jobs/` the job's name is
2953
- the file name, there is no `name` option.
3185
+ A job runs on a cron schedule. The file lives under `jobs/`, one class per file,
3186
+ `export default` required.
3187
+
3188
+ **`name` is required and DECLARED.** It is the job's identity — the row the
3189
+ scheduler holds it under — so it is not taken from the file name any more:
3190
+ renaming the file would otherwise start a different job. Lowercase letters,
3191
+ digits and dashes.
2954
3192
 
2955
3193
  ```ts
2956
3194
  // jobs/cleanup.ts
2957
3195
  import { Database, Job, Log, type JobMeta } from "@palbase/backend";
2958
3196
 
2959
- @Job({ schedule: "0 3 * * *", timeout: 120 }) // schedule: standard cron; timeout: optional, seconds
3197
+ // name: the scheduler's identity; schedule: standard 5-field cron;
3198
+ // timeout: optional seconds (default 30, ceiling 300);
3199
+ // retry: optional, how many times a FAILED run is retried (default 5, ceiling 10).
3200
+ @Job({ name: "cleanup", schedule: "0 3 * * *", timeout: 120 })
2960
3201
  export default class CleanupJob {
2961
3202
  async run(meta: JobMeta) {
2962
3203
  const expired = await Database.$findMany("sessions", { expired: true });
@@ -2975,53 +3216,87 @@ system-initiated).
2975
3216
 
2976
3217
  # Hooks & Webhooks
2977
3218
 
2978
- Like jobs, hooks and webhooks use the **singleton model** the same
2979
- imported service singletons as endpoints (`import { Database, Log } from
2980
- "@palbase/backend"`). They do **not** receive a `req`. A second `meta` argument
2981
- carries the non-service data (`env`, `environmentId`; webhooks also
2982
- get `requestId`).
3219
+ Like jobs, `hooks/` and `webhooks/` stay at the ROOTneither is part of a
3220
+ module and both reach the platform services through the same import as
3221
+ everything else (`import { Database, Log } from "@palbase/backend"`). They do
3222
+ **not** receive a `req`. A second `meta` argument carries the non-service data
3223
+ (`env`, `environmentId`; webhooks also get `requestId`).
2983
3224
 
2984
3225
  ## Hooks (platform events)
2985
3226
 
2986
- React to auth, storage, and document events. Files live under `hooks/`. Builders
2987
- are imported from `@palbase/backend`: `auth`, `storage`, `documents`.
3227
+ React to events this stack raises — auth, storage, documents. Files live under
3228
+ `hooks/`, one **default-exported class** per file, and the handlers are methods
3229
+ carrying a decorator. There are no builder functions to import.
3230
+
3231
+ Two decorators, and the difference is whether the handler can stop the event:
3232
+
3233
+ | | Where the event comes from | Can it block? |
3234
+ | --- | --- | --- |
3235
+ | `@Hook("before.…")` | this stack | **Yes** — `throw` cancels the operation |
3236
+ | `@On("after.…")` | this stack | No — a monitor; a throw reaches only the log |
3237
+ | `@Webhook` + `@On` | an OUTSIDE service | No — see the next section |
2988
3238
 
2989
3239
  ```ts
2990
3240
  // hooks/auth.ts
2991
- import { auth, Database, Log } from "@palbase/backend";
2992
-
2993
- export const onUserCreated = auth.onUserCreated(async (event, meta) => {
2994
- Log.info(`new user: ${event.user.email}`);
2995
- await Database.$insert("profiles", {
2996
- user_id: event.user.id,
2997
- email: event.user.email,
2998
- });
2999
- });
3241
+ import { Database, Deny, Hook, Log, On } from "@palbase/backend";
3242
+ import type { AuthHookEvent, HookMeta } from "@palbase/backend";
3243
+
3244
+ export default class AuthHooks {
3245
+ // The gate. A throw CANCELS the signup and the reason reaches the caller.
3246
+ @Hook("before.user.create")
3247
+ async gate(event: AuthHookEvent, _meta: HookMeta): Promise<void> {
3248
+ if ((event.user?.email ?? "").endsWith("@blocked.test")) {
3249
+ throw new Deny("this address is not accepted");
3250
+ }
3251
+ }
3000
3252
 
3001
- export const onSignIn = auth.onSignIn(async (event, meta) => {
3002
- Log.info(`sign in: ${event.user.email} via ${event.provider}`);
3003
- });
3253
+ // The monitor. It runs after the fact, so a throw here is logged and the
3254
+ // session is not un-created.
3255
+ @On("after.login.failed")
3256
+ async record(event: AuthHookEvent, meta: HookMeta): Promise<void> {
3257
+ Log.info(`failed login for ${event.user?.email ?? "(no email)"} req=${meta.requestId}`);
3258
+ await Database.$insert("login_failures", { email: event.user?.email ?? null });
3259
+ }
3260
+ }
3004
3261
  ```
3005
3262
 
3006
- `meta` shape: `{ env, environmentId }`. Environment variables are in
3007
- `meta.env`; services come from the imported singletons.
3263
+ `Deny` is exported for exactly this: any throw denies, but `Deny` carries the
3264
+ reason deliberately instead of surfacing whatever a stray `TypeError` said. The
3265
+ engine is fail-closed — an unreachable hook denies — so keep a blocking handler
3266
+ narrow.
3008
3267
 
3009
- Available hook builders: `auth.onUserCreated`, `auth.onSignIn`, `auth.onSignOut`,
3010
- `auth.onPasswordReset`, `storage.onFileUploaded`, `storage.onFileDeleted`,
3011
- `documents.onDocumentCreated`, `documents.onDocumentUpdated`,
3012
- `documents.onDocumentDeleted`.
3268
+ `meta` shape: `{ env, environmentId, requestId? }`. Environment variables are in
3269
+ `meta.env`; the platform services (`Database`, `Log`, …) are imported as
3270
+ singletons, never passed in.
3271
+
3272
+ > **Warning:** `auth`, `storage` and `documents` builder objects —
3273
+ > `auth.onUserCreated(handler)`, `storage.onFileUploaded(handler)` and friends —
3274
+ > **were removed in 26.0.0** and this page taught them until 33.0.0. They were
3275
+ > not merely renamed: nothing in the runtime or the bundler ever read the record
3276
+ > they returned, so a project that wrote one got a handler that was **never
3277
+ > called and no error said so**. The types went with them for the same reason —
3278
+ > `UserCreatedEvent` promised `user.role`, `user.metadata` and `user.createdAt`
3279
+ > and the auth engine sends none of the three. If you have such a file, the
3280
+ > import now fails to build, which is the point.
3013
3281
 
3014
3282
  ## Webhooks (inbound provider events)
3015
3283
 
3016
3284
  Receive and verify webhooks from third-party providers. Files live under
3017
- `webhooks/` the URL is `POST /webhooks/<file-name>` (e.g. `webhooks/stripe.ts`
3018
- → `POST /webhooks/stripe`); there is no `path` option.
3285
+ `webhooks/`, one class per file, `export default` required.
3286
+
3287
+ **`name` is required and DECLARED.** It is the path segment the webhook is served
3288
+ at — `POST /webhooks/<name>` — so `name: "stripe"` answers at
3289
+ `POST /webhooks/stripe`. It is not taken from the file name any more: a PUBLIC
3290
+ URL, the one a sender is configured with, belongs beside the provider and the
3291
+ secret rather than in the file system. Lowercase letters, digits and dashes.
3019
3292
 
3020
3293
  ```ts
3021
3294
  // webhooks/stripe.ts
3022
3295
  import { Database, Log, On, Webhook, type WebhookMeta } from "@palbase/backend";
3023
3296
 
3024
- @Webhook({ provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } }) // signing secret resolved from env
3297
+ // name: the path segment (POST /webhooks/stripe); secret: the signing secret,
3298
+ // resolved by the runtime from the named env var.
3299
+ @Webhook({ name: "stripe", provider: "stripe", secret: { env: "STRIPE_WEBHOOK_SECRET" } })
3025
3300
  export default class StripeWebhook {
3026
3301
  @On("checkout.session.completed")
3027
3302
  async checkoutCompleted(event: unknown, meta: WebhookMeta) {