@rebasepro/server 0.24.0 → 0.24.1-canary.g56af4a4
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/GCSStorageController-DivE9bAx.js +402 -0
- package/dist/GCSStorageController-DivE9bAx.js.map +1 -0
- package/dist/S3StorageController-D_vKs4kP.js +480 -0
- package/dist/S3StorageController-D_vKs4kP.js.map +1 -0
- package/dist/api/rest/api-generator.d.ts +53 -1
- package/dist/api/rest/auth-collection-writes.d.ts +28 -21
- package/dist/api/rest/field-access-query.d.ts +28 -8
- package/dist/api/rest/field-ops.d.ts +19 -7
- package/dist/api/rest/query-body.d.ts +10 -0
- package/dist/api/rest/write-validation.d.ts +7 -5
- package/dist/api/schema-editor-routes.d.ts +10 -1
- package/dist/api/sql-export-routes.d.ts +8 -0
- package/dist/api/types.d.ts +11 -0
- package/dist/{ast-schema-editor-CWqS_sLJ.js → ast-schema-editor-CSdL0iCk.js} +2 -2
- package/dist/{ast-schema-editor-CWqS_sLJ.js.map → ast-schema-editor-CSdL0iCk.js.map} +1 -1
- package/dist/auth/access.d.ts +16 -0
- package/dist/auth/admin-user-ops.d.ts +24 -2
- package/dist/auth/admin-users-route.d.ts +1 -0
- package/dist/auth/expired-token-sweep.d.ts +5 -1
- package/dist/auth/interfaces.d.ts +10 -0
- package/dist/auth/jwt-crypto.d.ts +7 -0
- package/dist/auth/jwt-keys.d.ts +4 -0
- package/dist/auth/jwt.d.ts +6 -1
- package/dist/auth/rate-limiter.d.ts +13 -1
- package/dist/auth/registration-policy.d.ts +17 -0
- package/dist/auth/user-picking.d.ts +35 -0
- package/dist/{auth-DMLngxn_.js → auth-OPq_FeMs.js} +972 -85
- package/dist/auth-OPq_FeMs.js.map +1 -0
- package/dist/auth_adapter-CIItCTc8.js +21 -0
- package/dist/auth_adapter-CIItCTc8.js.map +1 -0
- package/dist/backend-DTAOsLQc.js.map +1 -1
- package/dist/backup/backup-common.d.ts +14 -3
- package/dist/backup/backup-routes.d.ts +23 -2
- package/dist/backup/backup-storage.d.ts +3 -1
- package/dist/backup/index.d.ts +3 -1
- package/dist/{backup-CN0s50D2.js → backup-5d7XhLX3.js} +170 -33
- package/dist/backup-5d7XhLX3.js.map +1 -0
- package/dist/boot/driver.d.ts +3 -0
- package/dist/boot/env.d.ts +7 -0
- package/dist/boot/http-server.d.ts +22 -0
- package/dist/boot/options.d.ts +16 -1
- package/dist/boot/runtime-cors.d.ts +17 -0
- package/dist/boot/static-routing.d.ts +9 -0
- package/dist/{contract-routes-fz8i4pxs.js → contract-routes-02lASyJI.js} +3 -19
- package/dist/contract-routes-02lASyJI.js.map +1 -0
- package/dist/cron/cron-routes.d.ts +1 -1
- package/dist/cron/cron-scheduler.d.ts +69 -3
- package/dist/cron/cron-store.d.ts +27 -4
- package/dist/cron/define-cron.d.ts +6 -2
- package/dist/cron/index.d.ts +1 -1
- package/dist/{cron-loader-CwaANlOG.js → cron-loader-D7OmleAb.js} +8 -4
- package/dist/cron-loader-D7OmleAb.js.map +1 -0
- package/dist/{cron-routes-Bc-SB0Se.js → cron-routes-CBfiaqEL.js} +27 -4
- package/dist/cron-routes-CBfiaqEL.js.map +1 -0
- package/dist/{cron-scheduler-CYQgco86.js → cron-scheduler-C2vbETMo.js} +298 -32
- package/dist/cron-scheduler-C2vbETMo.js.map +1 -0
- package/dist/cron-store-B2Ih0gCN.js +540 -0
- package/dist/cron-store-B2Ih0gCN.js.map +1 -0
- package/dist/email/smtp-email-service.d.ts +23 -0
- package/dist/email/types.d.ts +18 -0
- package/dist/env.d.ts +0 -37
- package/dist/filter-dialect-REl-TjZr.js +1628 -0
- package/dist/filter-dialect-REl-TjZr.js.map +1 -0
- package/dist/{function-loader-D7o5Epjj.js → function-loader-BDKKiBmy.js} +4 -3
- package/dist/function-loader-BDKKiBmy.js.map +1 -0
- package/dist/{function-routes-CaNG4waN.js → function-routes-DWB7BBsi.js} +2 -2
- package/dist/function-routes-DWB7BBsi.js.map +1 -0
- package/dist/functions/context.d.ts +9 -0
- package/dist/functions/define-function.d.ts +7 -4
- package/dist/functions/index.d.ts +1 -1
- package/dist/functions/index.js +25 -2
- package/dist/functions/index.js.map +1 -1
- package/dist/functions/request-timeout.d.ts +7 -5
- package/dist/history/history-routes.d.ts +9 -0
- package/dist/history/retention-sweep.d.ts +81 -0
- package/dist/{history-recorder-Nr8zLvoU.js → history-recorder-DHenr3lm.js} +2 -2
- package/dist/{history-recorder-Nr8zLvoU.js.map → history-recorder-DHenr3lm.js.map} +1 -1
- package/dist/{history-store-rcAm_xFR.js → history-store-DPofGVDp.js} +2 -2
- package/dist/{history-store-rcAm_xFR.js.map → history-store-DPofGVDp.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.es.js +22671 -19191
- package/dist/index.es.js.map +1 -1
- package/dist/init/shutdown.d.ts +12 -2
- package/dist/init.d.ts +16 -6
- package/dist/internal-tables-L2t39RdT.js +136 -0
- package/dist/internal-tables-L2t39RdT.js.map +1 -0
- package/dist/jobs/job-queue.d.ts +8 -0
- package/dist/jobs/job-store.d.ts +24 -0
- package/dist/jobs/types.d.ts +25 -4
- package/dist/{jobs-DqYNfquG.js → jobs-BpD4Q0qR.js} +232 -22
- package/dist/jobs-BpD4Q0qR.js.map +1 -0
- package/dist/{jwt-R6bSPMjk.js → jwt-Bqcu7cGt.js} +171 -65
- package/dist/jwt-Bqcu7cGt.js.map +1 -0
- package/dist/load-failure-UvzZgGXT.js +48 -0
- package/dist/load-failure-UvzZgGXT.js.map +1 -0
- package/dist/mcp/mcp-tools.d.ts +8 -0
- package/dist/metrics/index.d.ts +18 -0
- package/dist/metrics/pool-wait.d.ts +50 -0
- package/dist/offline-8Ks3yp9n-CadBKm2D.js +3305 -0
- package/dist/offline-8Ks3yp9n-CadBKm2D.js.map +1 -0
- package/dist/offline-store-D9ytNNaa-BCr2vsFB.js +1903 -0
- package/dist/offline-store-D9ytNNaa-BCr2vsFB.js.map +1 -0
- package/dist/{openapi-generator-DAq_XVDu.js → openapi-generator-BqoUh4Ij.js} +365 -81
- package/dist/openapi-generator-BqoUh4Ij.js.map +1 -0
- package/dist/policy-PdzX-Opd.js +121 -0
- package/dist/policy-PdzX-Opd.js.map +1 -0
- package/dist/project_manifest-R6y_KHms.js +59 -0
- package/dist/project_manifest-R6y_KHms.js.map +1 -0
- package/dist/{query-parser-BgiKJKvc.js → query-parser-DK9eMkb9.js} +510 -15
- package/dist/query-parser-DK9eMkb9.js.map +1 -0
- package/dist/{request-timeout-DgH7j8qO.js → request-timeout-Dnl2_OBc.js} +15 -8
- package/dist/request-timeout-Dnl2_OBc.js.map +1 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js +24 -0
- package/dist/resumable-upload-threshold-CKv-Ry0l.js.map +1 -0
- package/dist/schema-edit/apply-schema-change.d.ts +10 -0
- package/dist/schema-edit/local-source.d.ts +29 -0
- package/dist/schema-edit/remote-source.d.ts +13 -3
- package/dist/schema-edit/source-editor.d.ts +28 -0
- package/dist/{schema-editor-routes-oIyuWl3L.js → schema-editor-routes-BFf3i8jm.js} +53 -9
- package/dist/schema-editor-routes-BFf3i8jm.js.map +1 -0
- package/dist/services/outbound-post.d.ts +50 -0
- package/dist/services/outbound-url-guard.d.ts +34 -5
- package/dist/services/routed-realtime-service.d.ts +5 -0
- package/dist/services/webhook-service.d.ts +66 -14
- package/dist/soft-delete-params-47yBGs3_.js +668 -0
- package/dist/soft-delete-params-47yBGs3_.js.map +1 -0
- package/dist/sql-export-routes-BNNuGGLP.js +240 -0
- package/dist/sql-export-routes-BNNuGGLP.js.map +1 -0
- package/dist/src-CatHFUym.js.map +1 -1
- package/dist/storage/GCSStorageController.d.ts +46 -2
- package/dist/storage/LocalStorageController.d.ts +67 -2
- package/dist/storage/S3StorageController.d.ts +37 -3
- package/dist/storage/image-transform.d.ts +5 -0
- package/dist/storage/maintenance-routes.d.ts +23 -0
- package/dist/storage/maintenance.d.ts +126 -0
- package/dist/storage/rendition-cache.d.ts +9 -11
- package/dist/storage/request-principals.d.ts +30 -0
- package/dist/storage/routes.d.ts +20 -0
- package/dist/storage/source-variables.d.ts +10 -0
- package/dist/storage/streams.d.ts +42 -0
- package/dist/storage/tus-handler.d.ts +46 -2
- package/dist/storage/types.d.ts +82 -7
- package/dist/storage/upload-validation.d.ts +13 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js +125 -0
- package/dist/storage-resumable-VO-cMpse-C_YX71pO.js.map +1 -0
- package/dist/strings.es-BeZWAodn.js +256 -0
- package/dist/strings.es-BeZWAodn.js.map +1 -0
- package/dist/upload-validation-Dd-fnsbA.js +172 -0
- package/dist/upload-validation-Dd-fnsbA.js.map +1 -0
- package/dist/utils/byte-size.d.ts +10 -0
- package/dist/utils/compression.d.ts +6 -0
- package/dist/utils/load-failure.d.ts +14 -0
- package/package.json +5 -5
- package/dist/GCSStorageController-BSiP1c-f.js +0 -256
- package/dist/GCSStorageController-BSiP1c-f.js.map +0 -1
- package/dist/S3StorageController-CAwFRgjV.js +0 -276
- package/dist/S3StorageController-CAwFRgjV.js.map +0 -1
- package/dist/auth-DMLngxn_.js.map +0 -1
- package/dist/backup-CN0s50D2.js.map +0 -1
- package/dist/contract-routes-fz8i4pxs.js.map +0 -1
- package/dist/cron-loader-CwaANlOG.js.map +0 -1
- package/dist/cron-routes-Bc-SB0Se.js.map +0 -1
- package/dist/cron-scheduler-CYQgco86.js.map +0 -1
- package/dist/cron-store-D2Q9-Aco.js +0 -300
- package/dist/cron-store-D2Q9-Aco.js.map +0 -1
- package/dist/dynamic-import-X40pTZUQ.js +0 -23
- package/dist/dynamic-import-X40pTZUQ.js.map +0 -1
- package/dist/function-loader-D7o5Epjj.js.map +0 -1
- package/dist/function-routes-CaNG4waN.js.map +0 -1
- package/dist/jobs-DqYNfquG.js.map +0 -1
- package/dist/jwt-R6bSPMjk.js.map +0 -1
- package/dist/openapi-generator-DAq_XVDu.js.map +0 -1
- package/dist/query-parser-BgiKJKvc.js.map +0 -1
- package/dist/request-timeout-DgH7j8qO.js.map +0 -1
- package/dist/schema-editor-routes-oIyuWl3L.js.map +0 -1
- package/dist/soft-delete-params-BWPilMPF.js +0 -59
- package/dist/soft-delete-params-BWPilMPF.js.map +0 -1
- package/dist/src-I3aG1PcY.js +0 -6870
- package/dist/src-I3aG1PcY.js.map +0 -1
- package/dist/types-BfKcm9do.js +0 -39
- package/dist/types-BfKcm9do.js.map +0 -1
|
@@ -3,14 +3,17 @@ import __rebaseProcess from "process";
|
|
|
3
3
|
globalThis.process ??= __rebaseProcess;
|
|
4
4
|
__rebaseCreateRequire(import.meta.url);
|
|
5
5
|
import { i as __toESM, n as __exportAll } from "./rolldown-runtime-dW7B1o5h.js";
|
|
6
|
-
import {
|
|
7
|
-
import { _ as scopesForRoles, a as KEY_MANAGEMENT_SCOPES, b as validateScopes, c as intersectScopes, d as isDataPlaneScope, f as knownScopes, g as scopesBeyond, h as scopeGrantsAny, i as EMPTY_ACCESS_MODEL, l as isAdminScope, m as scopeGrants, n as ADMIN_SCOPES, o as SCOPE_TARGETS, p as parseScope, r as BUILT_IN_SCOPES, s as hasAdminRole, t as ADMIN_ROLE, u as isBuiltInScope, v as summarizeRoles } from "./src-CatHFUym.js";
|
|
6
|
+
import { r as toSnakeCase } from "./strings.es-BeZWAodn.js";
|
|
7
|
+
import { B as rewriteLegacyRlsFunctions, _ as scopesForRoles, a as KEY_MANAGEMENT_SCOPES, b as validateScopes, c as intersectScopes, d as isDataPlaneScope, f as knownScopes, g as scopesBeyond, h as scopeGrantsAny, i as EMPTY_ACCESS_MODEL, l as isAdminScope, m as scopeGrants, n as ADMIN_SCOPES, o as SCOPE_TARGETS, p as parseScope, r as BUILT_IN_SCOPES, s as hasAdminRole, t as ADMIN_ROLE, u as isBuiltInScope, v as summarizeRoles } from "./src-CatHFUym.js";
|
|
8
|
+
import { n as isAnonymousUid, r as policy, t as ANONYMOUS_USER_ID } from "./policy-PdzX-Opd.js";
|
|
8
9
|
import { t as isSQLAdmin } from "./backend-DTAOsLQc.js";
|
|
10
|
+
import { t as IMPERSONATE_HEADER } from "./auth_adapter-CIItCTc8.js";
|
|
11
|
+
import { n as revokeInternalTableSql, t as REBASE_USER_ROLE } from "./internal-tables-L2t39RdT.js";
|
|
9
12
|
import { t as firstSqlRow } from "./sql-rows-C6GEc2oE.js";
|
|
13
|
+
import { a as resolveListLimitParam } from "./query-parser-DK9eMkb9.js";
|
|
10
14
|
import { r as logger } from "./logger-D-S-hO5e.js";
|
|
11
15
|
import { i as isDataException, r as errorHandler, t as ApiError } from "./errors-D6_y86c5.js";
|
|
12
|
-
import { a as
|
|
13
|
-
import { D as constantTimeEqual, E as require_jsonwebtoken, S as verifyMfaPendingToken, a as generateAccessToken, b as verifyDownloadToken, c as generateMfaPendingToken, d as getJwks, f as getRefreshTokenExpiry, g as isJwtConfigured, h as hashRefreshToken, i as customClaimsOf, j as sha256Hex, k as randomInt$1, l as generateRefreshToken, m as hasAsymmetricSigningKey, n as configureJwt, o as generateDownloadToken, p as getRefreshTokenTtlMs, r as configuredJwtSecret, t as MAX_COOKIE_AGE_MS, u as getAccessTokenExpiry, y as verifyAccessToken } from "./jwt-R6bSPMjk.js";
|
|
16
|
+
import { D as constantTimeEqual, E as require_jsonwebtoken, S as verifyMfaPendingToken, a as generateAccessToken, b as verifyDownloadToken, c as generateMfaPendingToken, d as getJwks, f as getRefreshTokenExpiry, g as isJwtConfigured, h as hashRefreshToken, i as customClaimsOf, j as sha256Hex, k as randomInt$1, l as generateRefreshToken, m as hasAsymmetricSigningKey, n as configureJwt, o as generateDownloadToken, p as getRefreshTokenTtlMs, r as configuredJwtSecret, t as MAX_COOKIE_AGE_MS, u as getAccessTokenExpiry, y as verifyAccessToken } from "./jwt-Bqcu7cGt.js";
|
|
14
17
|
import { a as canonicalStorageId, o as canonicalStorageKey, r as InvalidStorageKeyError } from "./keys-GAVZqbqx.js";
|
|
15
18
|
import { n as createDdlBootstrapper } from "./ddl-bootstrap-BaqMSa4Y.js";
|
|
16
19
|
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
@@ -20,20 +23,6 @@ import { createCipheriv, createDecipheriv, createHash as createHash$1, createHma
|
|
|
20
23
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
|
21
24
|
import { createMiddleware } from "hono/factory";
|
|
22
25
|
import { z } from "zod";
|
|
23
|
-
//#region ../types/src/types/auth_adapter.ts
|
|
24
|
-
/**
|
|
25
|
-
* The request header that asks for a data-plane request to run as another
|
|
26
|
-
* user, named by uid.
|
|
27
|
-
*
|
|
28
|
-
* The server honours it only on a signed-in administrator's own session —
|
|
29
|
-
* never on an API key or the service key — and only through an adapter that
|
|
30
|
-
* implements {@link AuthAdapter.resolveUser}. Everywhere else a request that
|
|
31
|
-
* carries it is refused, never run as its caller.
|
|
32
|
-
*
|
|
33
|
-
* @group Auth
|
|
34
|
-
*/
|
|
35
|
-
var IMPERSONATE_HEADER = "x-rebase-impersonate";
|
|
36
|
-
//#endregion
|
|
37
26
|
//#region ../types/src/controllers/storage.ts
|
|
38
27
|
/**
|
|
39
28
|
* Path prefix that marks an object as **public**. Files stored under this
|
|
@@ -102,8 +91,8 @@ function isPublicStoragePath(path) {
|
|
|
102
91
|
* so the key `default/photo.png` sent as it is names the root `photo.png`:
|
|
103
92
|
* {@link defaultBucketPath} is the spelling that names the key itself.
|
|
104
93
|
*
|
|
105
|
-
* Shared by every storage source that talks to those routes
|
|
106
|
-
*
|
|
94
|
+
* Shared by every storage source that talks to those routes, so they address
|
|
95
|
+
* an object alike.
|
|
107
96
|
*
|
|
108
97
|
* @group Models
|
|
109
98
|
*/
|
|
@@ -169,6 +158,476 @@ function normalizeEmail(email) {
|
|
|
169
158
|
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
|
170
159
|
}
|
|
171
160
|
//#endregion
|
|
161
|
+
//#region ../common/src/util/policy/sqlToPolicy.ts
|
|
162
|
+
/**
|
|
163
|
+
* A tiny, regex-based SQL "parser" for security rules.
|
|
164
|
+
*
|
|
165
|
+
* This is NOT a full SQL parser. It is designed to handle the subset of SQL
|
|
166
|
+
* commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
|
|
167
|
+
* optimistic client-side UI decision.
|
|
168
|
+
*
|
|
169
|
+
* It handles:
|
|
170
|
+
* - `field = 'literal'`
|
|
171
|
+
* - `field != 'literal'`
|
|
172
|
+
* - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
|
|
173
|
+
* - `A AND B`, `A OR B` — only where the keyword is at the top level
|
|
174
|
+
* - `true`
|
|
175
|
+
* - `IN (...)` (as optimistic true)
|
|
176
|
+
*
|
|
177
|
+
* For anything it doesn't understand, it returns a `raw` expression, which
|
|
178
|
+
* the evaluator treats as "unknown" (and usually optimistic true).
|
|
179
|
+
*
|
|
180
|
+
* **This output also round-trips back into DDL** via `policyToPostgres` (the
|
|
181
|
+
* schema/policy generators), so decomposing a clause the parser only partly
|
|
182
|
+
* understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
|
|
183
|
+
* prefer `raw`: it is reproduced verbatim.
|
|
184
|
+
*/
|
|
185
|
+
/** True when `keyword` starts at `i` as a standalone word. */
|
|
186
|
+
function isKeywordAt(upper, i, keyword) {
|
|
187
|
+
if (!upper.startsWith(keyword, i)) return false;
|
|
188
|
+
const before = i === 0 ? " " : upper[i - 1];
|
|
189
|
+
const after = upper[i + keyword.length] ?? " ";
|
|
190
|
+
return /[\s()]/.test(before) && /[\s()]/.test(after);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
|
|
194
|
+
* outside a string literal. Returns null when it never does, so the caller
|
|
195
|
+
* leaves the clause alone.
|
|
196
|
+
*
|
|
197
|
+
* This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
|
|
198
|
+
* `AND` inside
|
|
199
|
+
* `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
|
|
200
|
+
* split the expression, and re-emitting the halves produced
|
|
201
|
+
* `(EXISTS (...) AND m.user_id = rebase.uid())`
|
|
202
|
+
* where `m` is no longer in scope — SQL that Postgres rejects outright with
|
|
203
|
+
* "missing FROM-clause entry for table". Returning null instead keeps such a
|
|
204
|
+
* clause as a `raw` expression, which round-trips verbatim.
|
|
205
|
+
*/
|
|
206
|
+
function splitTopLevel(sql, keyword) {
|
|
207
|
+
const upper = sql.toUpperCase();
|
|
208
|
+
const parts = [];
|
|
209
|
+
let depth = 0;
|
|
210
|
+
let inString = false;
|
|
211
|
+
let start = 0;
|
|
212
|
+
for (let i = 0; i < sql.length; i++) {
|
|
213
|
+
const ch = sql[i];
|
|
214
|
+
if (inString) {
|
|
215
|
+
if (ch === "'") if (sql[i + 1] === "'") i++;
|
|
216
|
+
else inString = false;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (ch === "'") {
|
|
220
|
+
inString = true;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (ch === "(") {
|
|
224
|
+
depth++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (ch === ")") {
|
|
228
|
+
depth--;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (depth === 0 && isKeywordAt(upper, i, keyword)) {
|
|
232
|
+
parts.push(sql.slice(start, i));
|
|
233
|
+
i += keyword.length - 1;
|
|
234
|
+
start = i + 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (parts.length === 0) return null;
|
|
238
|
+
parts.push(sql.slice(start));
|
|
239
|
+
const trimmedParts = parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
240
|
+
return trimmedParts.length > 1 ? trimmedParts : null;
|
|
241
|
+
}
|
|
242
|
+
/** `SELECT` as the first word of `sql`. */
|
|
243
|
+
var STARTS_WITH_SELECT = /^select(?=[\s(])/i;
|
|
244
|
+
/**
|
|
245
|
+
* Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`
|
|
246
|
+
* — and never the parens of a scalar subquery: `(SELECT x)` without them is not
|
|
247
|
+
* an expression at all, and re-emitted bare it is a syntax error inside
|
|
248
|
+
* `USING (…)`.
|
|
249
|
+
*/
|
|
250
|
+
function stripOuterParens(sql) {
|
|
251
|
+
let s = sql.trim();
|
|
252
|
+
while (wrapsWhole(s) && !STARTS_WITH_SELECT.test(s.slice(1).trim())) s = s.slice(1, -1).trim();
|
|
253
|
+
return s;
|
|
254
|
+
}
|
|
255
|
+
/** Whether `s` is one parenthesised group: `(a AND b)`, not `(a) AND (b)`. */
|
|
256
|
+
function wrapsWhole(s) {
|
|
257
|
+
if (!s.startsWith("(") || !s.endsWith(")")) return false;
|
|
258
|
+
let depth = 0;
|
|
259
|
+
let inString = false;
|
|
260
|
+
for (let i = 0; i < s.length; i++) {
|
|
261
|
+
const ch = s[i];
|
|
262
|
+
if (inString) {
|
|
263
|
+
if (ch === "'") if (s[i + 1] === "'") i++;
|
|
264
|
+
else inString = false;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (ch === "'") {
|
|
268
|
+
inString = true;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (ch === "(") depth++;
|
|
272
|
+
else if (ch === ")") {
|
|
273
|
+
depth--;
|
|
274
|
+
if (depth === 0 && i < s.length - 1) return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
/** `x` when `s` is exactly `(SELECT x)`, otherwise null. */
|
|
280
|
+
function scalarSubqueryBody(s) {
|
|
281
|
+
if (!wrapsWhole(s)) return null;
|
|
282
|
+
const inner = s.slice(1, -1).trim();
|
|
283
|
+
return STARTS_WITH_SELECT.test(inner) ? inner.slice(6).trim() : null;
|
|
284
|
+
}
|
|
285
|
+
/** Whether any part of `expr` fell through to `raw`. */
|
|
286
|
+
function containsRaw(expr) {
|
|
287
|
+
switch (expr.kind) {
|
|
288
|
+
case "raw": return true;
|
|
289
|
+
case "and":
|
|
290
|
+
case "or": return expr.operands.some(containsRaw);
|
|
291
|
+
case "not": return containsRaw(expr.operand);
|
|
292
|
+
case "existsIn": return containsRaw(expr.where);
|
|
293
|
+
default: return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function sqlToPolicy(sql) {
|
|
297
|
+
const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
|
|
298
|
+
const subqueryBody = scalarSubqueryBody(trimmed);
|
|
299
|
+
if (subqueryBody !== null) {
|
|
300
|
+
const inner = sqlToPolicy(subqueryBody);
|
|
301
|
+
return containsRaw(inner) ? policy.raw(trimmed) : inner;
|
|
302
|
+
}
|
|
303
|
+
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
304
|
+
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
305
|
+
const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
|
|
306
|
+
if (overlapMatch) {
|
|
307
|
+
const roles = overlapMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
308
|
+
return policy.rolesOverlap(roles);
|
|
309
|
+
}
|
|
310
|
+
const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
311
|
+
if (containMatch) {
|
|
312
|
+
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
313
|
+
return policy.rolesContain(roles);
|
|
314
|
+
}
|
|
315
|
+
const orParts = splitTopLevel(trimmed, "OR");
|
|
316
|
+
if (orParts) return policy.or(...orParts.map(sqlToPolicy));
|
|
317
|
+
const andParts = splitTopLevel(trimmed, "AND");
|
|
318
|
+
if (andParts) return policy.and(...andParts.map(sqlToPolicy));
|
|
319
|
+
const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
|
|
320
|
+
if (match) {
|
|
321
|
+
const [, leftStr, op, rightStr] = match;
|
|
322
|
+
const left = parseOperand(leftStr.trim());
|
|
323
|
+
const right = parseOperand(rightStr.trim());
|
|
324
|
+
if (left && right) return policy.compare(left, op === "=" ? "eq" : "neq", right);
|
|
325
|
+
}
|
|
326
|
+
return policy.raw(trimmed);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Literals from other BaaS platforms that people compare `rebase.uid()` against
|
|
330
|
+
* out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
|
|
331
|
+
* `pgRoles`, one surface over: the same muscle memory inside a `using:` string
|
|
332
|
+
* is the more dangerous spelling, because it inverts a rule instead of
|
|
333
|
+
* emptying a table.
|
|
334
|
+
*/
|
|
335
|
+
/**
|
|
336
|
+
* A `Map`, not an object literal.
|
|
337
|
+
*
|
|
338
|
+
* As `Record<string, string>` this was indexed with a literal taken straight
|
|
339
|
+
* out of a policy, so every key on `Object.prototype` answered: a rule
|
|
340
|
+
* comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
|
|
341
|
+
* `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
|
|
342
|
+
* risk that does not exist — with the matched function interpolated into the
|
|
343
|
+
* explanation as the platform's name. A security warning that fires on
|
|
344
|
+
* innocent input is worse than none: it is what teaches people to skip the
|
|
345
|
+
* warnings that are real.
|
|
346
|
+
*
|
|
347
|
+
* Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
|
|
348
|
+
* `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
|
|
349
|
+
* object. Found by a property test, on the input `"valueOf"`.
|
|
350
|
+
*/
|
|
351
|
+
var FOREIGN_CONVENTION_UIDS = /* @__PURE__ */ new Map([
|
|
352
|
+
["anon", "Supabase"],
|
|
353
|
+
["authenticated", "Supabase"],
|
|
354
|
+
["service_role", "Supabase"]
|
|
355
|
+
]);
|
|
356
|
+
new RegExp(String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`, "i");
|
|
357
|
+
function parseOperand(str) {
|
|
358
|
+
if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) return policy.authUid();
|
|
359
|
+
if (/^\(\s*select\s+rebase\.uid\(\)(?:\s+as\s+"?uid"?)?\s*\)$/i.test(str)) return policy.authUid();
|
|
360
|
+
const literal = parseSingleQuoted(str);
|
|
361
|
+
if (literal !== null) return policy.literal(literal);
|
|
362
|
+
if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
|
|
363
|
+
if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
|
|
364
|
+
if (/^true$/i.test(str)) return policy.literal(true);
|
|
365
|
+
if (/^false$/i.test(str)) return policy.literal(false);
|
|
366
|
+
if (/^null$/i.test(str)) return policy.literal(null);
|
|
367
|
+
if (/^\w+$/.test(str) && toSnakeCase(str) !== "") return policy.field(str);
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Decode a single-quoted SQL literal, or null when `str` is not exactly one.
|
|
372
|
+
*
|
|
373
|
+
* Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
|
|
374
|
+
* operator, not one literal whose body contains a quote, and a regex anchored
|
|
375
|
+
* on the outer quotes would happily read it as the latter. Every interior quote
|
|
376
|
+
* must therefore be part of a `''` pair.
|
|
377
|
+
*/
|
|
378
|
+
function parseSingleQuoted(str) {
|
|
379
|
+
if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
|
|
380
|
+
const body = str.slice(1, -1);
|
|
381
|
+
let out = "";
|
|
382
|
+
for (let i = 0; i < body.length; i++) {
|
|
383
|
+
if (body[i] !== "'") {
|
|
384
|
+
out += body[i];
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (body[i + 1] === "'") {
|
|
388
|
+
out += "'";
|
|
389
|
+
i++;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
397
|
+
//#region ../common/src/util/policy/securityRuleToConditions.ts
|
|
398
|
+
/**
|
|
399
|
+
* Desugars a {@link SecurityRule} — its `access`/`ownerField`/`roles` shortcuts,
|
|
400
|
+
* structured `condition`/`check`, and raw `using`/`withCheck` — into a single
|
|
401
|
+
* normalized {@link PolicyExpression} pair.
|
|
402
|
+
*
|
|
403
|
+
* **This is the linchpin against drift:** both the Postgres DDL generators and
|
|
404
|
+
* the client-side evaluator consume this one function, so there is exactly one
|
|
405
|
+
* definition of what a rule means. In particular, application `roles` are folded
|
|
406
|
+
* into the expression here (AND'd with the base condition, matching how Postgres
|
|
407
|
+
* generates the clause) rather than being handled separately by each consumer.
|
|
408
|
+
*/
|
|
409
|
+
function securityRuleToConditions(rule) {
|
|
410
|
+
return {
|
|
411
|
+
usingExpr: withRoles(baseUsing(rule), rule),
|
|
412
|
+
withCheckExpr: withRoles(baseWithCheck(rule), rule)
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function baseUsing(rule) {
|
|
416
|
+
if (rule.condition) return rule.condition;
|
|
417
|
+
if (rule.using != null) return sqlToPolicy(rule.using);
|
|
418
|
+
if (rule.access === "public") return policy.true();
|
|
419
|
+
if (rule.ownerField) return policy.compare(policy.field(rule.ownerField), "eq", policy.authUid());
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
function baseWithCheck(rule) {
|
|
423
|
+
if (rule.check) return rule.check;
|
|
424
|
+
if (rule.withCheck != null) return sqlToPolicy(rule.withCheck);
|
|
425
|
+
return baseUsing(rule);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* AND the base condition with an application-role check, or produce a roles-only
|
|
429
|
+
* condition when there is no base. Mirrors the Postgres generator so that a
|
|
430
|
+
* role-scoped restrictive rule denies exactly the same set of users on both
|
|
431
|
+
* sides.
|
|
432
|
+
*/
|
|
433
|
+
function withRoles(base, rule) {
|
|
434
|
+
if (!rule.roles || rule.roles.length === 0) return base;
|
|
435
|
+
const rolesExpr = policy.rolesOverlap(rule.roles);
|
|
436
|
+
if (rule.mode === "restrictive") return base ? policy.or(policy.not(rolesExpr), base) : policy.not(rolesExpr);
|
|
437
|
+
return base ? policy.and(base, rolesExpr) : rolesExpr;
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region ../common/src/util/policy/evaluatePolicy.ts
|
|
441
|
+
/**
|
|
442
|
+
* Evaluates a {@link PolicyExpression} against a user + row, using three-valued
|
|
443
|
+
* (Kleene) logic so that `"unknown"` sub-results propagate soundly.
|
|
444
|
+
*
|
|
445
|
+
* This is the JavaScript twin of {@link policyToPostgres}: both derive from the
|
|
446
|
+
* same expression, so the admin UI matches database enforcement by construction
|
|
447
|
+
* for every non-raw rule.
|
|
448
|
+
*/
|
|
449
|
+
function evaluatePolicy(expr, ctx) {
|
|
450
|
+
switch (expr.kind) {
|
|
451
|
+
case "true": return true;
|
|
452
|
+
case "false": return false;
|
|
453
|
+
case "and": return kleeneAnd$1(expr.operands.map((o) => evaluatePolicy(o, ctx)));
|
|
454
|
+
case "or": return kleeneOr(expr.operands.map((o) => evaluatePolicy(o, ctx)));
|
|
455
|
+
case "not": return kleeneNot(evaluatePolicy(expr.operand, ctx));
|
|
456
|
+
case "compare": return evaluateCompare(expr.op, expr.left, expr.right, ctx);
|
|
457
|
+
case "rolesOverlap": {
|
|
458
|
+
const userRoles = ctx.roles ?? [];
|
|
459
|
+
return expr.roles.some((r) => r === "public" || userRoles.includes(r));
|
|
460
|
+
}
|
|
461
|
+
case "rolesContain": {
|
|
462
|
+
const userRoles = ctx.roles ?? [];
|
|
463
|
+
return expr.roles.every((r) => r === "public" || userRoles.includes(r));
|
|
464
|
+
}
|
|
465
|
+
case "authenticated": return ctx.uid != null && !isAnonymousUid(ctx.uid);
|
|
466
|
+
case "registered": return ctx.uid != null && !isAnonymousUid(ctx.uid) && ctx.isAnonymous !== true;
|
|
467
|
+
case "serverContext": return false;
|
|
468
|
+
case "existsIn": return "unknown";
|
|
469
|
+
case "raw": return "unknown";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function kleeneAnd$1(values) {
|
|
473
|
+
if (values.some((v) => v === false)) return false;
|
|
474
|
+
if (values.some((v) => v === "unknown")) return "unknown";
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
function kleeneOr(values) {
|
|
478
|
+
if (values.some((v) => v === true)) return true;
|
|
479
|
+
if (values.some((v) => v === "unknown")) return "unknown";
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
function kleeneNot(value) {
|
|
483
|
+
if (value === "unknown") return "unknown";
|
|
484
|
+
return !value;
|
|
485
|
+
}
|
|
486
|
+
function resolveOperand(operand, ctx) {
|
|
487
|
+
switch (operand.kind) {
|
|
488
|
+
case "literal": return {
|
|
489
|
+
known: true,
|
|
490
|
+
value: operand.value
|
|
491
|
+
};
|
|
492
|
+
case "authUid": return {
|
|
493
|
+
known: true,
|
|
494
|
+
value: ctx.uid ?? "anonymous"
|
|
495
|
+
};
|
|
496
|
+
case "authRoles": return {
|
|
497
|
+
known: true,
|
|
498
|
+
value: ctx.roles ?? []
|
|
499
|
+
};
|
|
500
|
+
case "field":
|
|
501
|
+
if (!ctx.entity) return { known: false };
|
|
502
|
+
return {
|
|
503
|
+
known: true,
|
|
504
|
+
value: ctx.entity.values[operand.name]
|
|
505
|
+
};
|
|
506
|
+
case "outerField": return { known: false };
|
|
507
|
+
case "authClaim": return { known: false };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function evaluateCompare(op, left, right, ctx) {
|
|
511
|
+
const l = resolveOperand(left, ctx);
|
|
512
|
+
const r = resolveOperand(right, ctx);
|
|
513
|
+
if (!l.known || !r.known) return "unknown";
|
|
514
|
+
const a = l.value;
|
|
515
|
+
const b = r.value;
|
|
516
|
+
if (a === null || b === null) return "unknown";
|
|
517
|
+
if (op === "eq") return a === b;
|
|
518
|
+
if (op === "neq") return a !== b;
|
|
519
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
520
|
+
if (op === "lt") return a < b;
|
|
521
|
+
if (op === "lte") return a <= b;
|
|
522
|
+
if (op === "gt") return a > b;
|
|
523
|
+
if (op === "gte") return a >= b;
|
|
524
|
+
}
|
|
525
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
526
|
+
if (op === "lt") return a < b;
|
|
527
|
+
if (op === "lte") return a <= b;
|
|
528
|
+
if (op === "gt") return a > b;
|
|
529
|
+
if (op === "gte") return a >= b;
|
|
530
|
+
}
|
|
531
|
+
if (typeof a === "bigint" && typeof b === "bigint") {
|
|
532
|
+
if (op === "lt") return a < b;
|
|
533
|
+
if (op === "lte") return a <= b;
|
|
534
|
+
if (op === "gt") return a > b;
|
|
535
|
+
if (op === "gte") return a >= b;
|
|
536
|
+
}
|
|
537
|
+
return "unknown";
|
|
538
|
+
}
|
|
539
|
+
//#endregion
|
|
540
|
+
//#region ../common/src/util/permissions.ts
|
|
541
|
+
/** Combine clause results with AND under three-valued (Kleene) logic. */
|
|
542
|
+
function kleeneAnd(values) {
|
|
543
|
+
if (values.some((v) => v === false)) return false;
|
|
544
|
+
if (values.some((v) => v === "unknown")) return "unknown";
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
/** The operations a rule covers, mirroring the Postgres generator's resolution. */
|
|
548
|
+
function ruleOperations(rule) {
|
|
549
|
+
return rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
550
|
+
}
|
|
551
|
+
function ruleApplies(rule, targetOperation) {
|
|
552
|
+
const ops = ruleOperations(rule);
|
|
553
|
+
return ops.includes(targetOperation) || ops.includes("all");
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Evaluate a single rule for one operation, returning a tri-state.
|
|
557
|
+
*
|
|
558
|
+
* A `null` clause (the rule contributes no condition for a required clause)
|
|
559
|
+
* denies — matching Postgres, which emits `USING (false)` / `WITH CHECK (false)`
|
|
560
|
+
* in that case. USING applies to SELECT/UPDATE/DELETE; WITH CHECK to
|
|
561
|
+
* INSERT/UPDATE; both must pass for UPDATE.
|
|
562
|
+
*/
|
|
563
|
+
function evaluateRuleForOperation(rule, ctx, targetOperation, clauses = "both") {
|
|
564
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
565
|
+
const clause = (expr) => expr === null ? false : evaluatePolicy(expr, ctx);
|
|
566
|
+
const needsUsing = targetOperation !== "insert" && clauses !== "withCheck";
|
|
567
|
+
const needsWithCheck = (targetOperation === "insert" || targetOperation === "update") && clauses !== "using";
|
|
568
|
+
const results = [];
|
|
569
|
+
if (needsUsing) results.push(clause(usingExpr));
|
|
570
|
+
if (needsWithCheck) results.push(clause(withCheckExpr));
|
|
571
|
+
return kleeneAnd(results);
|
|
572
|
+
}
|
|
573
|
+
function resolveTriState(value, onUnknown) {
|
|
574
|
+
if (value === "unknown") return onUnknown === "allow";
|
|
575
|
+
return value;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Decide whether an operation is permitted for a user on a (possibly null) row,
|
|
579
|
+
* by evaluating the collection's security rules with the shared policy model —
|
|
580
|
+
* the same model compiled to Postgres RLS DDL, so the decision matches database
|
|
581
|
+
* enforcement for every non-raw rule.
|
|
582
|
+
*
|
|
583
|
+
* Engine-independent by design. `securityRules` are a declaration about the
|
|
584
|
+
* data, not about Postgres: the engine decides *who* enforces them (Postgres
|
|
585
|
+
* compiles them to RLS DDL, a document driver applies them in-process), never
|
|
586
|
+
* *whether* they hold. Gating this function on the engine's `supportsRLS`
|
|
587
|
+
* capability is what made every `{ onUnknown: "deny" }` call site in the Mongo
|
|
588
|
+
* driver return `true` before it evaluated anything — and it did so only for
|
|
589
|
+
* collections that spelled their engine out, so declaring `engine: "mongodb"`
|
|
590
|
+
* was what switched authorization off.
|
|
591
|
+
*
|
|
592
|
+
* @param options.onUnknown how to treat rules that cannot be decided
|
|
593
|
+
* client-side (raw SQL, or row predicates with no row). Defaults to `"allow"`
|
|
594
|
+
* for optimistic UI gating; enforcement callers should pass `"deny"`.
|
|
595
|
+
* @param options.clauses which half of each rule to evaluate. See
|
|
596
|
+
* {@link PolicyClauses}; defaults to `"both"`.
|
|
597
|
+
*/
|
|
598
|
+
function checkOperation(collection, authContext, entity, targetOperation, options) {
|
|
599
|
+
const onUnknown = options?.onUnknown ?? "allow";
|
|
600
|
+
const clauses = options?.clauses ?? "both";
|
|
601
|
+
const securityRules = collection.securityRules;
|
|
602
|
+
if (!securityRules || securityRules.length === 0) return true;
|
|
603
|
+
const applicableRules = securityRules.filter((r) => ruleApplies(r, targetOperation));
|
|
604
|
+
if (applicableRules.length === 0) return false;
|
|
605
|
+
const ctx = {
|
|
606
|
+
uid: authContext.user?.uid,
|
|
607
|
+
roles: authContext.user?.roles ?? [],
|
|
608
|
+
isAnonymous: authContext.user?.isAnonymous === true,
|
|
609
|
+
entity
|
|
610
|
+
};
|
|
611
|
+
let grantedByPermissive = false;
|
|
612
|
+
let deniedByRestrictive = false;
|
|
613
|
+
let hasPermissive = false;
|
|
614
|
+
for (const rule of applicableRules) {
|
|
615
|
+
const mode = rule.mode || "permissive";
|
|
616
|
+
const passed = resolveTriState(evaluateRuleForOperation(rule, ctx, targetOperation, clauses), onUnknown);
|
|
617
|
+
if (mode === "restrictive") {
|
|
618
|
+
if (!passed) {
|
|
619
|
+
deniedByRestrictive = true;
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
hasPermissive = true;
|
|
624
|
+
if (passed) grantedByPermissive = true;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (deniedByRestrictive) return false;
|
|
628
|
+
return hasPermissive ? grantedByPermissive : false;
|
|
629
|
+
}
|
|
630
|
+
//#endregion
|
|
172
631
|
//#region src/auth/api-keys/http-operation.ts
|
|
173
632
|
/**
|
|
174
633
|
* Map an HTTP method to the operation it performs.
|
|
@@ -1155,6 +1614,37 @@ function buildCollectionContext(ctx) {
|
|
|
1155
1614
|
};
|
|
1156
1615
|
}
|
|
1157
1616
|
/**
|
|
1617
|
+
* The body field with which an administrator waives the password policy for
|
|
1618
|
+
* one create — an import of accounts whose passwords another system chose.
|
|
1619
|
+
* An instruction to the door, never data: it reaches neither the row nor a
|
|
1620
|
+
* create hook.
|
|
1621
|
+
*/
|
|
1622
|
+
var SKIP_PASSWORD_POLICY_FIELD = "skipPasswordPolicy";
|
|
1623
|
+
/**
|
|
1624
|
+
* Hold a create body's chosen `password` to the password policy, as
|
|
1625
|
+
* registration and every other door that sets a password are, and return the
|
|
1626
|
+
* body without {@link SKIP_PASSWORD_POLICY_FIELD}.
|
|
1627
|
+
*
|
|
1628
|
+
* The flag waives the policy only when the door says the caller may waive it
|
|
1629
|
+
* (`options.mayWaivePasswordPolicy`, an administrator); from anyone else it
|
|
1630
|
+
* waives nothing, and a refusal says so.
|
|
1631
|
+
*
|
|
1632
|
+
* @throws ApiError 400 `WEAK_PASSWORD`
|
|
1633
|
+
*/
|
|
1634
|
+
function enforceCreationPasswordPolicy(body, resolvedHooks, options) {
|
|
1635
|
+
const { [SKIP_PASSWORD_POLICY_FIELD]: skipRequested, ...rest } = body;
|
|
1636
|
+
const waived = skipRequested === true && options?.mayWaivePasswordPolicy === true;
|
|
1637
|
+
const password = rest.password;
|
|
1638
|
+
if (!waived && typeof password === "string" && password !== "") {
|
|
1639
|
+
const validation = resolvedHooks.validatePasswordStrength(password);
|
|
1640
|
+
if (!validation.valid) {
|
|
1641
|
+
const refusedWaiver = skipRequested === true ? `. \`${SKIP_PASSWORD_POLICY_FIELD}\` is honoured only for administrators and the service key` : "";
|
|
1642
|
+
throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}${refusedWaiver}`, "WEAK_PASSWORD");
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return rest;
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1158
1648
|
* Prepare user values for an admin-initiated user creation.
|
|
1159
1649
|
*
|
|
1160
1650
|
* Resolution order:
|
|
@@ -1162,10 +1652,14 @@ function buildCollectionContext(ctx) {
|
|
|
1162
1652
|
* 2. Backend-level `AuthHooks.onAdminCreateUser` — global override
|
|
1163
1653
|
* 3. Built-in default — generate password → hash → normalize email
|
|
1164
1654
|
*
|
|
1655
|
+
* Before any of them, a chosen password is held to the password policy
|
|
1656
|
+
* ({@link enforceCreationPasswordPolicy}).
|
|
1657
|
+
*
|
|
1165
1658
|
* The caller is responsible for persisting (via `driver.save()`).
|
|
1166
1659
|
*/
|
|
1167
|
-
async function prepareAdminUserValues(
|
|
1660
|
+
async function prepareAdminUserValues(rawBody, ctx, options) {
|
|
1168
1661
|
const { resolvedHooks, collectionAuthConfig } = ctx;
|
|
1662
|
+
const body = enforceCreationPasswordPolicy(rawBody, resolvedHooks, options);
|
|
1169
1663
|
if (collectionAuthConfig?.onCreateUser) {
|
|
1170
1664
|
const collectionCtx = buildCollectionContext(ctx);
|
|
1171
1665
|
const hookResult = await collectionAuthConfig.onCreateUser(body, collectionCtx);
|
|
@@ -1662,6 +2156,30 @@ function extractBearerToken(authHeader) {
|
|
|
1662
2156
|
}
|
|
1663
2157
|
//#endregion
|
|
1664
2158
|
//#region src/auth/access.ts
|
|
2159
|
+
/**
|
|
2160
|
+
* How a personal key finds its owner in an account store: the account as it
|
|
2161
|
+
* is now — a missing or disabled one is no owner — and its roles.
|
|
2162
|
+
*
|
|
2163
|
+
* One read where the store answers both at once: `getAccountAccessState`, the
|
|
2164
|
+
* statement every access token is already judged against, which reads roles
|
|
2165
|
+
* and `disabled_at` together. Otherwise the account, then its roles — the two
|
|
2166
|
+
* reads this always made, one round trip more per request.
|
|
2167
|
+
*/
|
|
2168
|
+
function keyOwnerResolverFor(repo) {
|
|
2169
|
+
if (typeof repo.getAccountAccessState === "function") {
|
|
2170
|
+
const readAccount = repo.getAccountAccessState.bind(repo);
|
|
2171
|
+
return async (uid) => {
|
|
2172
|
+
const account = await readAccount(uid);
|
|
2173
|
+
if (!account || account.disabled) return null;
|
|
2174
|
+
return { roles: account.roles };
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
return async (uid) => {
|
|
2178
|
+
const user = await repo.getUserById(uid);
|
|
2179
|
+
if (!user || user.disabled) return null;
|
|
2180
|
+
return { roles: await repo.getUserRoleIds(uid) };
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
1665
2183
|
var configured = EMPTY_ACCESS_MODEL;
|
|
1666
2184
|
var keyOwners;
|
|
1667
2185
|
/** The access model the running backend serves. Built-ins only until boot configures it. */
|
|
@@ -1753,6 +2271,13 @@ function callerScopes(c) {
|
|
|
1753
2271
|
if (!user) return [];
|
|
1754
2272
|
return scopesForRoles(rolesOf(user), configured);
|
|
1755
2273
|
}
|
|
2274
|
+
/**
|
|
2275
|
+
* Whether this request's caller holds the `admin` role. The service key signs
|
|
2276
|
+
* in as one.
|
|
2277
|
+
*/
|
|
2278
|
+
function callerIsAdmin(c) {
|
|
2279
|
+
return hasAdminRole(rolesOf(c.get("user")));
|
|
2280
|
+
}
|
|
1756
2281
|
/** Does this request's caller hold `scope`, on `target` when one is given? */
|
|
1757
2282
|
function hasScope(c, scope, target) {
|
|
1758
2283
|
return scopeGrants(callerScopes(c), scope, target);
|
|
@@ -2249,6 +2774,42 @@ function tryRequestedStorageObject(wildcard) {
|
|
|
2249
2774
|
}
|
|
2250
2775
|
}
|
|
2251
2776
|
//#endregion
|
|
2777
|
+
//#region src/storage/request-principals.ts
|
|
2778
|
+
/**
|
|
2779
|
+
* The two principals a storage read can be granted without a user: the reader
|
|
2780
|
+
* of a declared-public object, and the bearer of a download token.
|
|
2781
|
+
*
|
|
2782
|
+
* `checkAuthorized` does not ask the `storageAuthorize` hook about either — the
|
|
2783
|
+
* public prefix and the token are the authorization. That skip used to be
|
|
2784
|
+
* decided on the principal's `uid` string (`"public"`, `"download-token"`), so
|
|
2785
|
+
* any identity carrying that uid skipped the hook: a custom `AuthAdapter` whose
|
|
2786
|
+
* `verifyRequest` returns `{ uid: "public" }` for a guest could then upload,
|
|
2787
|
+
* delete and list anywhere, with the hook never asked.
|
|
2788
|
+
*
|
|
2789
|
+
* Each principal is now one object, set only by the middleware that granted
|
|
2790
|
+
* it (`publicObjectAuth`, `fileTokenAuth`) and recognised by identity. An
|
|
2791
|
+
* adapter builds a new object per request, so it can name the uid but never be
|
|
2792
|
+
* the grant.
|
|
2793
|
+
*/
|
|
2794
|
+
/** The principal `publicObjectAuth` grants an anonymous read of a public object. */
|
|
2795
|
+
var PUBLIC_OBJECT_PRINCIPAL = Object.freeze({
|
|
2796
|
+
uid: "public",
|
|
2797
|
+
roles: ["public"]
|
|
2798
|
+
});
|
|
2799
|
+
/** The principal `fileTokenAuth` grants a read covered by a valid download token. */
|
|
2800
|
+
var DOWNLOAD_TOKEN_PRINCIPAL = Object.freeze({
|
|
2801
|
+
uid: "download-token",
|
|
2802
|
+
roles: ["reader"]
|
|
2803
|
+
});
|
|
2804
|
+
/** Whether `user` is the grant `publicObjectAuth` made — the object, not the uid. */
|
|
2805
|
+
function isPublicObjectGrant(user) {
|
|
2806
|
+
return user === PUBLIC_OBJECT_PRINCIPAL;
|
|
2807
|
+
}
|
|
2808
|
+
/** Whether `user` is the grant `fileTokenAuth` made — the object, not the uid. */
|
|
2809
|
+
function isDownloadTokenGrant(user) {
|
|
2810
|
+
return user === DOWNLOAD_TOKEN_PRINCIPAL;
|
|
2811
|
+
}
|
|
2812
|
+
//#endregion
|
|
2252
2813
|
//#region src/auth/middleware.ts
|
|
2253
2814
|
/**
|
|
2254
2815
|
* Answer a refusal with the canonical envelope, through the same formatter
|
|
@@ -2564,10 +3125,7 @@ var queryTokenAuth = async (c, next) => {
|
|
|
2564
3125
|
var publicObjectAuth = async (c, next) => {
|
|
2565
3126
|
if (c.get("user")) return next();
|
|
2566
3127
|
const requested = tryRequestedStorageObject(storageRequestWildcard(c));
|
|
2567
|
-
if (requested && isPublicStorageKey(requested.key)) c.set("user",
|
|
2568
|
-
uid: "public",
|
|
2569
|
-
roles: ["public"]
|
|
2570
|
-
});
|
|
3128
|
+
if (requested && isPublicStorageKey(requested.key)) c.set("user", PUBLIC_OBJECT_PRINCIPAL);
|
|
2571
3129
|
return next();
|
|
2572
3130
|
};
|
|
2573
3131
|
/**
|
|
@@ -2638,10 +3196,7 @@ var fileTokenAuth = async (c, next) => {
|
|
|
2638
3196
|
if (payload) {
|
|
2639
3197
|
const outcome = evaluateGrant(payload);
|
|
2640
3198
|
if (outcome === "grant") {
|
|
2641
|
-
c.set("user",
|
|
2642
|
-
uid: "download-token",
|
|
2643
|
-
roles: ["reader"]
|
|
2644
|
-
});
|
|
3199
|
+
c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
|
|
2645
3200
|
return next();
|
|
2646
3201
|
}
|
|
2647
3202
|
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
@@ -2654,10 +3209,7 @@ var fileTokenAuth = async (c, next) => {
|
|
|
2654
3209
|
if (payload) {
|
|
2655
3210
|
const outcome = evaluateGrant(payload);
|
|
2656
3211
|
if (outcome === "grant") {
|
|
2657
|
-
c.set("user",
|
|
2658
|
-
uid: "download-token",
|
|
2659
|
-
roles: ["reader"]
|
|
2660
|
-
});
|
|
3212
|
+
c.set("user", DOWNLOAD_TOKEN_PRINCIPAL);
|
|
2661
3213
|
return next();
|
|
2662
3214
|
}
|
|
2663
3215
|
if (outcome !== "no-path") return denyMismatch(outcome);
|
|
@@ -2920,6 +3472,18 @@ function assertSafeHeaders(headers) {
|
|
|
2920
3472
|
if (/[\r\n]/.test(value)) throw new Error(`Invalid value for email header "${name}": header values cannot contain CR or LF. A newline here would end the header and begin another one.`);
|
|
2921
3473
|
}
|
|
2922
3474
|
}
|
|
3475
|
+
/** Connections a burst of mail may hold open at once, unless `smtp.maxConnections` says otherwise. */
|
|
3476
|
+
var DEFAULT_MAX_CONNECTIONS = 5;
|
|
3477
|
+
/**
|
|
3478
|
+
* How long the connection pool may sit unused before it is closed.
|
|
3479
|
+
*
|
|
3480
|
+
* Long enough that a burst — a team invited at once, a `Promise.all` over
|
|
3481
|
+
* `email.send` — reuses its connections; short enough that a quiet server
|
|
3482
|
+
* holds no socket open to the provider, and that a script which sends one
|
|
3483
|
+
* message and finishes is not kept alive by an idle connection for the ten
|
|
3484
|
+
* minutes nodemailer would otherwise wait.
|
|
3485
|
+
*/
|
|
3486
|
+
var POOL_IDLE_CLOSE_MS = 5e3;
|
|
2923
3487
|
/** `<abc@host>` → `abc@host`. See EmailSendResult.messageId for why. */
|
|
2924
3488
|
function normalizeMessageId(value) {
|
|
2925
3489
|
if (typeof value !== "string") return void 0;
|
|
@@ -2944,6 +3508,10 @@ var SMTPEmailService = class {
|
|
|
2944
3508
|
* life of the process.
|
|
2945
3509
|
*/
|
|
2946
3510
|
transporterReady = null;
|
|
3511
|
+
/** Sends between their start and their end — the pool is never closed under one. */
|
|
3512
|
+
sending = 0;
|
|
3513
|
+
/** Closes the pool once it has been unused for {@link POOL_IDLE_CLOSE_MS}. */
|
|
3514
|
+
idleTimer;
|
|
2947
3515
|
constructor(config) {
|
|
2948
3516
|
this.config = config;
|
|
2949
3517
|
}
|
|
@@ -2976,10 +3544,15 @@ var SMTPEmailService = class {
|
|
|
2976
3544
|
}
|
|
2977
3545
|
}
|
|
2978
3546
|
this.transporter = nodemailer.createTransport({
|
|
3547
|
+
pool: true,
|
|
3548
|
+
maxConnections: this.config.smtp.maxConnections ?? DEFAULT_MAX_CONNECTIONS,
|
|
2979
3549
|
name: smtpName,
|
|
2980
3550
|
host: this.config.smtp.host,
|
|
2981
3551
|
port: this.config.smtp.port,
|
|
2982
3552
|
secure: this.config.smtp.secure ?? this.config.smtp.port === 465,
|
|
3553
|
+
connectionTimeout: this.config.smtp.timeoutMs ?? 3e4,
|
|
3554
|
+
greetingTimeout: this.config.smtp.timeoutMs ?? 3e4,
|
|
3555
|
+
socketTimeout: this.config.smtp.timeoutMs ?? 3e4,
|
|
2983
3556
|
auth: this.config.smtp.auth ? {
|
|
2984
3557
|
user: this.config.smtp.auth.user,
|
|
2985
3558
|
pass: this.config.smtp.auth.pass
|
|
@@ -2999,6 +3572,16 @@ var SMTPEmailService = class {
|
|
|
2999
3572
|
async send(options) {
|
|
3000
3573
|
assertSafeHeaders(options.headers);
|
|
3001
3574
|
if (this.config.sendEmail) return await this.config.sendEmail(options) ?? {};
|
|
3575
|
+
this.sending++;
|
|
3576
|
+
clearTimeout(this.idleTimer);
|
|
3577
|
+
try {
|
|
3578
|
+
return await this.sendOverSmtp(options);
|
|
3579
|
+
} finally {
|
|
3580
|
+
this.sending--;
|
|
3581
|
+
this.closeWhenIdle();
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
async sendOverSmtp(options) {
|
|
3002
3585
|
await this.ensureTransporter();
|
|
3003
3586
|
if (!this.transporter) throw new Error("Email service not configured. Provide SMTP config or sendEmail function.");
|
|
3004
3587
|
const to = Array.isArray(options.to) ? options.to.join(", ") : options.to;
|
|
@@ -3023,6 +3606,35 @@ var SMTPEmailService = class {
|
|
|
3023
3606
|
throw new Error(`Failed to send email: ${message}`);
|
|
3024
3607
|
}
|
|
3025
3608
|
}
|
|
3609
|
+
/** Arm the idle close once nothing is sending. */
|
|
3610
|
+
closeWhenIdle() {
|
|
3611
|
+
if (this.sending > 0 || !this.transporter) return;
|
|
3612
|
+
clearTimeout(this.idleTimer);
|
|
3613
|
+
this.idleTimer = setTimeout(() => {
|
|
3614
|
+
this.idleTimer = void 0;
|
|
3615
|
+
if (this.sending === 0) this.releaseTransporter();
|
|
3616
|
+
}, POOL_IDLE_CLOSE_MS);
|
|
3617
|
+
this.idleTimer.unref?.();
|
|
3618
|
+
}
|
|
3619
|
+
/**
|
|
3620
|
+
* Close the pool and forget it; the next send builds a fresh one. A closed
|
|
3621
|
+
* nodemailer pool accepts nothing more, so it cannot be kept and reused.
|
|
3622
|
+
*/
|
|
3623
|
+
releaseTransporter() {
|
|
3624
|
+
const transporter = this.transporter;
|
|
3625
|
+
this.transporter = null;
|
|
3626
|
+
this.transporterReady = null;
|
|
3627
|
+
transporter?.close?.();
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* Close the connection pool now, rather than when it next goes idle — for
|
|
3631
|
+
* a shutdown. A send after this opens a new pool.
|
|
3632
|
+
*/
|
|
3633
|
+
close() {
|
|
3634
|
+
clearTimeout(this.idleTimer);
|
|
3635
|
+
this.idleTimer = void 0;
|
|
3636
|
+
if (this.sending === 0) this.releaseTransporter();
|
|
3637
|
+
}
|
|
3026
3638
|
/**
|
|
3027
3639
|
* Verify SMTP connection (useful for startup checks)
|
|
3028
3640
|
*/
|
|
@@ -3171,29 +3783,35 @@ var MemoryRateLimitStore = class {
|
|
|
3171
3783
|
}
|
|
3172
3784
|
async hit(key, windowMs, limit) {
|
|
3173
3785
|
const now = this.now();
|
|
3174
|
-
|
|
3175
|
-
if (
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
retryAfterMs: Math.max(0, timestamps[0] + windowMs - now)
|
|
3786
|
+
let window = this.store.get(key);
|
|
3787
|
+
if (!window) {
|
|
3788
|
+
window = {
|
|
3789
|
+
stamps: [],
|
|
3790
|
+
head: 0,
|
|
3791
|
+
ordered: true
|
|
3181
3792
|
};
|
|
3793
|
+
this.store.set(key, window);
|
|
3182
3794
|
}
|
|
3183
|
-
|
|
3184
|
-
|
|
3795
|
+
expire(window, now, windowMs);
|
|
3796
|
+
const live = window.stamps.length - window.head;
|
|
3797
|
+
if (live >= limit) return {
|
|
3798
|
+
allowed: false,
|
|
3799
|
+
remaining: 0,
|
|
3800
|
+
retryAfterMs: Math.max(0, window.stamps[window.head] + windowMs - now)
|
|
3801
|
+
};
|
|
3802
|
+
if (live > 0 && now < window.stamps[window.stamps.length - 1]) window.ordered = false;
|
|
3803
|
+
window.stamps.push(now);
|
|
3185
3804
|
return {
|
|
3186
3805
|
allowed: true,
|
|
3187
|
-
remaining: limit -
|
|
3806
|
+
remaining: limit - (live + 1),
|
|
3188
3807
|
retryAfterMs: 0
|
|
3189
3808
|
};
|
|
3190
3809
|
}
|
|
3191
3810
|
sweep(windowMs) {
|
|
3192
3811
|
const now = this.now();
|
|
3193
|
-
for (const [key,
|
|
3194
|
-
|
|
3195
|
-
if (
|
|
3196
|
-
else this.store.set(key, live);
|
|
3812
|
+
for (const [key, window] of this.store.entries()) {
|
|
3813
|
+
expire(window, now, windowMs);
|
|
3814
|
+
if (window.head === window.stamps.length) this.store.delete(key);
|
|
3197
3815
|
}
|
|
3198
3816
|
}
|
|
3199
3817
|
dispose() {
|
|
@@ -3202,6 +3820,25 @@ var MemoryRateLimitStore = class {
|
|
|
3202
3820
|
this.store.clear();
|
|
3203
3821
|
}
|
|
3204
3822
|
};
|
|
3823
|
+
/** Drop the hits at least `windowMs` old — `now - t < windowMs` stays, as before. */
|
|
3824
|
+
function expire(window, now, windowMs) {
|
|
3825
|
+
if (!window.ordered) {
|
|
3826
|
+
window.stamps = window.stamps.slice(window.head).filter((t) => now - t < windowMs);
|
|
3827
|
+
window.head = 0;
|
|
3828
|
+
if (window.stamps.length === 0) window.ordered = true;
|
|
3829
|
+
return;
|
|
3830
|
+
}
|
|
3831
|
+
const { stamps } = window;
|
|
3832
|
+
let head = window.head;
|
|
3833
|
+
while (head < stamps.length && now - stamps[head] >= windowMs) head++;
|
|
3834
|
+
if (head === stamps.length) {
|
|
3835
|
+
window.stamps = [];
|
|
3836
|
+
window.head = 0;
|
|
3837
|
+
} else if (head >= 32 && head * 2 >= stamps.length) {
|
|
3838
|
+
window.stamps = stamps.slice(head);
|
|
3839
|
+
window.head = 0;
|
|
3840
|
+
} else window.head = head;
|
|
3841
|
+
}
|
|
3205
3842
|
//#endregion
|
|
3206
3843
|
//#region src/auth/rate-limiter.ts
|
|
3207
3844
|
/**
|
|
@@ -3269,12 +3906,15 @@ function namedStore(name, windowMs) {
|
|
|
3269
3906
|
*/
|
|
3270
3907
|
function createRateLimiter(options = {}) {
|
|
3271
3908
|
const trustedProxyHops = resolveTrustedProxyHops(options.trustedProxyHops);
|
|
3272
|
-
const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", name, store = name ? namedStore(name, windowMs) : new MemoryRateLimitStore(windowMs), resolveLimit } = options;
|
|
3909
|
+
const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", name, store = name ? namedStore(name, windowMs) : new MemoryRateLimitStore(windowMs), resolveLimit, cost } = options;
|
|
3273
3910
|
return async (c, next) => {
|
|
3274
3911
|
const effectiveLimit = resolveLimit ? await resolveLimit(c) : limit;
|
|
3275
3912
|
if (effectiveLimit === null) return next();
|
|
3276
3913
|
const activeLimit = effectiveLimit ?? limit;
|
|
3277
|
-
const
|
|
3914
|
+
const key = await keyGenerator(c);
|
|
3915
|
+
let decision = await store.hit(key, windowMs, activeLimit);
|
|
3916
|
+
const charge = cost ? Math.max(1, Math.floor(await cost(c))) : 1;
|
|
3917
|
+
for (let hit = 1; hit < charge && decision.allowed; hit++) decision = await store.hit(key, windowMs, activeLimit);
|
|
3278
3918
|
c.header("X-RateLimit-Limit", String(activeLimit));
|
|
3279
3919
|
c.header("X-RateLimit-Remaining", String(decision.remaining));
|
|
3280
3920
|
if (!decision.allowed) {
|
|
@@ -3496,7 +4136,7 @@ var DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT = 3e3;
|
|
|
3496
4136
|
* set them, and one that already has a proxy doing this should pass
|
|
3497
4137
|
* `enabled: false` rather than pay for it twice.
|
|
3498
4138
|
*/
|
|
3499
|
-
function createDataRateLimiter(config = {}) {
|
|
4139
|
+
function createDataRateLimiter(config = {}, options = {}) {
|
|
3500
4140
|
const { windowMs = 900 * 1e3, store = new MemoryRateLimitStore(windowMs) } = config;
|
|
3501
4141
|
const trustedProxyHops = resolveTrustedProxyHops(config.trustedProxyHops);
|
|
3502
4142
|
const bucketFor = dataRateLimitBuckets(config);
|
|
@@ -3540,7 +4180,16 @@ function createDataRateLimiter(config = {}) {
|
|
|
3540
4180
|
const token = extractBearerToken(c.req.header("authorization"));
|
|
3541
4181
|
return token !== void 0 && safeCompare(token, config.serviceKey);
|
|
3542
4182
|
};
|
|
3543
|
-
const
|
|
4183
|
+
const bucketsByRequest = /* @__PURE__ */ new WeakMap();
|
|
4184
|
+
const bucketOf = (c) => {
|
|
4185
|
+
let bucket = bucketsByRequest.get(c);
|
|
4186
|
+
if (!bucket) {
|
|
4187
|
+
bucket = resolveBucket(c);
|
|
4188
|
+
bucketsByRequest.set(c, bucket);
|
|
4189
|
+
}
|
|
4190
|
+
return bucket;
|
|
4191
|
+
};
|
|
4192
|
+
const resolveBucket = async (c) => {
|
|
3544
4193
|
if (c.get("user")?.uid === SERVICE_IDENTITY.uid) return null;
|
|
3545
4194
|
if (isServiceKey(c)) return null;
|
|
3546
4195
|
const apiKey = c.get("apiKey");
|
|
@@ -3555,7 +4204,8 @@ function createDataRateLimiter(config = {}) {
|
|
|
3555
4204
|
store,
|
|
3556
4205
|
message: "Too many requests, please try again later.",
|
|
3557
4206
|
keyGenerator: async (c) => (await bucketOf(c))?.key ?? `ip:${defaultKeyGenerator(c, trustedProxyHops)}`,
|
|
3558
|
-
resolveLimit: async (c) => (await bucketOf(c))?.limit ?? null
|
|
4207
|
+
resolveLimit: async (c) => (await bucketOf(c))?.limit ?? null,
|
|
4208
|
+
cost: options.cost
|
|
3559
4209
|
});
|
|
3560
4210
|
}
|
|
3561
4211
|
/**
|
|
@@ -4455,7 +5105,7 @@ function mountMfaRoutes(opts) {
|
|
|
4455
5105
|
router.delete("/mfa/unenroll", strictAuthLimiter, requireLiveSession, async (c) => {
|
|
4456
5106
|
const userCtx = c.get("user");
|
|
4457
5107
|
if (!userCtx) throw ApiError.unauthorized("Not authenticated");
|
|
4458
|
-
|
|
5108
|
+
await requireStepUpForFactorChange(userCtx);
|
|
4459
5109
|
const unenrollSchema = z.object({ factorId: z.string().min(1, "Factor ID is required") });
|
|
4460
5110
|
const { factorId } = parseBody(unenrollSchema, await c.req.json());
|
|
4461
5111
|
const factor = await authRepo.getMfaFactorById(factorId);
|
|
@@ -4534,6 +5184,31 @@ function isBootstrapWindowOpen(env = process.env) {
|
|
|
4534
5184
|
return env.NODE_ENV !== "production";
|
|
4535
5185
|
}
|
|
4536
5186
|
/**
|
|
5187
|
+
* Whether the users table is empty — the question behind the bootstrap window.
|
|
5188
|
+
*
|
|
5189
|
+
* Through `AuthRepository.firstUserIds` when the repository has it: one
|
|
5190
|
+
* unordered `LIMIT 1` read. Otherwise through `listUsersPaginated`, which also
|
|
5191
|
+
* counts and sorts the whole table, so it is only the fallback.
|
|
5192
|
+
*/
|
|
5193
|
+
async function isUserTableEmpty(repo) {
|
|
5194
|
+
if (repo.firstUserIds) return (await repo.firstUserIds(1)).length === 0;
|
|
5195
|
+
const { total } = await repo.listUsersPaginated({ limit: 1 });
|
|
5196
|
+
return total === 0;
|
|
5197
|
+
}
|
|
5198
|
+
/**
|
|
5199
|
+
* Whether the account just created is the only one there is. Two ids at most:
|
|
5200
|
+
* whether anyone else exists, and, when nobody does, whether it is this one.
|
|
5201
|
+
* Same fallback as {@link isUserTableEmpty}.
|
|
5202
|
+
*/
|
|
5203
|
+
async function isOnlyUser(repo, uid) {
|
|
5204
|
+
if (repo.firstUserIds) {
|
|
5205
|
+
const ids = await repo.firstUserIds(2);
|
|
5206
|
+
return ids.length === 1 && ids[0] === uid;
|
|
5207
|
+
}
|
|
5208
|
+
const { users, total } = await repo.listUsersPaginated({ limit: 2 });
|
|
5209
|
+
return total === 1 && users[0]?.id === uid;
|
|
5210
|
+
}
|
|
5211
|
+
/**
|
|
4537
5212
|
* What to tell a caller who hit an empty production database with the window
|
|
4538
5213
|
* shut. Named here so the register route and the bootstrap route say the same
|
|
4539
5214
|
* thing.
|
|
@@ -4885,7 +5560,7 @@ function mountSessionRoutes(opts) {
|
|
|
4885
5560
|
if (!signedOutUid) {
|
|
4886
5561
|
const accessToken = extractBearerToken(c.req.header("authorization"));
|
|
4887
5562
|
if (accessToken !== void 0) {
|
|
4888
|
-
const { verifyAccessToken } = await import("./jwt-
|
|
5563
|
+
const { verifyAccessToken } = await import("./jwt-Bqcu7cGt.js").then((n) => n._);
|
|
4889
5564
|
signedOutUid = (await verifyAccessToken(accessToken))?.uid;
|
|
4890
5565
|
}
|
|
4891
5566
|
}
|
|
@@ -5692,18 +6367,6 @@ function createAuthRoutes(config) {
|
|
|
5692
6367
|
return dummyHash;
|
|
5693
6368
|
}
|
|
5694
6369
|
/**
|
|
5695
|
-
* Is the account just created the only one there is?
|
|
5696
|
-
*
|
|
5697
|
-
* Two rows at most: the total says whether anyone else exists, and the
|
|
5698
|
-
* one row left when nobody does says whether it is this account. It used
|
|
5699
|
-
* to be `listUsers()` — the whole users table, password hashes included,
|
|
5700
|
-
* on every sign-up, to compare its length with one.
|
|
5701
|
-
*/
|
|
5702
|
-
async function isOnlyUser(uid) {
|
|
5703
|
-
const { users, total } = await authRepo.listUsersPaginated({ limit: 2 });
|
|
5704
|
-
return total === 1 && users[0]?.id === uid;
|
|
5705
|
-
}
|
|
5706
|
-
/**
|
|
5707
6370
|
* Helper to generate and store session tokens.
|
|
5708
6371
|
*
|
|
5709
6372
|
* Every route that signs somebody in comes through here — password login,
|
|
@@ -5760,8 +6423,7 @@ function createAuthRoutes(config) {
|
|
|
5760
6423
|
if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
5761
6424
|
let bootstrapRegistration = false;
|
|
5762
6425
|
if (!isRegistrationAllowed()) {
|
|
5763
|
-
|
|
5764
|
-
bootstrapRegistration = total === 0;
|
|
6426
|
+
bootstrapRegistration = await isUserTableEmpty(authRepo);
|
|
5765
6427
|
if (!bootstrapRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
5766
6428
|
if (!isBootstrapWindowOpen()) throw ApiError.forbidden(SETUP_REQUIRED_MESSAGE, "SETUP_REQUIRED");
|
|
5767
6429
|
}
|
|
@@ -5788,7 +6450,7 @@ function createAuthRoutes(config) {
|
|
|
5788
6450
|
};
|
|
5789
6451
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
5790
6452
|
const user = await authRepo.createUser(createData);
|
|
5791
|
-
const isFirstUser = await isOnlyUser(user.id);
|
|
6453
|
+
const isFirstUser = await isOnlyUser(authRepo, user.id);
|
|
5792
6454
|
if (bootstrapRegistration && !isFirstUser) {
|
|
5793
6455
|
await authRepo.deleteUser(user.id);
|
|
5794
6456
|
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
@@ -5931,8 +6593,7 @@ function createAuthRoutes(config) {
|
|
|
5931
6593
|
if (config.disableSelfRegistration) throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
|
|
5932
6594
|
let bootstrapRegistration = false;
|
|
5933
6595
|
if (!isRegistrationAllowed()) {
|
|
5934
|
-
|
|
5935
|
-
bootstrapRegistration = total === 0;
|
|
6596
|
+
bootstrapRegistration = await isUserTableEmpty(authRepo);
|
|
5936
6597
|
if (!bootstrapRegistration) throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
|
|
5937
6598
|
if (!isBootstrapWindowOpen()) throw ApiError.forbidden(SETUP_REQUIRED_MESSAGE, "SETUP_REQUIRED");
|
|
5938
6599
|
}
|
|
@@ -5950,7 +6611,7 @@ function createAuthRoutes(config) {
|
|
|
5950
6611
|
} catch (err) {
|
|
5951
6612
|
logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
|
|
5952
6613
|
}
|
|
5953
|
-
const isFirstUser = await isOnlyUser(user.id);
|
|
6614
|
+
const isFirstUser = await isOnlyUser(authRepo, user.id);
|
|
5954
6615
|
if (bootstrapRegistration && !isFirstUser) {
|
|
5955
6616
|
await authRepo.deleteUser(user.id);
|
|
5956
6617
|
throw ApiError.forbidden(noSignupsMessage, "REGISTRATION_DISABLED");
|
|
@@ -6499,7 +7160,7 @@ function createResetPasswordRoute(config) {
|
|
|
6499
7160
|
const chosenPassword = body.password ? body.password : void 0;
|
|
6500
7161
|
if (chosenPassword) {
|
|
6501
7162
|
const validation = ops.validatePasswordStrength(chosenPassword);
|
|
6502
|
-
if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(", ")}
|
|
7163
|
+
if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(", ")}`, "WEAK_PASSWORD");
|
|
6503
7164
|
} else if (collectionAuthConfig?.onResetPassword) {
|
|
6504
7165
|
const isEmailConfigured = !!(emailService && emailService.isConfigured());
|
|
6505
7166
|
const hookResult = await collectionAuthConfig.onResetPassword(existing.id, {
|
|
@@ -6595,12 +7256,101 @@ function createAdminRolesRoute(config) {
|
|
|
6595
7256
|
return router;
|
|
6596
7257
|
}
|
|
6597
7258
|
//#endregion
|
|
7259
|
+
//#region src/auth/user-picking.ts
|
|
7260
|
+
var collectionsForPicking = () => [];
|
|
7261
|
+
/** Tell the picker which collections this backend serves. Called once at boot. */
|
|
7262
|
+
function configureUserPicking(collections) {
|
|
7263
|
+
collectionsForPicking = collections;
|
|
7264
|
+
}
|
|
7265
|
+
function propertyReferencesUsers(property) {
|
|
7266
|
+
if (!property) return false;
|
|
7267
|
+
if (property.type === "string" && property.userSelect === true) return true;
|
|
7268
|
+
if (property.type === "map") return propertiesReferenceUsers(property.properties);
|
|
7269
|
+
if (property.type === "array") {
|
|
7270
|
+
const of = property.of;
|
|
7271
|
+
if (Array.isArray(of)) return of.some(propertyReferencesUsers);
|
|
7272
|
+
if (of) return propertyReferencesUsers(of);
|
|
7273
|
+
return propertiesReferenceUsers(property.oneOf?.properties);
|
|
7274
|
+
}
|
|
7275
|
+
return false;
|
|
7276
|
+
}
|
|
7277
|
+
/** Whether any property, at any depth, is a `userSelect` field. */
|
|
7278
|
+
function propertiesReferenceUsers(properties) {
|
|
7279
|
+
return !!properties && Object.values(properties).some(propertyReferencesUsers);
|
|
7280
|
+
}
|
|
7281
|
+
/** Whether any part of a policy is raw SQL the evaluator cannot decide. */
|
|
7282
|
+
function containsRawSql(expr) {
|
|
7283
|
+
if (!expr) return false;
|
|
7284
|
+
switch (expr.kind) {
|
|
7285
|
+
case "raw": return true;
|
|
7286
|
+
case "and":
|
|
7287
|
+
case "or": return expr.operands.some(containsRawSql);
|
|
7288
|
+
case "not": return containsRawSql(expr.operand);
|
|
7289
|
+
case "existsIn": return containsRawSql(expr.where);
|
|
7290
|
+
default: return false;
|
|
7291
|
+
}
|
|
7292
|
+
}
|
|
7293
|
+
/**
|
|
7294
|
+
* The rule with each clause that holds raw SQL read as `false`, so an
|
|
7295
|
+
* undecidable rule never grants (permissive) and always refuses
|
|
7296
|
+
* (restrictive). The clauses are carried already desugared — roles folded
|
|
7297
|
+
* in — so nothing else in the rule is read again.
|
|
7298
|
+
*/
|
|
7299
|
+
function failClosedOnRawSql(rule) {
|
|
7300
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
7301
|
+
if (!containsRawSql(usingExpr) && !containsRawSql(withCheckExpr)) return rule;
|
|
7302
|
+
const clause = (expr) => expr === null || containsRawSql(expr) ? policy.false() : expr;
|
|
7303
|
+
return {
|
|
7304
|
+
operation: rule.operation,
|
|
7305
|
+
operations: rule.operations,
|
|
7306
|
+
mode: rule.mode,
|
|
7307
|
+
name: rule.name,
|
|
7308
|
+
condition: clause(usingExpr),
|
|
7309
|
+
check: clause(withCheckExpr)
|
|
7310
|
+
};
|
|
7311
|
+
}
|
|
7312
|
+
/** The signed-in person behind a request, as the policy evaluator reads one. */
|
|
7313
|
+
function personOf(c) {
|
|
7314
|
+
const user = c.get("user");
|
|
7315
|
+
if (typeof user !== "object" || user === null) return null;
|
|
7316
|
+
const uid = typeof user.uid === "string" ? user.uid : void 0;
|
|
7317
|
+
if (!uid || isAnonymousUid(uid)) return null;
|
|
7318
|
+
if ("isAnonymous" in user && user.isAnonymous === true) return null;
|
|
7319
|
+
return {
|
|
7320
|
+
uid,
|
|
7321
|
+
roles: Array.isArray(user.roles) ? user.roles.filter((role) => typeof role === "string") : [],
|
|
7322
|
+
isAnonymous: false,
|
|
7323
|
+
displayName: null,
|
|
7324
|
+
email: null,
|
|
7325
|
+
photoURL: null,
|
|
7326
|
+
providerId: "custom"
|
|
7327
|
+
};
|
|
7328
|
+
}
|
|
7329
|
+
/**
|
|
7330
|
+
* Whether this request's caller may read the user picker: they hold
|
|
7331
|
+
* `users:read`, or they may write a collection with a `userSelect` field.
|
|
7332
|
+
*/
|
|
7333
|
+
function mayPickUsers(c) {
|
|
7334
|
+
if (hasScope(c, "users:read")) return true;
|
|
7335
|
+
const person = personOf(c);
|
|
7336
|
+
if (!person) return false;
|
|
7337
|
+
return collectionsForPicking().some((collection) => {
|
|
7338
|
+
if (!propertiesReferenceUsers(collection.properties) || !hasScope(c, "data:write", collection.slug)) return false;
|
|
7339
|
+
const judged = collection.securityRules ? {
|
|
7340
|
+
...collection,
|
|
7341
|
+
securityRules: collection.securityRules.map(failClosedOnRawSql)
|
|
7342
|
+
} : collection;
|
|
7343
|
+
return checkOperation(judged, { user: person }, null, "insert") || checkOperation(judged, { user: person }, null, "update");
|
|
7344
|
+
});
|
|
7345
|
+
}
|
|
7346
|
+
//#endregion
|
|
6598
7347
|
//#region src/auth/admin-users-route.ts
|
|
6599
7348
|
/**
|
|
6600
7349
|
* Standalone admin endpoint for user management.
|
|
6601
7350
|
*
|
|
6602
7351
|
* Mounts:
|
|
6603
7352
|
* GET /users
|
|
7353
|
+
* GET /users/pick
|
|
6604
7354
|
* GET /users/:uid
|
|
6605
7355
|
* POST /users
|
|
6606
7356
|
* PUT /users/:uid
|
|
@@ -6732,6 +7482,48 @@ function createAdminUsersRoute(config) {
|
|
|
6732
7482
|
offset: result.offset
|
|
6733
7483
|
});
|
|
6734
7484
|
});
|
|
7485
|
+
/**
|
|
7486
|
+
* The user picker's read: who a `userSelect` field can name, as id, name,
|
|
7487
|
+
* email and avatar — nothing an administrator's list carries beyond that.
|
|
7488
|
+
* Open to anyone who may write a collection with such a field, as well as
|
|
7489
|
+
* to `users:read` ({@link mayPickUsers}). Registered before `/users/:uid`,
|
|
7490
|
+
* which would otherwise take `pick` for an id.
|
|
7491
|
+
*
|
|
7492
|
+
* `?ids=a,b` resolves the users a stored value names; otherwise a page of
|
|
7493
|
+
* `?search=`, `?limit=` (25 by default) and `?offset=`.
|
|
7494
|
+
*/
|
|
7495
|
+
router.get("/users/pick", async (c) => {
|
|
7496
|
+
if (!mayPickUsers(c)) throw new ApiError(403, "SCOPE_MISSING", "Picking a user needs write access to a collection with a user field, or the \"users:read\" scope.", { requiredScope: "users:read" });
|
|
7497
|
+
const toPickedUser = (u) => ({
|
|
7498
|
+
uid: u.id,
|
|
7499
|
+
email: u.email,
|
|
7500
|
+
displayName: u.displayName ?? null,
|
|
7501
|
+
photoURL: u.photoUrl ?? null
|
|
7502
|
+
});
|
|
7503
|
+
const idsParam = c.req.query("ids");
|
|
7504
|
+
if (idsParam !== void 0) {
|
|
7505
|
+
const ids = [...new Set(idsParam.split(",").map((id) => id.trim()).filter(Boolean))].slice(0, MAX_USER_IDS_PER_LOOKUP);
|
|
7506
|
+
const users = (await Promise.all(ids.map((id) => authRepo.getUserById(id)))).filter((u) => !!u).map(toPickedUser);
|
|
7507
|
+
return c.json({
|
|
7508
|
+
users,
|
|
7509
|
+
limit: ids.length,
|
|
7510
|
+
offset: 0
|
|
7511
|
+
});
|
|
7512
|
+
}
|
|
7513
|
+
const limit = resolveListLimitParam(c.req.query("limit"), { defaultLimit: 25 });
|
|
7514
|
+
const offsetParam = c.req.query("offset");
|
|
7515
|
+
const offset = offsetParam ? Math.max(0, parseInt(offsetParam, 10) || 0) : 0;
|
|
7516
|
+
const result = await authRepo.listUsersPaginated({
|
|
7517
|
+
limit,
|
|
7518
|
+
offset,
|
|
7519
|
+
search: c.req.query("search") || void 0
|
|
7520
|
+
});
|
|
7521
|
+
return c.json({
|
|
7522
|
+
users: result.users.map(toPickedUser),
|
|
7523
|
+
limit: result.limit,
|
|
7524
|
+
offset: result.offset
|
|
7525
|
+
});
|
|
7526
|
+
});
|
|
6735
7527
|
router.get("/users/:uid", requireScope("users:read"), async (c) => {
|
|
6736
7528
|
const uid = c.req.param("uid");
|
|
6737
7529
|
const result = await authRepo.getUserWithRoles(uid);
|
|
@@ -6751,7 +7543,7 @@ function createAdminUsersRoute(config) {
|
|
|
6751
7543
|
emailConfig,
|
|
6752
7544
|
resolvedHooks: ops,
|
|
6753
7545
|
collectionAuthConfig
|
|
6754
|
-
});
|
|
7546
|
+
}, { mayWaivePasswordPolicy: callerIsAdmin(c) });
|
|
6755
7547
|
let createData = {
|
|
6756
7548
|
email: prepResult.values.email,
|
|
6757
7549
|
passwordHash: prepResult.values.passwordHash,
|
|
@@ -6803,7 +7595,7 @@ function createAdminUsersRoute(config) {
|
|
|
6803
7595
|
let passwordHash;
|
|
6804
7596
|
if (password) {
|
|
6805
7597
|
const validation = ops.validatePasswordStrength(password);
|
|
6806
|
-
if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}
|
|
7598
|
+
if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`, "WEAK_PASSWORD");
|
|
6807
7599
|
passwordHash = await ops.hashPassword(password);
|
|
6808
7600
|
}
|
|
6809
7601
|
if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
|
|
@@ -7060,14 +7852,14 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7060
7852
|
router.route("/", adminUsersRoute);
|
|
7061
7853
|
return router;
|
|
7062
7854
|
},
|
|
7063
|
-
async prepareUserCreation(values, collectionAuth) {
|
|
7855
|
+
async prepareUserCreation(values, collectionAuth, options) {
|
|
7064
7856
|
return prepareAdminUserValues(values, {
|
|
7065
7857
|
authRepo: authRepository,
|
|
7066
7858
|
emailService,
|
|
7067
7859
|
emailConfig,
|
|
7068
7860
|
resolvedHooks: resolvedOps,
|
|
7069
7861
|
collectionAuthConfig: collectionAuth ?? collectionAuthConfig
|
|
7070
|
-
});
|
|
7862
|
+
}, options);
|
|
7071
7863
|
},
|
|
7072
7864
|
describeUserCreationContract(collectionAuth) {
|
|
7073
7865
|
if ((collectionAuth ?? collectionAuthConfig)?.onCreateUser || resolvedOps.onAdminCreateUser) return {
|
|
@@ -7076,7 +7868,8 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7076
7868
|
};
|
|
7077
7869
|
return {
|
|
7078
7870
|
validate: true,
|
|
7079
|
-
extraFields: ["password"]
|
|
7871
|
+
extraFields: ["password", SKIP_PASSWORD_POLICY_FIELD],
|
|
7872
|
+
independent: !authHooks?.hashPassword
|
|
7080
7873
|
};
|
|
7081
7874
|
},
|
|
7082
7875
|
async finalizeUserCreation(entity, clearPassword) {
|
|
@@ -7095,7 +7888,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
7095
7888
|
async getCapabilities() {
|
|
7096
7889
|
let needsSetup = false;
|
|
7097
7890
|
try {
|
|
7098
|
-
needsSetup = (
|
|
7891
|
+
needsSetup = isBootstrapWindowOpen() && await isUserTableEmpty(authRepository);
|
|
7099
7892
|
} catch (error) {
|
|
7100
7893
|
logger.warn("[Auth] Could not count users for `needsSetup`; reporting the backend as already set up.", { error: error instanceof Error ? error.message : String(error) });
|
|
7101
7894
|
}
|
|
@@ -8500,6 +9293,78 @@ function scopesFromStoredPermissions(permissions, admin) {
|
|
|
8500
9293
|
*/
|
|
8501
9294
|
var TABLE = "rebase.api_keys";
|
|
8502
9295
|
/**
|
|
9296
|
+
* Drops `rebase.idx_api_keys_hash` — a plain btree on `key_hash`, next to the
|
|
9297
|
+
* UNIQUE (key_hash) constraint's own index — but only while a valid, unique,
|
|
9298
|
+
* non-partial btree on the table leads with the same column, so a lookup is
|
|
9299
|
+
* never left without one. Under a one-second `lock_timeout`, local to this
|
|
9300
|
+
* statement's transaction: `DROP INDEX` takes the table's ACCESS EXCLUSIVE lock.
|
|
9301
|
+
* A no-op read once the index is gone.
|
|
9302
|
+
*/
|
|
9303
|
+
var DROP_DUPLICATE_KEY_HASH_INDEX_SQL = `
|
|
9304
|
+
DO $rebase_drop_hash_index$
|
|
9305
|
+
BEGIN
|
|
9306
|
+
IF EXISTS (
|
|
9307
|
+
SELECT 1
|
|
9308
|
+
FROM pg_index d
|
|
9309
|
+
JOIN pg_class ic ON ic.oid = d.indexrelid
|
|
9310
|
+
JOIN pg_namespace n ON n.oid = ic.relnamespace
|
|
9311
|
+
WHERE n.nspname = 'rebase' AND ic.relname = 'idx_api_keys_hash' AND d.indnatts = 1
|
|
9312
|
+
AND EXISTS (
|
|
9313
|
+
SELECT 1 FROM pg_index o
|
|
9314
|
+
JOIN pg_class oc ON oc.oid = o.indexrelid
|
|
9315
|
+
JOIN pg_am am ON am.oid = oc.relam
|
|
9316
|
+
WHERE o.indrelid = d.indrelid AND o.indexrelid <> d.indexrelid
|
|
9317
|
+
AND o.indisvalid AND o.indisunique AND o.indpred IS NULL AND o.indexprs IS NULL
|
|
9318
|
+
AND am.amname = 'btree' AND o.indkey[0] = d.indkey[0])
|
|
9319
|
+
) THEN
|
|
9320
|
+
PERFORM set_config('lock_timeout', '1000ms', true);
|
|
9321
|
+
DROP INDEX IF EXISTS rebase.idx_api_keys_hash;
|
|
9322
|
+
END IF;
|
|
9323
|
+
END
|
|
9324
|
+
$rebase_drop_hash_index$`.trim();
|
|
9325
|
+
/** The relations `ensureTable` creates in `rebase`: the table and its two indexes. */
|
|
9326
|
+
var BOOT_RELATIONS = [
|
|
9327
|
+
"api_keys",
|
|
9328
|
+
"idx_api_keys_prefix",
|
|
9329
|
+
"idx_api_keys_owner"
|
|
9330
|
+
];
|
|
9331
|
+
/** The columns `ensureTable` adds to a table created before scopes. */
|
|
9332
|
+
var BOOT_COLUMNS = [
|
|
9333
|
+
"kind",
|
|
9334
|
+
"scopes",
|
|
9335
|
+
"roles",
|
|
9336
|
+
"owner_uid"
|
|
9337
|
+
];
|
|
9338
|
+
/**
|
|
9339
|
+
* One catalogue read answering whether `ensureTable` has anything to do. It
|
|
9340
|
+
* returns one row: whether the table, its indexes and the scope columns are
|
|
9341
|
+
* there, whether the duplicate hash index and the pre-scopes `permissions`
|
|
9342
|
+
* column are gone, and whether the end-user role still holds a privilege (the
|
|
9343
|
+
* same test `revokeInternalTableSql` makes before it revokes). `readable` is
|
|
9344
|
+
* the connection's own access, the question `isReadable` asks.
|
|
9345
|
+
*/
|
|
9346
|
+
var BOOT_STATE_SQL = (() => {
|
|
9347
|
+
const list = (names) => names.map((name) => `'${name}'`).join(", ");
|
|
9348
|
+
const target = `(SELECT oid FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')`;
|
|
9349
|
+
const columnCount = (names) => `(SELECT count(*) FROM pg_attribute a WHERE a.attrelid = to_regclass('${TABLE}') AND a.attnum > 0 AND NOT a.attisdropped AND a.attname IN (${list(names)}))::int`;
|
|
9350
|
+
return `
|
|
9351
|
+
SELECT
|
|
9352
|
+
(SELECT count(*) FROM pg_class c WHERE c.relnamespace = to_regnamespace('rebase')
|
|
9353
|
+
AND c.relname IN (${list(BOOT_RELATIONS)}))::int AS present,
|
|
9354
|
+
(SELECT count(*) FROM pg_class c WHERE c.relnamespace = to_regnamespace('rebase')
|
|
9355
|
+
AND c.relname = 'idx_api_keys_hash')::int AS stale,
|
|
9356
|
+
${columnCount(BOOT_COLUMNS)} AS columns,
|
|
9357
|
+
${columnCount(["permissions"])} AS legacy,
|
|
9358
|
+
(SELECT count(*) FROM pg_class c
|
|
9359
|
+
WHERE c.oid = to_regclass('${TABLE}') AND NOT c.relrowsecurity AND ${target} IS NOT NULL
|
|
9360
|
+
AND (c.relacl IS NULL
|
|
9361
|
+
OR EXISTS (SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee = ${target})
|
|
9362
|
+
OR EXISTS (SELECT 1 FROM pg_attribute att, aclexplode(att.attacl) a
|
|
9363
|
+
WHERE att.attrelid = c.oid AND att.attacl IS NOT NULL AND a.grantee = ${target})))::int AS granted,
|
|
9364
|
+
(has_schema_privilege('rebase', 'USAGE') AND has_table_privilege(to_regclass('${TABLE}'), 'SELECT')) AS readable
|
|
9365
|
+
WHERE to_regnamespace('rebase') IS NOT NULL`.trim();
|
|
9366
|
+
})();
|
|
9367
|
+
/**
|
|
8503
9368
|
* Generate a plaintext API key with the `rk_live_` prefix.
|
|
8504
9369
|
*
|
|
8505
9370
|
* Format: `rk_live_` + 32 random hex characters.
|
|
@@ -8609,8 +9474,29 @@ function createApiKeyStore(driver) {
|
|
|
8609
9474
|
}
|
|
8610
9475
|
if (rows.length > 0) logger.info(`[api-key-store] Gave ${rows.length} stored API key(s) their scopes.`);
|
|
8611
9476
|
}
|
|
9477
|
+
/**
|
|
9478
|
+
* Whether a boot has nothing to do, from {@link BOOT_STATE_SQL}. Every
|
|
9479
|
+
* statement below is a no-op on such a database, but not a free one: each
|
|
9480
|
+
* `ADD COLUMN IF NOT EXISTS` queues for the table's ACCESS EXCLUSIVE lock
|
|
9481
|
+
* before it finds the column there, each `CREATE INDEX IF NOT EXISTS` takes
|
|
9482
|
+
* a SHARE lock, and the `REVOKE` rewrites the table's catalogue row. Anything
|
|
9483
|
+
* missing, granted or legacy — or a read that fails, as it does on a driver
|
|
9484
|
+
* that is not Postgres — runs the full bootstrap, exactly as before.
|
|
9485
|
+
*/
|
|
9486
|
+
async function bootIsCurrent() {
|
|
9487
|
+
try {
|
|
9488
|
+
const row = (await exec(BOOT_STATE_SQL))[0];
|
|
9489
|
+
return row !== void 0 && Number(row.present) === BOOT_RELATIONS.length && Number(row.stale) === 0 && Number(row.columns) === BOOT_COLUMNS.length && Number(row.legacy) === 0 && Number(row.granted) === 0 && row.readable === true;
|
|
9490
|
+
} catch {
|
|
9491
|
+
return false;
|
|
9492
|
+
}
|
|
9493
|
+
}
|
|
8612
9494
|
return {
|
|
8613
9495
|
async ensureTable() {
|
|
9496
|
+
if (await bootIsCurrent()) {
|
|
9497
|
+
logger.debug("✅ API keys table ready");
|
|
9498
|
+
return;
|
|
9499
|
+
}
|
|
8614
9500
|
await ddl.ensureObject("Creating schema rebase", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
8615
9501
|
await ddl.ensureObject(`Creating ${TABLE}`, `
|
|
8616
9502
|
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
|
@@ -8631,10 +9517,11 @@ function createApiKeyStore(driver) {
|
|
|
8631
9517
|
revoked_at TIMESTAMPTZ
|
|
8632
9518
|
)
|
|
8633
9519
|
`);
|
|
8634
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
9520
|
+
try {
|
|
9521
|
+
await exec(DROP_DUPLICATE_KEY_HASH_INDEX_SQL);
|
|
9522
|
+
} catch (err) {
|
|
9523
|
+
logger.warn(`[api-key-store] Left the duplicate idx_api_keys_hash in place for now: ${err instanceof Error ? err.message : String(err)}`);
|
|
9524
|
+
}
|
|
8638
9525
|
await ddl.ensureObject("Creating idx_api_keys_prefix", `
|
|
8639
9526
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
|
|
8640
9527
|
ON ${TABLE}(key_prefix)
|
|
@@ -9180,6 +10067,6 @@ var auth_exports = /* @__PURE__ */ __exportAll({
|
|
|
9180
10067
|
verifyPassword: () => verifyPassword
|
|
9181
10068
|
});
|
|
9182
10069
|
//#endregion
|
|
9183
|
-
export {
|
|
10070
|
+
export { publicObjectAuth as $, storageObjectPath as $t, createDataRateLimitCheck as A, isAccessTokenRevoked as At, registerDevEmailSink as B, getPasswordResetTemplate as Bt, pkceTokenParams as C, requireScope as Ct, configureUserPicking as D, SERVICE_IDENTITY as Dt, createBuiltinAuthAdapter as E, safeCompare as Et, MemoryRateLimitStore as F, getEmailChangeNoticeTemplate as Ft, validatePasswordStrength as G, escapeHtml as Gt, createEmailService as H, getWelcomeEmailTemplate as Ht, activeDevEmailSink as I, getEmailChangeTemplate as It, createAuthMiddleware as J, httpMethodToOperation as Jt, verifyPassword as K, html as Kt, clearActiveDevEmailSink as L, getEmailOtpTemplate as Lt, createRateLimiter as M, generateSecurePassword as Mt, defaultAuthLimiter as N, assertEmailLinkBases as Nt, isBootstrapWindowOpen as O, scopeDataDriver as Ot, setSharedRateLimitStore as P, resolveEmailLinkBase as Pt, optionalAuth as Q, isPublicStoragePath as Qt, createDevEmailSink as R, getEmailVerificationTemplate as Rt, oauthCodeFlowSchema as S, keyOwnerResolverFor as St, createJwksRoutes as T, extractBearerToken as Tt, resolveAuthHooks as U, resolveEmailBranding as Ut, SMTPEmailService as V, getUserInvitationTemplate as Vt, hashPassword as W, RawHtml as Wt, extractUserFromToken as X, PUBLIC_STORAGE_PREFIX as Xt, createRequireAuth as Y, securityRuleToConditions as Yt, fileTokenAuth as Z, isPublicStorageKey as Zt, tryVerifyOidcIdToken as _, accessModelFromCollections as _t, createApiKeyStore as a, isPublicObjectGrant as at, createLinkedinProvider as b, getAccessModel as bt, createSpotifyProvider as c, storageRequestWildcard as ct, createGitLabProvider as d, createApiKeyPreAuth as dt, queryTokenAuth as et, createDiscordProvider as f, createFunctionScopeGuard as ft, createMicrosoftProvider as g, validateApiKey as gt, createAppleProvider as h, resolveApiKey as ht, createPersonalKeyRoutes as i, isDownloadTokenGrant as it, createDataRateLimiter as j, completeUserCreation as jt, DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT as k, supportsRlsScoping as kt, createSlackProvider as l, decideImpersonation as lt, createFacebookProvider as m, isApiKeyToken as mt, createCustomAuthAdapter as n, requireAuth as nt, resolveRateLimitStoreKind as o, requestedStorageObject as ot, createTwitterProvider as p, createTusScopeGuard as pt, createAdapterAuthMiddleware as q, raw as qt, createApiKeyRoutes as r, PUBLIC_OBJECT_PRINCIPAL as rt, createSqlRateLimitStore as s, storageObjectOfPath as st, auth_exports as t, requireAdmin as tt, createBitbucketProvider as u, refuseUnhonouredImpersonation as ut, verifyOidcIdToken as v, callerScopes as vt, providerVerifiedEmail as w, requireScopeByMethod as wt, createGoogleProvider as x, hasScope as xt, createGitHubProvider as y, configureAccess as yt, extractLinks as z, getMagicLinkTemplate as zt };
|
|
9184
10071
|
|
|
9185
|
-
//# sourceMappingURL=auth-
|
|
10072
|
+
//# sourceMappingURL=auth-OPq_FeMs.js.map
|