@palbase/backend 23.1.0 → 24.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 (73) hide show
  1. package/dist/bin/palbase-backend.cjs +400 -47
  2. package/dist/bin/palbase-backend.cjs.map +1 -1
  3. package/dist/bin/palbase-backend.js +4 -5
  4. package/dist/bin/palbase-backend.js.map +1 -1
  5. package/dist/{chunk-OHALWEOG.js → chunk-7Z6MGMXQ.js} +57 -2
  6. package/dist/chunk-7Z6MGMXQ.js.map +1 -0
  7. package/dist/{chunk-RCLNBJCM.js → chunk-H3JAISUY.js} +136 -1
  8. package/dist/chunk-H3JAISUY.js.map +1 -0
  9. package/dist/{chunk-PY7YJDCT.js → chunk-LCL7TUAI.js} +32 -3
  10. package/dist/chunk-LCL7TUAI.js.map +1 -0
  11. package/dist/{chunk-NS5V43YQ.js → chunk-P2Q27SGP.js} +19 -3
  12. package/dist/chunk-P2Q27SGP.js.map +1 -0
  13. package/dist/{chunk-R3KN6RHD.js → chunk-T5IOSOE5.js} +7 -2
  14. package/dist/chunk-T5IOSOE5.js.map +1 -0
  15. package/dist/{chunk-M5MCBWJI.js → chunk-YSQBC2VL.js} +275 -31
  16. package/dist/chunk-YSQBC2VL.js.map +1 -0
  17. package/dist/db/index.cjs +48 -3
  18. package/dist/db/index.cjs.map +1 -1
  19. package/dist/db/index.d.cts +2 -2
  20. package/dist/db/index.d.ts +2 -2
  21. package/dist/db/index.js +2 -2
  22. package/dist/{endpoint-CVWXh6oG.d.ts → endpoint-0_DGBajf.d.ts} +100 -3
  23. package/dist/{endpoint-c9h5jriX.d.cts → endpoint-CcQ1a36a.d.cts} +100 -3
  24. package/dist/engine/index.cjs +389 -34
  25. package/dist/engine/index.cjs.map +1 -1
  26. package/dist/engine/index.d.cts +4 -4
  27. package/dist/engine/index.d.ts +4 -4
  28. package/dist/engine/index.js +4 -4
  29. package/dist/{index-CwAJ7HEe.d.ts → index-BOS_rFBO.d.ts} +125 -21
  30. package/dist/{index-CxeQSfJP.d.cts → index-C84bLgeO.d.cts} +134 -9
  31. package/dist/{index-By8Dle5U.d.cts → index-MoQ31B6M.d.cts} +125 -21
  32. package/dist/{index-BZrJXnVh.d.ts → index-dJNhDZ7j.d.ts} +134 -9
  33. package/dist/index.cjs +166 -5
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +54 -12
  36. package/dist/index.d.ts +54 -12
  37. package/dist/index.js +28 -11
  38. package/dist/index.js.map +1 -1
  39. package/dist/openapi/index.cjs +16 -1
  40. package/dist/openapi/index.cjs.map +1 -1
  41. package/dist/openapi/index.d.cts +6 -4
  42. package/dist/openapi/index.d.ts +6 -4
  43. package/dist/openapi/index.js +6 -7
  44. package/dist/openapi/index.js.map +1 -1
  45. package/dist/{registry-CqPK2Qby.d.cts → registry-1X-skBNu.d.cts} +1 -1
  46. package/dist/{registry-B3niOVYp.d.ts → registry-CEod_5sz.d.ts} +1 -1
  47. package/dist/test/index.cjs +27 -0
  48. package/dist/test/index.cjs.map +1 -1
  49. package/dist/test/index.d.cts +1 -1
  50. package/dist/test/index.d.ts +1 -1
  51. package/dist/test/index.js +27 -0
  52. package/dist/test/index.js.map +1 -1
  53. package/docs/README.md +4 -4
  54. package/docs/database.md +115 -11
  55. package/docs/getting-started.md +5 -4
  56. package/docs/llms-full.txt +385 -89
  57. package/docs/migrations.md +81 -59
  58. package/docs/schema.md +82 -2
  59. package/docs/services.md +98 -9
  60. package/package.json +2 -2
  61. package/template/AGENTS.md +121 -41
  62. package/template/controllers/notes.controller.ts +64 -0
  63. package/template/package.json +1 -1
  64. package/template/services/note.service.ts +74 -0
  65. package/template/tsconfig.json +11 -1
  66. package/dist/chunk-HQRJDARQ.js +0 -90
  67. package/dist/chunk-HQRJDARQ.js.map +0 -1
  68. package/dist/chunk-M5MCBWJI.js.map +0 -1
  69. package/dist/chunk-NS5V43YQ.js.map +0 -1
  70. package/dist/chunk-OHALWEOG.js.map +0 -1
  71. package/dist/chunk-PY7YJDCT.js.map +0 -1
  72. package/dist/chunk-R3KN6RHD.js.map +0 -1
  73. package/dist/chunk-RCLNBJCM.js.map +0 -1
@@ -18,10 +18,19 @@ import { Controller, Get, Post, Patch, Delete, Body, QueryParams, Param, User, z
18
18
 
19
19
  ```
20
20
  controllers/<name>.controller.ts a @Controller class; each method is a route
21
+ services/<name>.service.ts the logic, as a plain class
21
22
  db/schema.ts the database: tables, columns, RLS policies
22
- config/secrets.ts the secrets this backend needs (names only)
23
+ palbase-stack.d.ts GENERATED — the names your stack actually holds
24
+ palbase-env.d.ts GENERATED — your tables, as types
23
25
  ```
24
26
 
27
+ Both generated files are written by `palbase build` and **committed**. They are
28
+ what makes `Secrets.get("X")`, `Flags.isEnabled("y")`, `@Upload({ bucket })` and
29
+ `Database.tables.*` compile: the names come from the stack and the schema, so
30
+ there is no second list in this repo to drift out of date. A project that has
31
+ never run `palbase build` has no valid names and those calls will not compile —
32
+ that is the gate working, not a rough edge. Run it once and commit both files.
33
+
25
34
  Two folders appear as the project grows, and both are conventions the tooling
26
35
  already understands:
27
36
 
@@ -137,7 +146,18 @@ throw new TooManyRequests({ retryAfter: 30 }); // data-first
137
146
 
138
147
  Every route requires a signed-in user by default. `@Controller("/x", { auth: false })`
139
148
  opens all of a class's routes; `@Get("", { auth: true })` overrides it for one.
140
- Resolution is route → controller → default `true`.
149
+
150
+ An application-wide default sits under those two:
151
+
152
+ ```ts
153
+ import { defineDefaultAuth } from "@palbase/backend";
154
+ defineDefaultAuth({ verifiedEmail: true }); // every route, unless it says otherwise
155
+ ```
156
+
157
+ Put that call in `auth.ts` at the project root — the bundler imports that file by
158
+ name, so the declaration cannot be silently left out of the build. Resolution is
159
+ route → controller → application → `true`. Writing the same auth setting on ten
160
+ controllers is how the eleventh one gets forgotten.
141
161
 
142
162
  ## Models — schemas, one file per endpoint
143
163
 
@@ -155,27 +175,53 @@ Shared response schemas go in `models/<controller>/shared.ts`.
155
175
 
156
176
  ## Services — business logic
157
177
 
158
- Plain classes exported as a singleton. No DI container, no decorators.
178
+ Plain classes wired as a module-level singleton. No DI container, no decorators
179
+ — and this is the ONLY supported way to hold a dependency anywhere in this
180
+ runtime. A controller, hook, job or webhook is constructed with **no arguments**;
181
+ one that declares a constructor parameter is refused at build with the class
182
+ named, because there is no injector to fill it and the field would simply be
183
+ `undefined` in production.
159
184
 
160
- ```ts
161
- // services/todo.service.ts
162
- import { Database } from "@palbase/backend";
185
+ `services/note.service.ts` in this scaffold is the worked example. The shape:
163
186
 
164
- export class TodoService {
165
- list(userId: string) {
166
- return Database.tables.todos.findMany({ user_id: userId });
167
- }
168
- create(userId: string, title: string) {
169
- return Database.tables.todos.insert({ user_id: userId, title });
170
- }
187
+ ```ts
188
+ export class NoteService {
189
+ private readonly notes: typeof Database.tables.notes;
190
+ // Assigned in the BODY — `constructor(private notes: …)` is a parameter
191
+ // property, and Node's type-stripping test runner refuses those per FILE.
192
+ constructor(notes: typeof Database.tables.notes) { this.notes = notes; }
193
+ list(userId: string) { return this.notes.findMany({ user_id: userId }); }
171
194
  }
172
195
 
173
- export const todoService = new TodoService();
196
+ /** Controllers import THIS, never the class. */
197
+ export const noteService = new NoteService(Database.tables.notes);
174
198
  ```
175
199
 
176
- `Database.tables.<name>` gives `insert`, `update(id, data)`, `delete(id)`,
177
- `findById(id)`, `findMany(filter?)`, and `Database.transaction(fn)` for a plan
178
- that commits or rolls back as one.
200
+ The constructor is the seam: the class is handed the table rather than reaching
201
+ for the singleton, so a test constructs it with a stand-in and never needs a
202
+ database. `fakeDatabase()` from `@palbase/backend/test` is that stand-in when you
203
+ want the whole surface instead of one table.
204
+
205
+ `Database.tables.<name>` gives:
206
+
207
+ | | |
208
+ |---|---|
209
+ | `insert(row)` · `upsert(row, { onConflict })` | write one |
210
+ | `update(id, data)` · `delete(id)` | write one, by id |
211
+ | `updateMany(where, set)` · `deleteMany(where)` | write every row the filter matches, in ONE statement |
212
+ | `findById(id)` · `findMany(where?, opts?)` | read |
213
+ | `count(where?)` | how many match |
214
+
215
+ `where` takes a plain value for equality or an operator object —
216
+ `{ n: { gte: 3 } }`, `{ id: { in: [...] } }`, also `gt` / `lt` / `lte` / `neq`.
217
+ `opts` takes `{ orderBy: { column, direction }, limit, offset }`, so ordering and
218
+ paging happen in SQL rather than in JavaScript after fetching everything.
219
+ `updateMany`/`deleteMany` REFUSE an empty filter — a whole-table write is not
220
+ something to reach by accident.
221
+
222
+ `Database.transaction(fn)` runs a plan that commits or rolls back as one, and
223
+ `Database.attempt(fn)` opens a savepoint so one failed write does not poison the
224
+ rest of the request.
179
225
 
180
226
  ## The database — declared, not migrated
181
227
 
@@ -194,37 +240,62 @@ RLS is on by default and a table with no policies is deny-all. Add
194
240
  `policies: [policy(...)]` so Postgres enforces ownership — a query that forgets
195
241
  its `where user_id = …` still cannot read another user's rows.
196
242
 
197
- After a schema change, regenerate the typed surface so `Database.tables.*` keeps
198
- matching what is actually there (`palbase --help` names the command).
243
+ After a schema change run `palbase build`: it regenerates BOTH generated files —
244
+ `palbase-env.d.ts` (your tables) and `palbase-stack.d.ts` (the stack's names)
245
+ so `Database.tables.*` keeps matching what is actually there. Commit them.
199
246
 
200
- ## Config as code
247
+ ## Secrets, flags, buckets — read from the stack
201
248
 
202
- `config/*.ts` declares module configuration the same git-authoritative way the
203
- schema does, applied on deploy:
249
+ There is no `config/` directory and nothing to declare in this repo. Settings are
250
+ written to the STACK with the CLI, and the code READS them:
204
251
 
205
- - `config/secrets.ts` — `defineSecrets({ secrets: [secret("NAME", …)] })`
206
- - `config/storage.ts` `defineStorage(...)` buckets
207
- - `config/notifications.ts` `defineNotifications(...)` providers
208
- - `config/flags.ts` — `defineFlags(...)` feature flags
252
+ ```ts
253
+ import { Secrets, Flags } from "@palbase/backend";
254
+ const dsn = await Secrets.get("SENTRY_DSN"); // compiles only if the stack holds it
255
+ if (await Flags.isEnabled("newCheckout")) { … } // same
256
+ ```
257
+
258
+ The name in the quotes is checked by the compiler against `palbase-stack.d.ts`,
259
+ which `palbase build` generates from the stack. A secret's VALUE never enters
260
+ this repository, and you never read `process.env` for one.
261
+
262
+ Writing a setting is a CLI job, not a code job — `palbase --help` names the
263
+ commands (`secret`, `flags`, `storage`, `notifications`, `auth`). Settings have
264
+ one door; a second one is how the two come to disagree.
209
265
 
210
- ### Secrets
266
+ ## Lifecycle — for things that are expensive to build
211
267
 
212
- A secret's **value** never enters this repository. `config/secrets.ts` declares
213
- the NAME and whether it is required; the value lives in the environment's vault
214
- and reaches the process through `Secrets.get("NAME")`:
268
+ Most handlers need nothing here: the SDK's singletons are already pooled. But a
269
+ long-lived object you build yourself needs somewhere to close, and a process that
270
+ never closes one leaks it per deploy:
215
271
 
216
272
  ```ts
217
- import { Secrets } from "@palbase/backend";
218
- const dsn = await Secrets.get("SENTRY_DSN"); // string | null
273
+ import { onStart, onShutdown } from "@palbase/backend";
274
+
275
+ let client: SomeClient | undefined;
276
+ onStart("graph", () => { client = SomeClient.create({ maxPoolSize: 8 }); });
277
+ onShutdown("graph", async () => { await client?.close(); });
219
278
  ```
220
279
 
221
- Declaring it is what turns a missing secret into a refused push that names it,
222
- instead of a 500 on the first request that touches it. Never read
223
- `process.env` for one, and never commit a `.env`.
280
+ The NAME is the first argument, and it is what a failure is reported by — a stack
281
+ trace through an anonymous closure names nothing. `onStart` runs ONCE as the app
282
+ comes up, and a hook that throws refuses the boot rather than letting the app
283
+ serve traffic with a half-built dependency. `onShutdown` hooks run in reverse
284
+ order and are best-effort: one that throws is reported and the rest still run.
224
285
 
225
- Provider certs and keys are the same rule: author them with the guided CLI,
226
- which uploads the file to the vault and writes only the non-secret fields into
227
- `config/notifications.ts`.
286
+ **These hooks run OUTSIDE a request, so no Palbase service is reachable from
287
+ them** `Secrets`, `Database`, `Log` and the rest all throw "accessed outside a
288
+ request scope". If your object needs a secret, build it lazily on first use
289
+ inside a handler instead:
290
+
291
+ ```ts
292
+ let client: SomeClient | undefined;
293
+ async function graph() {
294
+ client ??= SomeClient.create({ url: await Secrets.get("GRAPH_URL") });
295
+ return client;
296
+ }
297
+ onShutdown("graph", async () => { await client?.close(); });
298
+ ```
228
299
 
229
300
  ## Tests
230
301
 
@@ -233,9 +304,18 @@ install and nothing to configure — Node discovers the files and strips the typ
233
304
  itself.
234
305
 
235
306
  Test the **service** layer. Controllers are thin by construction, so the logic
236
- that can be wrong lives in a service, which is a plain class you construct
237
- directly. A service that needs `Database` takes it as a constructor argument
238
- instead of importing the singleton, and the test passes a stand-in.
307
+ that can be wrong lives in a service a plain class you construct directly with
308
+ a stand-in, exactly as `services/note.service.ts` is written. For a stand-in
309
+ covering the whole database surface rather than one table:
310
+
311
+ ```ts
312
+ import { fakeDatabase } from "@palbase/backend/test";
313
+ const { db, queries } = fakeDatabase();
314
+ ```
315
+
316
+ It answers `tables.*` from memory and records the `query` calls made. What it
317
+ does NOT do is enforce RLS, constraints or unique violations — those are real
318
+ database behaviour, and the test that proves them runs against a real one.
239
319
 
240
320
  Node **strips** types, it never emits code, so TypeScript features that require
241
321
  emitted code are refused outright: parameter properties (`constructor(private x: T)`),
@@ -0,0 +1,64 @@
1
+ import { Body, Controller, Delete, Get, NotFound, Param, Post, User, z } from "@palbase/backend";
2
+ import type { UserT } from "@palbase/backend";
3
+
4
+ import { noteService } from "../services/note.service";
5
+
6
+ // The other half of the vertical that starts in `services/note.service.ts`.
7
+ //
8
+ // Everything a controller does here is HTTP: validate the body through a named
9
+ // schema, name the 200 shape as the return type, turn a missing row into a
10
+ // status. The logic — which rows, whose, in what order — lives in the service,
11
+ // which is why the service is the thing worth testing and this class has
12
+ // nothing in it to get wrong.
13
+ //
14
+ // So there is one rule, and it is the whole reason the layer exists: a
15
+ // controller does not import `Database`. If a method here starts reaching for a
16
+ // table, the logic has leaked into the layer that is hardest to test.
17
+ //
18
+ // The dependency is a MODULE-LEVEL SINGLETON, imported. It is not a constructor
19
+ // parameter: the runtime constructs this class with no arguments, and a
20
+ // controller that declares one is refused at build with the class named rather
21
+ // than handed `undefined` in production.
22
+
23
+ export const NoteSchema = z.object({
24
+ id: z.string(),
25
+ user_id: z.string(),
26
+ body: z.string(),
27
+ created_at: z.string(),
28
+ });
29
+ export type NoteSchema = z.infer<typeof NoteSchema>;
30
+
31
+ export const CreateNoteBody = z.object({
32
+ body: z.string().min(1),
33
+ });
34
+ export type CreateNoteBody = z.infer<typeof CreateNoteBody>;
35
+
36
+ // Auth is required unless a route opts out, so `@User()` is non-null here and
37
+ // every method below acts as somebody.
38
+ @Controller("/notes")
39
+ class NotesController {
40
+ @Get("")
41
+ list(@User() user: UserT): Promise<NoteSchema[]> {
42
+ return noteService.list(user.id);
43
+ }
44
+
45
+ @Post("")
46
+ create(@Body(CreateNoteBody) body: CreateNoteBody, @User() user: UserT): Promise<NoteSchema> {
47
+ return noteService.create(user.id, body.body);
48
+ }
49
+
50
+ @Get("/{id}")
51
+ async get(@Param("id") id: string): Promise<NoteSchema> {
52
+ // `null` covers both "no such note" and "not yours" — the second is not
53
+ // distinguishable from the outside on purpose, because telling a caller
54
+ // that a row they may not read exists is itself a leak.
55
+ const note = await noteService.get(id);
56
+ if (!note) throw new NotFound("no note with that id");
57
+ return note;
58
+ }
59
+
60
+ @Delete("/{id}")
61
+ remove(@Param("id") id: string): Promise<void> {
62
+ return noteService.remove(id);
63
+ }
64
+ }
@@ -9,7 +9,7 @@
9
9
  "typecheck": "tsc --noEmit"
10
10
  },
11
11
  "dependencies": {
12
- "@palbase/backend": "^23.0.0"
12
+ "@palbase/backend": "^24.0.0"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^22",
@@ -0,0 +1,74 @@
1
+ import { Database } from "@palbase/backend";
2
+ import type { Tables } from "@palbase/backend/env";
3
+
4
+ // A SERVICE is where the logic lives. It is a plain class — no decorator, no DI
5
+ // container, nothing to register. What makes the layer real is the two lines at
6
+ // the bottom of this file, not the folder name.
7
+ //
8
+ // THE SEAM IS THE CONSTRUCTOR. The class is handed the table it works on rather
9
+ // than reaching for the singleton itself, so a test constructs it with a
10
+ // stand-in and never needs a database:
11
+ //
12
+ // const svc = new NoteService(fakeNotesTable);
13
+ // assert.deepEqual(await svc.list("u_1"), []);
14
+ //
15
+ // The module then wires the real one ONCE, on the last line, and that instance
16
+ // is what a controller imports. That module-level singleton is also the
17
+ // supported way to hold a dependency ANYWHERE in this runtime: a controller,
18
+ // hook, job or webhook is constructed with no arguments, and one that declares
19
+ // a constructor parameter is refused at build with the class named — there is
20
+ // no injector to fill it, so the field would simply be `undefined` in
21
+ // production.
22
+ //
23
+ // `Database.tables.notes` is typed from `db/schema.ts` through the generated
24
+ // `palbase-env.d.ts`, which `palbase build` writes. Before the first build that
25
+ // file does not exist yet and the table is unknown to the type checker; build
26
+ // once and the whole surface below is typed with no import and no generic.
27
+
28
+ /** One row of `notes`, exactly as `db/schema.ts` declares it. */
29
+ export type Note = Tables["notes"]["row"];
30
+
31
+ /** The typed surface of one table: `insert`, `update`, `delete`, `findById`,
32
+ * `findMany`. Naming it here is what keeps the seam ONE table wide — a test
33
+ * fake implements five methods, not the whole `Database`. */
34
+ type NotesTable = typeof Database.tables.notes;
35
+
36
+ export class NoteService {
37
+ private readonly notes: NotesTable;
38
+
39
+ // Assigned in the BODY, never as a parameter property (`constructor(private
40
+ // notes: NotesTable)`). `npm test` runs Node's type-stripping test runner,
41
+ // which refuses parameter properties — and refuses them for the whole FILE,
42
+ // so one of them anywhere in a test's import graph reads like a dozen broken
43
+ // tests and is one keyword.
44
+ constructor(notes: NotesTable) {
45
+ this.notes = notes;
46
+ }
47
+
48
+ /** The caller's notes. The filter is written out even though the table's RLS
49
+ * policy already scopes the read to `auth.uid()`: the policy is the backstop
50
+ * that holds when a query forgets, not a reason to stop writing the query. */
51
+ list(userId: string): Promise<Note[]> {
52
+ return this.notes.findMany({ user_id: userId });
53
+ }
54
+
55
+ /** `user_id` is `notNull` with no default, so ownership is written here —
56
+ * it is not something the request body may carry. */
57
+ create(userId: string, body: string): Promise<Note> {
58
+ return this.notes.insert({ user_id: userId, body });
59
+ }
60
+
61
+ /** No `userId` argument, and that is not an oversight: another user's note is
62
+ * invisible to this read, so it comes back `null` exactly as a missing id
63
+ * does. Postgres enforces it, not this method. */
64
+ get(id: string): Promise<Note | null> {
65
+ return this.notes.findById(id);
66
+ }
67
+
68
+ remove(id: string): Promise<void> {
69
+ return this.notes.delete(id);
70
+ }
71
+ }
72
+
73
+ /** The wired instance. Controllers import THIS, never the class. */
74
+ export const noteService = new NoteService(Database.tables.notes);
@@ -19,12 +19,22 @@
19
19
  // it, so imports elsewhere stay extensionless.
20
20
  "allowImportingTsExtensions": true
21
21
  },
22
+ // Every directory the deploy path compiles has to be in here, or a file in it
23
+ // is type-checked by nothing until it is already running: `jobs/`, `hooks/`
24
+ // and `webhooks/` are read straight off disk by the bundler (one default-
25
+ // exported class per file), and `middleware/` only reaches a build through
26
+ // whichever controller imports it. `config/` is gone — 23.0.0 removed the
27
+ // declarations it held, and the settings it used to declare are written to the
28
+ // stack with the CLI.
22
29
  "include": [
23
30
  "controllers/**/*.ts",
24
31
  "models/**/*.ts",
25
32
  "services/**/*.ts",
26
- "config/**/*.ts",
27
33
  "db/**/*.ts",
34
+ "jobs/**/*.ts",
35
+ "hooks/**/*.ts",
36
+ "webhooks/**/*.ts",
37
+ "middleware/**/*.ts",
28
38
  "*.d.ts"
29
39
  ]
30
40
  }
@@ -1,90 +0,0 @@
1
- import {
2
- getRoutes
3
- } from "./chunk-RCLNBJCM.js";
4
-
5
- // src/decorators/controller.ts
6
- var CONTROLLER_META = /* @__PURE__ */ Symbol.for("palbase.backend.controllerMeta");
7
- var REGISTRY = /* @__PURE__ */ Symbol.for("palbase.backend.allControllers");
8
- function registry() {
9
- const g = globalThis;
10
- const existing = g[REGISTRY];
11
- if (existing) return existing;
12
- const fresh = [];
13
- g[REGISTRY] = fresh;
14
- return fresh;
15
- }
16
- function getRegisteredControllers() {
17
- return registry().slice();
18
- }
19
- function __resetRegisteredControllers() {
20
- registry().length = 0;
21
- }
22
- var RESERVED_FIRST_SEGMENT = "webhooks";
23
- function assertNotReserved(path, subject) {
24
- const [first] = path.split("/").filter(Boolean);
25
- if (first === RESERVED_FIRST_SEGMENT) {
26
- throw new Error(
27
- `${subject} resolves under the reserved /${RESERVED_FIRST_SEGMENT} path \u2014 inbound webhooks are served there and would shadow this route`
28
- );
29
- }
30
- }
31
- function Controller(basePath, options = {}) {
32
- return function(ctor) {
33
- assertNotReserved(basePath, `@Controller("${basePath}")`);
34
- for (const route of getRoutes(ctor)) {
35
- assertNotReserved(
36
- `${basePath}${route.subpath}`,
37
- `@${route.method}("${route.subpath}") in @Controller("${basePath}")`
38
- );
39
- }
40
- const carrier = ctor;
41
- const meta = {
42
- __palbase: "controller",
43
- basePath,
44
- ...options.auth !== void 0 ? { defaultAuth: options.auth } : {}
45
- };
46
- Object.defineProperty(carrier, CONTROLLER_META, {
47
- value: meta,
48
- enumerable: false,
49
- configurable: true,
50
- writable: false
51
- });
52
- Object.defineProperty(carrier, "__palbase", {
53
- value: "controller",
54
- enumerable: false,
55
- configurable: true,
56
- writable: false
57
- });
58
- const all = registry();
59
- if (!all.includes(ctor)) all.push(ctor);
60
- return ctor;
61
- };
62
- }
63
- function isController(value) {
64
- if (typeof value !== "function" && (typeof value !== "object" || value === null)) {
65
- return false;
66
- }
67
- const carrier = value;
68
- return carrier.__palbase === "controller" && carrier[CONTROLLER_META] !== void 0;
69
- }
70
- function resolveController(ctor) {
71
- if (typeof ctor !== "function" && (typeof ctor !== "object" || ctor === null)) {
72
- throw new TypeError("resolveController: value is not a class");
73
- }
74
- const meta = ctor[CONTROLLER_META];
75
- if (!meta) {
76
- throw new TypeError(
77
- "resolveController: class is not a @Controller \u2014 every controller file must `export default` a @Controller-decorated class"
78
- );
79
- }
80
- return meta;
81
- }
82
-
83
- export {
84
- getRegisteredControllers,
85
- __resetRegisteredControllers,
86
- Controller,
87
- isController,
88
- resolveController
89
- };
90
- //# sourceMappingURL=chunk-HQRJDARQ.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/decorators/controller.ts"],"sourcesContent":["// `@Controller(basePath, options?)` — the class decorator that marks a class as\n// a Palbase backend controller. It stamps a non-enumerable `__palbase`\n// discriminant + the resolved controller metadata onto the class so the\n// deploy/dispatch pipeline (and `isController`/`resolveController`) can detect\n// and read it without `reflect-metadata`.\nimport type { AuthSpec } from \"../endpoint.js\";\nimport { getRoutes } from \"./registry.js\";\n\n/** The controller metadata stamped onto a `@Controller`-decorated class. The\n * default export of a `controllers/*.controller.ts` file resolves to this via\n * {@link resolveController}. */\nexport interface ControllerMeta {\n /** Discriminant the runtime + tooling read. */\n readonly __palbase: \"controller\";\n /** The base path every route in this controller mounts under (e.g. \"/todos\"). */\n basePath: string;\n /** Controller-level default auth, applied to routes that don't set their own\n * (`@Get(\"/x\", { auth })` overrides this). `undefined` ⇒ secure-by-default. */\n defaultAuth?: AuthSpec;\n}\n\n/** Options accepted by `@Controller`. */\nexport interface ControllerOptions {\n /** Default auth for ALL routes in this controller (route-level overrides). */\n auth?: AuthSpec;\n}\n\n/** Symbol the controller metadata is stamped under. Symbol-keyed (not a string\n * property) so it never collides with an authored member and stays off the\n * structural surface. */\nexport const CONTROLLER_META: unique symbol = Symbol.for(\"palbase.backend.controllerMeta\");\n\n/**\n * Every class `@Controller` has decorated, in decoration order.\n *\n * This is what lets a controller file need no export at all: importing the file\n * runs the decorator, the decorator records the class here, and the runtime\n * reads the list. Without it the only handle on a class is its export name, so\n * every controller had to be exported AND named in a generated entry — the\n * ceremony NestJS still charges (`export class` PLUS\n * `@Module({controllers:[…]})`).\n *\n * Keyed on a well-known Symbol against globalThis rather than held in a module\n * variable, because a deployed bundle inlines its own copy of this package: two\n * copies would keep two lists, and the runtime would read the empty one. The\n * same hazard `runtimeHooks` exists for, closed the same way — one shared slot.\n */\nconst REGISTRY: unique symbol = Symbol.for(\"palbase.backend.allControllers\") as never;\n\nfunction registry(): unknown[] {\n const g = globalThis as unknown as Record<symbol, unknown[] | undefined>;\n const existing = g[REGISTRY];\n if (existing) return existing;\n const fresh: unknown[] = [];\n g[REGISTRY] = fresh;\n return fresh;\n}\n\n/**\n * The controller classes this process has loaded, in decoration order.\n *\n * Decoration order is import order, which the bundler fixes by sorting the\n * files it emits imports for — so two builds of one tree produce the same\n * route table, and route precedence is not a function of module-resolution\n * accidents.\n */\nexport function getRegisteredControllers(): readonly unknown[] {\n return registry().slice();\n}\n\n/** Empty the registry. For tests, which load controllers repeatedly. */\nexport function __resetRegisteredControllers(): void {\n registry().length = 0;\n}\n\n/** A class carrying the stamped controller metadata + discriminant. */\ninterface ControllerCarrier {\n __palbase?: \"controller\";\n [CONTROLLER_META]?: ControllerMeta;\n}\n\n/** The one path segment the platform owns. The isolate matches\n * `^/webhooks/([^/]+)$` on the raw request path BEFORE controller dispatch, so\n * anything a controller resolves to under it answers `404 webhook_not_found`\n * and never runs. */\nconst RESERVED_FIRST_SEGMENT = \"webhooks\";\n\n/**\n * Throw if `path` resolves under the reserved segment. Segments are compared the\n * way the isolate compares them — `split(\"/\").filter(Boolean)` — NOT by string\n * prefix, because empty segments collapse there: `@Controller(\"/\")` +\n * `@Post(\"/webhooks/x\")` composes to `//webhooks/x`, which the isolate serves as\n * `/webhooks/x`. A prefix check reads that as safe; the segment check does not.\n * `/webhooksy` stays allowed for the same reason — it is a different segment.\n *\n * Every verb is refused, not just the POST the isolate currently intercepts: the\n * reservation is of the URL namespace, so a `@Get(\"/webhooks/x\")` that happens\n * to work today would be silently shadowed the moment the isolate's method gate\n * widens. Refusing at build is recoverable; discovering it as a 404 is not.\n */\nfunction assertNotReserved(path: string, subject: string): void {\n const [first] = path.split(\"/\").filter(Boolean);\n if (first === RESERVED_FIRST_SEGMENT) {\n throw new Error(\n `${subject} resolves under the reserved /${RESERVED_FIRST_SEGMENT} path — ` +\n \"inbound webhooks are served there and would shadow this route\",\n );\n }\n}\n\n/**\n * Mark a class as a Palbase backend controller. `basePath` is the mount path\n * for every route the class declares; `options.auth` sets the controller-level\n * default auth (a route's own `auth` overrides it; absent ⇒ secure-by-default).\n *\n * @example\n * \\@Controller(\"/todos\", { auth: false })\n * export class TodosController {\n * \\@Get(\"\") list(\\@QueryParams(ListTodosQuery) q: ListTodosQuery): TodoSchema[] { … }\n * }\n */\nexport function Controller(basePath: string, options: ControllerOptions = {}) {\n return function <T extends abstract new (...args: never[]) => object>(ctor: T): T {\n // /webhooks/* belongs to the platform: the isolate matches the inbound\n // webhook route before controller dispatch, so a controller mounted here\n // would never receive a request. Silent shadowing is the failure mode this\n // whole change exists to remove, so refuse it at build.\n //\n // The COMPOSED path is what gets shadowed, not the base path. `@Controller(\"\")`\n // and `@Controller(\"/\")` both pass a base-path-only check while a\n // `@Post(\"/webhooks/stripe\")` inside them resolves to exactly the path the\n // isolate intercepts. Method decorators run BEFORE the class decorator (TS\n // evaluates members first), so every route this class declares is already in\n // the registry here — which is why the composed check can live at this one\n // seam instead of on the dispatch read path. The `@Controller(\"\") +\n // @Post(\"/webhooks/stripe\")` test is the lock on that ordering: if it ever\n // stopped holding, that test goes red.\n assertNotReserved(basePath, `@Controller(\"${basePath}\")`);\n for (const route of getRoutes(ctor)) {\n assertNotReserved(\n `${basePath}${route.subpath}`,\n `@${route.method}(\"${route.subpath}\") in @Controller(\"${basePath}\")`,\n );\n }\n\n const carrier = ctor as unknown as ControllerCarrier;\n const meta: ControllerMeta = {\n __palbase: \"controller\",\n basePath,\n ...(options.auth !== undefined ? { defaultAuth: options.auth } : {}),\n };\n // Non-enumerable so it doesn't leak onto instances / structural checks.\n Object.defineProperty(carrier, CONTROLLER_META, {\n value: meta,\n enumerable: false,\n configurable: true,\n writable: false,\n });\n // The bare `__palbase` discriminant is the cheap detection marker the\n // runtime/extractor checks; keep it readable but non-enumerable.\n Object.defineProperty(carrier, \"__palbase\", {\n value: \"controller\",\n enumerable: false,\n configurable: true,\n writable: false,\n });\n // Record it, so importing the file is enough and exporting is optional.\n // Guarded against a double-decoration re-entering the same class twice.\n const all = registry();\n if (!all.includes(ctor)) all.push(ctor);\n return ctor;\n };\n}\n\n/** True when `value` is a `@Controller`-decorated class (cheap discriminant\n * check). Accepts the class constructor (the default export of a controller\n * file). */\nexport function isController(value: unknown): boolean {\n if (typeof value !== \"function\" && (typeof value !== \"object\" || value === null)) {\n return false;\n }\n const carrier = value as ControllerCarrier;\n return carrier.__palbase === \"controller\" && carrier[CONTROLLER_META] !== undefined;\n}\n\n/** Read the resolved controller metadata off a `@Controller`-decorated class.\n * Throws if the class was not decorated — callers should gate with\n * {@link isController} first (the loader does). */\nexport function resolveController(ctor: unknown): ControllerMeta {\n if (typeof ctor !== \"function\" && (typeof ctor !== \"object\" || ctor === null)) {\n throw new TypeError(\"resolveController: value is not a class\");\n }\n const meta = (ctor as ControllerCarrier)[CONTROLLER_META];\n if (!meta) {\n throw new TypeError(\n \"resolveController: class is not a @Controller — every controller file must `export default` a @Controller-decorated class\",\n );\n }\n return meta;\n}\n"],"mappings":";;;;;AA8BO,IAAM,kBAAiC,uBAAO,IAAI,gCAAgC;AAiBzF,IAAM,WAA0B,uBAAO,IAAI,gCAAgC;AAE3E,SAAS,WAAsB;AAC7B,QAAM,IAAI;AACV,QAAM,WAAW,EAAE,QAAQ;AAC3B,MAAI,SAAU,QAAO;AACrB,QAAM,QAAmB,CAAC;AAC1B,IAAE,QAAQ,IAAI;AACd,SAAO;AACT;AAUO,SAAS,2BAA+C;AAC7D,SAAO,SAAS,EAAE,MAAM;AAC1B;AAGO,SAAS,+BAAqC;AACnD,WAAS,EAAE,SAAS;AACtB;AAYA,IAAM,yBAAyB;AAe/B,SAAS,kBAAkB,MAAc,SAAuB;AAC9D,QAAM,CAAC,KAAK,IAAI,KAAK,MAAM,GAAG,EAAE,OAAO,OAAO;AAC9C,MAAI,UAAU,wBAAwB;AACpC,UAAM,IAAI;AAAA,MACR,GAAG,OAAO,iCAAiC,sBAAsB;AAAA,IAEnE;AAAA,EACF;AACF;AAaO,SAAS,WAAW,UAAkB,UAA6B,CAAC,GAAG;AAC5E,SAAO,SAA+D,MAAY;AAehF,sBAAkB,UAAU,gBAAgB,QAAQ,IAAI;AACxD,eAAW,SAAS,UAAU,IAAI,GAAG;AACnC;AAAA,QACE,GAAG,QAAQ,GAAG,MAAM,OAAO;AAAA,QAC3B,IAAI,MAAM,MAAM,KAAK,MAAM,OAAO,sBAAsB,QAAQ;AAAA,MAClE;AAAA,IACF;AAEA,UAAM,UAAU;AAChB,UAAM,OAAuB;AAAA,MAC3B,WAAW;AAAA,MACX;AAAA,MACA,GAAI,QAAQ,SAAS,SAAY,EAAE,aAAa,QAAQ,KAAK,IAAI,CAAC;AAAA,IACpE;AAEA,WAAO,eAAe,SAAS,iBAAiB;AAAA,MAC9C,OAAO;AAAA,MACP,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,UAAU;AAAA,IACZ,CAAC;AAGD,WAAO,eAAe,SAAS,aAAa;AAAA,MAC1C,OAAO;AAAA,MACP,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,UAAU;AAAA,IACZ,CAAC;AAGD,UAAM,MAAM,SAAS;AACrB,QAAI,CAAC,IAAI,SAAS,IAAI,EAAG,KAAI,KAAK,IAAI;AACtC,WAAO;AAAA,EACT;AACF;AAKO,SAAS,aAAa,OAAyB;AACpD,MAAI,OAAO,UAAU,eAAe,OAAO,UAAU,YAAY,UAAU,OAAO;AAChF,WAAO;AAAA,EACT;AACA,QAAM,UAAU;AAChB,SAAO,QAAQ,cAAc,gBAAgB,QAAQ,eAAe,MAAM;AAC5E;AAKO,SAAS,kBAAkB,MAA+B;AAC/D,MAAI,OAAO,SAAS,eAAe,OAAO,SAAS,YAAY,SAAS,OAAO;AAC7E,UAAM,IAAI,UAAU,yCAAyC;AAAA,EAC/D;AACA,QAAM,OAAQ,KAA2B,eAAe;AACxD,MAAI,CAAC,MAAM;AACT,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;","names":[]}