@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.
- package/dist/bin/palbase-backend.cjs +79 -26
- package/dist/bin/palbase-backend.cjs.map +1 -1
- package/dist/bin/palbase-backend.js +5 -5
- package/dist/{chunk-AZJIKCOR.js → chunk-IKDONZ5D.js} +39 -23
- package/dist/{chunk-AZJIKCOR.js.map → chunk-IKDONZ5D.js.map} +1 -1
- package/dist/{chunk-IXAX5CON.js → chunk-SI4KGEM3.js} +1 -1
- package/dist/{chunk-IXAX5CON.js.map → chunk-SI4KGEM3.js.map} +1 -1
- package/dist/{chunk-XEGZ3S2Q.js → chunk-TVCCR6SO.js} +32 -7
- package/dist/chunk-TVCCR6SO.js.map +1 -0
- package/dist/{chunk-5C5UCILO.js → chunk-WWUG2QXF.js} +3 -3
- package/dist/{chunk-KGP6ALIU.js → chunk-XOX6RFPZ.js} +30 -8
- package/dist/chunk-XOX6RFPZ.js.map +1 -0
- package/dist/{chunk-Z4CZRMNF.js → chunk-YIQ4RS4F.js} +2 -2
- package/dist/db/index.cjs +14 -4
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +1 -1
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +3 -3
- package/dist/engine/index.cjs +79 -26
- package/dist/engine/index.cjs.map +1 -1
- package/dist/engine/index.d.cts +3 -3
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +5 -5
- package/dist/{index-BILC9WwS.d.ts → index-D-3duy8Y.d.ts} +2 -2
- package/dist/{index-DgYkdNT9.d.cts → index-DB_nW-AV.d.cts} +87 -30
- package/dist/{index-CWGiJ2Up.d.cts → index-DLveQoOf.d.cts} +2 -2
- package/dist/{index-BgWnP07a.d.ts → index-DMZG3kpo.d.ts} +87 -30
- package/dist/index.cjs +59 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +5 -5
- package/dist/openapi/index.d.cts +2 -2
- package/dist/openapi/index.d.ts +2 -2
- package/dist/{registry-BcRYIQ-R.d.cts → registry-DSTThhKf.d.cts} +1 -1
- package/dist/{registry-ClzjxIWy.d.ts → registry-JjF5lcj4.d.ts} +1 -1
- package/dist/test/index.cjs +1155 -12
- package/dist/test/index.cjs.map +1 -1
- package/dist/test/index.d.cts +1 -1
- package/dist/test/index.d.ts +1 -1
- package/dist/test/index.js +1126 -8
- package/dist/test/index.js.map +1 -1
- package/docs/README.md +124 -80
- package/docs/auth.md +112 -26
- package/docs/background.md +16 -7
- package/docs/database.md +22 -18
- package/docs/endpoints.md +26 -20
- package/docs/errors.md +2 -3
- package/docs/events.md +63 -29
- package/docs/getting-started.md +8 -7
- package/docs/llms-full.txt +519 -244
- package/docs/migrations.md +6 -5
- package/docs/routing.md +34 -11
- package/docs/schema.md +78 -27
- package/docs/services.md +30 -13
- package/package.json +2 -1
- package/stack-images.json +24 -0
- package/template/db/public.ts +23 -0
- package/template/package.json +1 -1
- package/dist/chunk-KGP6ALIU.js.map +0 -1
- package/dist/chunk-XEGZ3S2Q.js.map +0 -1
- /package/dist/{chunk-5C5UCILO.js.map → chunk-WWUG2QXF.js.map} +0 -0
- /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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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 `
|
|
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`
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
108
|
+
// modules/todos/todo.service.ts — @Injectable() classes. The real work.
|
|
100
109
|
import { Database, Injectable, NotFound } from "@palbase/backend";
|
|
101
|
-
import type {
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
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<
|
|
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<
|
|
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<
|
|
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 "./
|
|
143
|
-
import { TodoService } from "./
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
//
|
|
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 {
|
|
162
|
-
import { TodoSchema } from "
|
|
163
|
-
import { CreateTodoBody } from "
|
|
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
|
|
167
|
-
// The service arrives
|
|
168
|
-
//
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
return
|
|
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
|
-
|
|
178
|
-
return
|
|
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
|
-
|
|
183
|
-
return
|
|
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
|
-
|
|
188
|
-
|
|
222
|
+
remove(@Param("id") id: string, @User() user: UserT): Promise<void> {
|
|
223
|
+
return this.todos.remove(user.id, id);
|
|
189
224
|
}
|
|
190
225
|
}
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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
|
-
|
|
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
|
|
302
|
+
Every handler type reaches the PLATFORM services through the same import:
|
|
266
303
|
|
|
267
304
|
```ts
|
|
268
|
-
import { Database, Log, Cache
|
|
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** (`
|
|
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 —
|
|
283
|
-
|
|
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`.
|
|
287
|
-
the
|
|
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
|
|
294
|
-
├── tsconfig.json
|
|
295
|
-
├──
|
|
296
|
-
│
|
|
297
|
-
├──
|
|
298
|
-
│ └──
|
|
299
|
-
|
|
300
|
-
├──
|
|
301
|
-
├──
|
|
302
|
-
├──
|
|
303
|
-
|
|
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
|
|
311
|
-
(`@Controller("/base")` with `@Get`/`@Post`/… methods)
|
|
312
|
-
`controllers
|
|
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
|
|
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
|
|
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
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
|
204
|
+
return this.posts.publish(user.id);
|
|
119
205
|
}
|
|
120
206
|
```
|
|
121
207
|
|
package/docs/background.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Background Jobs
|
|
2
2
|
|
|
3
|
-
`jobs/` is the background rail
|
|
4
|
-
|
|
5
|
-
"@palbase/backend"`). It does **not** receive
|
|
6
|
-
carries the non-service data (`env`, correlation
|
|
3
|
+
`jobs/` is the background rail, and it stays at the ROOT — a 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.
|
|
20
|
-
|
|
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
|
-
|
|
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 });
|