@rebasepro/server 0.16.0 → 0.16.1-canary.g2d1aec8
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/api/contract-routes.d.ts +1 -1
- package/dist/api/errors.d.ts +1 -1
- package/dist/api/index.d.ts +3 -3
- package/dist/api/live-schema-routes.d.ts +111 -0
- package/dist/api/logs-routes.d.ts +1 -1
- package/dist/api/mount.d.ts +41 -0
- package/dist/api/rest/api-generator.d.ts +2 -2
- package/dist/api/rest/index.d.ts +1 -1
- package/dist/api/rest/query-parser.d.ts +1 -1
- package/dist/api/schema-editor-routes.d.ts +1 -1
- package/dist/api/types.d.ts +3 -4
- package/dist/{schema-editor-routes-CV9k0w3G.js → ast-schema-editor-BpFYpT38.js} +2 -39
- package/dist/ast-schema-editor-BpFYpT38.js.map +1 -0
- package/dist/auth/adapter-middleware.d.ts +2 -2
- package/dist/auth/admin-roles-route.d.ts +2 -2
- package/dist/auth/admin-user-ops.d.ts +3 -3
- package/dist/auth/admin-users-route.d.ts +4 -4
- package/dist/auth/api-keys/api-key-middleware.d.ts +2 -2
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +1 -1
- package/dist/auth/api-keys/api-key-routes.d.ts +2 -2
- package/dist/auth/api-keys/api-key-store.d.ts +1 -1
- package/dist/auth/api-keys/index.d.ts +9 -9
- package/dist/auth/apple-oauth.d.ts +2 -2
- package/dist/auth/auth-hooks.d.ts +3 -3
- package/dist/auth/bitbucket-oauth.d.ts +2 -2
- package/dist/auth/builtin-auth-adapter.d.ts +7 -4
- package/dist/auth/captcha.d.ts +86 -0
- package/dist/auth/cookie-utils.d.ts +2 -2
- package/dist/auth/discord-oauth.d.ts +2 -2
- package/dist/auth/facebook-oauth.d.ts +2 -2
- package/dist/auth/github-oauth.d.ts +2 -2
- package/dist/auth/gitlab-oauth.d.ts +2 -2
- package/dist/auth/google-oauth.d.ts +1 -1
- package/dist/auth/index.d.ts +56 -54
- package/dist/auth/jwks-routes.d.ts +1 -1
- package/dist/auth/jwt.d.ts +1 -1
- package/dist/auth/linkedin-oauth.d.ts +2 -2
- package/dist/auth/magic-link-routes.d.ts +10 -3
- package/dist/auth/mfa-gate.d.ts +1 -1
- package/dist/auth/mfa-routes.d.ts +3 -3
- package/dist/auth/microsoft-oauth.d.ts +2 -2
- package/dist/auth/middleware.d.ts +5 -5
- package/dist/auth/rate-limiter.d.ts +2 -2
- package/dist/auth/require-auth.d.ts +1 -1
- package/dist/auth/reset-password-admin.d.ts +4 -4
- package/dist/auth/routes.d.ts +13 -4
- package/dist/auth/session-routes.d.ts +3 -3
- package/dist/auth/slack-oauth.d.ts +2 -2
- package/dist/auth/spotify-oauth.d.ts +2 -2
- package/dist/auth/token-revocation.d.ts +2 -2
- package/dist/auth/twitter-oauth.d.ts +2 -2
- package/dist/{auth-5Et5mnUA.js → auth-C74dqR2B.js} +250 -15
- package/dist/auth-C74dqR2B.js.map +1 -0
- package/dist/backup/backup-common.d.ts +1 -1
- package/dist/backup/backup-routes.d.ts +3 -3
- package/dist/backup/index.d.ts +3 -3
- package/dist/{backup-C6ljYVTp.js → backup-BJ86ah4T.js} +2 -2
- package/dist/{backup-C6ljYVTp.js.map → backup-BJ86ah4T.js.map} +1 -1
- package/dist/boot/boot.d.ts +5 -5
- package/dist/boot/bundle.d.ts +1 -1
- package/dist/boot/driver.d.ts +1 -1
- package/dist/boot/env.d.ts +13 -1
- package/dist/boot/options.d.ts +30 -6
- package/dist/boot/role.d.ts +3 -2
- package/dist/boot/sources.d.ts +1 -1
- package/dist/collections/BackendCollectionRegistry.d.ts +1 -1
- package/dist/collections/loader.d.ts +1 -1
- package/dist/{contract-routes-DZ-LBpSL.js → contract-routes-BEq7euZg.js} +2 -2
- package/dist/contract-routes-BEq7euZg.js.map +1 -0
- package/dist/cron/cron-routes.d.ts +2 -2
- package/dist/cron/cron-scheduler.d.ts +2 -2
- package/dist/cron/index.d.ts +8 -8
- package/dist/{cron-loader-YhhQeVBM.js → cron-loader-BMvtW6-J.js} +2 -2
- package/dist/{cron-loader-YhhQeVBM.js.map → cron-loader-BMvtW6-J.js.map} +1 -1
- package/dist/{cron-routes-maM_RlUu.js → cron-routes-BvYk-Kmi.js} +2 -2
- package/dist/{cron-routes-maM_RlUu.js.map → cron-routes-BvYk-Kmi.js.map} +1 -1
- package/dist/{cron-scheduler-DIpYBmZP.js → cron-scheduler-Buf-uVam.js} +2 -2
- package/dist/{cron-scheduler-DIpYBmZP.js.map → cron-scheduler-Buf-uVam.js.map} +1 -1
- package/dist/{cron-store-DfH_4Cd9.js → cron-store-CyF-oInV.js} +3 -3
- package/dist/{cron-store-DfH_4Cd9.js.map → cron-store-CyF-oInV.js.map} +1 -1
- package/dist/{ddl-bootstrap-Cywoj8Ta.js → ddl-bootstrap-DgSnERYN.js} +17 -3
- package/dist/ddl-bootstrap-DgSnERYN.js.map +1 -0
- package/dist/dev-secrets.d.ts +52 -0
- package/dist/email/dev-sink.d.ts +67 -0
- package/dist/email/index.d.ts +9 -7
- package/dist/email/link-base.d.ts +1 -1
- package/dist/email/smtp-email-service.d.ts +1 -1
- package/dist/env.d.ts +1 -1
- package/dist/{errors-EBYiaJ2E.js → errors-D_LwNKRM.js} +5 -5
- package/dist/errors-D_LwNKRM.js.map +1 -0
- package/dist/{function-loader-DDS1v7YX.js → function-loader-BrLmC_-y.js} +3 -3
- package/dist/{function-loader-DDS1v7YX.js.map → function-loader-BrLmC_-y.js.map} +1 -1
- package/dist/functions/context.d.ts +141 -0
- package/dist/functions/define-function.d.ts +1 -1
- package/dist/functions/function-routes.d.ts +2 -2
- package/dist/functions/guards.d.ts +76 -0
- package/dist/functions/index.d.ts +96 -5
- package/dist/functions/index.js +919 -0
- package/dist/functions/index.js.map +1 -0
- package/dist/functions/internal.d.ts +26 -0
- package/dist/functions/proxy.d.ts +1 -1
- package/dist/functions/request-timeout.d.ts +9 -1
- package/dist/functions/runtime-env.d.ts +92 -0
- package/dist/functions/wait-until.d.ts +74 -0
- package/dist/history/history-routes.d.ts +2 -2
- package/dist/history/index.d.ts +1 -1
- package/dist/index.d.ts +63 -54
- package/dist/index.es.js +2183 -164
- package/dist/index.es.js.map +1 -1
- package/dist/init/docs.d.ts +1 -1
- package/dist/init/middlewares.d.ts +1 -1
- package/dist/init/shutdown.d.ts +4 -0
- package/dist/init/storage.d.ts +1 -1
- package/dist/init/surfaces.d.ts +10 -0
- package/dist/init.d.ts +104 -18
- package/dist/jobs/index.d.ts +5 -5
- package/dist/{jobs-CyOKXXlu.js → jobs-Bn6nhY8J.js} +3 -3
- package/dist/{jobs-CyOKXXlu.js.map → jobs-Bn6nhY8J.js.map} +1 -1
- package/dist/{jwt-DxH9fLPt.js → jwt-BbJi0TR0.js} +2 -2
- package/dist/{jwt-DxH9fLPt.js.map → jwt-BbJi0TR0.js.map} +1 -1
- package/dist/{logger-DfvF_8r-.js → logger-TdvXIGqR.js} +101 -8
- package/dist/logger-TdvXIGqR.js.map +1 -0
- package/dist/metrics/index.d.ts +1 -1
- package/dist/{proxy-Bj5DVllb.js → proxy-QJKSS-CV.js} +5 -3
- package/dist/{proxy-Bj5DVllb.js.map → proxy-QJKSS-CV.js.map} +1 -1
- package/dist/{request-timeout-BuFoEKwT.js → request-timeout-OofPCHQT.js} +17 -3
- package/dist/request-timeout-OofPCHQT.js.map +1 -0
- package/dist/rls-audit/index.d.ts +111 -0
- package/dist/schema-edit/apply-schema-change.d.ts +141 -0
- package/dist/schema-edit/github-repository.d.ts +67 -0
- package/dist/schema-edit/local-git-repository.d.ts +28 -0
- package/dist/schema-edit/project-root.d.ts +29 -0
- package/dist/schema-edit/remote-source.d.ts +25 -0
- package/dist/schema-edit/schema-edit-permissions.d.ts +129 -0
- package/dist/schema-editor-routes-yh2cQcdJ.js +44 -0
- package/dist/schema-editor-routes-yh2cQcdJ.js.map +1 -0
- package/dist/services/webhook-service.d.ts +1 -1
- package/dist/singleton.d.ts +7 -0
- package/dist/storage/GCSStorageController.d.ts +1 -1
- package/dist/storage/LocalStorageController.d.ts +1 -1
- package/dist/storage/S3StorageController.d.ts +1 -1
- package/dist/storage/cache-headers.d.ts +87 -0
- package/dist/storage/index.d.ts +11 -11
- package/dist/storage/policies.d.ts +88 -0
- package/dist/storage/range.d.ts +63 -0
- package/dist/storage/routes.d.ts +3 -3
- package/dist/storage/storage-registry.d.ts +1 -1
- package/dist/storage/tus-handler.d.ts +2 -2
- package/dist/utils/host.d.ts +58 -0
- package/dist/utils/logger.d.ts +0 -15
- package/dist/utils/request-id.d.ts +1 -1
- package/functions/package.json +24 -0
- package/package.json +13 -7
- package/dist/auth-5Et5mnUA.js.map +0 -1
- package/dist/contract-routes-DZ-LBpSL.js.map +0 -1
- package/dist/ddl-bootstrap-Cywoj8Ta.js.map +0 -1
- package/dist/errors-EBYiaJ2E.js.map +0 -1
- package/dist/logger-DfvF_8r-.js.map +0 -1
- package/dist/request-timeout-BuFoEKwT.js.map +0 -1
- package/dist/schema-editor-routes-CV9k0w3G.js.map +0 -1
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { env as env$1, getRuntimeKey } from "hono/adapter";
|
|
3
|
+
//#region src/utils/host.ts
|
|
4
|
+
/**
|
|
5
|
+
* The host globals this package reads, behind functions that do not assume Node.
|
|
6
|
+
*
|
|
7
|
+
* `process` is not defined on workerd, on Deno Deploy without the compat flag,
|
|
8
|
+
* or in a browser. That matters here for one specific reason: the portable
|
|
9
|
+
* authoring surface (`@rebasepro/server/functions`) reaches the logger and the
|
|
10
|
+
* error handler, and a bare `process.env.NODE_ENV` inside either of them turns
|
|
11
|
+
* the first log line of a request into a `ReferenceError` on a runtime that has
|
|
12
|
+
* no `process` — a failure that reads as "the framework crashed" rather than
|
|
13
|
+
* "this runtime has no process object".
|
|
14
|
+
*
|
|
15
|
+
* Nothing here throws and nothing here is async. A runtime that cannot answer
|
|
16
|
+
* gets the empty answer, because every caller in this file's blast radius is
|
|
17
|
+
* choosing a log level or a format, and the safe default for both is the
|
|
18
|
+
* development one.
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Where an adapter with no `process` can publish the environment.
|
|
24
|
+
*
|
|
25
|
+
* Cloudflare Workers hand the environment to the *request*, not to the module,
|
|
26
|
+
* so there is no global to read at import time. An edge adapter that has
|
|
27
|
+
* already seen a request can stash the bag here and every contextless reader in
|
|
28
|
+
* the framework — the logger, chiefly — starts answering correctly.
|
|
29
|
+
*
|
|
30
|
+
* `Symbol.for` rather than a module-local for the same reason the singleton
|
|
31
|
+
* uses it: more than one copy of this module can be loaded into one process,
|
|
32
|
+
* and a module-local would leave every copy but the writer's blind. See
|
|
33
|
+
* `../singleton.ts`.
|
|
34
|
+
*
|
|
35
|
+
* Request-scoped code should NOT read this. Use `getEnv(c)` from
|
|
36
|
+
* `@rebasepro/server/functions`, which reads the binding attached to the
|
|
37
|
+
* request it is serving — the only correct source on a runtime where two
|
|
38
|
+
* concurrent requests can carry different bindings.
|
|
39
|
+
*/
|
|
40
|
+
var ENV_SLOT = Symbol.for("@rebasepro/server:host-env");
|
|
41
|
+
/**
|
|
42
|
+
* The process environment, or the closest thing this runtime has to one.
|
|
43
|
+
*
|
|
44
|
+
* Order: a bag published by {@link setHostEnv} first, because an adapter that
|
|
45
|
+
* set one knows more than the ambient globals do; then `process.env`; then
|
|
46
|
+
* nothing.
|
|
47
|
+
*/
|
|
48
|
+
function hostEnv() {
|
|
49
|
+
const global = globalThis;
|
|
50
|
+
return global[ENV_SLOT] ?? global.process?.env ?? {};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Write one already-formatted line to the process's output.
|
|
54
|
+
*
|
|
55
|
+
* `process.stdout.write` is preferred where it exists because it is the only
|
|
56
|
+
* one of the two that does not append its own formatting to a line that is
|
|
57
|
+
* already a complete JSON document — `console.log` on Node is
|
|
58
|
+
* `process.stdout.write` plus `util.format`, and `util.format` will happily
|
|
59
|
+
* reinterpret a `%s` that appeared inside a user's log message.
|
|
60
|
+
*
|
|
61
|
+
* Where it does not exist, `console` is the runtime's log sink and is what its
|
|
62
|
+
* platform collects.
|
|
63
|
+
*/
|
|
64
|
+
function writeLine(stream, line) {
|
|
65
|
+
const proc = globalThis.process;
|
|
66
|
+
const sink = stream === "err" ? proc?.stderr : proc?.stdout;
|
|
67
|
+
if (typeof sink?.write === "function") {
|
|
68
|
+
sink.write(line + "\n");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (stream === "err") console.error(line);
|
|
72
|
+
else console.log(line);
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/singleton.ts
|
|
76
|
+
/**
|
|
77
|
+
* The backing instance lives on a process-global slot, NOT in a module-local
|
|
78
|
+
* variable — because more than one copy of this module can be loaded into one
|
|
79
|
+
* process, and a module-local would leave every copy but the booting one dead.
|
|
80
|
+
*
|
|
81
|
+
* That is the normal layout under the managed runtime, not an edge case: the
|
|
82
|
+
* image ships the framework at `/app/node_modules`, while a project's bundle
|
|
83
|
+
* installs its own dependencies into `/bundle/node_modules` — and every custom
|
|
84
|
+
* function imports `defineFunction` from `@rebasepro/server`, which resolves to
|
|
85
|
+
* the bundle's transitively-installed copy. `initializeRebaseBackend()` then ran
|
|
86
|
+
* against `/app`'s copy while every function held `/bundle`'s, so `rebase.data`,
|
|
87
|
+
* `rebase.storage` and `rebase.dataAsAdmin` threw "server not initialized yet"
|
|
88
|
+
* on EVERY request, forever, in an otherwise healthy process.
|
|
89
|
+
*
|
|
90
|
+
* `Symbol.for` is the fix because its registry is per-process rather than
|
|
91
|
+
* per-module: whichever copy boots publishes here, and every other copy — same
|
|
92
|
+
* version or not — reads the same live client.
|
|
93
|
+
*/
|
|
94
|
+
var INSTANCE_SLOT = Symbol.for("@rebasepro/server:singleton-instance");
|
|
95
|
+
/**
|
|
96
|
+
* A *function* that answers "which client is this call for", for hosts where
|
|
97
|
+
* one answer per process is the wrong shape.
|
|
98
|
+
*
|
|
99
|
+
* A Node server boots once and serves every request from the same client, so
|
|
100
|
+
* {@link INSTANCE_SLOT} is the whole story. An isolate-based host is different
|
|
101
|
+
* in a way that matters: the environment arrives attached to the **request**,
|
|
102
|
+
* not to the module, so there is nothing to publish at import time, and an
|
|
103
|
+
* isolate can be reused across requests that must not share state. Such a host
|
|
104
|
+
* registers a resolver — typically reading an `AsyncLocalStorage` populated per
|
|
105
|
+
* request — and it is consulted first.
|
|
106
|
+
*
|
|
107
|
+
* This exists now, before there is a host that needs it, because of what it
|
|
108
|
+
* protects: `rebase` is a lazy Proxy, so every property access already goes
|
|
109
|
+
* through `getInstance()`. Backing that lookup with a resolver is therefore the
|
|
110
|
+
* entire porting story for the singleton — **no function anyone has already
|
|
111
|
+
* written changes**. Take the resolver away and the only alternative is asking
|
|
112
|
+
* users to thread a client through their handlers, which is a rewrite of every
|
|
113
|
+
* function file in existence.
|
|
114
|
+
*/
|
|
115
|
+
var RESOLVER_SLOT = Symbol.for("@rebasepro/server:singleton-resolver");
|
|
116
|
+
function getInstance() {
|
|
117
|
+
const global = globalThis;
|
|
118
|
+
const resolver = global[RESOLVER_SLOT];
|
|
119
|
+
if (resolver) {
|
|
120
|
+
const resolved = resolver();
|
|
121
|
+
if (resolved) return resolved;
|
|
122
|
+
}
|
|
123
|
+
return global[INSTANCE_SLOT] ?? null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The server-side Rebase singleton.
|
|
127
|
+
*
|
|
128
|
+
* Initialized automatically during server startup. Provides access to all
|
|
129
|
+
* app-scoped services: **data**, **auth**, **storage**, and **email**.
|
|
130
|
+
*
|
|
131
|
+
* **Admin data plane** (`rebase.dataAsAdmin`):
|
|
132
|
+
* Backed by the native DataDriver — calls go directly to the database without
|
|
133
|
+
* JSON serialization, HTTP dispatch, or middleware overhead. The driver is
|
|
134
|
+
* scoped once as `{ uid: "service", roles: ["admin"] }` (`SERVICE_IDENTITY`),
|
|
135
|
+
* which makes it **admin-scoped, not RLS-bypassing**: every read and write runs
|
|
136
|
+
* in a transaction that has done `SET LOCAL ROLE rebase_user` with
|
|
137
|
+
* `app.uid = 'service'`, and policies are evaluated against that. No
|
|
138
|
+
* `REBASE_SERVICE_KEY` is required.
|
|
139
|
+
*
|
|
140
|
+
* It clears the default policies through their `rolesOverlap(['admin'])` arm —
|
|
141
|
+
* which is why the difference rarely shows. It shows when you write your own:
|
|
142
|
+
*
|
|
143
|
+
* - `policy.serverContext()` compiles to `rebase.uid() IS NULL` and is therefore
|
|
144
|
+
* **false** for this accessor. A collection with `disableDefaultPolicies:
|
|
145
|
+
* true` whose rule is `serverContext()` denies these writes (`42501`) and
|
|
146
|
+
* returns zero rows — HTTP 200, empty — for these reads.
|
|
147
|
+
* - Its reach equals an `admin`-roled application user's reach. It is not a
|
|
148
|
+
* private door.
|
|
149
|
+
*
|
|
150
|
+
* `rebase.sql()` *is* an unconditional bypass — it runs on the owner connection
|
|
151
|
+
* and never goes through `withAuth`. Of the two accessors on this object, the
|
|
152
|
+
* quieter one is the more privileged.
|
|
153
|
+
*
|
|
154
|
+
* ⚠️ `rebase.dataAsAdmin` is for trusted background work (cron jobs,
|
|
155
|
+
* migrations, service tasks) — **not** for serving user-facing data. Inside a
|
|
156
|
+
* request handler, run user-scoped queries through the request-scoped driver
|
|
157
|
+
* (`c.var.driver`), which carries the caller's identity.
|
|
158
|
+
*
|
|
159
|
+
* `rebase.data` is **gone from the type**: `RebaseServerClient` omits it, so the
|
|
160
|
+
* admin-scoped accessor has exactly one name and the privilege is visible at the
|
|
161
|
+
* call site. The property still exists at runtime, aliasing `dataAsAdmin`, so an
|
|
162
|
+
* untyped JavaScript caller keeps working rather than failing on `undefined`.
|
|
163
|
+
*
|
|
164
|
+
* **Control plane** (`rebase.auth`, `rebase.admin`, `rebase.storage`, etc.):
|
|
165
|
+
* Routes through the Hono app's internal request handler. An internal per-boot
|
|
166
|
+
* credential is generated automatically when `REBASE_SERVICE_KEY` is not set,
|
|
167
|
+
* so control-plane calls always authenticate.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```typescript
|
|
171
|
+
* import { rebase } from "@rebasepro/server";
|
|
172
|
+
*
|
|
173
|
+
* // In a cron job, hook, or trusted service file (admin scope, RLS evaluated
|
|
174
|
+
* // as `{ uid: "service", roles: ["admin"] }`):
|
|
175
|
+
* await rebase.email.send({ to: "admin@co.com", subject: "Alert", html: "<p>Hi</p>" });
|
|
176
|
+
* const jobs = await rebase.dataAsAdmin.jobs.find({ limit: 10 });
|
|
177
|
+
* ```
|
|
178
|
+
*/
|
|
179
|
+
var rebase = new Proxy({}, {
|
|
180
|
+
get(_, prop) {
|
|
181
|
+
const instance = getInstance();
|
|
182
|
+
if (!instance) throw new Error(`rebase.${String(prop)}: server not initialized yet. The singleton is available after Rebase starts — don't call it at import time.`);
|
|
183
|
+
return instance[prop];
|
|
184
|
+
},
|
|
185
|
+
set(_, prop) {
|
|
186
|
+
throw new Error(`Cannot set rebase.${String(prop)} directly. The singleton is read-only. Use _initRebase() during server startup.`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/functions/define-function.ts
|
|
191
|
+
/**
|
|
192
|
+
* Typed authoring contract for a custom backend function.
|
|
193
|
+
*
|
|
194
|
+
* A custom function is a file in the `functionsDir` that default-exports a
|
|
195
|
+
* Hono app; the loader mounts it at `/<filename>`. `defineFunction` is the
|
|
196
|
+
* typed opt-in for that contract: it hands you a pre-typed `Hono<HonoEnv>`
|
|
197
|
+
* app (so `c.var.user` / `c.var.driver` are typed) plus a
|
|
198
|
+
* {@link RebaseFunctionContext}, and returns exactly the Hono app the loader
|
|
199
|
+
* already accepts — so it is fully interchangeable with a plain
|
|
200
|
+
* `export default new Hono()`.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* import { defineFunction, requireAuth } from "@rebasepro/server";
|
|
205
|
+
*
|
|
206
|
+
* export default defineFunction((app, { rebase }) => {
|
|
207
|
+
* app.use("/*", requireAuth);
|
|
208
|
+
* app.get("/home", async (c) => {
|
|
209
|
+
* // `rebase.sql` runs on the owner connection: no RLS, no policies,
|
|
210
|
+
* // every row. It is the most privileged thing in this context —
|
|
211
|
+
* // more so than `dataAsAdmin`, which is merely admin-scoped.
|
|
212
|
+
* const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
|
|
213
|
+
* return c.json({ orders: Number(stats.n) });
|
|
214
|
+
* });
|
|
215
|
+
* });
|
|
216
|
+
* ```
|
|
217
|
+
*
|
|
218
|
+
* @param definition Receives the function's Hono app and the typed context.
|
|
219
|
+
* Register routes on the provided `app` and return nothing, or return your
|
|
220
|
+
* own `Hono<HonoEnv>` app to use instead.
|
|
221
|
+
* @returns The Hono app to default-export from the function file.
|
|
222
|
+
*/
|
|
223
|
+
function defineFunction(definition) {
|
|
224
|
+
const app = new Hono();
|
|
225
|
+
const returned = definition(app, { rebase });
|
|
226
|
+
return returned instanceof Hono ? returned : app;
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/auth/admin-roles.ts
|
|
230
|
+
/**
|
|
231
|
+
* Which role names carry administrative privilege.
|
|
232
|
+
*
|
|
233
|
+
* One definition, because there used to be several and they disagreed.
|
|
234
|
+
* `requireAdmin` accepted `admin` **or** `schema-admin`; the guard that refuses
|
|
235
|
+
* a dangerous `defaultRole` compared against `admin` alone. So
|
|
236
|
+
* `AUTH_DEFAULT_ROLE=schema-admin` passed the guard and made every public
|
|
237
|
+
* registrant an administrator — and, since a `schema-admin` may edit users, one
|
|
238
|
+
* of them could then grant themselves real `admin` and keep it.
|
|
239
|
+
*
|
|
240
|
+
* The guard and the check have to read the same list or the gap comes back, so
|
|
241
|
+
* neither of them owns it.
|
|
242
|
+
*
|
|
243
|
+
* `schema-admin` is here deliberately: it can reach the schema editor and the
|
|
244
|
+
* SQL surfaces, which is administrative by any definition that matters. If a
|
|
245
|
+
* genuinely lesser role is added later, it does not belong in this list — it
|
|
246
|
+
* belongs in a capability check of its own.
|
|
247
|
+
*/
|
|
248
|
+
var ADMINISTRATIVE_ROLES = ["admin", "schema-admin"];
|
|
249
|
+
/** Does this single role name carry administrative privilege? */
|
|
250
|
+
function isAdministrativeRole(role) {
|
|
251
|
+
return ADMINISTRATIVE_ROLES.includes(role);
|
|
252
|
+
}
|
|
253
|
+
/** Does any role in this list carry administrative privilege? */
|
|
254
|
+
function hasAdministrativeRole(roles) {
|
|
255
|
+
return !!roles?.some(isAdministrativeRole);
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/functions/context.ts
|
|
259
|
+
function read(c, key) {
|
|
260
|
+
return c.get(key);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The authenticated caller, or `undefined` for an anonymous request.
|
|
264
|
+
*
|
|
265
|
+
* **`undefined` is not a permission decision.** The functions router mounts its
|
|
266
|
+
* auth middleware with `requireAuth: false` on purpose — a webhook receiver has
|
|
267
|
+
* no token to send — so an anonymous caller reaches the handler and reads
|
|
268
|
+
* `undefined` here while the handler runs on regardless. Use {@link requireAuth}
|
|
269
|
+
* (or a `!user` branch that returns 401) to make it a decision.
|
|
270
|
+
*
|
|
271
|
+
* A caller who presented a *bad* token never gets this far: both auth
|
|
272
|
+
* middlewares reject an unverifiable token with 401 before the router is
|
|
273
|
+
* reached, precisely so an expired session cannot be silently downgraded to an
|
|
274
|
+
* anonymous one.
|
|
275
|
+
*/
|
|
276
|
+
function getUser(c) {
|
|
277
|
+
const raw = read(c, "user");
|
|
278
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
279
|
+
const record = raw;
|
|
280
|
+
const uid = typeof record.uid === "string" ? record.uid : typeof record.userId === "string" ? record.userId : void 0;
|
|
281
|
+
if (uid === void 0) return void 0;
|
|
282
|
+
const roles = Array.isArray(record.roles) ? record.roles.filter((role) => typeof role === "string") : [];
|
|
283
|
+
return {
|
|
284
|
+
...record,
|
|
285
|
+
uid,
|
|
286
|
+
roles
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
/** The caller's id, or `undefined` when nobody is signed in. */
|
|
290
|
+
function getUserId(c) {
|
|
291
|
+
return getUser(c)?.uid;
|
|
292
|
+
}
|
|
293
|
+
/** The caller's roles. Empty for an anonymous request — never `undefined`. */
|
|
294
|
+
function getRoles(c) {
|
|
295
|
+
return getUser(c)?.roles ?? [];
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Whether the caller holds **any** of the named roles.
|
|
299
|
+
*
|
|
300
|
+
* Any rather than all, because that is what a route guard means by a list of
|
|
301
|
+
* roles; require several by calling this more than once.
|
|
302
|
+
*/
|
|
303
|
+
function hasRole(c, ...roles) {
|
|
304
|
+
if (roles.length === 0) return false;
|
|
305
|
+
const held = new Set(getRoles(c));
|
|
306
|
+
return roles.some((role) => held.has(role));
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Whether the caller holds an administrative role.
|
|
310
|
+
*
|
|
311
|
+
* Delegates to the single definition in `auth/admin-roles.ts` — which is
|
|
312
|
+
* `admin` **or** `schema-admin` — rather than comparing against `"admin"`.
|
|
313
|
+
* Those two lists disagreed once, and the gap made every public registrant an
|
|
314
|
+
* administrator; see that file.
|
|
315
|
+
*/
|
|
316
|
+
function isAdmin(c) {
|
|
317
|
+
return hasAdministrativeRole(getRoles(c));
|
|
318
|
+
}
|
|
319
|
+
/** Whether the request carries an identity at all. */
|
|
320
|
+
function isAuthenticated(c) {
|
|
321
|
+
return getUser(c) !== void 0;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The request-scoped data driver: reads and writes run as **the caller**, with
|
|
325
|
+
* your row-level security policies evaluated against their identity.
|
|
326
|
+
*
|
|
327
|
+
* This is the accessor to reach for when a function serves user-facing data.
|
|
328
|
+
* `rebase.dataAsAdmin` is the other one, and it is not the same thing — it runs
|
|
329
|
+
* as `{ uid: "service", roles: ["admin"] }` for every caller alike, which is
|
|
330
|
+
* correct for trusted background work and wrong for a request.
|
|
331
|
+
*
|
|
332
|
+
* `undefined` only when no Rebase auth middleware ran (see
|
|
333
|
+
* {@link identityResolved}); inside a function mounted by the framework it is
|
|
334
|
+
* always present, anonymous requests included — they get an anon-scoped driver
|
|
335
|
+
* so policies still have an identity to evaluate.
|
|
336
|
+
*/
|
|
337
|
+
function getDriver(c) {
|
|
338
|
+
return read(c, "driver");
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* {@link getDriver}, but throws instead of handing back `undefined`.
|
|
342
|
+
*
|
|
343
|
+
* For the common case where a handler cannot proceed without it and would
|
|
344
|
+
* otherwise write `c.get("driver")!` — an assertion that turns a wiring problem
|
|
345
|
+
* into `Cannot read properties of undefined (reading 'fetchCollection')` twenty
|
|
346
|
+
* lines away from the cause.
|
|
347
|
+
*/
|
|
348
|
+
function requireDriver(c) {
|
|
349
|
+
const driver = getDriver(c);
|
|
350
|
+
if (!driver) throw new Error("No request-scoped driver on this context. A Rebase auth middleware populates it before any custom function runs, so this means the handler was mounted outside the functions router — e.g. added to your own Hono app directly. Mount it from the functions directory, or use `rebase.dataAsAdmin` if the work is genuinely service-scoped.");
|
|
351
|
+
return driver;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The API key this request authenticated with, masked, or `undefined` when it
|
|
355
|
+
* did not use one.
|
|
356
|
+
*
|
|
357
|
+
* Useful for attribution and for per-key behaviour. The permission check itself
|
|
358
|
+
* has already happened — reaching a handler means the key was allowed to.
|
|
359
|
+
*/
|
|
360
|
+
function getApiKey(c) {
|
|
361
|
+
return read(c, "apiKey");
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* The correlation id for this request — generated, or taken from an inbound
|
|
365
|
+
* `X-Request-ID`.
|
|
366
|
+
*
|
|
367
|
+
* Log it. It is the only thing that ties a line written inside a function to
|
|
368
|
+
* the framework's own lines for the same request.
|
|
369
|
+
*/
|
|
370
|
+
function getRequestId(c) {
|
|
371
|
+
return read(c, "requestId");
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Whether a Rebase auth middleware has run on this request.
|
|
375
|
+
*
|
|
376
|
+
* Both middlewares populate `driver` for *every* outcome, anonymous included,
|
|
377
|
+
* and populate `user` whenever there is one. So "neither is set" does not mean
|
|
378
|
+
* "anonymous" — it means nothing resolved the identity, and treating that as
|
|
379
|
+
* anonymous is the dangerous reading. The guards use this to tell a genuinely
|
|
380
|
+
* anonymous caller (401) from a misconfigured mount (500), because answering
|
|
381
|
+
* 401 to the second sends whoever is debugging it to look at the token.
|
|
382
|
+
*/
|
|
383
|
+
function identityResolved(c) {
|
|
384
|
+
return read(c, "user") !== void 0 || read(c, "driver") !== void 0;
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/functions/guards.ts
|
|
388
|
+
/**
|
|
389
|
+
* The answer to "a guard ran, but no middleware had resolved anything".
|
|
390
|
+
*
|
|
391
|
+
* Deliberately a 500 and not a 401. A 401 tells the caller their credential is
|
|
392
|
+
* the problem, and here the caller's credential was never looked at — sending
|
|
393
|
+
* them to check their token is sending them to the one place the answer is not.
|
|
394
|
+
*/
|
|
395
|
+
function unresolvedIdentity() {
|
|
396
|
+
return { error: {
|
|
397
|
+
message: "This route's identity was never resolved: no Rebase auth middleware ran before the guard. A function loaded from the functions directory always has one. This usually means the Hono app was mounted onto your own server directly, bypassing the functions router.",
|
|
398
|
+
code: "AUTH_MIDDLEWARE_MISSING"
|
|
399
|
+
} };
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Reject anonymous callers with 401.
|
|
403
|
+
*
|
|
404
|
+
* Put it in the route's own middleware slot rather than `app.use("/*", …)`:
|
|
405
|
+
* `use()` covers only the routes declared *below* it, so a route appended later
|
|
406
|
+
* — by you, months from now, at the bottom of the file — is silently
|
|
407
|
+
* unprotected. The per-route form cannot drift that way.
|
|
408
|
+
*
|
|
409
|
+
* @example
|
|
410
|
+
* ```ts
|
|
411
|
+
* app.post("/", requireAuth, async (c) => {
|
|
412
|
+
* const user = getUser(c)!; // guaranteed by the guard
|
|
413
|
+
* return c.json({ uid: user.uid });
|
|
414
|
+
* });
|
|
415
|
+
* ```
|
|
416
|
+
*/
|
|
417
|
+
var requireAuth = async (c, next) => {
|
|
418
|
+
if (getUser(c)) return next();
|
|
419
|
+
if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);
|
|
420
|
+
return c.json({ error: {
|
|
421
|
+
message: "Authentication required",
|
|
422
|
+
code: "UNAUTHORIZED"
|
|
423
|
+
} }, 401);
|
|
424
|
+
};
|
|
425
|
+
/**
|
|
426
|
+
* Reject callers without an administrative role with 403.
|
|
427
|
+
*
|
|
428
|
+
* Must come **after** {@link requireAuth}: on its own it answers 401 for an
|
|
429
|
+
* anonymous caller, which is right, but pairing them keeps the two failures
|
|
430
|
+
* distinguishable — 401 "who are you", 403 "not you".
|
|
431
|
+
*
|
|
432
|
+
* Administrative means `admin` or `schema-admin`, from the single list in
|
|
433
|
+
* `auth/admin-roles.ts`. Do not compare against `"admin"` by hand; that is the
|
|
434
|
+
* divergence that list exists to prevent.
|
|
435
|
+
*/
|
|
436
|
+
var requireAdmin = async (c, next) => {
|
|
437
|
+
if (!getUser(c)) {
|
|
438
|
+
if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);
|
|
439
|
+
return c.json({ error: {
|
|
440
|
+
message: "Authentication required",
|
|
441
|
+
code: "UNAUTHORIZED"
|
|
442
|
+
} }, 401);
|
|
443
|
+
}
|
|
444
|
+
if (!isAdmin(c)) return c.json({ error: {
|
|
445
|
+
message: "Admin privileges required for this operation",
|
|
446
|
+
code: "FORBIDDEN"
|
|
447
|
+
} }, 403);
|
|
448
|
+
return next();
|
|
449
|
+
};
|
|
450
|
+
/**
|
|
451
|
+
* Reject callers holding none of the named roles with 403.
|
|
452
|
+
*
|
|
453
|
+
* Any of them, not all — require several by chaining the guard twice. Naming no
|
|
454
|
+
* role at all is a programming error and throws at module load rather than at
|
|
455
|
+
* request time, because `requireRole()` with an empty list would otherwise read
|
|
456
|
+
* as a guard while admitting everyone.
|
|
457
|
+
*
|
|
458
|
+
* @example
|
|
459
|
+
* ```ts
|
|
460
|
+
* app.post("/publish", requireAuth, requireRole("editor", "admin"), handler);
|
|
461
|
+
* ```
|
|
462
|
+
*/
|
|
463
|
+
function requireRole(...roles) {
|
|
464
|
+
if (roles.length === 0) throw new Error("requireRole() needs at least one role. An empty list would admit every signed-in caller while reading as a restriction.");
|
|
465
|
+
const allowed = new Set(roles);
|
|
466
|
+
return async (c, next) => {
|
|
467
|
+
if (!getUser(c)) {
|
|
468
|
+
if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);
|
|
469
|
+
return c.json({ error: {
|
|
470
|
+
message: "Authentication required",
|
|
471
|
+
code: "UNAUTHORIZED"
|
|
472
|
+
} }, 401);
|
|
473
|
+
}
|
|
474
|
+
if (!getRoles(c).some((role) => allowed.has(role))) return c.json({ error: {
|
|
475
|
+
message: `This operation requires one of these roles: ${roles.join(", ")}`,
|
|
476
|
+
code: "FORBIDDEN"
|
|
477
|
+
} }, 403);
|
|
478
|
+
return next();
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/functions/runtime-env.ts
|
|
483
|
+
/**
|
|
484
|
+
* Every environment variable visible to this request.
|
|
485
|
+
*
|
|
486
|
+
* `c.env` on workerd, `Deno.env` on Deno, `process.env` on Node, Bun and
|
|
487
|
+
* Vercel's edge runtime. Reading it through here rather than through `process`
|
|
488
|
+
* is the whole of what makes a function's configuration portable.
|
|
489
|
+
*/
|
|
490
|
+
function getEnv(c) {
|
|
491
|
+
return env$1(c);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* One environment variable, or `undefined`.
|
|
495
|
+
*
|
|
496
|
+
* Trimmed, and a blank value counts as absent — declaring a variable and
|
|
497
|
+
* leaving it empty is the ordinary way to write a compose file or a `.env`
|
|
498
|
+
* line, and nobody has ever meant `""` by it.
|
|
499
|
+
*/
|
|
500
|
+
function env(c, name) {
|
|
501
|
+
const raw = getEnv(c)[name];
|
|
502
|
+
if (typeof raw !== "string") return void 0;
|
|
503
|
+
const trimmed = raw.trim();
|
|
504
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* One environment variable, or a thrown error naming it.
|
|
508
|
+
*
|
|
509
|
+
* For configuration a handler cannot run without. Throwing here — inside the
|
|
510
|
+
* request — is deliberately better than the module-scope `process.env.KEY!`
|
|
511
|
+
* it replaces: that one takes the whole *file* down at load time, and the
|
|
512
|
+
* loader reports it as "this function could not be imported", which names the
|
|
513
|
+
* file but not the variable. This fails one request, with the name in the
|
|
514
|
+
* message, while every other route in the file keeps serving.
|
|
515
|
+
*/
|
|
516
|
+
function requireEnv(c, name) {
|
|
517
|
+
const value = env(c, name);
|
|
518
|
+
if (value === void 0) throw new Error(`Missing required environment variable ${name}. Set it on the process (or as a binding, on a host that has them) — and read it inside the handler, not at module scope, so the same file works on both.`);
|
|
519
|
+
return value;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Which host this is: `"node"`, `"workerd"`, `"deno"`, `"bun"`, `"edge-light"`,
|
|
523
|
+
* `"fastly"` or `"other"`.
|
|
524
|
+
*
|
|
525
|
+
* Use it to *degrade*, not to branch a whole implementation — a function that
|
|
526
|
+
* needs two implementations is two functions. It is honest about one thing in
|
|
527
|
+
* particular: a Node-only capability, such as `rebase.sql()`, can check this
|
|
528
|
+
* and say so, instead of failing at the call.
|
|
529
|
+
*/
|
|
530
|
+
function runtimeKey() {
|
|
531
|
+
return getRuntimeKey();
|
|
532
|
+
}
|
|
533
|
+
/** Whether this is a Node-like host — Node itself, or Bun's Node compatibility. */
|
|
534
|
+
function isNodeRuntime() {
|
|
535
|
+
const key = getRuntimeKey();
|
|
536
|
+
return key === "node" || key === "bun";
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Build something expensive once per environment, on first use, from inside a
|
|
540
|
+
* request.
|
|
541
|
+
*
|
|
542
|
+
* This is the sanctioned replacement for the module-scope client:
|
|
543
|
+
*
|
|
544
|
+
* ```ts
|
|
545
|
+
* // Don't: runs at import time, before bindings exist on some hosts.
|
|
546
|
+
* const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
547
|
+
*
|
|
548
|
+
* // Do: runs on first request, reads that request's configuration.
|
|
549
|
+
* const stripe = lazyResource(env => new Stripe(env.STRIPE_SECRET_KEY!));
|
|
550
|
+
* app.post("/", async (c) => { await stripe(c).charges.list(); });
|
|
551
|
+
* ```
|
|
552
|
+
*
|
|
553
|
+
* The factory runs once per distinct environment object and the result is
|
|
554
|
+
* cached against it. On Node that is once per process, because `process.env` is
|
|
555
|
+
* one object for the life of the process — identical to the module-scope
|
|
556
|
+
* version in cost, and unlike it, deferred until configuration exists. On a
|
|
557
|
+
* host that hands each request a fresh binding object it is once per object,
|
|
558
|
+
* which is the only correct answer there: caching across bindings would serve
|
|
559
|
+
* one tenant's client to another.
|
|
560
|
+
*
|
|
561
|
+
* A {@link WeakMap} holds the cache, so an environment object the host has
|
|
562
|
+
* finished with takes the resource with it.
|
|
563
|
+
*/
|
|
564
|
+
function lazyResource(factory) {
|
|
565
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
566
|
+
return (c) => {
|
|
567
|
+
const bindings = getEnv(c);
|
|
568
|
+
if (!bindings || typeof bindings !== "object") return factory(bindings ?? {});
|
|
569
|
+
const existing = cache.get(bindings);
|
|
570
|
+
if (existing !== void 0) return existing;
|
|
571
|
+
const created = factory(bindings);
|
|
572
|
+
cache.set(bindings, created);
|
|
573
|
+
return created;
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/utils/logger.ts
|
|
578
|
+
/**
|
|
579
|
+
* Structured Logger for Rebase Backend
|
|
580
|
+
*
|
|
581
|
+
* Outputs JSON lines when `NODE_ENV=production`, human-readable prefixed
|
|
582
|
+
* lines otherwise. Designed to work with Google Cloud Logging severity levels.
|
|
583
|
+
*
|
|
584
|
+
* Every line — message and data, at any depth — passes through the redaction
|
|
585
|
+
* below, which strips Drizzle's `Failed query: … / params: …` wrapper and the
|
|
586
|
+
* values of secret-looking keys. See the block above `serialiseError`.
|
|
587
|
+
*
|
|
588
|
+
* Usage:
|
|
589
|
+
* import { logger } from "./utils/logger";
|
|
590
|
+
* logger.info("Server started", { port: 3001 });
|
|
591
|
+
* logger.error("Request failed", { path: "/api/test", error: err });
|
|
592
|
+
*
|
|
593
|
+
* Every host global goes through `./host`, and that is load-bearing rather than
|
|
594
|
+
* tidy: this module is reachable from `@rebasepro/server/functions`, the
|
|
595
|
+
* authoring surface that has to import cleanly on a runtime with no `process`.
|
|
596
|
+
* A bare `process.env.NODE_ENV` here would make the first log line of the first
|
|
597
|
+
* request on workerd a `ReferenceError`.
|
|
598
|
+
*/
|
|
599
|
+
/** Google Cloud Logging severity strings. */
|
|
600
|
+
var GCP_SEVERITY = {
|
|
601
|
+
debug: "DEBUG",
|
|
602
|
+
info: "INFO",
|
|
603
|
+
warn: "WARNING",
|
|
604
|
+
error: "ERROR"
|
|
605
|
+
};
|
|
606
|
+
var LOG_PRIORITY = {
|
|
607
|
+
debug: 0,
|
|
608
|
+
info: 1,
|
|
609
|
+
warn: 2,
|
|
610
|
+
error: 3
|
|
611
|
+
};
|
|
612
|
+
function isProduction() {
|
|
613
|
+
return hostEnv().NODE_ENV === "production";
|
|
614
|
+
}
|
|
615
|
+
function getMinLevel() {
|
|
616
|
+
const env = (hostEnv().LOG_LEVEL || "info").toLowerCase();
|
|
617
|
+
if (env in LOG_PRIORITY) return env;
|
|
618
|
+
return "info";
|
|
619
|
+
}
|
|
620
|
+
var FAILED_QUERY_MARKER = "Failed query:";
|
|
621
|
+
var REDACTED_QUERY = "Failed query: [redacted]";
|
|
622
|
+
var REDACTED_VALUE = "[redacted]";
|
|
623
|
+
/**
|
|
624
|
+
* Key fragments whose values are never safe to publish. Compared against the
|
|
625
|
+
* key with separators and case removed, so `api_key`, `apiKey` and `API-KEY`
|
|
626
|
+
* all match `apikey`.
|
|
627
|
+
*/
|
|
628
|
+
var SENSITIVE_KEY_FRAGMENTS = [
|
|
629
|
+
"password",
|
|
630
|
+
"passwd",
|
|
631
|
+
"passphrase",
|
|
632
|
+
"secret",
|
|
633
|
+
"token",
|
|
634
|
+
"apikey",
|
|
635
|
+
"authorization",
|
|
636
|
+
"credential",
|
|
637
|
+
"cookie",
|
|
638
|
+
"privatekey",
|
|
639
|
+
"sessionid"
|
|
640
|
+
];
|
|
641
|
+
/** Longest structure the redactor will walk before giving up. */
|
|
642
|
+
var MAX_REDACT_DEPTH = 8;
|
|
643
|
+
function isSensitiveKey(key) {
|
|
644
|
+
const normalised = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
645
|
+
return SENSITIVE_KEY_FRAGMENTS.some((fragment) => normalised.includes(fragment));
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Local escape hatch for the `Failed query:` strip only — the statement is the
|
|
649
|
+
* fastest way to diagnose a failing query on a developer machine. Ignored in
|
|
650
|
+
* production, so a runtime that inherits the variable cannot leak because of
|
|
651
|
+
* it, and it never re-enables the key deny-list.
|
|
652
|
+
*/
|
|
653
|
+
function rawQueriesAllowed() {
|
|
654
|
+
return hostEnv().NODE_ENV !== "production" && hostEnv().REBASE_LOG_RAW_QUERIES === "true";
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Strip every `Failed query: … / params: …` span out of a message or stack,
|
|
658
|
+
* keeping the surrounding text (including stack frames, which carry no user
|
|
659
|
+
* data). When no `params:` line follows the marker the rest of the string is
|
|
660
|
+
* dropped: a statement of unknown extent is treated as sensitive rather than
|
|
661
|
+
* guessed at.
|
|
662
|
+
*
|
|
663
|
+
* Idempotent, and it has to be: an already-redacted span still starts with the
|
|
664
|
+
* marker but has no `params:` line, so a second pass over it would fall into
|
|
665
|
+
* the drop-the-rest branch and eat the stack frames behind it. Redaction runs
|
|
666
|
+
* more than once on the same string in practice — the cron scheduler redacts
|
|
667
|
+
* before persisting and then logs the result.
|
|
668
|
+
*/
|
|
669
|
+
function redactSensitiveText(text) {
|
|
670
|
+
if (!text.includes(FAILED_QUERY_MARKER) || rawQueriesAllowed()) return text;
|
|
671
|
+
let out = text;
|
|
672
|
+
let idx = out.indexOf(FAILED_QUERY_MARKER);
|
|
673
|
+
while (idx !== -1) {
|
|
674
|
+
if (out.startsWith(REDACTED_QUERY, idx)) {
|
|
675
|
+
idx = out.indexOf(FAILED_QUERY_MARKER, idx + 24);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const paramsIdx = out.indexOf("\nparams:", idx);
|
|
679
|
+
let end;
|
|
680
|
+
if (paramsIdx === -1) end = out.length;
|
|
681
|
+
else {
|
|
682
|
+
const eol = out.indexOf("\n", paramsIdx + 1);
|
|
683
|
+
end = eol === -1 ? out.length : eol;
|
|
684
|
+
}
|
|
685
|
+
out = out.slice(0, idx) + REDACTED_QUERY + out.slice(end);
|
|
686
|
+
idx = out.indexOf(FAILED_QUERY_MARKER, idx + 24);
|
|
687
|
+
}
|
|
688
|
+
return out;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Serialise an Error into a plain object (stack + message), with the query
|
|
692
|
+
* text redacted out of both. `query`/`params` own-properties — which
|
|
693
|
+
* `DrizzleQueryError` carries beside the message — are deliberately not
|
|
694
|
+
* copied.
|
|
695
|
+
* Handles non-Error values gracefully.
|
|
696
|
+
*/
|
|
697
|
+
function serialiseError(value) {
|
|
698
|
+
if (value instanceof Error) return {
|
|
699
|
+
name: value.name,
|
|
700
|
+
message: redactSensitiveText(value.message),
|
|
701
|
+
stack: value.stack ? redactSensitiveText(value.stack) : void 0
|
|
702
|
+
};
|
|
703
|
+
return { value: redactSensitiveText(String(value)) };
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Redact one logged value: errors are serialised, strings are stripped of
|
|
707
|
+
* query text, objects and arrays are walked. Cycles and over-deep structures
|
|
708
|
+
* collapse to a marker rather than throwing — a logger that can fail is worse
|
|
709
|
+
* than one that logs less. (An object referenced twice in one payload is
|
|
710
|
+
* reported as `[circular]` the second time; bounding the walk matters more
|
|
711
|
+
* than rendering a shared reference twice.)
|
|
712
|
+
*/
|
|
713
|
+
function redactValue(value, depth, seen) {
|
|
714
|
+
if (value instanceof Error) return serialiseError(value);
|
|
715
|
+
if (typeof value === "string") return redactSensitiveText(value);
|
|
716
|
+
if (value === null || typeof value !== "object") return value;
|
|
717
|
+
if (depth >= MAX_REDACT_DEPTH) return "[truncated]";
|
|
718
|
+
if (seen.has(value)) return "[circular]";
|
|
719
|
+
seen.add(value);
|
|
720
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item, depth + 1, seen));
|
|
721
|
+
if (value instanceof Date) return value;
|
|
722
|
+
const out = {};
|
|
723
|
+
for (const [key, val] of Object.entries(value)) out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, depth + 1, seen);
|
|
724
|
+
return out;
|
|
725
|
+
}
|
|
726
|
+
function formatData(data) {
|
|
727
|
+
if (!data) return void 0;
|
|
728
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
729
|
+
const out = {};
|
|
730
|
+
for (const [key, val] of Object.entries(data)) out[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactValue(val, 0, seen);
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
function createLogger(rawDefaultFields = {}) {
|
|
734
|
+
const minLevel = getMinLevel();
|
|
735
|
+
const defaultFields = formatData(rawDefaultFields) ?? {};
|
|
736
|
+
function emit(level, message, data) {
|
|
737
|
+
if (LOG_PRIORITY[level] < LOG_PRIORITY[minLevel]) return;
|
|
738
|
+
const safeMessage = redactSensitiveText(message);
|
|
739
|
+
const merged = {
|
|
740
|
+
...defaultFields,
|
|
741
|
+
...formatData(data)
|
|
742
|
+
};
|
|
743
|
+
if (isProduction()) {
|
|
744
|
+
const entry = {
|
|
745
|
+
severity: GCP_SEVERITY[level],
|
|
746
|
+
message: safeMessage,
|
|
747
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
748
|
+
...merged
|
|
749
|
+
};
|
|
750
|
+
const line = JSON.stringify(entry);
|
|
751
|
+
if (level === "error") writeLine("err", line);
|
|
752
|
+
else writeLine("out", line);
|
|
753
|
+
} else {
|
|
754
|
+
const prefix = level === "error" ? "❌" : level === "warn" ? "⚠️" : level === "info" ? "ℹ️" : "🐛";
|
|
755
|
+
const extra = Object.keys(merged).length > 0 ? ` ${JSON.stringify(merged)}` : "";
|
|
756
|
+
const out = `${prefix} [${level.toUpperCase()}] ${safeMessage}${extra}`;
|
|
757
|
+
if (level === "error") console.error(out);
|
|
758
|
+
else if (level === "warn") console.warn(out);
|
|
759
|
+
else console.log(out);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return {
|
|
763
|
+
debug: (msg, data) => emit("debug", msg, data),
|
|
764
|
+
info: (msg, data) => emit("info", msg, data),
|
|
765
|
+
warn: (msg, data) => emit("warn", msg, data),
|
|
766
|
+
error: (msg, data) => emit("error", msg, data),
|
|
767
|
+
child(fields) {
|
|
768
|
+
return createLogger({
|
|
769
|
+
...defaultFields,
|
|
770
|
+
...fields
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Singleton logger instance.
|
|
777
|
+
* In production: emits JSON lines with `severity`, `message`, `timestamp`.
|
|
778
|
+
* In development: emits human-readable prefixed lines.
|
|
779
|
+
*/
|
|
780
|
+
var logger = createLogger();
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/functions/wait-until.ts
|
|
783
|
+
/**
|
|
784
|
+
* Pending background work, on a process-global slot for the reason given in
|
|
785
|
+
* `../singleton.ts`: two copies of this module in one process must not each
|
|
786
|
+
* hold half the work, or shutdown drains one half and drops the other.
|
|
787
|
+
*/
|
|
788
|
+
var PENDING_SLOT = Symbol.for("@rebasepro/server:pending-background-work");
|
|
789
|
+
function pending() {
|
|
790
|
+
const global = globalThis;
|
|
791
|
+
if (!global[PENDING_SLOT]) global[PENDING_SLOT] = /* @__PURE__ */ new Set();
|
|
792
|
+
return global[PENDING_SLOT];
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Keep `work` alive past the response, on any runtime.
|
|
796
|
+
*
|
|
797
|
+
* Hand it a promise or a function returning one. Nothing is awaited: the
|
|
798
|
+
* response goes out immediately, exactly as an un-awaited call would.
|
|
799
|
+
*
|
|
800
|
+
* What it buys, per host:
|
|
801
|
+
*
|
|
802
|
+
* - **Isolate hosts** (Workers, Deno Deploy, Vercel Edge): the host is told to
|
|
803
|
+
* keep the isolate alive until the promise settles. Without this the work is
|
|
804
|
+
* dropped when the response resolves.
|
|
805
|
+
* - **Node**: the promise is tracked so {@link drainBackgroundWork} can wait for
|
|
806
|
+
* it during graceful shutdown instead of the process exiting out from under
|
|
807
|
+
* it.
|
|
808
|
+
*
|
|
809
|
+
* On every host a rejection is logged rather than left to the unhandled-rejection
|
|
810
|
+
* handler, so the failure names the function it came from.
|
|
811
|
+
*
|
|
812
|
+
* @example
|
|
813
|
+
* ```ts
|
|
814
|
+
* app.post("/orders", requireAuth, async (c) => {
|
|
815
|
+
* const order = await createOrder(c);
|
|
816
|
+
* waitUntil(c, notifyWarehouse(order)); // caller does not wait for this
|
|
817
|
+
* return c.json({ id: order.id });
|
|
818
|
+
* });
|
|
819
|
+
* ```
|
|
820
|
+
*/
|
|
821
|
+
function waitUntil(c, work) {
|
|
822
|
+
let promise;
|
|
823
|
+
try {
|
|
824
|
+
promise = typeof work === "function" ? Promise.resolve(work()) : work;
|
|
825
|
+
} catch (error) {
|
|
826
|
+
logBackgroundFailure(c, error);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const tracked = promise.catch((error) => {
|
|
830
|
+
logBackgroundFailure(c, error);
|
|
831
|
+
});
|
|
832
|
+
const set = pending();
|
|
833
|
+
set.add(tracked);
|
|
834
|
+
tracked.finally(() => set.delete(tracked));
|
|
835
|
+
try {
|
|
836
|
+
c.executionCtx?.waitUntil?.(tracked);
|
|
837
|
+
} catch {}
|
|
838
|
+
}
|
|
839
|
+
function logBackgroundFailure(c, error) {
|
|
840
|
+
logger.error("[functions] Background work failed after the response was sent", {
|
|
841
|
+
method: c.req?.method,
|
|
842
|
+
path: c.req?.path,
|
|
843
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/api/errors.ts
|
|
848
|
+
/**
|
|
849
|
+
* Standardized API error class.
|
|
850
|
+
* Throw this from any route handler — the errorHandler middleware
|
|
851
|
+
* will format it into `{ error: { message, code, details? } }`.
|
|
852
|
+
*/
|
|
853
|
+
var ApiError = class ApiError extends Error {
|
|
854
|
+
statusCode;
|
|
855
|
+
code;
|
|
856
|
+
details;
|
|
857
|
+
/**
|
|
858
|
+
* Whether this outcome is a routine part of normal operation rather than
|
|
859
|
+
* something an operator should look at. Expected errors log at debug; every
|
|
860
|
+
* other operational error logs at warn.
|
|
861
|
+
*
|
|
862
|
+
* The motivating case is `POST /auth/refresh` with no session: clients
|
|
863
|
+
* refresh on page load before they know whether one exists, so every
|
|
864
|
+
* anonymous page view is a 401 — correct, and not worth a warning line.
|
|
865
|
+
*
|
|
866
|
+
* The other class is a caller-caused 4xx that never reached the database: a
|
|
867
|
+
* mistyped filter operator, sort direction or limit, a request for a
|
|
868
|
+
* collection that does not exist. Nothing on this server is wrong, and the
|
|
869
|
+
* response body has already told the caller what to fix — while one client
|
|
870
|
+
* holding a stale name would otherwise write a warning per request, forever,
|
|
871
|
+
* until the level means nothing. See `api/rest/query-parser.ts`.
|
|
872
|
+
*
|
|
873
|
+
* What stays at warn is anything that says something about the *server*:
|
|
874
|
+
* a schema that has drifted from the code, a permission the database
|
|
875
|
+
* refused, a dependency that failed. Those are 4xx too, and they are still
|
|
876
|
+
* incidents.
|
|
877
|
+
*/
|
|
878
|
+
expected;
|
|
879
|
+
constructor(statusCode, code, message, details, expected = false) {
|
|
880
|
+
super(message);
|
|
881
|
+
this.name = "ApiError";
|
|
882
|
+
this.statusCode = statusCode;
|
|
883
|
+
this.code = code;
|
|
884
|
+
this.details = details;
|
|
885
|
+
this.expected = expected;
|
|
886
|
+
}
|
|
887
|
+
static badRequest(message, code = "BAD_REQUEST", details) {
|
|
888
|
+
return new ApiError(400, code, message, details);
|
|
889
|
+
}
|
|
890
|
+
static unauthorized(message, code = "UNAUTHORIZED") {
|
|
891
|
+
return new ApiError(401, code, message);
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* A 401 that is a normal outcome, not an incident — logged at debug.
|
|
895
|
+
* See {@link ApiError.expected}.
|
|
896
|
+
*/
|
|
897
|
+
static unauthenticated(message, code = "UNAUTHORIZED") {
|
|
898
|
+
return new ApiError(401, code, message, void 0, true);
|
|
899
|
+
}
|
|
900
|
+
static forbidden(message, code = "FORBIDDEN") {
|
|
901
|
+
return new ApiError(403, code, message);
|
|
902
|
+
}
|
|
903
|
+
static notFound(message, code = "NOT_FOUND") {
|
|
904
|
+
return new ApiError(404, code, message);
|
|
905
|
+
}
|
|
906
|
+
static conflict(message, code = "CONFLICT") {
|
|
907
|
+
return new ApiError(409, code, message);
|
|
908
|
+
}
|
|
909
|
+
static internal(message, code = "INTERNAL_ERROR") {
|
|
910
|
+
return new ApiError(500, code, message);
|
|
911
|
+
}
|
|
912
|
+
static serviceUnavailable(message, code = "SERVICE_UNAVAILABLE") {
|
|
913
|
+
return new ApiError(503, code, message);
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
//#endregion
|
|
917
|
+
export { ApiError, defineFunction, env, getApiKey, getDriver, getEnv, getRequestId, getRoles, getUser, getUserId, hasRole, identityResolved, isAdmin, isAuthenticated, isNodeRuntime, lazyResource, rebase, requireAdmin, requireAuth, requireDriver, requireEnv, requireRole, runtimeKey, waitUntil };
|
|
918
|
+
|
|
919
|
+
//# sourceMappingURL=index.js.map
|