@palbase/backend 17.3.0 → 18.0.0

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 +1848 -0
  2. package/dist/bin/palbase-backend.cjs.map +1 -0
  3. package/dist/bin/palbase-backend.d.cts +1 -0
  4. package/dist/bin/palbase-backend.d.ts +1 -0
  5. package/dist/bin/palbase-backend.js +168 -0
  6. package/dist/bin/palbase-backend.js.map +1 -0
  7. package/dist/chunk-7D4SUZUM.js +38 -0
  8. package/dist/chunk-7D4SUZUM.js.map +1 -0
  9. package/dist/chunk-N32VDWKH.js +172 -0
  10. package/dist/chunk-N32VDWKH.js.map +1 -0
  11. package/dist/chunk-POYAFBLF.js +189 -0
  12. package/dist/chunk-POYAFBLF.js.map +1 -0
  13. package/dist/chunk-QMVK4X3V.js +200 -0
  14. package/dist/chunk-QMVK4X3V.js.map +1 -0
  15. package/dist/chunk-SSGAMC26.js +342 -0
  16. package/dist/chunk-SSGAMC26.js.map +1 -0
  17. package/dist/chunk-VYH4U7ZQ.js +1138 -0
  18. package/dist/chunk-VYH4U7ZQ.js.map +1 -0
  19. package/dist/{chunk-IBIME7N2.js → chunk-W5ODXPY3.js} +2 -321
  20. package/dist/chunk-W5ODXPY3.js.map +1 -0
  21. package/dist/chunk-YL4C5NRY.js +90 -0
  22. package/dist/chunk-YL4C5NRY.js.map +1 -0
  23. package/dist/db/env.cjs.map +1 -1
  24. package/dist/db/env.d.cts +21 -1
  25. package/dist/db/env.d.ts +21 -1
  26. package/dist/db/index.cjs +15 -0
  27. package/dist/db/index.cjs.map +1 -1
  28. package/dist/db/index.d.cts +2 -1
  29. package/dist/db/index.d.ts +2 -1
  30. package/dist/db/index.js +9 -6
  31. package/dist/{index-J1tnAnFj.d.ts → endpoint-B0LpZixz.d.cts} +124 -667
  32. package/dist/{index-D6mRh4yq.d.cts → endpoint-B0LpZixz.d.ts} +124 -667
  33. package/dist/engine/index.cjs +1797 -0
  34. package/dist/engine/index.cjs.map +1 -0
  35. package/dist/engine/index.d.cts +7 -0
  36. package/dist/engine/index.d.ts +7 -0
  37. package/dist/engine/index.js +43 -0
  38. package/dist/engine/index.js.map +1 -0
  39. package/dist/index-B46CGNvx.d.cts +839 -0
  40. package/dist/index-BGSCWlUa.d.cts +674 -0
  41. package/dist/index-DZDUMth5.d.ts +839 -0
  42. package/dist/index-g-EzitI-.d.ts +674 -0
  43. package/dist/index.cjs +1046 -11
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.d.cts +290 -532
  46. package/dist/index.d.ts +290 -532
  47. package/dist/index.js +999 -509
  48. package/dist/index.js.map +1 -1
  49. package/dist/openapi/index.cjs +6464 -0
  50. package/dist/openapi/index.cjs.map +1 -0
  51. package/dist/openapi/index.d.cts +170 -0
  52. package/dist/openapi/index.d.ts +170 -0
  53. package/dist/openapi/index.js +6248 -0
  54. package/dist/openapi/index.js.map +1 -0
  55. package/dist/registry-3BLYv4si.d.ts +338 -0
  56. package/dist/registry-Cw0YEYCg.d.cts +338 -0
  57. package/dist/test/index.js +2 -0
  58. package/dist/test/index.js.map +1 -1
  59. package/docs/database.md +16 -3
  60. package/docs/llms-full.txt +16 -3
  61. package/package.json +43 -13
  62. package/stager/package.json +4 -0
  63. package/stager/return_types.js +338 -0
  64. package/stager/stage.js +78 -0
  65. package/stager/throw_analysis.js +726 -0
  66. package/template/AGENTS.md +261 -0
  67. package/template/config/secrets.ts +24 -0
  68. package/template/controllers/health.controller.ts +30 -0
  69. package/template/db/schema.ts +35 -0
  70. package/template/package.json +18 -0
  71. package/template/tsconfig.json +30 -0
  72. package/LICENSE +0 -21
  73. package/dist/chunk-IBIME7N2.js.map +0 -1
@@ -0,0 +1,839 @@
1
+ import { Buckets, BucketTypes } from './db/env.cjs';
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { C as CacheClient, P as PalbaseDocsClient, a as PalbaseFlagsClient, L as Logger, b as PalbaseNotificationsClient, c as PalbaseRealtimeClient, D as DBClient, S as SecretsService, d as PalbaseStorageClient, e as PalbaseBucketClient, T as TxPlanBody, f as TxPlanResponse } from './endpoint-B0LpZixz.cjs';
4
+ import { E as EnvTypedDatabase } from './index-BGSCWlUa.cjs';
5
+ import { R as RouteMeta } from './registry-Cw0YEYCg.cjs';
6
+
7
+ /**
8
+ * The purchases surface the decorators need, declared as a NARROW STRUCTURAL
9
+ * interface rather than an import of `@palstore/purchases`.
10
+ *
11
+ * `PurchasesClient` (palstore's `sk_` backend SDK) satisfies this shape as-is,
12
+ * so the runtime injects the real client with no adapter — but `@palbase/backend`
13
+ * itself gains no dependency on it. That matters: this package is published
14
+ * public and baked into the br-pod image from a tarball, so a dependency on an
15
+ * unpublished sibling would break `npm install palbase-backend.tgz`. It is also
16
+ * the pattern `withSpend` already uses for the same reason (`SpendCapableClient`
17
+ * in palstore's own spend.ts: "a narrow structural interface … so this file has
18
+ * no dependency on client.ts").
19
+ *
20
+ * The methods here are a SUBSET of `PurchasesClient` — only what the two
21
+ * decorators call. Grants, refunds, credits and customer-info reads stay off
22
+ * this interface: a tenant that wants them imports the palstore SDK directly.
23
+ */
24
+ /** Store environment a subject is fixed to. Mirrors `StoreEnv` in `@palstore/purchases`. */
25
+ type StoreEnv = "production" | "sandbox";
26
+ /** Quota/credit state carried by a 429. Mirrors `LimitState` in `@palstore/purchases`
27
+ * (SPEC-purchases-v1 §11) — re-declared, not imported, for the reason above. */
28
+ interface LimitState {
29
+ key: string;
30
+ scope: string;
31
+ window: string;
32
+ used: number;
33
+ reserved: number;
34
+ max: number;
35
+ remaining: number;
36
+ resetAt: string;
37
+ }
38
+ /** Options for one spend. `idempotencyKey` is required by the server (§9). */
39
+ interface SpendOptions {
40
+ /** Defaults to 1. */
41
+ count?: number;
42
+ idempotencyKey: string;
43
+ }
44
+ interface PurchasesService {
45
+ /** Map a tenant-side user reference to its palstore subject, creating one on
46
+ * first sight. Server-authoritative — the caller never names a subject. */
47
+ resolveSubject(input: {
48
+ userRef: string;
49
+ storeEnv: StoreEnv;
50
+ }): Promise<{
51
+ subjectId: string;
52
+ }>;
53
+ /** Resolve silently when `entitlementKey` is active for `subjectId`; throw
54
+ * `EntitlementRequiredError` otherwise. Consumes nothing. */
55
+ require(subjectId: string, entitlementKey: string): Promise<void>;
56
+ /** Reserve → run `handler` → commit on success, cancel on throw, always
57
+ * rethrowing the handler's own error. The whole reason the decorators are a
58
+ * thin layer: this lifecycle is already written and tested in palstore's SDK. */
59
+ withSpend<T>(subjectId: string, key: string, opts: SpendOptions, handler: () => Promise<T>): Promise<T>;
60
+ }
61
+
62
+ /**
63
+ * runtime.ts — request-scoped service singletons.
64
+ *
65
+ * The backend SDK no longer threads a `ctx` god-object through every handler.
66
+ * Instead, controller methods import PascalCase service singletons directly:
67
+ *
68
+ * import { Controller, Post, Body, Database } from "@palbase/backend";
69
+ *
70
+ * \@Controller("/todos")
71
+ * export default class TodosController {
72
+ * \@Post("") create(\@Body(CreateTodoBody) body: CreateTodoBody): unknown {
73
+ * return Database.insert("todos", { title: body.title });
74
+ * }
75
+ * }
76
+ *
77
+ * The singletons are thin Proxies. Every property access forwards to the live
78
+ * client for the CURRENT request scope, resolved through {@link __getRuntime}.
79
+ *
80
+ * # Request-scope resolution (persistent app-server)
81
+ *
82
+ * The runtime is a long-running Node process that serves many concurrent
83
+ * requests on one event loop (NOT a fresh subprocess per request). A single
84
+ * module-global slot would let one in-flight request's services bleed into
85
+ * another's. So the services are carried in an {@link AsyncLocalStorage} store
86
+ * ({@link __requestALS}) that the runtime sets per request with
87
+ * {@link __runWithRuntime}; every async continuation of that request reads its
88
+ * own store. `__getRuntime` reads the ALS store first; the module-global slot
89
+ * (set by {@link __setRuntime}) is only a fallback for callers that run OUTSIDE
90
+ * an ALS scope (dev-server, unit tests, the legacy single-shot path). Because
91
+ * each `br-<ref>` pod is single-tenant, there is no cross-tenant leakage; the
92
+ * ALS store is what prevents cross-REQUEST leakage within the shared process.
93
+ *
94
+ * The seam that makes `import { Database } from "@palbase/backend"` resolve to
95
+ * the runtime-injected client: `@palbase/backend` is marked esbuild-EXTERNAL
96
+ * when the tenant bundle is built, and the package is installed globally in the
97
+ * pod (NODE_PATH=/usr/local/lib/node_modules). So worker.js's
98
+ * `require('@palbase/backend')` and the bundle's `import` resolve to ONE shared
99
+ * module instance — the ALS store and `__setRuntime` slot on that instance are
100
+ * visible to the singletons the bundle imported.
101
+ */
102
+
103
+ /** The set of live clients the runtime injects per request scope.
104
+ *
105
+ * Realtime is BROADCAST-ONLY here (a stateless handler can push an event but
106
+ * cannot hold a subscription socket — `subscribe()` lives on the client SDK).
107
+ *
108
+ * EXCLUDED on purpose: Functions, Links, Analytics, Auth. They are not
109
+ * exposed as backend handler singletons (auth lives on the client SDK; the rest
110
+ * are out of scope for backend endpoints). */
111
+ interface RuntimeServices {
112
+ Database: DBClient;
113
+ Secrets: SecretsService;
114
+ Documents: PalbaseDocsClient;
115
+ Storage: PalbaseStorageClient;
116
+ Cache: CacheClient;
117
+ Log: Logger;
118
+ Notifications: PalbaseNotificationsClient;
119
+ Flags: PalbaseFlagsClient;
120
+ Realtime: PalbaseRealtimeClient;
121
+ Purchases: PurchasesService;
122
+ }
123
+ /**
124
+ * The per-request ALS box.
125
+ *
126
+ * `runtime` is the service bundle. `userId` is the request's authenticated user
127
+ * id, written by the runtime immediately after it assembles the request object
128
+ * (worker.js: `requestALS.getStore().userId = pbReq.user?.id || null`) and
129
+ * `null` on an anonymous request. It was already being written there for the
130
+ * Flags client's auto-bind; declaring it here makes the existing contract typed
131
+ * instead of implicit, which is what lets `currentSubjectId()` read a
132
+ * server-owned identity rather than trusting anything the caller sent.
133
+ */
134
+ interface RequestStore {
135
+ runtime: RuntimeServices;
136
+ userId?: string | null;
137
+ /** This request's id (`req_…`), written by the runtime. Used as the spend
138
+ * idempotency scope when the caller supplied no `Idempotency-Key`. */
139
+ requestId?: string;
140
+ /** The caller's `Idempotency-Key` header, or `null` when absent. What makes a
141
+ * client's retry replay its first result instead of charging twice. */
142
+ idempotencyKey?: string | null;
143
+ }
144
+ /**
145
+ * Per-request store. The persistent runtime runs each request inside
146
+ * {@link __runWithRuntime}, so every async continuation of that request reads
147
+ * its OWN `runtime` (and any other request-scoped fields the runtime adds).
148
+ *
149
+ * Exported with a `__` prefix so the runtime (worker.js) shares the SAME ALS
150
+ * instance across the one module instance — two ALS instances would silently
151
+ * not see each other's stores. NOT part of the public author-facing API.
152
+ */
153
+ declare const __requestALS: AsyncLocalStorage<RequestStore>;
154
+ /** Install the live clients in the process-global fallback slot.
155
+ *
156
+ * Persistent-server requests should use {@link __runWithRuntime} instead; this
157
+ * remains for dev-server / tests / the legacy single-shot path that run without
158
+ * an ALS scope. NOT part of the public author-facing API. */
159
+ declare function __setRuntime(services: RuntimeServices): void;
160
+ /** Run `fn` with `services` bound as the request-scoped runtime.
161
+ *
162
+ * The persistent worker calls this once per request so concurrent requests
163
+ * never share a services slot. NOT part of the public author-facing API. */
164
+ declare function __runWithRuntime<T>(services: RuntimeServices, fn: () => T): T;
165
+ /** Read the live clients, throwing if accessed outside a request scope.
166
+ *
167
+ * Resolves the ALS store first (persistent server, per-request), then the
168
+ * process-global fallback (dev-server / tests). NOT part of the public
169
+ * author-facing API — used by the runtime and the singleton Proxies. */
170
+ declare function __getRuntime(): RuntimeServices;
171
+ /**
172
+ * The project's own Postgres (pgx, schema `env_<envId>`).
173
+ *
174
+ * Typed by default: `Database.tables.<name>.insert({...})` is typed against
175
+ * the project's generated `palbase-env.d.ts` with NO import and NO generic.
176
+ * The raw string ops (`query`/`insert`/`update`/`delete`/`findById`/`findMany`)
177
+ * are also available for dynamic table names and read-only SQL.
178
+ *
179
+ * RLS is enforced by default (the runtime runs each op as `authenticated` with
180
+ * the verified user's claims). To bypass RLS, call `Database.asService()` —
181
+ * explicit and greppable — which runs as the `service_role` (BYPASSRLS).
182
+ *
183
+ * @example
184
+ * import { Database } from "@palbase/backend";
185
+ *
186
+ * const todo = await Database.tables.todos.insert({ title: req.input.title });
187
+ * todo.id; // string ✓
188
+ * const rows = await Database.query("SELECT id FROM todos WHERE done = $1", [false]);
189
+ * const all = await Database.asService().tables.todos.findMany({}); // RLS bypass
190
+ */
191
+ declare const Database: EnvTypedDatabase;
192
+ /** Firestore-like document client (PalDocs). */
193
+ declare const Documents: PalbaseDocsClient;
194
+ /**
195
+ * `buckets.<name>` — the storage twin of `Database.tables.<name>`, and the same
196
+ * mechanism: `config/storage.ts` generates a `Buckets` augmentation into
197
+ * `palbase-env.d.ts`, so a bucket name is a property with no import and no
198
+ * generic, and a typo is a compile error.
199
+ *
200
+ * The intermediate `.buckets` is there for the reason `.tables` is: bucket
201
+ * names must not share a namespace with the client's own methods, or the day
202
+ * somebody declares a bucket called `bucket` the surface breaks.
203
+ */
204
+ type EnvBuckets = {
205
+ [K in keyof Buckets]: TypedBucketClient<Buckets[K] extends BucketTypes ? Buckets[K] : BucketTypes>;
206
+ };
207
+ /** One bucket, with its declared variant names in the type. */
208
+ interface TypedBucketClient<B extends BucketTypes> extends Omit<PalbaseBucketClient, "getPublicUrl"> {
209
+ /** The public URL for the object, or for one of THIS bucket's declared
210
+ * renditions. A name the bucket does not declare is a compile error. */
211
+ getPublicUrl(path: string, options?: {
212
+ variant?: B["variants"];
213
+ }): string;
214
+ }
215
+ /**
216
+ * Object storage: buckets, objects, renditions and signed URLs.
217
+ *
218
+ * `Storage.buckets.posts.upload(...)` is the typed path. `Storage.bucket(name)`
219
+ * remains for a name computed at runtime — rare, and it gives up the typing,
220
+ * which is the honest trade rather than a second blessed way to do it.
221
+ */
222
+ declare const Storage: PalbaseStorageClient & {
223
+ buckets: EnvBuckets;
224
+ };
225
+ /** JSON-typed cache (get/set/incr/getOrSet). */
226
+ declare const Cache: CacheClient;
227
+ /**
228
+ * This tenant's secrets — API keys, provider credentials, signing material the
229
+ * BACKEND owns.
230
+ *
231
+ * `await Secrets.get("STRIPE_KEY")` in a handler. There is no `.env` to read
232
+ * and no file to mount: a secret is written through the vault's authenticated
233
+ * API (or the CLI/MCP that calls it), and this is how the deployed code reads
234
+ * it back. A name this tenant has not set answers null — as does every name
235
+ * the STACK holds, because no route returns a platform secret's value at all.
236
+ */
237
+ declare const Secrets: SecretsService;
238
+ /** Structured logger. */
239
+ declare const Log: Logger;
240
+ /** Push / email / SMS / in-app notifications. */
241
+ declare const Notifications: PalbaseNotificationsClient;
242
+ /**
243
+ * Palstore purchases (entitlements + quota/credit spend).
244
+ *
245
+ * Reached by handlers through the `@RequireEntitlement` / `@Spend` decorators
246
+ * rather than called directly in the common case; exposed as a singleton for
247
+ * the cases the decorators deliberately do not cover (a dynamic spend count,
248
+ * which must run BEFORE the billable side-effect).
249
+ */
250
+ declare const Purchases: PurchasesService;
251
+ /**
252
+ * Feature flags.
253
+ *
254
+ * Mirrors the `Database` / `Database.asService()` model. The default surface is
255
+ * RLS-equivalent for flags: reads resolve against the CURRENT request user and
256
+ * `Flags.setOverride(key, value)` writes an override for that same signed-in
257
+ * user (no userId argument, no admin power). Cross-user admin writes
258
+ * (`setOverrideForUser`, …) live behind `Flags.asService()` — explicit and
259
+ * greppable, just like `Database.asService()`.
260
+ *
261
+ * @example
262
+ * import { Flags } from "@palbase/backend";
263
+ *
264
+ * if (await Flags.isEnabled("new_checkout")) { ... } // current user
265
+ * await Flags.setOverride("new_checkout", true); // current user
266
+ * await Flags.asService().setOverrideForUser("u_9", "x", true); // cross-user
267
+ */
268
+ declare const Flags: PalbaseFlagsClient;
269
+ /**
270
+ * The Realtime broadcast singleton for the current request scope. Backend-side
271
+ * Realtime is BROADCAST-ONLY (a stateless handler can push but not subscribe —
272
+ * `subscribe()` lives on the client SDK's `pb.realtime`). Fire-and-forget:
273
+ * `broadcast` resolves once accepted (or with an `error`), never blocking the
274
+ * handler on subscribers.
275
+ *
276
+ * @example
277
+ * import { Realtime } from "@palbase/backend";
278
+ *
279
+ * await Realtime.broadcast("room:42", "message", { text, from: user.id });
280
+ */
281
+ declare const Realtime: PalbaseRealtimeClient;
282
+
283
+ /**
284
+ * engine/config.ts — settings from the environment, and the gate that refuses
285
+ * to boot without them.
286
+ *
287
+ * A mandatory module that is not configured must stop the process, by name.
288
+ * The failure this prevents is the expensive one: a stack that boots, passes
289
+ * its probes, and answers 500 on first contact — where the missing value is
290
+ * discovered by a customer rather than by the operator who could fix it.
291
+ *
292
+ * Database and Auth are mandatory. That is a product decision (2026-08-14), not
293
+ * a technical necessity: a backend whose data layer or whose notion of "who is
294
+ * calling" is undefined has nothing safe to do with a request.
295
+ */
296
+ /** Everything the engine needs to serve. Built once, at boot, never re-read. */
297
+ interface EngineConfig {
298
+ /** Postgres connection string. MANDATORY. */
299
+ databaseUrl: string;
300
+ /** Where this stack publishes its token signing keys. MANDATORY. */
301
+ authJwksUrl: string;
302
+ /** When set, a token whose `iss` differs is rejected. */
303
+ authIssuer?: string;
304
+ /** Base URL of the module surface (`/v1/*`, `/auth/*`). Empty ⇒ module
305
+ * singletons throw a named error on first use rather than silently no-op. */
306
+ moduleBaseUrl: string;
307
+ /** Shared secret storage signs its internal upload calls with. Empty means
308
+ * uploads are not wired, and those calls are refused. */
309
+ uploadSecret: string;
310
+ /** Publishable key, sent as `apikey` on module calls. */
311
+ anonKey: string;
312
+ /** Secret key. Used for privileged module calls. */
313
+ serviceRoleKey: string;
314
+ /** HMAC the realtime broadcast token is signed with. Empty ⇒ broadcast
315
+ * returns a clear `realtime_unconfigured` error instead of failing silently. */
316
+ realtimeSecret: string;
317
+ port: number;
318
+ /** The Postgres role each request is bound to. RLS policies are written
319
+ * against it, so changing it changes who the database thinks is asking. */
320
+ dbRole: string;
321
+ /**
322
+ * The Postgres role `Database.asService()` is bound to. It is the one that
323
+ * carries BYPASSRLS, which is the whole of what "as service" means — a name
324
+ * pointing at a role without it does not fail, it returns fewer rows.
325
+ *
326
+ * Configurable for the same reason `dbRole` is, and beside it on purpose: a
327
+ * stack that renames one of the pair must rename both, or the request and its
328
+ * service sibling stop being two identities of the same installation.
329
+ */
330
+ dbServiceRole: string;
331
+ poolMax: number;
332
+ }
333
+ /** Thrown when a mandatory module is unconfigured. Carries the missing names. */
334
+ declare class BootRefused extends Error {
335
+ readonly missing: readonly string[];
336
+ constructor(missing: readonly string[], message: string);
337
+ }
338
+ /**
339
+ * Read the engine's settings, or refuse.
340
+ *
341
+ * @throws {BootRefused} naming every missing mandatory value at once — one
342
+ * restart per missing variable is a bad way to learn what a stack needs.
343
+ */
344
+ declare function loadConfig(env: Record<string, string | undefined>): EngineConfig;
345
+
346
+ /**
347
+ * engine/db.ts — a real pooled connection, and the identity every request is
348
+ * bound to inside it.
349
+ *
350
+ * # Why one transaction per request
351
+ *
352
+ * In the isolate architecture every `Database.*` call was its own HTTP hop to a
353
+ * capability surface, so two writes in one handler could not be atomic — a
354
+ * handler that wrote and then threw left the first write behind. Here the whole
355
+ * request runs inside one transaction: it commits when the handler returns and
356
+ * rolls back when it throws. Atomicity stops being something the author has to
357
+ * ask for.
358
+ *
359
+ * # Why it opens lazily
360
+ *
361
+ * A handler that touches no table must cost no round trip. Opening eagerly cost
362
+ * four (BEGIN + bind + … + COMMIT) on endpoints that never query — measured at
363
+ * 1,243 rps against 31,579 for the same endpoint once the open became lazy.
364
+ *
365
+ * # How the caller's identity reaches RLS
366
+ *
367
+ * One statement, not three:
368
+ *
369
+ * select set_config('role',$1,true),
370
+ * set_config('search_path','public',true),
371
+ * set_config('request.jwt.claims',$2,true)
372
+ *
373
+ * `set_config(..., is_local => true)` is transaction-scoped exactly like
374
+ * `SET LOCAL`, but takes BOUND PARAMETERS, which `SET LOCAL` cannot. So the
375
+ * role and the caller's claims travel as parameters — user identity is never
376
+ * spliced into SQL text — and `auth.uid()` resolves inside RLS policies, which
377
+ * means the row filter is enforced by Postgres rather than by our code.
378
+ */
379
+
380
+ /** The slice of a SQL driver the engine uses. `Bun.sql` satisfies it. */
381
+ interface SqlDriver {
382
+ /** Run a parameterised statement. */
383
+ unsafe(sql: string, params?: unknown[]): Promise<unknown>;
384
+ /** Open a transaction; the driver commits when `cb` resolves and rolls back
385
+ * when it rejects. */
386
+ begin<T>(cb: (tx: SqlTx) => Promise<T>): Promise<T>;
387
+ }
388
+ interface SqlTx {
389
+ unsafe(sql: string, params?: unknown[]): Promise<unknown>;
390
+ savepoint<T>(cb: (sp: SqlTx) => Promise<T>): Promise<T>;
391
+ }
392
+ type Row = Record<string, unknown>;
393
+ /** Quote an identifier. Table and column names reach here from the schema and
394
+ * from handler arguments; neither is allowed to become syntax. */
395
+ declare function quoteIdent(name: string): string;
396
+ /**
397
+ * A transaction that does not exist until somebody reads or writes.
398
+ *
399
+ * `begin(cb)` is callback-scoped, so to hold one open across a whole request
400
+ * the callback parks on a promise this object controls: `commit()` resolves it
401
+ * (the driver commits), `rollback()` rejects it (the driver rolls back). A
402
+ * request that never touches the database never enters the callback at all.
403
+ */
404
+ declare function createLazyTransaction(sql: SqlDriver, role: string, claimsJson: string, options?: {
405
+ lockTimeout?: string;
406
+ }): {
407
+ ensure: () => Promise<SqlTx>;
408
+ readonly opened: boolean;
409
+ commit(): Promise<void>;
410
+ rollback(reason: unknown): Promise<void>;
411
+ };
412
+ type LazyTransaction = ReturnType<typeof createLazyTransaction>;
413
+ /** Either a live driver transaction or the lazy holder above. */
414
+ type TxLike = SqlTx | LazyTransaction;
415
+ /** The six string-keyed operations, plus an interactive `transaction`. */
416
+ declare function createOps(tx: TxLike): {
417
+ query(sql: string, params?: unknown[]): Promise<Row[]>;
418
+ insert(table: string, data: Row): Promise<Row>;
419
+ update(table: string, id: string, data: Row): Promise<Row | null>;
420
+ delete(table: string, id: string): Promise<void>;
421
+ findById(table: string, id: string): Promise<Row | null>;
422
+ findMany(table: string, query?: Row): Promise<Row[]>;
423
+ /** A real SAVEPOINT inside the request's transaction. */
424
+ transaction<T>(cb: (t: unknown) => Promise<T>): Promise<T>;
425
+ /**
426
+ * Execute a whole transaction plan — what `Database.transaction(fn)` builds.
427
+ *
428
+ * WHY IT RUNS HERE. The platform used to carry a complete implementation
429
+ * of this at `/internal-api/db/tx`, for tenant code that ran in an isolate
430
+ * with no connection of its own. Running the plan there means running it on
431
+ * a DIFFERENT connection: a transaction would not see the uncommitted
432
+ * writes of the request that started it, and the two would hold separate
433
+ * RLS bindings of the same identity. In this stack the tenant's code and
434
+ * the connection share a process, so the plan runs on the request's own
435
+ * transaction inside one SAVEPOINT — and that surface was removed on
436
+ * 2026-08-15, once this was the last thing that could have called it.
437
+ *
438
+ * Until 2026-08-15 it ran NOWHERE: `runTxPlan` called `transport.txPlan` and
439
+ * nothing here implemented it, so a live handler answered
440
+ * "transport.txPlan is not a function" while every test that covered
441
+ * transactions passed against a mock that did implement it.
442
+ */
443
+ txPlan(plan: TxPlanBody): Promise<TxPlanResponse>;
444
+ };
445
+ /**
446
+ * Merge the typed `.tables` accessor onto a raw op surface.
447
+ *
448
+ * Mirrors what the pod runtime does, including the recursive application to the
449
+ * transaction callback: without it `tx.tables.rooms.insert(...)` throws
450
+ * "Cannot read properties of undefined".
451
+ */
452
+ declare function withTables<T extends ReturnType<typeof createOps>>(ops: T, schema?: {
453
+ tables?: Record<string, {
454
+ name?: string;
455
+ }>;
456
+ }): T & {
457
+ tables: Record<string, unknown>;
458
+ };
459
+ /** The two transactions a request may hold, and the single `Database` over them. */
460
+ interface RequestDatabase {
461
+ /** What the engine injects as the request's `Database` singleton. */
462
+ readonly client: DBClient;
463
+ /** Commit whatever was opened. Called once, after the handler returns. */
464
+ commit(): Promise<void>;
465
+ /** Roll back whatever was opened. Called once, when the handler throws. */
466
+ rollback(reason: unknown): Promise<void>;
467
+ }
468
+ /**
469
+ * The `Database` one request sees: RLS-enforced by default, with the
470
+ * service-role sibling behind `asService()`.
471
+ *
472
+ * # Why the sibling cannot ride the request's own transaction
473
+ *
474
+ * The role reaches Postgres ONCE, in the BEGIN's bind statement, and it is
475
+ * transaction-scoped. So a sibling built on the same transaction runs as
476
+ * `backend_authenticated` no matter what it is called — RLS still filters every
477
+ * row and `asService()` silently means nothing. That is the failure mode worth
478
+ * naming: it does not throw, it does not log, it simply returns the caller's own
479
+ * rows where the author asked for everyone's, and a handler that trusts it
480
+ * (`if (existing) throw new Conflict()`) makes the wrong decision on data it was
481
+ * never shown.
482
+ *
483
+ * The obvious repair — re-issue `set_config('role', …)` around each service op
484
+ * — is worse than the bug. Two statements are not one: `Promise.all([
485
+ * Database.query(…), Database.asService().query(…) ])` interleaves them on the
486
+ * single connection, and the user's query can execute between the service's
487
+ * set-role and its own statement. That is RLS silently OFF on the DEFAULT path,
488
+ * which is precisely the direction a security seam must never fail.
489
+ *
490
+ * So the service surface gets its own transaction, on its own connection, bound
491
+ * to the service role at BEGIN. The identity separation is physical: no
492
+ * statement of either surface can change what the other runs as.
493
+ *
494
+ * # What that costs, stated plainly
495
+ *
496
+ * - **One extra connection per request that uses it**, and only then: the second
497
+ * transaction is lazy exactly like the first, so `asService()` called and
498
+ * never used opens nothing.
499
+ * - **Called twice, it is the same surface** — one transaction per REQUEST, not
500
+ * per call — so a handler cannot leak connections by reaching for it in a
501
+ * loop.
502
+ * - **The two are not atomic with each other.** Both settle with the request
503
+ * (commit when the handler returns, roll back when it throws), but they settle
504
+ * as two transactions: if the second COMMIT fails, the first has already
505
+ * landed. The request's own work commits first, so the failure that survives
506
+ * is never "the audit row exists and the thing it audits does not".
507
+ * - **They can wait on each other's locks.** Bounded in the service direction by
508
+ * {@link SERVICE_LOCK_TIMEOUT}; in the other direction — a `Database.*` write
509
+ * to a row `asService()` has already written — the wait is the request's own,
510
+ * and the answer is not to write one row from both surfaces.
511
+ *
512
+ * # Claims travel unchanged
513
+ *
514
+ * The service transaction carries the SAME `request.jwt.claims` as the user's.
515
+ * `asService()` changes what the caller may TOUCH, not who they are, so
516
+ * `auth.uid()` still resolves inside a trigger or a column default. It is also
517
+ * the fail-closed direction: a service role provisioned WITHOUT `BYPASSRLS`
518
+ * (measured live on 2026-08-13, created by hand during a diagnosis) is not named
519
+ * by any policy, so it reads zero rows instead of quietly reading everyone's.
520
+ */
521
+ declare function createRequestDatabase(sql: SqlDriver, identity: {
522
+ role: string;
523
+ serviceRole: string;
524
+ claimsJson: string;
525
+ }): RequestDatabase;
526
+
527
+ interface RouteEntry {
528
+ method: string;
529
+ /** Path segments; a parameter segment is stored as `:name`. */
530
+ segments: string[];
531
+ meta: RouteMeta;
532
+ /** The controller instance the method is invoked on. */
533
+ instance: Record<string, (...args: unknown[]) => unknown>;
534
+ /** `GET /todos/{id}` — stable, human-readable, used as the rate-limit key. */
535
+ id: string;
536
+ /** The controller's `auth` default, if it declared one. */
537
+ controllerAuth: unknown;
538
+ }
539
+ /**
540
+ * Build the table from controller classes.
541
+ *
542
+ * @throws when a class carries no routes — a controller that collected zero
543
+ * endpoints is the silent failure this whole runtime is built to refuse, and
544
+ * it must be loud at boot rather than a 404 in production.
545
+ */
546
+ declare function buildRouteTable(controllers: readonly unknown[]): RouteEntry[];
547
+ interface RouteMatch {
548
+ entry: RouteEntry;
549
+ params: Record<string, string>;
550
+ }
551
+ /** First match wins; the table is small and declaration order is the tiebreak. */
552
+ declare function matchRoute(table: readonly RouteEntry[], method: string, pathname: string): RouteMatch | null;
553
+
554
+ /**
555
+ * engine/auth.ts — verifying the stack's own access tokens.
556
+ *
557
+ * The engine does this itself rather than trusting a header stamped upstream.
558
+ * In the isolate architecture a gateway verified the token and the runtime read
559
+ * the result; a backend that boots on its own has no such upstream, so the
560
+ * verification lives here — against the keys the stack publishes.
561
+ *
562
+ * Deliberately narrow: ES256 over P-256, which is what palauth mints. An
563
+ * unrecognised `alg` is refused rather than accommodated, because the classic
564
+ * JWT break is a verifier that is helpful about algorithms.
565
+ */
566
+ /** The claims the engine reads. Everything else rides along untyped. */
567
+ interface VerifiedClaims extends Record<string, unknown> {
568
+ sub?: string;
569
+ role?: string;
570
+ email?: string;
571
+ email_verified?: boolean;
572
+ exp?: number;
573
+ iss?: string;
574
+ }
575
+ interface AuthVerifierOptions {
576
+ jwksUrl: string;
577
+ issuer?: string;
578
+ /** Injectable for tests; defaults to global fetch. */
579
+ fetchImpl?: typeof fetch;
580
+ /** How long a fetched keyset is trusted before it is fetched again. A key
581
+ * rotation must become visible without a restart, and an unknown `kid` must
582
+ * not be able to force a fetch per request (that is a free DoS lever). */
583
+ keysetTtlMs?: number;
584
+ }
585
+ declare class AuthVerifier {
586
+ private keys;
587
+ private fetchedAt;
588
+ private inflight;
589
+ private readonly jwksUrl;
590
+ private readonly issuer?;
591
+ private readonly fetchImpl;
592
+ private readonly ttl;
593
+ constructor(opts: AuthVerifierOptions);
594
+ /** Fetch the keyset at most once per TTL, and at most once concurrently. */
595
+ private refresh;
596
+ private key;
597
+ /**
598
+ * Verify an `Authorization` header value.
599
+ *
600
+ * @returns the verified claims, or `null` for absent / malformed / expired /
601
+ * wrong-issuer / bad-signature. One `null` for every failure on purpose:
602
+ * the caller answers 401 either way, and a detailed reason is an oracle.
603
+ */
604
+ verify(authorization: string | null | undefined): Promise<VerifiedClaims | null>;
605
+ }
606
+ /** What a route demands, after the route's own spec and the controller's
607
+ * default have been reconciled. */
608
+ interface EffectiveAuth {
609
+ required: boolean;
610
+ role?: string;
611
+ verifiedEmail: boolean;
612
+ }
613
+ /**
614
+ * Reconcile route-level and controller-level auth.
615
+ *
616
+ * The route's own spec wins when it says anything at all; otherwise the
617
+ * controller's default applies; when NEITHER speaks, the answer is `required`.
618
+ * That last clause is the whole point — a route that forgot to declare must be
619
+ * closed, not open. (Measured: an engine that only read the route level served
620
+ * a controller marked `auth: false` as 401, and would have served the reverse
621
+ * mistake as an open endpoint.)
622
+ */
623
+ declare function effectiveAuth(routeAuth: unknown, controllerAuth: unknown): EffectiveAuth;
624
+
625
+ /**
626
+ * engine/ratelimit.ts — the customer's own per-route limit, enforced here.
627
+ *
628
+ * This is the PRODUCT feature (`@Get("/x", { rateLimit: { max, window } })`),
629
+ * not a quota the platform imposes. It runs in this process, on this pod,
630
+ * because the route table lives here: the edge proxies by path and has never
631
+ * seen a route's options, so teaching it would mean shipping the table twice
632
+ * and keeping the copies in step.
633
+ *
634
+ * Fixed window, in memory. A single-tenant backend is the whole stack rather
635
+ * than a shard of it, so "in process" is not an approximation. A restart
636
+ * forgets the window, which for an endpoint guard fails in the right
637
+ * direction: it forgives, it never invents a refusal.
638
+ */
639
+ interface RateLimitRule {
640
+ max: number;
641
+ /** Seconds. */
642
+ window: number;
643
+ }
644
+ declare class RateLimiter {
645
+ private readonly maxKeys;
646
+ private buckets;
647
+ /** Bound on distinct keys held, so an attacker cycling identities cannot
648
+ * grow this map without limit. On overflow the oldest windows are dropped —
649
+ * forgiving, consistent with the restart behaviour above. */
650
+ constructor(maxKeys?: number);
651
+ /**
652
+ * Identify the caller: the signed-in user when the route resolved one,
653
+ * otherwise the address the edge forwarded. Callers the edge did not
654
+ * identify share one bucket — deliberately conservative, since the
655
+ * alternative is a limit anyone resets by omitting a header.
656
+ */
657
+ static key(routeId: string, userId: string | undefined, headers: Headers): string;
658
+ /**
659
+ * @returns `null` when the request may proceed, or the number of seconds to
660
+ * wait (never 0 — a caller told to wait 0 comes straight back to the same
661
+ * refusal).
662
+ */
663
+ check(rule: RateLimitRule | undefined, key: string, now: number): number | null;
664
+ /** Drop expired windows; if none are expired, drop the earliest-resetting
665
+ * quarter so the map cannot wedge at the ceiling. */
666
+ private evict;
667
+ /** Test seam. */
668
+ get size(): number;
669
+ }
670
+
671
+ /**
672
+ * engine/cache.ts — the cache, in this process's own memory.
673
+ *
674
+ * A stack that serves one tenant has nobody to share a cache with; palsvc drew
675
+ * exactly this conclusion for itself when it dropped Redis, and a backend that
676
+ * reaches over a network for a hash map is paying a round trip for nothing.
677
+ *
678
+ * JSON-typed, matching `CacheClient`: values round-trip as whatever was stored.
679
+ */
680
+
681
+ interface MemoryCacheOptions {
682
+ /** Bound on entries held. On overflow the soonest-to-expire are dropped. */
683
+ maxEntries?: number;
684
+ /** Injectable clock, for tests. */
685
+ now?: () => number;
686
+ }
687
+ /**
688
+ * Build an in-process cache.
689
+ *
690
+ * `getOrSet` is single-flight: concurrent misses on one key share one fill, so
691
+ * a cold key under load does not become N identical expensive calls.
692
+ */
693
+ declare function makeMemoryCache(opts?: MemoryCacheOptions): CacheClient;
694
+
695
+ /**
696
+ * engine/fence.ts — what the tenant's own code may reach.
697
+ *
698
+ * The isolate used to answer this by construction: tenant code ran in a realm
699
+ * with no ambient network and an environment scrubbed of every secret, and each
700
+ * privileged call hopped to a host that held the credentials. Running the
701
+ * backend as one process removes that wall, so the two guarantees it carried
702
+ * have to be re-made here — deliberately, and with the honest note that a
703
+ * same-process fence is a SPEED BUMP against the tenant's own code, not a
704
+ * sandbox. The real boundary is the machine: each tenant has its own.
705
+ *
706
+ * That is not a hole, it is a scope. The tenant owns this database and this
707
+ * network namespace; the thing worth preventing is an ACCIDENT — a dependency
708
+ * that reads `process.env` and posts it somewhere, a handler that opens its own
709
+ * unscoped connection and quietly serves every user's rows — not a determined
710
+ * operator attacking their own stack.
711
+ */
712
+ interface ScrubResult {
713
+ removed: string[];
714
+ kept: string[];
715
+ }
716
+ /**
717
+ * Delete the engine's own credentials from `process.env`.
718
+ *
719
+ * MUST run AFTER the config is read and BEFORE the tenant bundle is imported —
720
+ * a bundle's module-level code runs at import, so a scrub that comes later has
721
+ * already lost the race.
722
+ *
723
+ * The tenant's OWN variables (`PALBASE_VAR_*` and anything else) are untouched:
724
+ * this removes what the platform put there, not what the operator did.
725
+ *
726
+ * Why RLS makes this matter: the engine binds every request to the caller with
727
+ * `set_config('role', …)` so Postgres does the row filtering. Code that finds
728
+ * `DATABASE_URL` can open its own connection as the owner and read every user's
729
+ * rows — not by attacking anything, just by using a driver.
730
+ */
731
+ declare function scrubSecrets(env: Record<string, string | undefined>): ScrubResult;
732
+ interface EgressPolicy {
733
+ /** Hostnames the tenant declared. Empty ⇒ no declaration was made. */
734
+ allow: readonly string[];
735
+ /** Per-call ceiling in ms. 0 ⇒ no ceiling declared. */
736
+ timeoutMs: number;
737
+ /** What to do when nothing was declared. */
738
+ whenUndeclared: "allow" | "deny";
739
+ /**
740
+ * Hosts the BACKEND ITSELF needs: its module surface, the JWKS it verifies
741
+ * tokens against, the artifact store it reloads from.
742
+ *
743
+ * These are not egress. `config/egress.ts` declares where the tenant's own
744
+ * code may reach; a call to the platform this backend is part of is internal
745
+ * traffic, and fencing it means the first deploy with an allowlist takes the
746
+ * backend down — which is exactly what happened when this list did not exist:
747
+ * every request 500'd with "egress denied: palsvc is not in this backend's
748
+ * declared allowlist", and the artifact reload loop stopped with it.
749
+ */
750
+ alwaysAllow?: readonly string[];
751
+ }
752
+ /** `api.stripe.com` matches itself; `*.stripe.com` matches any subdomain. */
753
+ declare function hostAllowed(host: string, allow: readonly string[]): boolean;
754
+ /**
755
+ * Install the tenant's declared outbound allowlist over `globalThis.fetch`.
756
+ *
757
+ * The engine's own traffic is exempted by HOST (`alwaysAllow`), not by holding
758
+ * a captured reference: the module clients and the verifier resolve
759
+ * `globalThis.fetch` at CALL time, so a captured original never reaches them.
760
+ * That distinction is not academic — the first version of this file claimed the
761
+ * capture worked, and every request 500'd on the first deploy that declared an
762
+ * allowlist.
763
+ *
764
+ * @returns the original fetch, for the engine's own use.
765
+ */
766
+ declare function installEgressFence(policy: EgressPolicy): typeof fetch;
767
+
768
+ /**
769
+ * engine/index.ts — the engine: a backend that boots itself.
770
+ *
771
+ * `createApp` turns a set of `@Controller` classes into a `fetch(Request)`
772
+ * handler. No V8 isolate, no capability hop: this process owns its database
773
+ * pool, verifies its own tokens, applies its own rate limits, and calls the
774
+ * modules directly.
775
+ *
776
+ * import { createApp, loadConfig } from "@palbase/backend/engine";
777
+ *
778
+ * const app = await createApp({
779
+ * config: loadConfig(process.env),
780
+ * controllers: [TodosController],
781
+ * schema,
782
+ * });
783
+ * Bun.serve({ port: app.config.port, fetch: app.handle });
784
+ *
785
+ * The Web-standard `fetch` signature is the point: the same handler runs under
786
+ * Bun, Deno and any host that speaks Request/Response, so "works locally" and
787
+ * "works in the cloud" are the same code path rather than two.
788
+ */
789
+
790
+ /** The `__`-prefixed request-scope seam, as re-exported by a deployed bundle. */
791
+ interface RuntimeHooks {
792
+ __runWithRuntime: typeof __runWithRuntime;
793
+ __requestALS: typeof __requestALS;
794
+ }
795
+ /** The module singletons the engine injects, minus the two it owns itself. */
796
+ type ModuleClients = Partial<Pick<RuntimeServices, "Documents" | "Storage" | "Notifications" | "Flags" | "Realtime" | "Purchases" | "Secrets">>;
797
+ interface CreateAppOptions {
798
+ config: EngineConfig;
799
+ /** `@Controller` classes. A class that collected zero routes is fatal. */
800
+ controllers: readonly unknown[];
801
+ /** The project's `defineSchema()` result, for the typed `.tables` surface. */
802
+ schema?: unknown;
803
+ /** The SQL driver. Omitted ⇒ built from `Bun.sql` when running under Bun. */
804
+ sql?: SqlDriver;
805
+ /** Module clients. Omitted ⇒ each corresponding singleton throws when used. */
806
+ modules?: ModuleClients;
807
+ /** Cache. Omitted ⇒ this process's own memory. */
808
+ cache?: CacheClient;
809
+ /**
810
+ * The request-scope hooks to run handlers inside.
811
+ *
812
+ * MUST come from the SAME `@palbase/backend` module instance the loaded
813
+ * controllers were bundled against. A deployed bundle inlines its own copy of
814
+ * the SDK and re-exports these two; the engine here has its own. Two copies
815
+ * mean two AsyncLocalStorage instances, and the store this engine sets is not
816
+ * the store the handler's `Database` proxy reads — every service would be
817
+ * undefined at the first call, with nothing in the logs to say why. So the
818
+ * host passes the BUNDLE's hooks and the seam closes.
819
+ *
820
+ * Omitted ⇒ this module's own, which is correct only when the controllers
821
+ * were built against this same instance (tests, a single-package project).
822
+ */
823
+ runtimeHooks?: RuntimeHooks;
824
+ logger?: Pick<Console, "info" | "warn" | "error" | "debug">;
825
+ }
826
+ interface App {
827
+ handle: (req: Request) => Promise<Response>;
828
+ routes: readonly RouteEntry[];
829
+ config: EngineConfig;
830
+ /** Close the pool and release resources. */
831
+ shutdown: () => Promise<void>;
832
+ }
833
+ /**
834
+ * Build the app. Fails fast: the database is reached here, at boot, rather than
835
+ * on the first request that needs it.
836
+ */
837
+ declare function createApp(opts: CreateAppOptions): Promise<App>;
838
+
839
+ export { type App as A, BootRefused as B, Cache as C, Database as D, type EgressPolicy as E, Flags as F, effectiveAuth as G, hostAllowed as H, installEgressFence as I, loadConfig as J, makeMemoryCache as K, type LimitState as L, type ModuleClients as M, Notifications as N, matchRoute as O, Purchases as P, quoteIdent as Q, Realtime as R, Secrets as S, scrubSecrets as T, withTables as U, __getRuntime as _, Documents as a, Log as b, type PurchasesService as c, type RequestStore as d, type RuntimeServices as e, type SpendOptions as f, Storage as g, type StoreEnv as h, __requestALS as i, __runWithRuntime as j, __setRuntime as k, AuthVerifier as l, type CreateAppOptions as m, type EngineConfig as n, RateLimiter as o, type RequestDatabase as p, type RouteEntry as q, type RuntimeHooks as r, type ScrubResult as s, type SqlDriver as t, type SqlTx as u, buildRouteTable as v, createApp as w, createLazyTransaction as x, createOps as y, createRequestDatabase as z };