@palbase/backend 17.4.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.
- package/dist/bin/palbase-backend.cjs +1848 -0
- package/dist/bin/palbase-backend.cjs.map +1 -0
- package/dist/bin/palbase-backend.d.cts +1 -0
- package/dist/bin/palbase-backend.d.ts +1 -0
- package/dist/bin/palbase-backend.js +168 -0
- package/dist/bin/palbase-backend.js.map +1 -0
- package/dist/chunk-7D4SUZUM.js +38 -0
- package/dist/chunk-7D4SUZUM.js.map +1 -0
- package/dist/chunk-N32VDWKH.js +172 -0
- package/dist/chunk-N32VDWKH.js.map +1 -0
- package/dist/chunk-POYAFBLF.js +189 -0
- package/dist/chunk-POYAFBLF.js.map +1 -0
- package/dist/chunk-QMVK4X3V.js +200 -0
- package/dist/chunk-QMVK4X3V.js.map +1 -0
- package/dist/chunk-SSGAMC26.js +342 -0
- package/dist/chunk-SSGAMC26.js.map +1 -0
- package/dist/chunk-VYH4U7ZQ.js +1138 -0
- package/dist/chunk-VYH4U7ZQ.js.map +1 -0
- package/dist/{chunk-AAN642N5.js → chunk-W5ODXPY3.js} +2 -336
- package/dist/chunk-W5ODXPY3.js.map +1 -0
- package/dist/chunk-YL4C5NRY.js +90 -0
- package/dist/chunk-YL4C5NRY.js.map +1 -0
- package/dist/db/env.cjs.map +1 -1
- package/dist/db/env.d.cts +21 -1
- package/dist/db/env.d.ts +21 -1
- package/dist/db/index.cjs.map +1 -1
- package/dist/db/index.d.cts +2 -1
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +9 -6
- package/dist/{index-VLrU7rSW.d.ts → endpoint-B0LpZixz.d.cts} +124 -685
- package/dist/{index-BA_oFAz9.d.cts → endpoint-B0LpZixz.d.ts} +124 -685
- package/dist/engine/index.cjs +1797 -0
- package/dist/engine/index.cjs.map +1 -0
- package/dist/engine/index.d.cts +7 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.js +43 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/index-B46CGNvx.d.cts +839 -0
- package/dist/index-BGSCWlUa.d.cts +674 -0
- package/dist/index-DZDUMth5.d.ts +839 -0
- package/dist/index-g-EzitI-.d.ts +674 -0
- package/dist/index.cjs +1031 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +290 -532
- package/dist/index.d.ts +290 -532
- package/dist/index.js +999 -509
- package/dist/index.js.map +1 -1
- package/dist/openapi/index.cjs +6464 -0
- package/dist/openapi/index.cjs.map +1 -0
- package/dist/openapi/index.d.cts +170 -0
- package/dist/openapi/index.d.ts +170 -0
- package/dist/openapi/index.js +6248 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/registry-3BLYv4si.d.ts +338 -0
- package/dist/registry-Cw0YEYCg.d.cts +338 -0
- package/dist/test/index.js +2 -0
- package/dist/test/index.js.map +1 -1
- package/docs/database.md +16 -3
- package/docs/llms-full.txt +16 -3
- package/package.json +43 -13
- package/stager/package.json +4 -0
- package/stager/return_types.js +338 -0
- package/stager/stage.js +78 -0
- package/stager/throw_analysis.js +726 -0
- package/template/AGENTS.md +261 -0
- package/template/config/secrets.ts +24 -0
- package/template/controllers/health.controller.ts +30 -0
- package/template/db/schema.ts +35 -0
- package/template/package.json +18 -0
- package/template/tsconfig.json +30 -0
- package/LICENSE +0 -21
- package/dist/chunk-AAN642N5.js.map +0 -1
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import { Buckets, BucketTypes } from './db/env.js';
|
|
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.js';
|
|
4
|
+
import { E as EnvTypedDatabase } from './index-g-EzitI-.js';
|
|
5
|
+
import { R as RouteMeta } from './registry-3BLYv4si.js';
|
|
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 };
|