@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
package/dist/index.es.js
CHANGED
|
@@ -4,43 +4,50 @@ __createRequire(import.meta.url);
|
|
|
4
4
|
import { A as GeoPoint, M as RebaseApiError, N as RebaseClientError, O as EntityReference, S as toSnakeCase, _ as fieldKeyForColumn, b as buildCompositeId, c as collectAllPages, d as normalizeOrderBy, f as serializeOrderBy, g as getEffectiveSecurityRules, h as resolveDataSource, j as Vector, k as EntityRelation, l as paginateFind, m as createDataSourceRegistry, n as buildSdkData, o as serializeFilter, p as CollectionRegistry, s as serializeLogicalCondition, t as buildRoutedRebaseData, u as resolveFindWindow, v as findRelation, x as resolvePrimaryKeys, y as resolveCollectionRelations } from "./src-BPfYOeN4.js";
|
|
5
5
|
import { c as DEFAULT_DATA_SOURCE_KEY, g as toCanonicalOp, i as getCollectionDataPath, m as parseRelationAggregateSort, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-CrCxd8km.js";
|
|
6
6
|
import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-DLILvzle.js";
|
|
7
|
-
import { a as isDuplicateObjectRace, i as isConcurrentDdlRace, n as createDdlBootstrapper, o as revokeInternalTableSql, s as isSQLAdmin, t as CONCURRENT_DDL_SQLSTATES } from "./ddl-bootstrap-
|
|
8
|
-
import { C as canonicalStorageKey, D as findStorageSuffixCollision, E as DEFAULT_STORAGE_SOURCE_KEY, O as normalizeStorageSources, S as canonicalStorageId, b as InvalidStorageKeyError, c as getJwks, d as hasAsymmetricSigningKey, i as generateDownloadToken, k as storageEnvSuffix, n as configureJwt, p as isJwtConfigured, v as normalizePemFromEnv, x as canonicalStorageBucket, y as InvalidStorageBucketError } from "./jwt-
|
|
9
|
-
import { n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-
|
|
10
|
-
import { $ as
|
|
11
|
-
import { t as logger } from "./logger-
|
|
12
|
-
import { n as errorHandler, t as ApiError } from "./errors-
|
|
7
|
+
import { a as isDuplicateObjectRace, c as isSchemaEditingAdmin, i as isConcurrentDdlRace, n as createDdlBootstrapper, o as revokeInternalTableSql, s as isSQLAdmin, t as CONCURRENT_DDL_SQLSTATES } from "./ddl-bootstrap-DgSnERYN.js";
|
|
8
|
+
import { C as canonicalStorageKey, D as findStorageSuffixCollision, E as DEFAULT_STORAGE_SOURCE_KEY, O as normalizeStorageSources, S as canonicalStorageId, b as InvalidStorageKeyError, c as getJwks, d as hasAsymmetricSigningKey, i as generateDownloadToken, k as storageEnvSuffix, n as configureJwt, p as isJwtConfigured, v as normalizePemFromEnv, x as canonicalStorageBucket, y as InvalidStorageBucketError } from "./jwt-BbJi0TR0.js";
|
|
9
|
+
import { n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-BEq7euZg.js";
|
|
10
|
+
import { $ as html, A as _coercedNumber, B as resolveAuthHooks, C as providerVerifiedEmail, Ct as orderByEntriesToTuples, D as _enum, Dt as resolveListLimitParam, E as ZodNumber, Et as parseQueryOptions, F as extractLinks, G as getEmailVerificationTemplate, H as validatePasswordStrength, I as SMTPEmailService, J as getUserInvitationTemplate, K as getMagicLinkTemplate, L as createEmailService, M as createDataRateLimiter, N as MemoryRateLimitStore, O as object, Ot as PUBLIC_STORAGE_PREFIX, P as createDevEmailSink, Q as escapeHtml, R as assertEmailLinkBases, S as pkceTokenParams, St as isOperationAllowed, T as createBuiltinAuthAdapter, Tt as parseGroupBy, U as verifyPassword, V as hashPassword, W as generateSecurePassword, X as resolveEmailBranding, Y as getWelcomeEmailTemplate, Z as RawHtml, _ as verifyOidcIdToken, _t as safeCompare, a as resolveRateLimitStoreKind, at as fileTokenAuth, b as createGoogleProvider, bt as hasAdministrativeRole, c as createSlackProvider, ct as queryTokenAuth, d as createDiscordProvider, dt as createApiKeyPreAuth, et as raw, f as createTwitterProvider, ft as createFunctionApiKeyGuard, g as tryVerifyOidcIdToken, gt as extractBearerToken, h as createMicrosoftProvider, ht as validateApiKey, i as createApiKeyStore, it as extractUserFromToken, j as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, k as string, kt as isPublicStoragePath, l as createBitbucketProvider, lt as requireAdmin, m as createAppleProvider, mt as isApiKeyToken, n as createCustomAuthAdapter, nt as createAuthMiddleware, o as createSqlRateLimitStore, ot as optionalAuth, p as createFacebookProvider, pt as createStorageApiKeyGuard, q as getPasswordResetTemplate, r as createApiKeyRoutes, rt as createRequireAuth, s as createSpotifyProvider, st as publicObjectAuth, tt as createAdapterAuthMiddleware, u as createGitLabProvider, ut as requireAuth, v as createGitHubProvider, vt as SERVICE_IDENTITY, w as createJwksRoutes, wt as parseAggregateSelect, x as oauthCodeFlowSchema, xt as httpMethodToOperation, y as createLinkedinProvider, yt as scopeDataDriver, z as resolveEmailLinkBase } from "./auth-C74dqR2B.js";
|
|
11
|
+
import { r as hostEnv, t as logger } from "./logger-TdvXIGqR.js";
|
|
12
|
+
import { n as errorHandler, t as ApiError } from "./errors-D_LwNKRM.js";
|
|
13
13
|
import { t as logMiddleware } from "./logs-routes-CWBLQj2l.js";
|
|
14
14
|
import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-DSnOC4mF.js";
|
|
15
15
|
import { t as S3StorageController } from "./S3StorageController-B2EDXXMH.js";
|
|
16
16
|
import { t as GCSStorageController } from "./GCSStorageController-BEmDYFKc.js";
|
|
17
|
-
import { n as loadFunctionsFromDirectory } from "./function-loader-
|
|
17
|
+
import { n as loadFunctionsFromDirectory, r as loadFunctionsWithDiagnostics } from "./function-loader-BrLmC_-y.js";
|
|
18
18
|
import { t as createFunctionRoutes } from "./function-routes-Btcez1T-.js";
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import { t as createCronRoutes } from "./cron-routes-maM_RlUu.js";
|
|
22
|
-
import { t as createCronStore } from "./cron-store-DfH_4Cd9.js";
|
|
23
|
-
import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-C6ljYVTp.js";
|
|
24
|
-
import { i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-CyOKXXlu.js";
|
|
19
|
+
import "./proxy-QJKSS-CV.js";
|
|
20
|
+
import "./request-timeout-OofPCHQT.js";
|
|
25
21
|
import { t as FunctionSelectionError } from "./selection-_z6TM1DB.js";
|
|
26
|
-
import {
|
|
27
|
-
import
|
|
22
|
+
import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-BMvtW6-J.js";
|
|
23
|
+
import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-Buf-uVam.js";
|
|
24
|
+
import { t as createCronRoutes } from "./cron-routes-BvYk-Kmi.js";
|
|
25
|
+
import { t as createCronStore } from "./cron-store-CyF-oInV.js";
|
|
26
|
+
import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-BJ86ah4T.js";
|
|
27
|
+
import { i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-Bn6nhY8J.js";
|
|
28
|
+
import { createHash, createSign, randomBytes, randomUUID } from "node:crypto";
|
|
29
|
+
import * as fs$3 from "fs";
|
|
28
30
|
import fs, { existsSync } from "fs";
|
|
29
|
-
import * as path$
|
|
31
|
+
import * as path$3 from "path";
|
|
30
32
|
import path, { join } from "path";
|
|
31
33
|
import { pathToFileURL } from "url";
|
|
32
34
|
import { Hono } from "hono";
|
|
33
35
|
import { promisify } from "util";
|
|
34
36
|
import * as crypto$1 from "crypto";
|
|
35
37
|
import { createHmac, randomUUID as randomUUID$1 } from "crypto";
|
|
38
|
+
import * as path$2 from "node:path";
|
|
36
39
|
import path$1 from "node:path";
|
|
37
40
|
import { bodyLimit } from "hono/body-limit";
|
|
38
41
|
import { csrf } from "hono/csrf";
|
|
39
42
|
import { compress } from "hono/compress";
|
|
40
43
|
import fs$1 from "node:fs";
|
|
44
|
+
import * as fs$2 from "node:fs/promises";
|
|
41
45
|
import fsp from "node:fs/promises";
|
|
42
46
|
import os from "node:os";
|
|
43
47
|
import { mkdir, open, unlink, writeFile } from "fs/promises";
|
|
48
|
+
import { execFile } from "node:child_process";
|
|
49
|
+
import { promisify as promisify$1 } from "node:util";
|
|
50
|
+
import { env, getRuntimeKey } from "hono/adapter";
|
|
44
51
|
import { sql } from "drizzle-orm";
|
|
45
52
|
import { lookup } from "node:dns/promises";
|
|
46
53
|
import { isIP } from "node:net";
|
|
@@ -49,8 +56,14 @@ import { createServer } from "http";
|
|
|
49
56
|
import { cors } from "hono/cors";
|
|
50
57
|
import { secureHeaders } from "hono/secure-headers";
|
|
51
58
|
import { getRequestListener } from "@hono/node-server";
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
//#region ../types/src/types/schema_editing.ts
|
|
60
|
+
var DEFAULT_COMMIT_PATHS = {
|
|
61
|
+
schemaFile: "backend/src/schema.generated.ts",
|
|
62
|
+
ddlFile: "drizzle/schema.sql",
|
|
63
|
+
policiesFile: "drizzle/policies.sql",
|
|
64
|
+
searchFile: "drizzle/search.sql"
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
54
67
|
//#region src/collections/BackendCollectionRegistry.ts
|
|
55
68
|
/**
|
|
56
69
|
* Backend-agnostic collection registry.
|
|
@@ -161,7 +174,7 @@ var PROPERTY_KEYS_BY_TYPE = {
|
|
|
161
174
|
],
|
|
162
175
|
geopoint: [],
|
|
163
176
|
binary: [],
|
|
164
|
-
vector: ["dimensions"],
|
|
177
|
+
vector: ["dimensions", "index"],
|
|
165
178
|
reference: [
|
|
166
179
|
"isId",
|
|
167
180
|
"path",
|
|
@@ -464,8 +477,8 @@ async function importModule(filePath) {
|
|
|
464
477
|
/** Read `defaultSecurityRules` from a collections directory's index module. */
|
|
465
478
|
async function readDefaults(directory) {
|
|
466
479
|
for (const name of ["index.ts", "index.js"]) {
|
|
467
|
-
const indexPath = path$
|
|
468
|
-
if (!fs$
|
|
480
|
+
const indexPath = path$3.join(directory, name);
|
|
481
|
+
if (!fs$3.existsSync(indexPath)) continue;
|
|
469
482
|
try {
|
|
470
483
|
return { defaultSecurityRules: (await importModule(indexPath)).defaultSecurityRules };
|
|
471
484
|
} catch (err) {
|
|
@@ -502,23 +515,23 @@ function applyCollectionDefaults(collections, defaults) {
|
|
|
502
515
|
* rejects.
|
|
503
516
|
*/
|
|
504
517
|
async function loadCollectionsFromDirectory(source, options = {}) {
|
|
505
|
-
const resolved = path$
|
|
518
|
+
const resolved = path$3.resolve(source);
|
|
506
519
|
const validate = (collections) => {
|
|
507
520
|
if (options.validate !== false) assertCollectionConfigs(collections, options.validate ?? {});
|
|
508
521
|
return collections;
|
|
509
522
|
};
|
|
510
|
-
if (!fs$
|
|
523
|
+
if (!fs$3.existsSync(resolved)) {
|
|
511
524
|
logger.warn(`[collections] Not found: ${resolved}`);
|
|
512
525
|
return [];
|
|
513
526
|
}
|
|
514
|
-
if (!fs$
|
|
527
|
+
if (!fs$3.statSync(resolved).isDirectory()) {
|
|
515
528
|
const mod = await importModule(resolved);
|
|
516
529
|
return validate(applyCollectionDefaults([...mod.backendCollections || mod.collections || []], { defaultSecurityRules: mod.defaultSecurityRules }));
|
|
517
530
|
}
|
|
518
531
|
const collections = [];
|
|
519
532
|
const failures = [];
|
|
520
|
-
for (const file of fs$
|
|
521
|
-
const mod = await importModule(path$
|
|
533
|
+
for (const file of fs$3.readdirSync(resolved).filter(isCollectionFile)) try {
|
|
534
|
+
const mod = await importModule(path$3.join(resolved, file));
|
|
522
535
|
if (mod?.default) collections.push(mod.default);
|
|
523
536
|
else failures.push(`${file}: no default export`);
|
|
524
537
|
} catch (err) {
|
|
@@ -2632,13 +2645,13 @@ function configureMiddlewares(app, basePath, isProduction, config) {
|
|
|
2632
2645
|
/**
|
|
2633
2646
|
* Local filesystem storage controller
|
|
2634
2647
|
*/
|
|
2635
|
-
var mkdir$1 = promisify(fs$
|
|
2636
|
-
var writeFile$1 = promisify(fs$
|
|
2637
|
-
var readFile = promisify(fs$
|
|
2638
|
-
var unlink$1 = promisify(fs$
|
|
2639
|
-
var readdir = promisify(fs$
|
|
2640
|
-
var stat = promisify(fs$
|
|
2641
|
-
var access = promisify(fs$
|
|
2648
|
+
var mkdir$1 = promisify(fs$3.mkdir);
|
|
2649
|
+
var writeFile$1 = promisify(fs$3.writeFile);
|
|
2650
|
+
var readFile = promisify(fs$3.readFile);
|
|
2651
|
+
var unlink$1 = promisify(fs$3.unlink);
|
|
2652
|
+
var readdir = promisify(fs$3.readdir);
|
|
2653
|
+
var stat = promisify(fs$3.stat);
|
|
2654
|
+
var access = promisify(fs$3.access);
|
|
2642
2655
|
/**
|
|
2643
2656
|
* Remove initial and trailing slashes from a path.
|
|
2644
2657
|
* Handles paths like "/images/", "images/", "/images" → "images"
|
|
@@ -2658,7 +2671,7 @@ var LocalStorageController = class {
|
|
|
2658
2671
|
basePath;
|
|
2659
2672
|
constructor(config) {
|
|
2660
2673
|
this.config = config;
|
|
2661
|
-
this.basePath = path$
|
|
2674
|
+
this.basePath = path$3.resolve(config.basePath);
|
|
2662
2675
|
}
|
|
2663
2676
|
getType() {
|
|
2664
2677
|
return "local";
|
|
@@ -2699,10 +2712,10 @@ var LocalStorageController = class {
|
|
|
2699
2712
|
* depend on it having run.
|
|
2700
2713
|
*/
|
|
2701
2714
|
getFullPath(storagePath, bucket) {
|
|
2702
|
-
const bucketPath = path$
|
|
2703
|
-
if (!bucketPath.startsWith(this.basePath + path$
|
|
2704
|
-
const resolved = path$
|
|
2705
|
-
if (!resolved.startsWith(bucketPath + path$
|
|
2715
|
+
const bucketPath = path$3.resolve(this.basePath, bucket || "default");
|
|
2716
|
+
if (!bucketPath.startsWith(this.basePath + path$3.sep)) throw new Error("Path traversal detected: bucket resolves outside the storage root.");
|
|
2717
|
+
const resolved = path$3.resolve(bucketPath, storagePath);
|
|
2718
|
+
if (!resolved.startsWith(bucketPath + path$3.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
2706
2719
|
return resolved;
|
|
2707
2720
|
}
|
|
2708
2721
|
/**
|
|
@@ -2720,7 +2733,7 @@ var LocalStorageController = class {
|
|
|
2720
2733
|
const usedBucket = bucket || "default";
|
|
2721
2734
|
const fullStoragePath = key;
|
|
2722
2735
|
const fullPath = this.getFullPath(fullStoragePath, usedBucket);
|
|
2723
|
-
await this.ensureDir(path$
|
|
2736
|
+
await this.ensureDir(path$3.dirname(fullPath));
|
|
2724
2737
|
const arrayBuffer = await file.arrayBuffer();
|
|
2725
2738
|
await writeFile$1(fullPath, Buffer.from(arrayBuffer));
|
|
2726
2739
|
await writeFile$1(`${fullPath}.metadata.json`, JSON.stringify({
|
|
@@ -2749,7 +2762,7 @@ var LocalStorageController = class {
|
|
|
2749
2762
|
resolvedPath = normalizeStoragePath(resolvedPath);
|
|
2750
2763
|
const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
|
|
2751
2764
|
try {
|
|
2752
|
-
await access(fullPath, fs$
|
|
2765
|
+
await access(fullPath, fs$3.constants.R_OK);
|
|
2753
2766
|
} catch {
|
|
2754
2767
|
return {
|
|
2755
2768
|
url: null,
|
|
@@ -2765,7 +2778,7 @@ var LocalStorageController = class {
|
|
|
2765
2778
|
metadata = {
|
|
2766
2779
|
bucket: resolvedBucket ?? "default",
|
|
2767
2780
|
fullPath: resolvedPath,
|
|
2768
|
-
name: path$
|
|
2781
|
+
name: path$3.basename(resolvedPath),
|
|
2769
2782
|
size: fileStat.size,
|
|
2770
2783
|
contentType: savedMetadata.contentType || "application/octet-stream",
|
|
2771
2784
|
customMetadata: savedMetadata
|
|
@@ -2776,7 +2789,7 @@ var LocalStorageController = class {
|
|
|
2776
2789
|
metadata = {
|
|
2777
2790
|
bucket: resolvedBucket ?? "default",
|
|
2778
2791
|
fullPath: resolvedPath,
|
|
2779
|
-
name: path$
|
|
2792
|
+
name: path$3.basename(resolvedPath),
|
|
2780
2793
|
size: fileStat.size,
|
|
2781
2794
|
contentType: "application/octet-stream",
|
|
2782
2795
|
customMetadata: {}
|
|
@@ -2802,7 +2815,7 @@ var LocalStorageController = class {
|
|
|
2802
2815
|
resolvedPath = normalizeStoragePath(resolvedPath);
|
|
2803
2816
|
const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
|
|
2804
2817
|
try {
|
|
2805
|
-
await access(fullPath, fs$
|
|
2818
|
+
await access(fullPath, fs$3.constants.R_OK);
|
|
2806
2819
|
const buffer = await readFile(fullPath);
|
|
2807
2820
|
let contentType = "application/octet-stream";
|
|
2808
2821
|
try {
|
|
@@ -2810,7 +2823,7 @@ var LocalStorageController = class {
|
|
|
2810
2823
|
contentType = JSON.parse(metadataContent).contentType || contentType;
|
|
2811
2824
|
} catch {}
|
|
2812
2825
|
const blob = new Blob([buffer], { type: contentType });
|
|
2813
|
-
return new File([blob], path$
|
|
2826
|
+
return new File([blob], path$3.basename(resolvedPath), { type: contentType });
|
|
2814
2827
|
} catch {
|
|
2815
2828
|
return null;
|
|
2816
2829
|
}
|
|
@@ -2830,12 +2843,12 @@ var LocalStorageController = class {
|
|
|
2830
2843
|
if (!resolvedPath) return;
|
|
2831
2844
|
const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
|
|
2832
2845
|
try {
|
|
2833
|
-
await access(fullPath, fs$
|
|
2846
|
+
await access(fullPath, fs$3.constants.F_OK);
|
|
2834
2847
|
} catch {
|
|
2835
2848
|
return;
|
|
2836
2849
|
}
|
|
2837
2850
|
try {
|
|
2838
|
-
if ((await stat(fullPath)).isDirectory()) await fs$
|
|
2851
|
+
if ((await stat(fullPath)).isDirectory()) await fs$3.promises.rmdir(fullPath);
|
|
2839
2852
|
else {
|
|
2840
2853
|
await unlink$1(fullPath);
|
|
2841
2854
|
try {
|
|
@@ -2856,7 +2869,7 @@ var LocalStorageController = class {
|
|
|
2856
2869
|
const items = [];
|
|
2857
2870
|
const prefixes = [];
|
|
2858
2871
|
try {
|
|
2859
|
-
await access(fullPath, fs$
|
|
2872
|
+
await access(fullPath, fs$3.constants.R_OK);
|
|
2860
2873
|
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
2861
2874
|
let count = 0;
|
|
2862
2875
|
const maxResults = options?.maxResults ?? 1e3;
|
|
@@ -3032,6 +3045,138 @@ function isStorageController(obj) {
|
|
|
3032
3045
|
return typeof controller.putObject === "function" && typeof controller.getSignedUrl === "function" && typeof controller.deleteObject === "function" && typeof controller.listObjects === "function" && typeof controller.getType === "function";
|
|
3033
3046
|
}
|
|
3034
3047
|
//#endregion
|
|
3048
|
+
//#region src/storage/cache-headers.ts
|
|
3049
|
+
/**
|
|
3050
|
+
* A weak validator built from size and modification time.
|
|
3051
|
+
*
|
|
3052
|
+
* Weak (`W/`) because it is not a digest of the body: two different bodies of
|
|
3053
|
+
* the same length written in the same millisecond would collide. That is the
|
|
3054
|
+
* same trade nginx makes for static files, and the alternative — hashing every
|
|
3055
|
+
* object on every request — costs more than it saves for content this server is
|
|
3056
|
+
* already streaming from somewhere else.
|
|
3057
|
+
*/
|
|
3058
|
+
function buildEntityTag(size, lastModifiedMs) {
|
|
3059
|
+
return `W/"${size.toString(16)}-${Math.floor(lastModifiedMs).toString(16)}"`;
|
|
3060
|
+
}
|
|
3061
|
+
function objectValidators(size, lastModifiedMs) {
|
|
3062
|
+
const seconds = Math.floor(lastModifiedMs / 1e3) * 1e3;
|
|
3063
|
+
return {
|
|
3064
|
+
etag: buildEntityTag(size, lastModifiedMs),
|
|
3065
|
+
lastModified: new Date(seconds).toUTCString(),
|
|
3066
|
+
lastModifiedMs: seconds
|
|
3067
|
+
};
|
|
3068
|
+
}
|
|
3069
|
+
function cacheControl(options) {
|
|
3070
|
+
const parts = [options.isPublic ? "public" : "private", `max-age=${options.maxAgeSeconds}`];
|
|
3071
|
+
if (options.isPublic && options.staleWhileRevalidateSeconds) parts.push(`stale-while-revalidate=${options.staleWhileRevalidateSeconds}`);
|
|
3072
|
+
parts.push("must-revalidate");
|
|
3073
|
+
return parts.join(", ");
|
|
3074
|
+
}
|
|
3075
|
+
/** Every `ETag` in an `If-None-Match` header, unquoted comparison-ready. */
|
|
3076
|
+
function parseIfNoneMatch(header) {
|
|
3077
|
+
return header.split(",").map((part) => part.trim()).filter(Boolean);
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* Weak comparison, which is the only comparison `If-None-Match` on a GET is
|
|
3081
|
+
* allowed to use (RFC 9110 §8.8.3.2). `W/"x"` and `"x"` are the same entity for
|
|
3082
|
+
* this purpose, so the prefix is stripped from both sides before comparing.
|
|
3083
|
+
*/
|
|
3084
|
+
function weakMatch(a, b) {
|
|
3085
|
+
const strip = (v) => v.startsWith("W/") ? v.slice(2) : v;
|
|
3086
|
+
return strip(a) === strip(b);
|
|
3087
|
+
}
|
|
3088
|
+
/**
|
|
3089
|
+
* Whether the request already holds this exact representation.
|
|
3090
|
+
*
|
|
3091
|
+
* `If-None-Match` wins outright when present: it is an exact validator, whereas
|
|
3092
|
+
* `If-Modified-Since` has second resolution and is only consulted in its
|
|
3093
|
+
* absence — again the order RFC 9110 requires.
|
|
3094
|
+
*/
|
|
3095
|
+
function isNotModified(c, validators) {
|
|
3096
|
+
const ifNoneMatch = c.req.header("if-none-match");
|
|
3097
|
+
if (ifNoneMatch) {
|
|
3098
|
+
const tags = parseIfNoneMatch(ifNoneMatch);
|
|
3099
|
+
return tags.includes("*") || tags.some((tag) => weakMatch(tag, validators.etag));
|
|
3100
|
+
}
|
|
3101
|
+
const ifModifiedSince = c.req.header("if-modified-since");
|
|
3102
|
+
if (ifModifiedSince) {
|
|
3103
|
+
const since = Date.parse(ifModifiedSince);
|
|
3104
|
+
if (!Number.isNaN(since)) return validators.lastModifiedMs <= since;
|
|
3105
|
+
}
|
|
3106
|
+
return false;
|
|
3107
|
+
}
|
|
3108
|
+
/**
|
|
3109
|
+
* Put the validators and the cache policy on a response.
|
|
3110
|
+
*
|
|
3111
|
+
* Applied to the 304 as well as the 200: a 304 that omits them tells the client
|
|
3112
|
+
* nothing about how long the answer holds, so the next load revalidates again
|
|
3113
|
+
* immediately and the round trip repeats forever.
|
|
3114
|
+
*/
|
|
3115
|
+
function applyCacheHeaders(c, validators, options) {
|
|
3116
|
+
c.header("ETag", validators.etag);
|
|
3117
|
+
c.header("Last-Modified", validators.lastModified);
|
|
3118
|
+
c.header("Cache-Control", cacheControl(options));
|
|
3119
|
+
c.header("Vary", "Authorization");
|
|
3120
|
+
}
|
|
3121
|
+
//#endregion
|
|
3122
|
+
//#region src/storage/range.ts
|
|
3123
|
+
/**
|
|
3124
|
+
* Parse a `Range` header against an object of `size` bytes.
|
|
3125
|
+
*
|
|
3126
|
+
* Deliberately conservative: anything malformed, multi-range, or in a unit other
|
|
3127
|
+
* than bytes resolves to `none`, which serves the whole object. A range request
|
|
3128
|
+
* answered with `200` is always legal; a range answered wrongly is a corrupted
|
|
3129
|
+
* download.
|
|
3130
|
+
*/
|
|
3131
|
+
function parseRange(header, size) {
|
|
3132
|
+
if (!header) return { kind: "none" };
|
|
3133
|
+
const match = /^bytes=(.*)$/i.exec(header.trim());
|
|
3134
|
+
if (!match) return { kind: "none" };
|
|
3135
|
+
const spec = match[1].trim();
|
|
3136
|
+
if (spec.includes(",")) return { kind: "none" };
|
|
3137
|
+
const parts = /^(\d*)-(\d*)$/.exec(spec);
|
|
3138
|
+
if (!parts) return { kind: "none" };
|
|
3139
|
+
const [, rawStart, rawEnd] = parts;
|
|
3140
|
+
if (size === 0) return { kind: "unsatisfiable" };
|
|
3141
|
+
let start;
|
|
3142
|
+
let end;
|
|
3143
|
+
if (rawStart === "") {
|
|
3144
|
+
if (rawEnd === "") return { kind: "none" };
|
|
3145
|
+
const suffix = Number(rawEnd);
|
|
3146
|
+
if (!Number.isSafeInteger(suffix)) return { kind: "none" };
|
|
3147
|
+
if (suffix === 0) return { kind: "unsatisfiable" };
|
|
3148
|
+
start = Math.max(0, size - suffix);
|
|
3149
|
+
end = size - 1;
|
|
3150
|
+
} else {
|
|
3151
|
+
start = Number(rawStart);
|
|
3152
|
+
if (!Number.isSafeInteger(start)) return { kind: "none" };
|
|
3153
|
+
if (start >= size) return { kind: "unsatisfiable" };
|
|
3154
|
+
if (rawEnd === "") end = size - 1;
|
|
3155
|
+
else {
|
|
3156
|
+
end = Number(rawEnd);
|
|
3157
|
+
if (!Number.isSafeInteger(end)) return { kind: "none" };
|
|
3158
|
+
end = Math.min(end, size - 1);
|
|
3159
|
+
}
|
|
3160
|
+
if (end < start) return { kind: "unsatisfiable" };
|
|
3161
|
+
}
|
|
3162
|
+
return {
|
|
3163
|
+
kind: "range",
|
|
3164
|
+
range: {
|
|
3165
|
+
start,
|
|
3166
|
+
end,
|
|
3167
|
+
length: end - start + 1
|
|
3168
|
+
}
|
|
3169
|
+
};
|
|
3170
|
+
}
|
|
3171
|
+
/** The `Content-Range` for a served range. */
|
|
3172
|
+
function contentRange(range, size) {
|
|
3173
|
+
return `bytes ${range.start}-${range.end}/${size}`;
|
|
3174
|
+
}
|
|
3175
|
+
/** The `Content-Range` for a `416`, which names the size and no range. */
|
|
3176
|
+
function unsatisfiableContentRange(size) {
|
|
3177
|
+
return `bytes */${size}`;
|
|
3178
|
+
}
|
|
3179
|
+
//#endregion
|
|
3035
3180
|
//#region src/storage/image-transform.ts
|
|
3036
3181
|
/**
|
|
3037
3182
|
* Image Transformation Service
|
|
@@ -3893,11 +4038,20 @@ function createStorageRoutes(config) {
|
|
|
3893
4038
|
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
3894
4039
|
c.header("X-Content-Type-Options", "nosniff");
|
|
3895
4040
|
const transformKeyPrefix = `${storageId || "(default)"}/${bucket}/${resolvedPath}`;
|
|
4041
|
+
const sharedCacheable = publicRead === true || isPublicStoragePath(resolvedPath);
|
|
4042
|
+
const cachePolicy = (maxAgeSeconds) => ({
|
|
4043
|
+
isPublic: sharedCacheable,
|
|
4044
|
+
maxAgeSeconds,
|
|
4045
|
+
staleWhileRevalidateSeconds: 86400
|
|
4046
|
+
});
|
|
4047
|
+
const OBJECT_MAX_AGE = 60;
|
|
4048
|
+
const TRANSFORM_MAX_AGE = 3600;
|
|
3896
4049
|
const transformOpts = transformOptionsOrBadRequest(c.req.query());
|
|
3897
4050
|
if (resolved.getType() === "local") {
|
|
3898
4051
|
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket ?? "default");
|
|
4052
|
+
let localStat;
|
|
3899
4053
|
try {
|
|
3900
|
-
await fsp.
|
|
4054
|
+
localStat = await fsp.stat(absolutePath);
|
|
3901
4055
|
} catch {
|
|
3902
4056
|
throw ApiError.notFound("File not found");
|
|
3903
4057
|
}
|
|
@@ -3908,31 +4062,75 @@ function createStorageRoutes(config) {
|
|
|
3908
4062
|
contentType = JSON.parse(metadataRaw).contentType || contentType;
|
|
3909
4063
|
} catch {}
|
|
3910
4064
|
if (transformOpts && isTransformableImage(contentType)) {
|
|
3911
|
-
const
|
|
4065
|
+
const sourceVersion = buildEntityTag(localStat.size, localStat.mtimeMs);
|
|
4066
|
+
const transformed = await transformOnce(transformCache.buildKey(`${transformKeyPrefix}@${sourceVersion}`, transformOpts), transformOpts, async () => Buffer.from(await fsp.readFile(absolutePath)));
|
|
4067
|
+
const validators = objectValidators(transformed.data.byteLength, localStat.mtimeMs);
|
|
4068
|
+
applyCacheHeaders(c, validators, cachePolicy(TRANSFORM_MAX_AGE));
|
|
4069
|
+
if (isNotModified(c, validators)) return c.body(null, 304);
|
|
3912
4070
|
c.header("Content-Type", transformed.contentType);
|
|
3913
|
-
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
3914
4071
|
return c.body(new Uint8Array(transformed.data));
|
|
3915
4072
|
}
|
|
3916
|
-
const
|
|
4073
|
+
const validators = objectValidators(localStat.size, localStat.mtimeMs);
|
|
4074
|
+
applyCacheHeaders(c, validators, cachePolicy(OBJECT_MAX_AGE));
|
|
3917
4075
|
const served = resolveServedContentType(contentType);
|
|
3918
4076
|
c.header("Content-Type", served.contentType);
|
|
3919
4077
|
if (served.attachment) c.header("Content-Disposition", "attachment");
|
|
4078
|
+
c.header("Accept-Ranges", "bytes");
|
|
4079
|
+
if (isNotModified(c, validators)) return c.body(null, 304);
|
|
4080
|
+
const localRange = parseRange(c.req.header("range"), localStat.size);
|
|
4081
|
+
if (localRange.kind === "unsatisfiable") {
|
|
4082
|
+
c.header("Content-Range", unsatisfiableContentRange(localStat.size));
|
|
4083
|
+
return c.body(null, 416);
|
|
4084
|
+
}
|
|
4085
|
+
if (localRange.kind === "range") {
|
|
4086
|
+
const { start, length } = localRange.range;
|
|
4087
|
+
const handle = await fsp.open(absolutePath, "r");
|
|
4088
|
+
try {
|
|
4089
|
+
const buffer = Buffer.alloc(length);
|
|
4090
|
+
await handle.read(buffer, 0, length, start);
|
|
4091
|
+
c.header("Content-Range", contentRange(localRange.range, localStat.size));
|
|
4092
|
+
c.header("Content-Length", String(length));
|
|
4093
|
+
return c.body(new Uint8Array(buffer), 206);
|
|
4094
|
+
} finally {
|
|
4095
|
+
await handle.close();
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
c.header("Content-Length", String(localStat.size));
|
|
4099
|
+
const fileContent = await fsp.readFile(absolutePath);
|
|
3920
4100
|
return c.body(new Uint8Array(fileContent));
|
|
3921
4101
|
}
|
|
3922
4102
|
const fileObject = await resolved.getObject(resolvedPath, bucket);
|
|
3923
4103
|
if (!fileObject) throw ApiError.notFound("File not found");
|
|
3924
4104
|
const remoteContentType = fileObject.type || "application/octet-stream";
|
|
3925
4105
|
if (transformOpts && isTransformableImage(remoteContentType)) {
|
|
3926
|
-
const
|
|
4106
|
+
const sourceVersion = buildEntityTag(fileObject.size, fileObject.lastModified);
|
|
4107
|
+
const transformed = await transformOnce(transformCache.buildKey(`${transformKeyPrefix}@${sourceVersion}`, transformOpts), transformOpts, async () => Buffer.from(await fileObject.arrayBuffer()));
|
|
4108
|
+
const validators = objectValidators(transformed.data.byteLength, fileObject.lastModified);
|
|
4109
|
+
applyCacheHeaders(c, validators, cachePolicy(TRANSFORM_MAX_AGE));
|
|
4110
|
+
if (isNotModified(c, validators)) return c.body(null, 304);
|
|
3927
4111
|
c.header("Content-Type", transformed.contentType);
|
|
3928
|
-
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
3929
4112
|
return c.body(new Uint8Array(transformed.data));
|
|
3930
4113
|
}
|
|
3931
4114
|
const servedRemote = resolveServedContentType(remoteContentType);
|
|
3932
4115
|
c.header("Content-Type", servedRemote.contentType);
|
|
3933
4116
|
if (servedRemote.attachment) c.header("Content-Disposition", "attachment");
|
|
3934
|
-
|
|
4117
|
+
const remoteValidators = objectValidators(fileObject.size, fileObject.lastModified);
|
|
4118
|
+
applyCacheHeaders(c, remoteValidators, cachePolicy(OBJECT_MAX_AGE));
|
|
4119
|
+
c.header("Accept-Ranges", "bytes");
|
|
4120
|
+
if (isNotModified(c, remoteValidators)) return c.body(null, 304);
|
|
4121
|
+
const remoteRange = parseRange(c.req.header("range"), fileObject.size);
|
|
4122
|
+
if (remoteRange.kind === "unsatisfiable") {
|
|
4123
|
+
c.header("Content-Range", unsatisfiableContentRange(fileObject.size));
|
|
4124
|
+
return c.body(null, 416);
|
|
4125
|
+
}
|
|
3935
4126
|
const buf = await fileObject.arrayBuffer();
|
|
4127
|
+
if (remoteRange.kind === "range") {
|
|
4128
|
+
const { start, end, length } = remoteRange.range;
|
|
4129
|
+
c.header("Content-Range", contentRange(remoteRange.range, fileObject.size));
|
|
4130
|
+
c.header("Content-Length", String(length));
|
|
4131
|
+
return c.body(new Uint8Array(buf.slice(start, end + 1)), 206);
|
|
4132
|
+
}
|
|
4133
|
+
c.header("Content-Length", String(fileObject.size));
|
|
3936
4134
|
return c.body(new Uint8Array(buf));
|
|
3937
4135
|
});
|
|
3938
4136
|
/**
|
|
@@ -4161,6 +4359,114 @@ function assertStorageAccessControlConfigured(state, isProduction) {
|
|
|
4161
4359
|
logger.warn(STORAGE_NO_ACCESS_CONTROL_MESSAGE);
|
|
4162
4360
|
}
|
|
4163
4361
|
//#endregion
|
|
4362
|
+
//#region src/storage/policies.ts
|
|
4363
|
+
var StoragePolicyError = class extends Error {
|
|
4364
|
+
constructor(message) {
|
|
4365
|
+
super(message);
|
|
4366
|
+
this.name = "StoragePolicyError";
|
|
4367
|
+
}
|
|
4368
|
+
};
|
|
4369
|
+
var ALL_OPERATIONS = [
|
|
4370
|
+
"read",
|
|
4371
|
+
"write",
|
|
4372
|
+
"delete",
|
|
4373
|
+
"list"
|
|
4374
|
+
];
|
|
4375
|
+
/**
|
|
4376
|
+
* Split a key into segments, dropping empty ones.
|
|
4377
|
+
*
|
|
4378
|
+
* Empty segments come from a leading slash or a `//`, and treating them as real
|
|
4379
|
+
* would let `users//x` match a pattern expecting a captured segment with the
|
|
4380
|
+
* empty string. Keys reaching here are already sanitized against traversal; this
|
|
4381
|
+
* is about matching, not safety.
|
|
4382
|
+
*/
|
|
4383
|
+
function segmentsOf(value) {
|
|
4384
|
+
return value.split("/").filter((segment) => segment.length > 0);
|
|
4385
|
+
}
|
|
4386
|
+
function compile(policy, index) {
|
|
4387
|
+
const label = `storagePolicies[${index}]`;
|
|
4388
|
+
if (typeof policy.path !== "string" || policy.path.trim() === "") throw new StoragePolicyError(`${label}: \`path\` must be a non-empty string.`);
|
|
4389
|
+
const segments = segmentsOf(policy.path);
|
|
4390
|
+
if (segments.length === 0) throw new StoragePolicyError(`${label}: \`path\` "${policy.path}" names no segments. Use "**" to match every key.`);
|
|
4391
|
+
const starIndex = segments.indexOf("**");
|
|
4392
|
+
if (starIndex !== -1 && starIndex !== segments.length - 1) throw new StoragePolicyError(`${label}: "**" is only allowed as the last segment of \`path\`, and "${policy.path}" puts it in the middle. Use "*" for a single segment.`);
|
|
4393
|
+
const captures = segments.filter((s) => s.startsWith(":")).map((s) => s.slice(1));
|
|
4394
|
+
for (const name of captures) {
|
|
4395
|
+
if (name === "") throw new StoragePolicyError(`${label}: a ":" placeholder in "${policy.path}" has no name.`);
|
|
4396
|
+
if (captures.filter((c) => c === name).length > 1) throw new StoragePolicyError(`${label}: "${policy.path}" captures ":${name}" twice, so one would silently win.`);
|
|
4397
|
+
}
|
|
4398
|
+
if (policy.operations !== void 0) {
|
|
4399
|
+
if (!Array.isArray(policy.operations) || policy.operations.length === 0) throw new StoragePolicyError(`${label}: \`operations\` is empty, so this policy grants nothing. Remove it to grant all, or name the operations.`);
|
|
4400
|
+
for (const operation of policy.operations) if (!ALL_OPERATIONS.includes(operation)) throw new StoragePolicyError(`${label}: \`operations\` names "${operation}", which is not a storage operation. Use ${ALL_OPERATIONS.map((o) => `"${o}"`).join(", ")}.`);
|
|
4401
|
+
}
|
|
4402
|
+
if (policy.allow !== "public" && policy.allow !== "authenticated" && typeof policy.allow !== "function") throw new StoragePolicyError(`${label}: \`allow\` must be "public", "authenticated", or a function. A policy with no \`allow\` grants nothing, which is never what was meant.`);
|
|
4403
|
+
return {
|
|
4404
|
+
segments,
|
|
4405
|
+
trailing: starIndex !== -1,
|
|
4406
|
+
operations: new Set(policy.operations ?? ALL_OPERATIONS),
|
|
4407
|
+
allow: policy.allow,
|
|
4408
|
+
source: policy.path
|
|
4409
|
+
};
|
|
4410
|
+
}
|
|
4411
|
+
/** Match a key's segments against a compiled pattern, capturing as it goes. */
|
|
4412
|
+
function match(compiled, key) {
|
|
4413
|
+
const keySegments = segmentsOf(key);
|
|
4414
|
+
const pattern = compiled.trailing ? compiled.segments.slice(0, -1) : compiled.segments;
|
|
4415
|
+
if (compiled.trailing ? keySegments.length < pattern.length : keySegments.length !== pattern.length) return;
|
|
4416
|
+
const params = {};
|
|
4417
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
4418
|
+
const expected = pattern[i];
|
|
4419
|
+
const actual = keySegments[i];
|
|
4420
|
+
if (expected === "*") continue;
|
|
4421
|
+
if (expected.startsWith(":")) {
|
|
4422
|
+
params[expected.slice(1)] = actual;
|
|
4423
|
+
continue;
|
|
4424
|
+
}
|
|
4425
|
+
if (expected !== actual) return void 0;
|
|
4426
|
+
}
|
|
4427
|
+
return params;
|
|
4428
|
+
}
|
|
4429
|
+
/**
|
|
4430
|
+
* Compile a policy list into a {@link StorageAuthorize}.
|
|
4431
|
+
*
|
|
4432
|
+
* Compiled once, at boot, so a malformed pattern fails the start rather than the
|
|
4433
|
+
* first upload. `fallback` is an explicit `storageAuthorize`, consulted only
|
|
4434
|
+
* when no policy matched.
|
|
4435
|
+
*/
|
|
4436
|
+
function compileStoragePolicies(policies, fallback) {
|
|
4437
|
+
if (!Array.isArray(policies)) throw new StoragePolicyError("`storagePolicies` must be an array.");
|
|
4438
|
+
const compiled = policies.map(compile);
|
|
4439
|
+
return async (ctx) => {
|
|
4440
|
+
for (const policy of compiled) {
|
|
4441
|
+
if (!policy.operations.has(ctx.operation)) continue;
|
|
4442
|
+
const params = match(policy, ctx.key);
|
|
4443
|
+
if (!params) continue;
|
|
4444
|
+
if (policy.allow === "public") return true;
|
|
4445
|
+
if (policy.allow === "authenticated") {
|
|
4446
|
+
if (ctx.user?.uid) return true;
|
|
4447
|
+
continue;
|
|
4448
|
+
}
|
|
4449
|
+
if (await policy.allow({
|
|
4450
|
+
...ctx,
|
|
4451
|
+
params
|
|
4452
|
+
})) return true;
|
|
4453
|
+
}
|
|
4454
|
+
if (fallback) return await fallback(ctx);
|
|
4455
|
+
return false;
|
|
4456
|
+
};
|
|
4457
|
+
}
|
|
4458
|
+
/**
|
|
4459
|
+
* The access-control function for a configuration, or `undefined` when it
|
|
4460
|
+
* declares none.
|
|
4461
|
+
*
|
|
4462
|
+
* Kept separate from {@link compileStoragePolicies} so `init` has one call to
|
|
4463
|
+
* make and one thing to reason about: policies, a hook, both, or neither.
|
|
4464
|
+
*/
|
|
4465
|
+
function resolveStorageAccessControl(config) {
|
|
4466
|
+
if (config.storagePolicies?.length) return compileStoragePolicies(config.storagePolicies, config.storageAuthorize);
|
|
4467
|
+
return config.storageAuthorize;
|
|
4468
|
+
}
|
|
4469
|
+
//#endregion
|
|
4164
4470
|
//#region src/init/docs.ts
|
|
4165
4471
|
/**
|
|
4166
4472
|
* Serve the OpenAPI document, and Swagger UI outside production.
|
|
@@ -4256,6 +4562,101 @@ function createHealthCheck(defaultDriver, authSchemaCheck) {
|
|
|
4256
4562
|
};
|
|
4257
4563
|
}
|
|
4258
4564
|
//#endregion
|
|
4565
|
+
//#region src/functions/wait-until.ts
|
|
4566
|
+
/**
|
|
4567
|
+
* Pending background work, on a process-global slot for the reason given in
|
|
4568
|
+
* `../singleton.ts`: two copies of this module in one process must not each
|
|
4569
|
+
* hold half the work, or shutdown drains one half and drops the other.
|
|
4570
|
+
*/
|
|
4571
|
+
var PENDING_SLOT = Symbol.for("@rebasepro/server:pending-background-work");
|
|
4572
|
+
function pending() {
|
|
4573
|
+
const global = globalThis;
|
|
4574
|
+
if (!global[PENDING_SLOT]) global[PENDING_SLOT] = /* @__PURE__ */ new Set();
|
|
4575
|
+
return global[PENDING_SLOT];
|
|
4576
|
+
}
|
|
4577
|
+
/**
|
|
4578
|
+
* Keep `work` alive past the response, on any runtime.
|
|
4579
|
+
*
|
|
4580
|
+
* Hand it a promise or a function returning one. Nothing is awaited: the
|
|
4581
|
+
* response goes out immediately, exactly as an un-awaited call would.
|
|
4582
|
+
*
|
|
4583
|
+
* What it buys, per host:
|
|
4584
|
+
*
|
|
4585
|
+
* - **Isolate hosts** (Workers, Deno Deploy, Vercel Edge): the host is told to
|
|
4586
|
+
* keep the isolate alive until the promise settles. Without this the work is
|
|
4587
|
+
* dropped when the response resolves.
|
|
4588
|
+
* - **Node**: the promise is tracked so {@link drainBackgroundWork} can wait for
|
|
4589
|
+
* it during graceful shutdown instead of the process exiting out from under
|
|
4590
|
+
* it.
|
|
4591
|
+
*
|
|
4592
|
+
* On every host a rejection is logged rather than left to the unhandled-rejection
|
|
4593
|
+
* handler, so the failure names the function it came from.
|
|
4594
|
+
*
|
|
4595
|
+
* @example
|
|
4596
|
+
* ```ts
|
|
4597
|
+
* app.post("/orders", requireAuth, async (c) => {
|
|
4598
|
+
* const order = await createOrder(c);
|
|
4599
|
+
* waitUntil(c, notifyWarehouse(order)); // caller does not wait for this
|
|
4600
|
+
* return c.json({ id: order.id });
|
|
4601
|
+
* });
|
|
4602
|
+
* ```
|
|
4603
|
+
*/
|
|
4604
|
+
function waitUntil(c, work) {
|
|
4605
|
+
let promise;
|
|
4606
|
+
try {
|
|
4607
|
+
promise = typeof work === "function" ? Promise.resolve(work()) : work;
|
|
4608
|
+
} catch (error) {
|
|
4609
|
+
logBackgroundFailure(c, error);
|
|
4610
|
+
return;
|
|
4611
|
+
}
|
|
4612
|
+
const tracked = promise.catch((error) => {
|
|
4613
|
+
logBackgroundFailure(c, error);
|
|
4614
|
+
});
|
|
4615
|
+
const set = pending();
|
|
4616
|
+
set.add(tracked);
|
|
4617
|
+
tracked.finally(() => set.delete(tracked));
|
|
4618
|
+
try {
|
|
4619
|
+
c.executionCtx?.waitUntil?.(tracked);
|
|
4620
|
+
} catch {}
|
|
4621
|
+
}
|
|
4622
|
+
function logBackgroundFailure(c, error) {
|
|
4623
|
+
logger.error("[functions] Background work failed after the response was sent", {
|
|
4624
|
+
method: c.req?.method,
|
|
4625
|
+
path: c.req?.path,
|
|
4626
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
4627
|
+
});
|
|
4628
|
+
}
|
|
4629
|
+
/** How many background promises are still in flight. Diagnostics and tests. */
|
|
4630
|
+
function pendingBackgroundWork() {
|
|
4631
|
+
return pending().size;
|
|
4632
|
+
}
|
|
4633
|
+
/**
|
|
4634
|
+
* Wait for tracked background work to finish, up to `timeoutMs`.
|
|
4635
|
+
*
|
|
4636
|
+
* Called by the shutdown path after the server stops accepting connections. It
|
|
4637
|
+
* resolves to the number of promises still outstanding when it returned — `0`
|
|
4638
|
+
* for a clean drain, more than that for a timeout, which is worth a log line
|
|
4639
|
+
* because it means work was dropped.
|
|
4640
|
+
*
|
|
4641
|
+
* Rejections do not propagate: every tracked promise has already had a `catch`
|
|
4642
|
+
* attached by {@link waitUntil}.
|
|
4643
|
+
*/
|
|
4644
|
+
async function drainBackgroundWork(timeoutMs = 5e3) {
|
|
4645
|
+
const set = pending();
|
|
4646
|
+
if (set.size === 0) return 0;
|
|
4647
|
+
let timer;
|
|
4648
|
+
const expired = new Promise((resolve) => {
|
|
4649
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
4650
|
+
timer.unref?.();
|
|
4651
|
+
});
|
|
4652
|
+
try {
|
|
4653
|
+
await Promise.race([Promise.allSettled([...set]), expired]);
|
|
4654
|
+
} finally {
|
|
4655
|
+
if (timer) clearTimeout(timer);
|
|
4656
|
+
}
|
|
4657
|
+
return set.size;
|
|
4658
|
+
}
|
|
4659
|
+
//#endregion
|
|
4259
4660
|
//#region src/init/shutdown.ts
|
|
4260
4661
|
/**
|
|
4261
4662
|
* Install graceful-shutdown signal handlers for a Rebase backend.
|
|
@@ -4280,77 +4681,1072 @@ function createHealthCheck(defaultDriver, authSchemaCheck) {
|
|
|
4280
4681
|
* installShutdownHandlers(backend, { onCleanup: () => pool.end() });
|
|
4281
4682
|
* ```
|
|
4282
4683
|
*/
|
|
4283
|
-
function installShutdownHandlers(backend, options = {}) {
|
|
4284
|
-
const { onCleanup, timeoutMs = 15e3, signals = ["SIGTERM", "SIGINT"], exit = process.exit } = options;
|
|
4285
|
-
let shuttingDown = false;
|
|
4286
|
-
const shutdownSequence = async (signal) => {
|
|
4287
|
-
if (shuttingDown) return;
|
|
4288
|
-
shuttingDown = true;
|
|
4289
|
-
logger.info(`Received ${signal}, shutting down gracefully...`);
|
|
4290
|
-
const forceTimer = setTimeout(() => {
|
|
4291
|
-
logger.error(`Shutdown timed out after ${Math.round(timeoutMs / 1e3)}s. Forcefully exiting.`);
|
|
4292
|
-
exit(1);
|
|
4293
|
-
}, timeoutMs);
|
|
4294
|
-
forceTimer.unref();
|
|
4684
|
+
function installShutdownHandlers(backend, options = {}) {
|
|
4685
|
+
const { onCleanup, timeoutMs = 15e3, signals = ["SIGTERM", "SIGINT"], exit = process.exit } = options;
|
|
4686
|
+
let shuttingDown = false;
|
|
4687
|
+
const shutdownSequence = async (signal) => {
|
|
4688
|
+
if (shuttingDown) return;
|
|
4689
|
+
shuttingDown = true;
|
|
4690
|
+
logger.info(`Received ${signal}, shutting down gracefully...`);
|
|
4691
|
+
const forceTimer = setTimeout(() => {
|
|
4692
|
+
logger.error(`Shutdown timed out after ${Math.round(timeoutMs / 1e3)}s. Forcefully exiting.`);
|
|
4693
|
+
exit(1);
|
|
4694
|
+
}, timeoutMs);
|
|
4695
|
+
forceTimer.unref();
|
|
4696
|
+
try {
|
|
4697
|
+
await backend.shutdown(timeoutMs);
|
|
4698
|
+
if (onCleanup) await onCleanup();
|
|
4699
|
+
clearTimeout(forceTimer);
|
|
4700
|
+
logger.info("Graceful shutdown complete.");
|
|
4701
|
+
exit(0);
|
|
4702
|
+
} catch (err) {
|
|
4703
|
+
logger.error("Error during shutdown cleanup:", { error: err instanceof Error ? err : new Error(String(err)) });
|
|
4704
|
+
exit(1);
|
|
4705
|
+
}
|
|
4706
|
+
};
|
|
4707
|
+
const listeners = signals.map((signal) => {
|
|
4708
|
+
const listener = () => {
|
|
4709
|
+
shutdownSequence(signal);
|
|
4710
|
+
};
|
|
4711
|
+
process.on(signal, listener);
|
|
4712
|
+
return {
|
|
4713
|
+
signal,
|
|
4714
|
+
listener
|
|
4715
|
+
};
|
|
4716
|
+
});
|
|
4717
|
+
return () => {
|
|
4718
|
+
for (const { signal, listener } of listeners) process.removeListener(signal, listener);
|
|
4719
|
+
};
|
|
4720
|
+
}
|
|
4721
|
+
function createShutdown(config) {
|
|
4722
|
+
return (timeoutMs = 15e3) => {
|
|
4723
|
+
return new Promise((resolve) => {
|
|
4724
|
+
(async () => {
|
|
4725
|
+
logger.info("Shutting down Rebase Backend...");
|
|
4726
|
+
if (config.cronScheduler) {
|
|
4727
|
+
config.cronScheduler.stop();
|
|
4728
|
+
logger.info("Cron scheduler stopped");
|
|
4729
|
+
}
|
|
4730
|
+
if (config.rlsAudit) config.rlsAudit.stop();
|
|
4731
|
+
if (config.jobQueue) {
|
|
4732
|
+
await config.jobQueue.stop();
|
|
4733
|
+
logger.info("Job queue stopped");
|
|
4734
|
+
}
|
|
4735
|
+
const drainBudget = timeoutMs > 0 ? Math.min(5e3, timeoutMs) : 5e3;
|
|
4736
|
+
const stillPending = await drainBackgroundWork(drainBudget);
|
|
4737
|
+
if (stillPending > 0) logger.warn(`${stillPending} background task(s) handed to waitUntil() did not finish within ${drainBudget}ms and are being dropped. Give them an AbortSignal, or move work this long into a cron job or the job queue, where it is restartable.`);
|
|
4738
|
+
for (const [key, rt] of Object.entries(config.realtimeServices)) try {
|
|
4739
|
+
if (typeof rt.destroy === "function") {
|
|
4740
|
+
await rt.destroy();
|
|
4741
|
+
logger.info(`Realtime service "${key}" destroyed`);
|
|
4742
|
+
} else if (typeof rt.stopListening === "function") {
|
|
4743
|
+
await rt.stopListening();
|
|
4744
|
+
logger.info(`Realtime service "${key}" LISTEN client stopped`);
|
|
4745
|
+
}
|
|
4746
|
+
} catch (err) {
|
|
4747
|
+
logger.warn(`Error destroying realtime service "${key}":`, { error: err });
|
|
4748
|
+
}
|
|
4749
|
+
config.server.close(() => {
|
|
4750
|
+
logger.info("HTTP server closed");
|
|
4751
|
+
resolve();
|
|
4752
|
+
});
|
|
4753
|
+
if (timeoutMs > 0) setTimeout(() => {
|
|
4754
|
+
logger.warn(`Forced shutdown after ${timeoutMs / 1e3}s timeout`);
|
|
4755
|
+
resolve();
|
|
4756
|
+
}, timeoutMs).unref();
|
|
4757
|
+
})();
|
|
4758
|
+
});
|
|
4759
|
+
};
|
|
4760
|
+
}
|
|
4761
|
+
//#endregion
|
|
4762
|
+
//#region src/schema-edit/apply-schema-change.ts
|
|
4763
|
+
/**
|
|
4764
|
+
* The plan says the change is not applicable.
|
|
4765
|
+
*
|
|
4766
|
+
* Re-checked here rather than trusted from the planner: this module is the one
|
|
4767
|
+
* that writes and runs things, so the guarantee belongs where the consequence
|
|
4768
|
+
* is. A caller that built a plan by hand cannot route around it.
|
|
4769
|
+
*/
|
|
4770
|
+
var UnapplicableChangeError = class extends Error {
|
|
4771
|
+
classified;
|
|
4772
|
+
constructor(message, classified) {
|
|
4773
|
+
super(message);
|
|
4774
|
+
this.classified = classified;
|
|
4775
|
+
this.name = "UnapplicableChangeError";
|
|
4776
|
+
}
|
|
4777
|
+
};
|
|
4778
|
+
var DirtyWorkingTreeError = class extends Error {
|
|
4779
|
+
paths;
|
|
4780
|
+
constructor(paths) {
|
|
4781
|
+
super("The working tree has uncommitted changes to files this would commit:\n" + paths.map((p) => ` • ${p}`).join("\n") + "\n Commit or stash them first. A schema change must not sweep up work in progress.");
|
|
4782
|
+
this.paths = paths;
|
|
4783
|
+
this.name = "DirtyWorkingTreeError";
|
|
4784
|
+
}
|
|
4785
|
+
};
|
|
4786
|
+
/**
|
|
4787
|
+
* Generate the commit, land it, then run the DDL.
|
|
4788
|
+
*
|
|
4789
|
+
* Throws only for things that mean *nothing happened*: a change the ensure path
|
|
4790
|
+
* cannot express, a dirty tree, or a failed commit. Once the commit lands,
|
|
4791
|
+
* every outcome is a result.
|
|
4792
|
+
*/
|
|
4793
|
+
async function applySchemaChange(input) {
|
|
4794
|
+
const commit = input.plan;
|
|
4795
|
+
if (!commit.classified.applicable) throw new UnapplicableChangeError("This change cannot be applied to a running database:\n" + commit.classified.changes.filter((change) => change.verdict !== "safe").map((change) => ` • ${change.detail}${change.remedy ? `\n ${change.remedy}` : ""}`).join("\n"), commit.classified);
|
|
4796
|
+
const plannedPaths = [...commit.files.map((file) => file.path), ...input.sourcePaths ?? []];
|
|
4797
|
+
const dirty = (await input.repository.dirtyPaths()).filter((path) => plannedPaths.includes(path));
|
|
4798
|
+
if (dirty.length > 0) throw new DirtyWorkingTreeError(dirty);
|
|
4799
|
+
const branch = await input.repository.currentBranch();
|
|
4800
|
+
const files = [...input.writeSource ? await input.writeSource() : [], ...commit.files];
|
|
4801
|
+
const paths = files.map((file) => file.path);
|
|
4802
|
+
await input.repository.writeFiles(files);
|
|
4803
|
+
const sha = await input.repository.commit(paths, commit.message);
|
|
4804
|
+
const committed = {
|
|
4805
|
+
sha,
|
|
4806
|
+
branch,
|
|
4807
|
+
files: paths
|
|
4808
|
+
};
|
|
4809
|
+
if (commit.statements.length === 0) return {
|
|
4810
|
+
committed,
|
|
4811
|
+
applied: true,
|
|
4812
|
+
statements: [],
|
|
4813
|
+
classified: commit.classified,
|
|
4814
|
+
summary: `Committed ${sha.slice(0, 9)} on ${branch}. No DDL was needed.`
|
|
4815
|
+
};
|
|
4816
|
+
try {
|
|
4817
|
+
await input.apply(commit.statements);
|
|
4818
|
+
return {
|
|
4819
|
+
committed,
|
|
4820
|
+
applied: true,
|
|
4821
|
+
statements: commit.statements,
|
|
4822
|
+
classified: commit.classified,
|
|
4823
|
+
summary: `Committed ${sha.slice(0, 9)} on ${branch} and applied ${commit.statements.length} statement(s).`
|
|
4824
|
+
};
|
|
4825
|
+
} catch (err) {
|
|
4826
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
4827
|
+
return {
|
|
4828
|
+
committed,
|
|
4829
|
+
applied: false,
|
|
4830
|
+
applyError: detail,
|
|
4831
|
+
statements: commit.statements,
|
|
4832
|
+
classified: commit.classified,
|
|
4833
|
+
summary: `Committed ${sha.slice(0, 9)} on ${branch}, but the database was not changed: ${detail}. The change will be applied on the next boot.`
|
|
4834
|
+
};
|
|
4835
|
+
}
|
|
4836
|
+
}
|
|
4837
|
+
//#endregion
|
|
4838
|
+
//#region src/schema-edit/schema-edit-permissions.ts
|
|
4839
|
+
/**
|
|
4840
|
+
* A caller whose identity is a credential rather than a person.
|
|
4841
|
+
*
|
|
4842
|
+
* Two shapes, both set by the auth middleware:
|
|
4843
|
+
*
|
|
4844
|
+
* - the service key resolves to `uid: "service"` with `roles: ["admin"]`;
|
|
4845
|
+
* - an API key resolves to `uid: "api-key:<id>"`, with `service` among its
|
|
4846
|
+
* roles and `admin` too when the key is an admin key.
|
|
4847
|
+
*
|
|
4848
|
+
* Detected by uid rather than by role, because `service` is also a role a
|
|
4849
|
+
* person could be granted, and a person with an unfortunate role name should
|
|
4850
|
+
* not lose the ability to commit under their own name.
|
|
4851
|
+
*/
|
|
4852
|
+
function machineKindOf(uid) {
|
|
4853
|
+
if (uid === "service") return "service-key";
|
|
4854
|
+
if (uid.startsWith("api-key:")) return "api-key";
|
|
4855
|
+
}
|
|
4856
|
+
/** Read the caller off the request context's `user`. */
|
|
4857
|
+
function classifyPrincipal(user) {
|
|
4858
|
+
if (!user || typeof user !== "object") return {
|
|
4859
|
+
kind: "anonymous",
|
|
4860
|
+
uid: "",
|
|
4861
|
+
roles: []
|
|
4862
|
+
};
|
|
4863
|
+
const { uid, roles } = user;
|
|
4864
|
+
if (typeof uid !== "string" || uid.length === 0) return {
|
|
4865
|
+
kind: "anonymous",
|
|
4866
|
+
uid: "",
|
|
4867
|
+
roles: []
|
|
4868
|
+
};
|
|
4869
|
+
const roleList = Array.isArray(roles) ? roles.filter((r) => typeof r === "string") : [];
|
|
4870
|
+
const machineKind = machineKindOf(uid);
|
|
4871
|
+
return {
|
|
4872
|
+
kind: machineKind ? "machine" : "person",
|
|
4873
|
+
uid,
|
|
4874
|
+
roles: roleList,
|
|
4875
|
+
machineKind
|
|
4876
|
+
};
|
|
4877
|
+
}
|
|
4878
|
+
var MACHINE_REFUSAL = {
|
|
4879
|
+
"service-key": "This request is authenticated with the server's service key, which identifies the server rather than a person. Applying a schema change writes a commit to your repository, and that commit needs an author.",
|
|
4880
|
+
"api-key": "This request is authenticated with an API key, which identifies a credential rather than a person. Applying a schema change writes a commit to your repository, and that commit needs an author."
|
|
4881
|
+
};
|
|
4882
|
+
/**
|
|
4883
|
+
* What this caller may do.
|
|
4884
|
+
*
|
|
4885
|
+
* Assumes the admin gate has already run — this refines that decision rather
|
|
4886
|
+
* than replacing it, and an anonymous caller should never have reached here.
|
|
4887
|
+
* Handled anyway, because a capability function that trusts its caller to have
|
|
4888
|
+
* checked is one bad refactor away from granting everything.
|
|
4889
|
+
*/
|
|
4890
|
+
function schemaEditCapabilities(principal, policy = {}) {
|
|
4891
|
+
if (principal.kind === "anonymous") return {
|
|
4892
|
+
plan: false,
|
|
4893
|
+
apply: false,
|
|
4894
|
+
code: "UNAUTHORIZED",
|
|
4895
|
+
reason: "This request carries no identity."
|
|
4896
|
+
};
|
|
4897
|
+
if (principal.kind === "machine" && !policy.allowMachineApply) return {
|
|
4898
|
+
plan: true,
|
|
4899
|
+
apply: false,
|
|
4900
|
+
code: "SCHEMA_EDIT_REQUIRES_A_PERSON",
|
|
4901
|
+
reason: `${MACHINE_REFUSAL[principal.machineKind ?? "api-key"]} Sign in as a user to apply it, or set \`liveSchema.allowMachineApply\` if an automated schema change is what you want.`
|
|
4902
|
+
};
|
|
4903
|
+
return {
|
|
4904
|
+
plan: true,
|
|
4905
|
+
apply: true
|
|
4906
|
+
};
|
|
4907
|
+
}
|
|
4908
|
+
/**
|
|
4909
|
+
* The git identity for a machine that has been allowed to apply.
|
|
4910
|
+
*
|
|
4911
|
+
* Named for what it is. An operator reading `git log` a month from now should
|
|
4912
|
+
* be able to tell a change somebody made from one a pipeline made, and a
|
|
4913
|
+
* credential wearing a person-shaped name is how that distinction is lost.
|
|
4914
|
+
*/
|
|
4915
|
+
function machineCommitAuthor(principal) {
|
|
4916
|
+
return {
|
|
4917
|
+
name: principal.machineKind === "service-key" ? "Rebase service key" : `Rebase API key (${principal.uid.replace(/^api-key:/, "")})`,
|
|
4918
|
+
email: `${principal.uid.replace(/[^A-Za-z0-9._-]/g, "-")}@machines.noreply.rebase.pro`
|
|
4919
|
+
};
|
|
4920
|
+
}
|
|
4921
|
+
//#endregion
|
|
4922
|
+
//#region src/api/live-schema-routes.ts
|
|
4923
|
+
/**
|
|
4924
|
+
* Creating and altering collections against a running backend.
|
|
4925
|
+
*
|
|
4926
|
+
* The existing schema editor rewrites collection source and is off in
|
|
4927
|
+
* production, for an honest reason: a deployed server's files are rebuilt from
|
|
4928
|
+
* the repository on every deploy, so an edit there would be discarded. These
|
|
4929
|
+
* routes are the answer to that — they commit the change to the repository the
|
|
4930
|
+
* server is running in, and *then* apply the DDL, so the edit survives the next
|
|
4931
|
+
* deploy because the deploy is built from it.
|
|
4932
|
+
*
|
|
4933
|
+
* Two routes, and the first one is the important one.
|
|
4934
|
+
*
|
|
4935
|
+
* ## Why `plan` exists separately from `apply`
|
|
4936
|
+
*
|
|
4937
|
+
* `POST /plan` has no side effects and answers what would happen. That is not a
|
|
4938
|
+
* convenience: a schema change has three possible verdicts and two of them are
|
|
4939
|
+
* refusals, so a UI that could only try-and-see would be asking people to
|
|
4940
|
+
* discover `needs-migration` by pressing a button on a live database.
|
|
4941
|
+
*
|
|
4942
|
+
* It also surfaces the `diverges` cases, which are the ones nothing else in the
|
|
4943
|
+
* system reports — a required property that will arrive nullable, an enum value
|
|
4944
|
+
* that will never land. Those apply "successfully" today and leave a database
|
|
4945
|
+
* that does not match its configuration.
|
|
4946
|
+
*
|
|
4947
|
+
* ## What these routes will not do
|
|
4948
|
+
*
|
|
4949
|
+
* Anything the boot-time ensure path cannot express. No drops, no type changes,
|
|
4950
|
+
* no primary keys. The planner refuses them and this refuses to route around it
|
|
4951
|
+
* — `applySchemaChange` re-checks rather than trusting the plan it is handed.
|
|
4952
|
+
*/
|
|
4953
|
+
/**
|
|
4954
|
+
* The git identity for the caller.
|
|
4955
|
+
*
|
|
4956
|
+
* `undefined` when the request carries no user — a service key, say — which
|
|
4957
|
+
* lets the repository fall back to its own git config rather than inventing a
|
|
4958
|
+
* name for somebody.
|
|
4959
|
+
*/
|
|
4960
|
+
function commitAuthor(user) {
|
|
4961
|
+
if (!user || typeof user !== "object") return void 0;
|
|
4962
|
+
const { uid, email, displayName } = user;
|
|
4963
|
+
if (!uid) return void 0;
|
|
4964
|
+
return {
|
|
4965
|
+
name: displayName || email || uid,
|
|
4966
|
+
email: email || `${uid}@users.noreply.rebase.pro`
|
|
4967
|
+
};
|
|
4968
|
+
}
|
|
4969
|
+
/**
|
|
4970
|
+
* What a collection id may contain.
|
|
4971
|
+
*
|
|
4972
|
+
* The same alphabet the AST editor sanitises to, stated at the boundary
|
|
4973
|
+
* instead. The id becomes a *filename* — `<collectionsDir>/<id>.ts` — in two
|
|
4974
|
+
* places, and only one of them checked it: the editor refuses a traversal when
|
|
4975
|
+
* it writes, but the caller derives the same path first, to hand the dirty
|
|
4976
|
+
* check the paths it is about to touch. A refusal that arrives one layer in is
|
|
4977
|
+
* a refusal that the layer above already acted on.
|
|
4978
|
+
*/
|
|
4979
|
+
var SAFE_COLLECTION_ID = /^[A-Za-z0-9_-]+$/;
|
|
4980
|
+
var parseProposed = (body) => {
|
|
4981
|
+
const candidate = body;
|
|
4982
|
+
if (!candidate?.collectionId || typeof candidate.collectionId !== "string") throw ApiError.badRequest("`collectionId` is required.", "INVALID_CHANGE");
|
|
4983
|
+
if (!SAFE_COLLECTION_ID.test(candidate.collectionId)) throw ApiError.badRequest(`"${candidate.collectionId}" is not a usable collection id. It becomes a filename, so it may contain only letters, numbers, underscores and hyphens.`, "INVALID_CHANGE");
|
|
4984
|
+
if (!candidate.collection || typeof candidate.collection !== "object") throw ApiError.badRequest("`collection` is required.", "INVALID_CHANGE");
|
|
4985
|
+
return {
|
|
4986
|
+
collectionId: candidate.collectionId,
|
|
4987
|
+
collection: candidate.collection
|
|
4988
|
+
};
|
|
4989
|
+
};
|
|
4990
|
+
/**
|
|
4991
|
+
* The proposed collection set: everything as it is, with one collection
|
|
4992
|
+
* replaced or added.
|
|
4993
|
+
*/
|
|
4994
|
+
function proposedCollections(current, change) {
|
|
4995
|
+
const next = {
|
|
4996
|
+
...change.collection,
|
|
4997
|
+
slug: change.collectionId
|
|
4998
|
+
};
|
|
4999
|
+
const replaced = current.map((collection) => collection.slug === change.collectionId ? next : collection);
|
|
5000
|
+
return replaced.some((collection) => collection.slug === change.collectionId) ? replaced : [...replaced, next];
|
|
5001
|
+
}
|
|
5002
|
+
function createLiveSchemaRoutes(config) {
|
|
5003
|
+
const router = new Hono();
|
|
5004
|
+
router.onError(errorHandler);
|
|
5005
|
+
/**
|
|
5006
|
+
* Whether this backend can edit its schema, and what is missing when it
|
|
5007
|
+
* cannot.
|
|
5008
|
+
*
|
|
5009
|
+
* Every optional surface in this API answers `GET …/status` — see
|
|
5010
|
+
* `docs/api-conventions.md`. The panel asks before it offers the control,
|
|
5011
|
+
* because the alternative is a button that looks available and fails on
|
|
5012
|
+
* press with a refusal the person could have been told about up front.
|
|
5013
|
+
*
|
|
5014
|
+
* Ordered by how fundamental the obstacle is: a driver that cannot plan is
|
|
5015
|
+
* a different conversation from a missing dependency, which is a different
|
|
5016
|
+
* conversation from a deployment with no source. Reporting the first one
|
|
5017
|
+
* that applies is what keeps the message actionable.
|
|
5018
|
+
*/
|
|
5019
|
+
router.get("/status", (c) => {
|
|
5020
|
+
const admin = config.getAdmin();
|
|
5021
|
+
const repository = config.getRepository();
|
|
5022
|
+
if (!isSchemaEditingAdmin(admin)) return c.json({
|
|
5023
|
+
enabled: false,
|
|
5024
|
+
canPlan: false,
|
|
5025
|
+
code: "SCHEMA_EDITING_UNSUPPORTED",
|
|
5026
|
+
reason: "This backend's driver cannot plan schema changes. Live editing is available on Postgres."
|
|
5027
|
+
});
|
|
5028
|
+
if (!config.writeSource) return c.json({
|
|
5029
|
+
enabled: false,
|
|
5030
|
+
canPlan: true,
|
|
5031
|
+
code: "SCHEMA_EDITOR_MISSING_DEPENDENCY",
|
|
5032
|
+
reason: "Rewriting collection source needs `ts-morph`, which is not installed on this server. Run `pnpm add -D ts-morph@28.0.0` to enable it."
|
|
5033
|
+
});
|
|
5034
|
+
if (!repository) return c.json({
|
|
5035
|
+
enabled: false,
|
|
5036
|
+
canPlan: true,
|
|
5037
|
+
code: "SCHEMA_EDITING_NO_REPOSITORY",
|
|
5038
|
+
reason: "There is no repository to commit to. Live schema editing writes the change to your project's source before applying it, so it needs the project mounted — a deployment running from a built bundle has compiled output and no source to edit."
|
|
5039
|
+
});
|
|
5040
|
+
const capabilities = schemaEditCapabilities(classifyPrincipal(c.get("user")), config.policy);
|
|
5041
|
+
return c.json({
|
|
5042
|
+
enabled: true,
|
|
5043
|
+
canPlan: capabilities.plan,
|
|
5044
|
+
canApply: capabilities.apply,
|
|
5045
|
+
applyRefusedBecause: capabilities.reason,
|
|
5046
|
+
applyRefusedCode: capabilities.code,
|
|
5047
|
+
repository: repository.root
|
|
5048
|
+
});
|
|
5049
|
+
});
|
|
5050
|
+
/** The admin, or a refusal naming what is missing rather than a 500. */
|
|
5051
|
+
const requirePlanner = () => {
|
|
5052
|
+
const admin = config.getAdmin();
|
|
5053
|
+
if (!isSchemaEditingAdmin(admin)) throw ApiError.serviceUnavailable("This backend's driver cannot plan schema changes. Live editing is available on Postgres.", "SCHEMA_EDITING_UNSUPPORTED");
|
|
5054
|
+
return admin;
|
|
5055
|
+
};
|
|
5056
|
+
const requireRepository = (author) => {
|
|
5057
|
+
const repository = config.getRepository(author);
|
|
5058
|
+
if (!repository) throw ApiError.serviceUnavailable("There is no repository to commit to. Live schema editing writes the change to your project's source before applying it, so it needs the project mounted — a deployment running from a built bundle has compiled output and no source to edit.", "SCHEMA_EDITING_NO_REPOSITORY");
|
|
5059
|
+
return repository;
|
|
5060
|
+
};
|
|
5061
|
+
/**
|
|
5062
|
+
* What would happen. No side effects, and deliberately reachable even when
|
|
5063
|
+
* there is no repository — knowing a change is blocked is useful before
|
|
5064
|
+
* knowing where it would be committed.
|
|
5065
|
+
*/
|
|
5066
|
+
router.post("/plan", async (c) => {
|
|
5067
|
+
const change = parseProposed(await c.req.json());
|
|
5068
|
+
const admin = requirePlanner();
|
|
5069
|
+
const capabilities = schemaEditCapabilities(classifyPrincipal(c.get("user")), config.policy);
|
|
5070
|
+
if (!capabilities.plan) throw ApiError.forbidden(capabilities.reason ?? "This caller may not plan schema changes.", capabilities.code ?? "FORBIDDEN");
|
|
5071
|
+
const before = config.getCollections();
|
|
5072
|
+
const after = proposedCollections(before, change);
|
|
5073
|
+
const plan = await admin.planSchemaChange(before, after, { paths: config.commitPaths });
|
|
5074
|
+
return c.json({
|
|
5075
|
+
applicable: plan.classified.applicable,
|
|
5076
|
+
verdict: plan.classified.verdict,
|
|
5077
|
+
changes: plan.classified.changes,
|
|
5078
|
+
statements: plan.statements,
|
|
5079
|
+
files: plan.files.map((file) => file.path),
|
|
5080
|
+
message: plan.message,
|
|
5081
|
+
withheldConstraints: plan.withheldConstraints ?? []
|
|
5082
|
+
});
|
|
5083
|
+
});
|
|
5084
|
+
router.post("/apply", async (c) => {
|
|
5085
|
+
const change = parseProposed(await c.req.json());
|
|
5086
|
+
const admin = requirePlanner();
|
|
5087
|
+
const principal = classifyPrincipal(c.get("user"));
|
|
5088
|
+
const capabilities = schemaEditCapabilities(principal, config.policy);
|
|
5089
|
+
if (!capabilities.apply) throw ApiError.forbidden(capabilities.reason ?? "This caller may not apply schema changes.", capabilities.code ?? "FORBIDDEN");
|
|
5090
|
+
const author = principal.kind === "machine" ? machineCommitAuthor(principal) : commitAuthor(c.get("user"));
|
|
5091
|
+
const repository = requireRepository(author);
|
|
5092
|
+
const before = config.getCollections();
|
|
5093
|
+
const after = proposedCollections(before, change);
|
|
5094
|
+
const plan = await admin.planSchemaChange(before, after, { paths: config.commitPaths });
|
|
5095
|
+
if (!plan.classified.applicable) {
|
|
5096
|
+
const blocking = plan.classified.changes.filter((c) => c.verdict !== "safe");
|
|
5097
|
+
throw ApiError.badRequest("This change cannot be applied to a running database:\n" + blocking.map((c) => ` • ${c.detail}${c.remedy ? `\n ${c.remedy}` : ""}`).join("\n"), "SCHEMA_CHANGE_UNAPPLICABLE", { changes: plan.classified.changes });
|
|
5098
|
+
}
|
|
5099
|
+
try {
|
|
5100
|
+
const result = await applySchemaChange({
|
|
5101
|
+
plan,
|
|
5102
|
+
repository,
|
|
5103
|
+
sourcePaths: config.sourcePathsFor?.(change) ?? [],
|
|
5104
|
+
writeSource: config.writeSource ? () => config.writeSource(change) : void 0,
|
|
5105
|
+
apply: async (statements) => {
|
|
5106
|
+
const sql = config.getAdmin();
|
|
5107
|
+
if (!isSQLAdmin(sql)) throw new Error("The driver cannot run SQL, so the change cannot be applied here.");
|
|
5108
|
+
for (const statement of statements) await sql.executeSql(statement);
|
|
5109
|
+
}
|
|
5110
|
+
});
|
|
5111
|
+
logger.info(`[schema-edit] ${result.summary}`);
|
|
5112
|
+
return c.json({
|
|
5113
|
+
...result,
|
|
5114
|
+
withheldConstraints: plan.withheldConstraints ?? []
|
|
5115
|
+
});
|
|
5116
|
+
} catch (err) {
|
|
5117
|
+
if (err instanceof UnapplicableChangeError) throw ApiError.badRequest(err.message, "SCHEMA_CHANGE_UNAPPLICABLE", { changes: err.classified.changes });
|
|
5118
|
+
if (err instanceof DirtyWorkingTreeError) throw ApiError.conflict(err.message, "SCHEMA_EDIT_DIRTY_TREE");
|
|
5119
|
+
throw ApiError.badRequest(err instanceof Error ? err.message : String(err), "SCHEMA_CHANGE_FAILED");
|
|
5120
|
+
}
|
|
5121
|
+
});
|
|
5122
|
+
return router;
|
|
5123
|
+
}
|
|
5124
|
+
//#endregion
|
|
5125
|
+
//#region src/api/mount.ts
|
|
5126
|
+
/**
|
|
5127
|
+
* Mounting a surface at its canonical path, and at the path it used to have.
|
|
5128
|
+
*
|
|
5129
|
+
* Several admin-only surfaces grew up at the top level — `/api/cron`,
|
|
5130
|
+
* `/api/logs`, `/api/schema-editor` — while the ones added later went under
|
|
5131
|
+
* `/api/admin`. Nothing was wrong with either choice on its own; what was wrong
|
|
5132
|
+
* was having both, because the shape of the path stopped predicting whether a
|
|
5133
|
+
* caller needed to be an admin. `docs/api-conventions.md` states the rule the
|
|
5134
|
+
* repo now follows and this module is how existing callers survive it.
|
|
5135
|
+
*
|
|
5136
|
+
* ## Why an alias rather than a redirect
|
|
5137
|
+
*
|
|
5138
|
+
* A 308 would be correct and would break things anyway: `fetch` follows
|
|
5139
|
+
* redirects but drops an `Authorization` header on a cross-origin hop, and a
|
|
5140
|
+
* good deal of client code posts to these paths with one attached. An alias
|
|
5141
|
+
* serves the same handler at both paths, so nothing observable changes for a
|
|
5142
|
+
* caller that has not moved yet.
|
|
5143
|
+
*
|
|
5144
|
+
* The alias is not silent. Every response through it carries `Deprecation` and
|
|
5145
|
+
* a `Link` naming the successor, which is what makes the old path removable
|
|
5146
|
+
* later: an operator can find the callers in their own logs rather than
|
|
5147
|
+
* discovering them when the path is gone.
|
|
5148
|
+
*/
|
|
5149
|
+
/**
|
|
5150
|
+
* Mount `router` at its canonical path, and at its legacy path when it has one.
|
|
5151
|
+
*
|
|
5152
|
+
* The same router instance serves both, so the two paths cannot drift: there is
|
|
5153
|
+
* one set of handlers and one gate, and an alias that forgot to apply the admin
|
|
5154
|
+
* gate is not a state this can reach.
|
|
5155
|
+
*/
|
|
5156
|
+
function mountWithLegacyAlias(app, router, options) {
|
|
5157
|
+
app.route(options.canonical, router);
|
|
5158
|
+
if (!options.legacy) {
|
|
5159
|
+
logger.debug(`${options.surface} routes mounted`, { path: options.canonical });
|
|
5160
|
+
return;
|
|
5161
|
+
}
|
|
5162
|
+
const alias = new Hono();
|
|
5163
|
+
alias.use("/*", async (c, next) => {
|
|
5164
|
+
await next();
|
|
5165
|
+
c.header("Deprecation", "true");
|
|
5166
|
+
c.header("Link", `<${options.canonical}>; rel="successor-version"`);
|
|
5167
|
+
});
|
|
5168
|
+
alias.route("/", router);
|
|
5169
|
+
app.route(options.legacy, alias);
|
|
5170
|
+
logger.debug(`${options.surface} routes mounted`, {
|
|
5171
|
+
path: options.canonical,
|
|
5172
|
+
deprecatedAlias: options.legacy
|
|
5173
|
+
});
|
|
5174
|
+
}
|
|
5175
|
+
function findProjectRoot(startDir, levels = 5) {
|
|
5176
|
+
let dir = path$1.resolve(startDir);
|
|
5177
|
+
for (let i = 0; i <= levels; i++) {
|
|
5178
|
+
if (fs$1.existsSync(path$1.join(dir, "rebase.json"))) return dir;
|
|
5179
|
+
const parent = path$1.dirname(dir);
|
|
5180
|
+
if (parent === dir) return void 0;
|
|
5181
|
+
dir = parent;
|
|
5182
|
+
}
|
|
5183
|
+
}
|
|
5184
|
+
/**
|
|
5185
|
+
* The generated-artifact paths for a project, expressed relative to the
|
|
5186
|
+
* repository that will hold the commit.
|
|
5187
|
+
*
|
|
5188
|
+
* Returns `undefined` when there is nothing to correct — no project marker, or
|
|
5189
|
+
* a project that *is* the repository — so the caller passes no override and the
|
|
5190
|
+
* defaults stand. Saying "no change needed" is different from saying
|
|
5191
|
+
* "the change is a no-op prefix", and only the first leaves the existing
|
|
5192
|
+
* behaviour untouched for every project that already worked.
|
|
5193
|
+
*/
|
|
5194
|
+
function commitPathsFor(collectionsDir, repositoryRoot) {
|
|
5195
|
+
const projectRoot = findProjectRoot(collectionsDir);
|
|
5196
|
+
if (!projectRoot) return void 0;
|
|
5197
|
+
const prefix = path$1.relative(path$1.resolve(repositoryRoot), projectRoot);
|
|
5198
|
+
if (prefix === "" || prefix.startsWith("..")) return void 0;
|
|
5199
|
+
const relocate = (p) => path$1.posix.join(...prefix.split(path$1.sep), p);
|
|
5200
|
+
return {
|
|
5201
|
+
schemaFile: relocate(DEFAULT_COMMIT_PATHS.schemaFile),
|
|
5202
|
+
ddlFile: relocate(DEFAULT_COMMIT_PATHS.ddlFile),
|
|
5203
|
+
policiesFile: relocate(DEFAULT_COMMIT_PATHS.policiesFile),
|
|
5204
|
+
searchFile: relocate(DEFAULT_COMMIT_PATHS.searchFile)
|
|
5205
|
+
};
|
|
5206
|
+
}
|
|
5207
|
+
//#endregion
|
|
5208
|
+
//#region src/schema-edit/github-repository.ts
|
|
5209
|
+
/**
|
|
5210
|
+
* A {@link SchemaEditRepository} backed by a repository on GitHub.
|
|
5211
|
+
*
|
|
5212
|
+
* This is the implementation for a deployment whose source is not on the
|
|
5213
|
+
* machine — a Cloud tenant runs a built bundle, and its repository lives
|
|
5214
|
+
* somewhere else entirely. The local one commits with `git`; this one has no
|
|
5215
|
+
* working tree at all and uses the Git Data API, which creates a blob, a tree,
|
|
5216
|
+
* a commit and moves a ref in four calls. Nothing is cloned.
|
|
5217
|
+
*
|
|
5218
|
+
* ## Authentication
|
|
5219
|
+
*
|
|
5220
|
+
* A GitHub App, not a token per project. The app holds one private key
|
|
5221
|
+
* globally; each project stores an `installationId`, which is not a secret. A
|
|
5222
|
+
* per-project personal access token would mean a credential to hold, rotate and
|
|
5223
|
+
* leak for every customer.
|
|
5224
|
+
*
|
|
5225
|
+
* Three steps, and the middle one is the one people miss:
|
|
5226
|
+
*
|
|
5227
|
+
* 1. sign a JWT with the private key — RS256, `iss` the app's id, ten minutes
|
|
5228
|
+
* at most, which authenticates *the app* and can touch no repository;
|
|
5229
|
+
* 2. exchange it for an **installation access token**, which expires in an hour
|
|
5230
|
+
* and is what can actually write;
|
|
5231
|
+
* 3. use that token as the bearer for the Git Data calls.
|
|
5232
|
+
*
|
|
5233
|
+
* The token is cached until shortly before it expires, because minting one is a
|
|
5234
|
+
* round trip and a schema edit makes several calls.
|
|
5235
|
+
*
|
|
5236
|
+
* ## What `dirtyPaths` means without a working tree
|
|
5237
|
+
*
|
|
5238
|
+
* Nothing, directly — there is no tree to be dirty. The equivalent hazard is the
|
|
5239
|
+
* branch moving under us between reading its head and writing a commit onto it,
|
|
5240
|
+
* and that is handled where it belongs: the commit is created against the head
|
|
5241
|
+
* read at the start, and updating the ref is **not** forced, so GitHub rejects
|
|
5242
|
+
* it if anything else landed meanwhile. A lost race is a refusal, never a
|
|
5243
|
+
* silent overwrite of somebody's commit.
|
|
5244
|
+
*/
|
|
5245
|
+
var GITHUB_API = "https://api.github.com";
|
|
5246
|
+
/** GitHub caps app JWTs at ten minutes; nine leaves room for clock skew. */
|
|
5247
|
+
var APP_JWT_TTL_SECONDS = 540;
|
|
5248
|
+
/** Renew an installation token this long before it lapses. */
|
|
5249
|
+
var TOKEN_REFRESH_MARGIN_MS = 6e4;
|
|
5250
|
+
var GitHubApiError = class extends Error {
|
|
5251
|
+
status;
|
|
5252
|
+
path;
|
|
5253
|
+
constructor(status, path, detail) {
|
|
5254
|
+
super(`GitHub ${path} failed with ${status}: ${detail}`);
|
|
5255
|
+
this.status = status;
|
|
5256
|
+
this.path = path;
|
|
5257
|
+
this.name = "GitHubApiError";
|
|
5258
|
+
}
|
|
5259
|
+
};
|
|
5260
|
+
/** base64url, which JWT requires and `Buffer` does not produce by default. */
|
|
5261
|
+
var b64url = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5262
|
+
/**
|
|
5263
|
+
* A JWT authenticating the app itself.
|
|
5264
|
+
*
|
|
5265
|
+
* `iat` is backdated by a minute: GitHub rejects a token whose `iat` is in its
|
|
5266
|
+
* future, and a server clock a few seconds fast is enough to trigger it.
|
|
5267
|
+
*/
|
|
5268
|
+
function createAppJwt(appId, privateKey, nowMs) {
|
|
5269
|
+
const issued = Math.floor(nowMs / 1e3) - 60;
|
|
5270
|
+
const header = b64url(JSON.stringify({
|
|
5271
|
+
alg: "RS256",
|
|
5272
|
+
typ: "JWT"
|
|
5273
|
+
}));
|
|
5274
|
+
const payload = b64url(JSON.stringify({
|
|
5275
|
+
iat: issued,
|
|
5276
|
+
exp: issued + APP_JWT_TTL_SECONDS,
|
|
5277
|
+
iss: appId
|
|
5278
|
+
}));
|
|
5279
|
+
const signer = createSign("RSA-SHA256");
|
|
5280
|
+
signer.update(`${header}.${payload}`);
|
|
5281
|
+
signer.end();
|
|
5282
|
+
return `${header}.${payload}.${b64url(signer.sign(privateKey))}`;
|
|
5283
|
+
}
|
|
5284
|
+
function createGitHubRepository(options) {
|
|
5285
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
5286
|
+
const now = options.now ?? (() => Date.now());
|
|
5287
|
+
const { owner, repo, branch } = options;
|
|
5288
|
+
/** Files staged by `writeFiles`, held until `commit`. */
|
|
5289
|
+
let staged = [];
|
|
5290
|
+
let token;
|
|
5291
|
+
const call = async (path, init) => {
|
|
5292
|
+
const { auth, ...rest } = init;
|
|
5293
|
+
const response = await doFetch(`${GITHUB_API}${path}`, {
|
|
5294
|
+
...rest,
|
|
5295
|
+
headers: {
|
|
5296
|
+
accept: "application/vnd.github+json",
|
|
5297
|
+
"x-github-api-version": "2022-11-28",
|
|
5298
|
+
authorization: `Bearer ${auth}`,
|
|
5299
|
+
"content-type": "application/json",
|
|
5300
|
+
...rest.headers ?? {}
|
|
5301
|
+
}
|
|
5302
|
+
});
|
|
5303
|
+
if (!response.ok) throw new GitHubApiError(response.status, path, (await response.text()).slice(0, 400));
|
|
5304
|
+
return await response.json();
|
|
5305
|
+
};
|
|
5306
|
+
/**
|
|
5307
|
+
* The bearer for the Git Data calls.
|
|
5308
|
+
*
|
|
5309
|
+
* A token is already one. An app has to mint one: sign a JWT with the
|
|
5310
|
+
* private key, which authenticates the *app* and can touch no repository,
|
|
5311
|
+
* then exchange it for an installation token, which expires in an hour and
|
|
5312
|
+
* is what can actually write. Cached until shortly before it lapses,
|
|
5313
|
+
* because a schema edit makes several calls and minting is a round trip.
|
|
5314
|
+
*/
|
|
5315
|
+
const bearer = async () => {
|
|
5316
|
+
if (options.auth.kind === "token") return options.auth.token;
|
|
5317
|
+
if (token && token.expiresAtMs - TOKEN_REFRESH_MARGIN_MS > now()) return token.value;
|
|
5318
|
+
const jwt = createAppJwt(options.auth.appId, options.auth.privateKey, now());
|
|
5319
|
+
const minted = await call(`/app/installations/${options.auth.installationId}/access_tokens`, {
|
|
5320
|
+
method: "POST",
|
|
5321
|
+
auth: jwt
|
|
5322
|
+
});
|
|
5323
|
+
token = {
|
|
5324
|
+
value: minted.token,
|
|
5325
|
+
expiresAtMs: Date.parse(minted.expires_at)
|
|
5326
|
+
};
|
|
5327
|
+
return token.value;
|
|
5328
|
+
};
|
|
5329
|
+
return {
|
|
5330
|
+
root: `${owner}/${repo}`,
|
|
5331
|
+
async currentBranch() {
|
|
5332
|
+
return branch;
|
|
5333
|
+
},
|
|
5334
|
+
async dirtyPaths() {
|
|
5335
|
+
return [];
|
|
5336
|
+
},
|
|
5337
|
+
async writeFiles(files) {
|
|
5338
|
+
staged = files;
|
|
5339
|
+
},
|
|
5340
|
+
/**
|
|
5341
|
+
* One file's current contents, read from the branch this commits onto.
|
|
5342
|
+
*
|
|
5343
|
+
* `undefined` for a file that is not there — a collection being created
|
|
5344
|
+
* has no source yet — and that is a normal answer rather than a
|
|
5345
|
+
* failure. Anything else is surfaced: a 403 from a token without
|
|
5346
|
+
* `contents:read` must not be mistaken for an empty repository, or the
|
|
5347
|
+
* editor would rewrite from nothing and the commit would delete
|
|
5348
|
+
* somebody's collection.
|
|
5349
|
+
*/
|
|
5350
|
+
async readFile(filePath) {
|
|
5351
|
+
const auth = await bearer();
|
|
5352
|
+
const response = await doFetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/${filePath.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(branch)}`, { headers: {
|
|
5353
|
+
accept: "application/vnd.github+json",
|
|
5354
|
+
"x-github-api-version": "2022-11-28",
|
|
5355
|
+
authorization: `Bearer ${auth}`
|
|
5356
|
+
} });
|
|
5357
|
+
if (response.status === 404) return void 0;
|
|
5358
|
+
if (!response.ok) throw new GitHubApiError(response.status, `/repos/${owner}/${repo}/contents/${filePath}`, (await response.text()).slice(0, 400));
|
|
5359
|
+
const body = await response.json();
|
|
5360
|
+
if (typeof body.content !== "string") return void 0;
|
|
5361
|
+
if (body.encoding && body.encoding !== "base64") return void 0;
|
|
5362
|
+
return Buffer.from(body.content, "base64").toString("utf8");
|
|
5363
|
+
},
|
|
5364
|
+
async commit(paths, message) {
|
|
5365
|
+
if (staged.length === 0) throw new Error("Refusing to commit with nothing staged.");
|
|
5366
|
+
const auth = await bearer();
|
|
5367
|
+
const base = `/repos/${owner}/${repo}`;
|
|
5368
|
+
const headSha = (await call(`${base}/git/ref/heads/${encodeURIComponent(branch)}`, { auth })).object.sha;
|
|
5369
|
+
const head = await call(`${base}/git/commits/${headSha}`, { auth });
|
|
5370
|
+
const tree = await Promise.all(staged.filter((file) => paths.includes(file.path)).map(async (file) => {
|
|
5371
|
+
const blob = await call(`${base}/git/blobs`, {
|
|
5372
|
+
method: "POST",
|
|
5373
|
+
auth,
|
|
5374
|
+
body: JSON.stringify({
|
|
5375
|
+
content: file.contents,
|
|
5376
|
+
encoding: "utf-8"
|
|
5377
|
+
})
|
|
5378
|
+
});
|
|
5379
|
+
return {
|
|
5380
|
+
path: file.path,
|
|
5381
|
+
mode: "100644",
|
|
5382
|
+
type: "blob",
|
|
5383
|
+
sha: blob.sha
|
|
5384
|
+
};
|
|
5385
|
+
}));
|
|
5386
|
+
const created = await call(`${base}/git/trees`, {
|
|
5387
|
+
method: "POST",
|
|
5388
|
+
auth,
|
|
5389
|
+
body: JSON.stringify({
|
|
5390
|
+
base_tree: head.tree.sha,
|
|
5391
|
+
tree
|
|
5392
|
+
})
|
|
5393
|
+
});
|
|
5394
|
+
const commit = await call(`${base}/git/commits`, {
|
|
5395
|
+
method: "POST",
|
|
5396
|
+
auth,
|
|
5397
|
+
body: JSON.stringify({
|
|
5398
|
+
message,
|
|
5399
|
+
tree: created.sha,
|
|
5400
|
+
parents: [headSha],
|
|
5401
|
+
...options.author ? { author: {
|
|
5402
|
+
...options.author,
|
|
5403
|
+
date: new Date(now()).toISOString()
|
|
5404
|
+
} } : {}
|
|
5405
|
+
})
|
|
5406
|
+
});
|
|
5407
|
+
await call(`${base}/git/refs/heads/${encodeURIComponent(branch)}`, {
|
|
5408
|
+
method: "PATCH",
|
|
5409
|
+
auth,
|
|
5410
|
+
body: JSON.stringify({
|
|
5411
|
+
sha: commit.sha,
|
|
5412
|
+
force: false
|
|
5413
|
+
})
|
|
5414
|
+
});
|
|
5415
|
+
staged = [];
|
|
5416
|
+
return commit.sha;
|
|
5417
|
+
}
|
|
5418
|
+
};
|
|
5419
|
+
}
|
|
5420
|
+
//#endregion
|
|
5421
|
+
//#region src/schema-edit/remote-source.ts
|
|
5422
|
+
/**
|
|
5423
|
+
* Rewriting a collection whose source is not on this machine.
|
|
5424
|
+
*
|
|
5425
|
+
* A bundle deployment — every Cloud tenant, and any self-host running built
|
|
5426
|
+
* output — ships *compiled* collections. The AST editor rewrites the
|
|
5427
|
+
* TypeScript, opening it from a directory on disk, so there is nothing for it
|
|
5428
|
+
* to open. That single fact is why live schema editing was local-only: not
|
|
5429
|
+
* authentication, not the Git Data API, which have worked for a while, but the
|
|
5430
|
+
* editor needing a file.
|
|
5431
|
+
*
|
|
5432
|
+
* So the file is brought to it. Read the current source from the repository,
|
|
5433
|
+
* put it in a scratch directory shaped the way the editor expects, let the
|
|
5434
|
+
* editor do exactly what it does locally, and read the result back. The editor
|
|
5435
|
+
* is unchanged and untouched — which matters, because it is the part that knows
|
|
5436
|
+
* how to preserve a collection file's imports, comments and formatting, and a
|
|
5437
|
+
* second implementation of that for the remote case would be a second thing to
|
|
5438
|
+
* get wrong.
|
|
5439
|
+
*
|
|
5440
|
+
* ## What the scratch directory holds
|
|
5441
|
+
*
|
|
5442
|
+
* One file: the collection being edited. Not the whole directory.
|
|
5443
|
+
*
|
|
5444
|
+
* The editor resolves `<dir>/<id>.ts` and rewrites it in place; it does not
|
|
5445
|
+
* read its siblings to do that. Fetching them all would be a request per
|
|
5446
|
+
* collection on every save, for files nothing reads. A collection that does not
|
|
5447
|
+
* exist yet is simply absent, which is the same state the editor sees locally
|
|
5448
|
+
* when somebody adds one.
|
|
5449
|
+
*/
|
|
5450
|
+
/**
|
|
5451
|
+
* Fetch, rewrite, and return the changed file — without leaving anything behind.
|
|
5452
|
+
*
|
|
5453
|
+
* The scratch directory is removed on every path, including the failing one. It
|
|
5454
|
+
* holds a copy of the customer's collection source, and a temp directory that
|
|
5455
|
+
* accumulates those on a long-running server is a slow leak of exactly the
|
|
5456
|
+
* thing worth not leaking.
|
|
5457
|
+
*/
|
|
5458
|
+
async function rewriteRemoteCollection(options, collectionId, collection) {
|
|
5459
|
+
const { repository, collectionsPath, edit } = options;
|
|
5460
|
+
if (!repository.readFile) throw new Error("This repository cannot read a file, so the collection source cannot be rewritten for a deployment that has none on disk.");
|
|
5461
|
+
const repoPath = `${collectionsPath.replace(/\/+$/, "")}/${collectionId}.ts`;
|
|
5462
|
+
const existing = await repository.readFile(repoPath);
|
|
5463
|
+
const scratch = await fsp.mkdtemp(path$1.join(os.tmpdir(), "rebase-remote-source-"));
|
|
5464
|
+
try {
|
|
5465
|
+
if (existing !== void 0) await fsp.writeFile(path$1.join(scratch, `${collectionId}.ts`), existing, "utf8");
|
|
5466
|
+
await edit(scratch, collectionId, collection);
|
|
5467
|
+
return [{
|
|
5468
|
+
path: repoPath,
|
|
5469
|
+
contents: await fsp.readFile(path$1.join(scratch, `${collectionId}.ts`), "utf8")
|
|
5470
|
+
}];
|
|
5471
|
+
} finally {
|
|
5472
|
+
await fsp.rm(scratch, {
|
|
5473
|
+
recursive: true,
|
|
5474
|
+
force: true
|
|
5475
|
+
});
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5478
|
+
//#endregion
|
|
5479
|
+
//#region src/schema-edit/local-git-repository.ts
|
|
5480
|
+
/**
|
|
5481
|
+
* A {@link SchemaEditRepository} backed by the working tree the server is
|
|
5482
|
+
* running in.
|
|
5483
|
+
*
|
|
5484
|
+
* This is the implementation for the two deployment shapes that have their
|
|
5485
|
+
* source on disk — `rebase dev` on a developer's machine, and a self-host with
|
|
5486
|
+
* the project mounted. Both have a `.git` next to the collections, so a commit
|
|
5487
|
+
* is a local `git commit`: nothing to authenticate, nothing to fetch, no token.
|
|
5488
|
+
*
|
|
5489
|
+
* The remote implementation, for a Cloud tenant whose repository lives on
|
|
5490
|
+
* GitHub, satisfies the same interface and is a separate file. Neither knows
|
|
5491
|
+
* about the other.
|
|
5492
|
+
*
|
|
5493
|
+
* ## Why it stages paths explicitly
|
|
5494
|
+
*
|
|
5495
|
+
* Never `git add -A`. A schema change runs while somebody may have unrelated
|
|
5496
|
+
* work in the tree, and sweeping that into a commit nobody wrote is worse than
|
|
5497
|
+
* refusing — it is a commit that cannot be reviewed because nobody knows what
|
|
5498
|
+
* is in it. `applySchemaChange` refuses up front when the tree already has
|
|
5499
|
+
* *our* files modified; this stages the exact list and nothing else.
|
|
5500
|
+
*/
|
|
5501
|
+
var run$1 = promisify$1(execFile);
|
|
5502
|
+
var GitCommandError = class extends Error {
|
|
5503
|
+
constructor(command, detail) {
|
|
5504
|
+
super(`git ${command} failed: ${detail}`);
|
|
5505
|
+
this.name = "GitCommandError";
|
|
5506
|
+
}
|
|
5507
|
+
};
|
|
5508
|
+
/**
|
|
5509
|
+
* `--` before paths, always: a file called `main` would otherwise be read as a
|
|
5510
|
+
* revision, and the failure mode is a command that does something else entirely.
|
|
5511
|
+
*/
|
|
5512
|
+
async function git(root, args) {
|
|
5513
|
+
try {
|
|
5514
|
+
const { stdout } = await run$1("git", [
|
|
5515
|
+
"-c",
|
|
5516
|
+
"core.fsmonitor=false",
|
|
5517
|
+
...args
|
|
5518
|
+
], {
|
|
5519
|
+
cwd: root,
|
|
5520
|
+
maxBuffer: 32 * 1024 * 1024
|
|
5521
|
+
});
|
|
5522
|
+
return stdout;
|
|
5523
|
+
} catch (err) {
|
|
5524
|
+
const detail = err instanceof Error ? err.stderr?.trim() || err.message : String(err);
|
|
5525
|
+
throw new GitCommandError(args[0] ?? "", detail);
|
|
5526
|
+
}
|
|
5527
|
+
}
|
|
5528
|
+
/**
|
|
5529
|
+
* The top of the working tree containing `startDir`, or undefined.
|
|
5530
|
+
*
|
|
5531
|
+
* Resolved by asking git rather than by walking for a `.git`, because a
|
|
5532
|
+
* worktree's `.git` is a file and a submodule's is neither where nor what a
|
|
5533
|
+
* naive walk expects.
|
|
5534
|
+
*/
|
|
5535
|
+
async function findRepositoryRoot(startDir) {
|
|
5536
|
+
try {
|
|
5537
|
+
const root = (await git(startDir, ["rev-parse", "--show-toplevel"])).trim();
|
|
5538
|
+
return root.length > 0 ? root : void 0;
|
|
5539
|
+
} catch {
|
|
5540
|
+
return;
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
5543
|
+
function createLocalGitRepository(options) {
|
|
5544
|
+
const { root } = options;
|
|
5545
|
+
return {
|
|
5546
|
+
root,
|
|
5547
|
+
async currentBranch() {
|
|
5548
|
+
const branch = (await git(root, [
|
|
5549
|
+
"rev-parse",
|
|
5550
|
+
"--abbrev-ref",
|
|
5551
|
+
"HEAD"
|
|
5552
|
+
])).trim();
|
|
5553
|
+
if (branch === "HEAD") throw new GitCommandError("rev-parse", "the working tree is on a detached HEAD, so there is no branch to commit to. Check out a branch first.");
|
|
5554
|
+
return branch;
|
|
5555
|
+
},
|
|
5556
|
+
async dirtyPaths() {
|
|
5557
|
+
const out = await git(root, [
|
|
5558
|
+
"status",
|
|
5559
|
+
"--porcelain=v1",
|
|
5560
|
+
"-z"
|
|
5561
|
+
]);
|
|
5562
|
+
const paths = [];
|
|
5563
|
+
for (const entry of out.split("\0")) {
|
|
5564
|
+
if (!entry) continue;
|
|
5565
|
+
const file = entry.slice(3);
|
|
5566
|
+
if (file) paths.push(file);
|
|
5567
|
+
}
|
|
5568
|
+
return paths;
|
|
5569
|
+
},
|
|
5570
|
+
async writeFiles(files) {
|
|
5571
|
+
for (const file of files) {
|
|
5572
|
+
const absolute = path$2.resolve(root, file.path);
|
|
5573
|
+
if (!absolute.startsWith(path$2.resolve(root) + path$2.sep)) throw new Error(`Refusing to write outside the repository: ${file.path}`);
|
|
5574
|
+
await fs$2.mkdir(path$2.dirname(absolute), { recursive: true });
|
|
5575
|
+
await fs$2.writeFile(absolute, file.contents, "utf8");
|
|
5576
|
+
}
|
|
5577
|
+
},
|
|
5578
|
+
async commit(paths, message) {
|
|
5579
|
+
if (paths.length === 0) throw new Error("Refusing to commit an empty path list.");
|
|
5580
|
+
await git(root, [
|
|
5581
|
+
"add",
|
|
5582
|
+
"--",
|
|
5583
|
+
...paths
|
|
5584
|
+
]);
|
|
5585
|
+
if ((await git(root, [
|
|
5586
|
+
"diff",
|
|
5587
|
+
"--cached",
|
|
5588
|
+
"--name-only",
|
|
5589
|
+
"--",
|
|
5590
|
+
...paths
|
|
5591
|
+
])).trim() === "") return (await git(root, ["rev-parse", "HEAD"])).trim();
|
|
5592
|
+
const identity = options.author ? [
|
|
5593
|
+
"-c",
|
|
5594
|
+
`user.name=${options.author.name}`,
|
|
5595
|
+
"-c",
|
|
5596
|
+
`user.email=${options.author.email}`
|
|
5597
|
+
] : [];
|
|
5598
|
+
await git(root, [
|
|
5599
|
+
...identity,
|
|
5600
|
+
"commit",
|
|
5601
|
+
"--only",
|
|
5602
|
+
"-m",
|
|
5603
|
+
message,
|
|
5604
|
+
"--",
|
|
5605
|
+
...paths
|
|
5606
|
+
]);
|
|
5607
|
+
return (await git(root, ["rev-parse", "HEAD"])).trim();
|
|
5608
|
+
}
|
|
5609
|
+
};
|
|
5610
|
+
}
|
|
5611
|
+
//#endregion
|
|
5612
|
+
//#region src/rls-audit/index.ts
|
|
5613
|
+
/**
|
|
5614
|
+
* The row-level-security audit, on a schedule.
|
|
5615
|
+
*
|
|
5616
|
+
* `@rebasepro/rls-check` is a thorough, read-only audit of a database's RLS —
|
|
5617
|
+
* more thorough, on security specifically, than the dashboard advisors it
|
|
5618
|
+
* competes with. And nobody runs it, because it is a command, and a command is
|
|
5619
|
+
* something you have to remember. A check that has to be remembered is a check
|
|
5620
|
+
* that reports a clean database right up until the day it matters.
|
|
5621
|
+
*
|
|
5622
|
+
* This runs it on a timer against the database the server is already using, and
|
|
5623
|
+
* makes the result readable without a terminal. Nothing else about the checker
|
|
5624
|
+
* changes: same package, same checks, same output.
|
|
5625
|
+
*
|
|
5626
|
+
* ## Why it is off by default
|
|
5627
|
+
*
|
|
5628
|
+
* It opens its own connection and introspects the whole catalog. That is cheap
|
|
5629
|
+
* and read-only, but it is not nothing, and a background query nobody asked for
|
|
5630
|
+
* is a poor default for a library. Enabling it is one line, and the boot log
|
|
5631
|
+
* says so when it is off.
|
|
5632
|
+
*
|
|
5633
|
+
* ## Why the scanner is injected rather than imported
|
|
5634
|
+
*
|
|
5635
|
+
* `@rebasepro/server` is engine-agnostic — it has no `pg` and no other database
|
|
5636
|
+
* driver, deliberately. `@rebasepro/rls-check` has `pg`, because it connects to
|
|
5637
|
+
* Postgres. Importing it here would put a Postgres driver in the install of
|
|
5638
|
+
* every Mongo and Firebase user, for a feature none of them can use, and an
|
|
5639
|
+
* audit of row-level security is a Postgres concept living in the one package
|
|
5640
|
+
* that is supposed to know nothing about Postgres.
|
|
5641
|
+
*
|
|
5642
|
+
* So the scan function is handed in:
|
|
5643
|
+
*
|
|
5644
|
+
* ```ts
|
|
5645
|
+
* import { scan } from "@rebasepro/rls-check";
|
|
5646
|
+
* rlsAudit: { enabled: true, scan }
|
|
5647
|
+
* ```
|
|
5648
|
+
*
|
|
5649
|
+
* Everything else — the schedule, the summary, the status, the admin route —
|
|
5650
|
+
* lives here and is engine-agnostic in fact as well as in principle.
|
|
5651
|
+
*/
|
|
5652
|
+
var SEVERITY_ORDER = [
|
|
5653
|
+
"info",
|
|
5654
|
+
"low",
|
|
5655
|
+
"medium",
|
|
5656
|
+
"high",
|
|
5657
|
+
"critical"
|
|
5658
|
+
];
|
|
5659
|
+
/** Rank for comparison. Unknown severities sort lowest rather than throwing. */
|
|
5660
|
+
var rank = (severity) => {
|
|
5661
|
+
const index = SEVERITY_ORDER.indexOf(severity);
|
|
5662
|
+
return index === -1 ? 0 : index;
|
|
5663
|
+
};
|
|
5664
|
+
var DEFAULT_INTERVAL_MS = 1440 * 60 * 1e3;
|
|
5665
|
+
var DEFAULT_STATEMENT_TIMEOUT_MS = 3e4;
|
|
5666
|
+
/** One line naming what the run found, at the right level to be noticed. */
|
|
5667
|
+
function summarize(result, warnAt) {
|
|
5668
|
+
const counts = /* @__PURE__ */ new Map();
|
|
5669
|
+
for (const finding of result.findings) counts.set(finding.severity, (counts.get(finding.severity) ?? 0) + 1);
|
|
5670
|
+
const worst = result.findings.reduce((acc, f) => rank(f.severity) > rank(acc) ? f.severity : acc, "info");
|
|
5671
|
+
if (result.findings.length === 0) return {
|
|
5672
|
+
level: "info",
|
|
5673
|
+
message: `RLS audit clean: ${result.stats.tables} table(s), ${result.stats.policies} polic(ies), ${result.stats.checksRun} check(s), no findings.`
|
|
5674
|
+
};
|
|
5675
|
+
const breakdown = [...SEVERITY_ORDER].reverse().filter((severity) => counts.get(severity)).map((severity) => `${counts.get(severity)} ${severity}`).join(", ");
|
|
5676
|
+
const message = `RLS audit found ${result.findings.length} issue(s) — ${breakdown}. ${result.stats.tablesWithoutRls} table(s) without RLS. Read the detail at GET /api/admin/rls-audit, or run \`npx @rebasepro/rls-check\` for the full report.`;
|
|
5677
|
+
return {
|
|
5678
|
+
level: rank(worst) >= rank(warnAt) ? "warn" : "info",
|
|
5679
|
+
message
|
|
5680
|
+
};
|
|
5681
|
+
}
|
|
5682
|
+
/**
|
|
5683
|
+
* Create the audit. Nothing runs until {@link RlsAudit.start}.
|
|
5684
|
+
*
|
|
5685
|
+
* Because the scanner is supplied by the caller, the schedule, the summary and
|
|
5686
|
+
* the failure handling are all testable without a database and without
|
|
5687
|
+
* `@rebasepro/rls-check` present.
|
|
5688
|
+
*/
|
|
5689
|
+
function createRlsAudit(config) {
|
|
5690
|
+
const enabled = config.enabled === true;
|
|
5691
|
+
const intervalMs = config.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
5692
|
+
const warnAt = config.warnAtSeverity ?? "high";
|
|
5693
|
+
const connectionString = config.connectionString ?? process.env.DATABASE_URL;
|
|
5694
|
+
let timer;
|
|
5695
|
+
let status = { enabled };
|
|
5696
|
+
if (enabled && !config.scan) status = {
|
|
5697
|
+
enabled: false,
|
|
5698
|
+
reason: "The scheduled RLS audit is enabled but no scanner was supplied. Pass `scan` from `@rebasepro/rls-check`: `rlsAudit: { enabled: true, scan }`."
|
|
5699
|
+
};
|
|
5700
|
+
else if (enabled && !connectionString) status = {
|
|
5701
|
+
enabled: false,
|
|
5702
|
+
reason: "The scheduled RLS audit is enabled but no connection string is available. Set DATABASE_URL, or pass `rlsAudit.connectionString`."
|
|
5703
|
+
};
|
|
5704
|
+
const runnable = enabled && !!connectionString && !!config.scan;
|
|
5705
|
+
const run = async () => {
|
|
5706
|
+
if (!runnable) return;
|
|
4295
5707
|
try {
|
|
4296
|
-
await
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
5708
|
+
const result = await config.scan({
|
|
5709
|
+
connectionString,
|
|
5710
|
+
schemas: config.schemas,
|
|
5711
|
+
statementTimeoutMs: config.statementTimeoutMs ?? DEFAULT_STATEMENT_TIMEOUT_MS
|
|
5712
|
+
});
|
|
5713
|
+
const { level, message } = summarize(result, warnAt);
|
|
5714
|
+
logger[level](`[rls-audit] ${message}`);
|
|
5715
|
+
status = {
|
|
5716
|
+
enabled: true,
|
|
5717
|
+
lastRunAt: result.scannedAt,
|
|
5718
|
+
result
|
|
5719
|
+
};
|
|
4301
5720
|
} catch (err) {
|
|
4302
|
-
|
|
4303
|
-
|
|
5721
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
5722
|
+
logger.error(`[rls-audit] Scan failed: ${detail}`);
|
|
5723
|
+
status = {
|
|
5724
|
+
...status,
|
|
5725
|
+
enabled: true,
|
|
5726
|
+
lastRunAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5727
|
+
lastError: detail
|
|
5728
|
+
};
|
|
4304
5729
|
}
|
|
4305
5730
|
};
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
if (config.cronScheduler) {
|
|
4326
|
-
config.cronScheduler.stop();
|
|
4327
|
-
logger.info("Cron scheduler stopped");
|
|
4328
|
-
}
|
|
4329
|
-
if (config.jobQueue) {
|
|
4330
|
-
await config.jobQueue.stop();
|
|
4331
|
-
logger.info("Job queue stopped");
|
|
4332
|
-
}
|
|
4333
|
-
for (const [key, rt] of Object.entries(config.realtimeServices)) try {
|
|
4334
|
-
if (typeof rt.destroy === "function") {
|
|
4335
|
-
await rt.destroy();
|
|
4336
|
-
logger.info(`Realtime service "${key}" destroyed`);
|
|
4337
|
-
} else if (typeof rt.stopListening === "function") {
|
|
4338
|
-
await rt.stopListening();
|
|
4339
|
-
logger.info(`Realtime service "${key}" LISTEN client stopped`);
|
|
4340
|
-
}
|
|
4341
|
-
} catch (err) {
|
|
4342
|
-
logger.warn(`Error destroying realtime service "${key}":`, { error: err });
|
|
4343
|
-
}
|
|
4344
|
-
config.server.close(() => {
|
|
4345
|
-
logger.info("HTTP server closed");
|
|
4346
|
-
resolve();
|
|
4347
|
-
});
|
|
4348
|
-
if (timeoutMs > 0) setTimeout(() => {
|
|
4349
|
-
logger.warn(`Forced shutdown after ${timeoutMs / 1e3}s timeout`);
|
|
4350
|
-
resolve();
|
|
4351
|
-
}, timeoutMs).unref();
|
|
4352
|
-
})();
|
|
4353
|
-
});
|
|
5731
|
+
return {
|
|
5732
|
+
start() {
|
|
5733
|
+
if (!runnable) {
|
|
5734
|
+
if (status.reason) logger.warn(`[rls-audit] ${status.reason}`);
|
|
5735
|
+
return;
|
|
5736
|
+
}
|
|
5737
|
+
if (timer) return;
|
|
5738
|
+
timer = setInterval(() => {
|
|
5739
|
+
run();
|
|
5740
|
+
}, intervalMs);
|
|
5741
|
+
timer.unref?.();
|
|
5742
|
+
if (config.runOnBoot !== false) run();
|
|
5743
|
+
},
|
|
5744
|
+
stop() {
|
|
5745
|
+
if (timer) clearInterval(timer);
|
|
5746
|
+
timer = void 0;
|
|
5747
|
+
},
|
|
5748
|
+
runNow: run,
|
|
5749
|
+
status: () => status
|
|
4354
5750
|
};
|
|
4355
5751
|
}
|
|
4356
5752
|
//#endregion
|
|
@@ -4383,7 +5779,8 @@ function resolveSurfaces(options) {
|
|
|
4383
5779
|
function resolveOwnership(options) {
|
|
4384
5780
|
return {
|
|
4385
5781
|
cronScheduler: options?.cronScheduler ?? true,
|
|
4386
|
-
jobWorkers: options?.jobWorkers ?? true
|
|
5782
|
+
jobWorkers: options?.jobWorkers ?? true,
|
|
5783
|
+
rlsAudit: options?.rlsAudit ?? true
|
|
4387
5784
|
};
|
|
4388
5785
|
}
|
|
4389
5786
|
/** The surfaces a process is not serving, for a boot log line. */
|
|
@@ -10385,8 +11782,35 @@ function createHistoryRoutes(params) {
|
|
|
10385
11782
|
* version or not — reads the same live client.
|
|
10386
11783
|
*/
|
|
10387
11784
|
var INSTANCE_SLOT = Symbol.for("@rebasepro/server:singleton-instance");
|
|
11785
|
+
/**
|
|
11786
|
+
* A *function* that answers "which client is this call for", for hosts where
|
|
11787
|
+
* one answer per process is the wrong shape.
|
|
11788
|
+
*
|
|
11789
|
+
* A Node server boots once and serves every request from the same client, so
|
|
11790
|
+
* {@link INSTANCE_SLOT} is the whole story. An isolate-based host is different
|
|
11791
|
+
* in a way that matters: the environment arrives attached to the **request**,
|
|
11792
|
+
* not to the module, so there is nothing to publish at import time, and an
|
|
11793
|
+
* isolate can be reused across requests that must not share state. Such a host
|
|
11794
|
+
* registers a resolver — typically reading an `AsyncLocalStorage` populated per
|
|
11795
|
+
* request — and it is consulted first.
|
|
11796
|
+
*
|
|
11797
|
+
* This exists now, before there is a host that needs it, because of what it
|
|
11798
|
+
* protects: `rebase` is a lazy Proxy, so every property access already goes
|
|
11799
|
+
* through `getInstance()`. Backing that lookup with a resolver is therefore the
|
|
11800
|
+
* entire porting story for the singleton — **no function anyone has already
|
|
11801
|
+
* written changes**. Take the resolver away and the only alternative is asking
|
|
11802
|
+
* users to thread a client through their handlers, which is a rewrite of every
|
|
11803
|
+
* function file in existence.
|
|
11804
|
+
*/
|
|
11805
|
+
var RESOLVER_SLOT = Symbol.for("@rebasepro/server:singleton-resolver");
|
|
10388
11806
|
function getInstance() {
|
|
10389
|
-
|
|
11807
|
+
const global = globalThis;
|
|
11808
|
+
const resolver = global[RESOLVER_SLOT];
|
|
11809
|
+
if (resolver) {
|
|
11810
|
+
const resolved = resolver();
|
|
11811
|
+
if (resolved) return resolved;
|
|
11812
|
+
}
|
|
11813
|
+
return global[INSTANCE_SLOT] ?? null;
|
|
10390
11814
|
}
|
|
10391
11815
|
function setInstance(client) {
|
|
10392
11816
|
globalThis[INSTANCE_SLOT] = client;
|
|
@@ -10403,7 +11827,7 @@ function _initRebase(client) {
|
|
|
10403
11827
|
* Throws an error if used in a non-test environment to prevent production abuse.
|
|
10404
11828
|
*/
|
|
10405
11829
|
function _setRebaseMock(mockInstance) {
|
|
10406
|
-
if (
|
|
11830
|
+
if (hostEnv().NODE_ENV !== "test") throw new Error("_setRebaseMock can only be called in a test environment (NODE_ENV=test).");
|
|
10407
11831
|
setInstance({
|
|
10408
11832
|
...getInstance() || {},
|
|
10409
11833
|
...mockInstance
|
|
@@ -10413,7 +11837,7 @@ function _setRebaseMock(mockInstance) {
|
|
|
10413
11837
|
* @internal Resets the singleton instance, useful for afterEach() in test suites.
|
|
10414
11838
|
*/
|
|
10415
11839
|
function _resetRebaseMock() {
|
|
10416
|
-
if (
|
|
11840
|
+
if (hostEnv().NODE_ENV !== "test") throw new Error("_resetRebaseMock can only be called in a test environment.");
|
|
10417
11841
|
setInstance(null);
|
|
10418
11842
|
}
|
|
10419
11843
|
/**
|
|
@@ -10865,7 +12289,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10865
12289
|
]) {
|
|
10866
12290
|
const providerConfig = safeAuthConfig[key];
|
|
10867
12291
|
if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
|
|
10868
|
-
const createFn = (await import("./auth-
|
|
12292
|
+
const createFn = (await import("./auth-C74dqR2B.js").then((n) => n.t))[factory];
|
|
10869
12293
|
oauthProviders.push(createFn(providerConfig));
|
|
10870
12294
|
}
|
|
10871
12295
|
}
|
|
@@ -10879,6 +12303,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10879
12303
|
disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
|
|
10880
12304
|
allowAnonymous: safeAuthConfig.allowAnonymous ?? false,
|
|
10881
12305
|
allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
|
|
12306
|
+
captcha: safeAuthConfig.captcha,
|
|
10882
12307
|
defaultRole: safeAuthConfig.defaultRole,
|
|
10883
12308
|
oauthProviders,
|
|
10884
12309
|
allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
|
|
@@ -10965,7 +12390,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10965
12390
|
let schemaEditorOff = schemaEditorUnavailable();
|
|
10966
12391
|
let schemaEditorRoutes;
|
|
10967
12392
|
if (!schemaEditorOff && config.collectionsDir) try {
|
|
10968
|
-
schemaEditorRoutes = (await import("./schema-editor-routes-
|
|
12393
|
+
schemaEditorRoutes = (await import("./schema-editor-routes-yh2cQcdJ.js")).createSchemaEditorRoutes(config.collectionsDir);
|
|
10969
12394
|
} catch (err) {
|
|
10970
12395
|
if (err?.code === "ERR_MODULE_NOT_FOUND") {
|
|
10971
12396
|
schemaEditorOff = {
|
|
@@ -10988,18 +12413,101 @@ async function _initializeRebaseBackend(config) {
|
|
|
10988
12413
|
code: schemaEditorOff.code,
|
|
10989
12414
|
message: schemaEditorOff.message
|
|
10990
12415
|
} }, 501));
|
|
10991
|
-
config.
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
|
|
10995
|
-
|
|
12416
|
+
const remoteRepo = config.liveSchema?.repository;
|
|
12417
|
+
const canEditSchema = Boolean(config.collectionsDir) || Boolean(remoteRepo);
|
|
12418
|
+
if (!canEditSchema) {
|
|
12419
|
+
const unconfigured = new Hono();
|
|
12420
|
+
applyAdminGate(unconfigured, "Live schema editing");
|
|
12421
|
+
unconfigured.all("/*", (c) => c.json({ error: {
|
|
12422
|
+
code: "SCHEMA_EDITING_NO_COLLECTIONS_DIR",
|
|
12423
|
+
message: "Live schema editing needs `collectionsDir` — the directory holding this project's collection definitions — or `liveSchema.repository`, a repository to fetch them from. This server was started with neither, so there is nothing for it to edit."
|
|
12424
|
+
} }, 501));
|
|
12425
|
+
mountWithLegacyAlias(config.app, unconfigured, {
|
|
12426
|
+
canonical: `${basePath}/admin/schema`,
|
|
12427
|
+
surface: "Live schema editing (unconfigured)"
|
|
12428
|
+
});
|
|
12429
|
+
}
|
|
12430
|
+
if (canEditSchema) {
|
|
12431
|
+
const collectionsDir = config.collectionsDir;
|
|
12432
|
+
const repositoryRoot = collectionsDir ? await findRepositoryRoot(collectionsDir) : void 0;
|
|
12433
|
+
const liveSchemaRouter = new Hono();
|
|
12434
|
+
applyAdminGate(liveSchemaRouter, "Live schema editing");
|
|
12435
|
+
const commitPaths = remoteRepo ? commitPathsFor(path$1.join("/", remoteRepo.collectionsPath ?? "config/collections"), "/") : collectionsDir && repositoryRoot ? commitPathsFor(collectionsDir, repositoryRoot) : void 0;
|
|
12436
|
+
if (commitPaths) logger.debug("Live schema editing commit paths relocated", { commitPaths });
|
|
12437
|
+
liveSchemaRouter.route("/", createLiveSchemaRoutes({
|
|
12438
|
+
commitPaths,
|
|
12439
|
+
policy: { allowMachineApply: config.liveSchema?.allowMachineApply ?? process.env.REBASE_LIVE_SCHEMA_ALLOW_MACHINE_APPLY === "true" },
|
|
12440
|
+
getCollections: () => collectionRegistry.getRawCollections(),
|
|
12441
|
+
getAdmin: () => defaultDriver.admin,
|
|
12442
|
+
getRepository: (author) => {
|
|
12443
|
+
if (remoteRepo) return createGitHubRepository({
|
|
12444
|
+
auth: remoteRepo.auth,
|
|
12445
|
+
owner: remoteRepo.owner,
|
|
12446
|
+
repo: remoteRepo.repo,
|
|
12447
|
+
branch: remoteRepo.branch ?? "main",
|
|
12448
|
+
author
|
|
12449
|
+
});
|
|
12450
|
+
if (!repositoryRoot) return void 0;
|
|
12451
|
+
return createLocalGitRepository({
|
|
12452
|
+
root: repositoryRoot,
|
|
12453
|
+
author
|
|
12454
|
+
});
|
|
12455
|
+
},
|
|
12456
|
+
writeSource: schemaEditorRoutes ? async (change) => {
|
|
12457
|
+
const applyEdit = async (dir, collectionId, collection) => {
|
|
12458
|
+
const { AstSchemaEditor } = await import("./ast-schema-editor-BpFYpT38.js");
|
|
12459
|
+
await new AstSchemaEditor(dir).saveCollection(collectionId, collection, { partial: false });
|
|
12460
|
+
};
|
|
12461
|
+
if (remoteRepo) {
|
|
12462
|
+
const repository = config.liveSchema?.repository ? createGitHubRepository({
|
|
12463
|
+
auth: remoteRepo.auth,
|
|
12464
|
+
owner: remoteRepo.owner,
|
|
12465
|
+
repo: remoteRepo.repo,
|
|
12466
|
+
branch: remoteRepo.branch ?? "main"
|
|
12467
|
+
}) : void 0;
|
|
12468
|
+
if (!repository) return [];
|
|
12469
|
+
return rewriteRemoteCollection({
|
|
12470
|
+
repository,
|
|
12471
|
+
collectionsPath: remoteRepo.collectionsPath ?? "config/collections",
|
|
12472
|
+
edit: applyEdit
|
|
12473
|
+
}, change.collectionId, change.collection);
|
|
12474
|
+
}
|
|
12475
|
+
await applyEdit(collectionsDir, change.collectionId, change.collection);
|
|
12476
|
+
const file = path$1.join(collectionsDir, `${change.collectionId}.ts`);
|
|
12477
|
+
const contents = await fsp.readFile(file, "utf8");
|
|
12478
|
+
return [{
|
|
12479
|
+
path: path$1.relative(repositoryRoot, file),
|
|
12480
|
+
contents
|
|
12481
|
+
}];
|
|
12482
|
+
} : void 0,
|
|
12483
|
+
sourcePathsFor: (change) => {
|
|
12484
|
+
if (remoteRepo) return [`${(remoteRepo.collectionsPath ?? "config/collections").replace(/\/+$/, "")}/${change.collectionId}.ts`];
|
|
12485
|
+
if (!collectionsDir || !repositoryRoot) return [];
|
|
12486
|
+
return [path$1.relative(repositoryRoot, path$1.join(collectionsDir, `${change.collectionId}.ts`))];
|
|
12487
|
+
}
|
|
12488
|
+
}));
|
|
12489
|
+
mountWithLegacyAlias(config.app, liveSchemaRouter, {
|
|
12490
|
+
canonical: `${basePath}/admin/schema`,
|
|
12491
|
+
surface: "Live schema editing"
|
|
12492
|
+
});
|
|
12493
|
+
logger.debug("Live schema editing repository", { repository: repositoryRoot ?? "(none — refusals only)" });
|
|
12494
|
+
}
|
|
12495
|
+
mountWithLegacyAlias(config.app, schemaEditorRouter, {
|
|
12496
|
+
canonical: `${basePath}/admin/schema-editor`,
|
|
12497
|
+
legacy: `${basePath}/schema-editor`,
|
|
12498
|
+
surface: "Schema editor"
|
|
10996
12499
|
});
|
|
12500
|
+
if (!schemaEditorRoutes) logger.debug("Schema Editor unavailable", { code: schemaEditorOff.code });
|
|
10997
12501
|
}
|
|
10998
12502
|
const storageAuthorizeData = {};
|
|
10999
12503
|
if (storageController) {
|
|
11000
12504
|
const storageMaxSize = (config.storage && typeof config.storage === "object" && "type" in config.storage ? config.storage.maxFileSize : void 0) ?? 50 * 1024 * 1024;
|
|
12505
|
+
const storageAccessControl = resolveStorageAccessControl({
|
|
12506
|
+
storagePolicies: config.storagePolicies,
|
|
12507
|
+
storageAuthorize: config.storageAuthorize
|
|
12508
|
+
});
|
|
11001
12509
|
assertStorageAccessControlConfigured({
|
|
11002
|
-
hasAuthorize: !!config.storageAuthorize,
|
|
12510
|
+
hasAuthorize: !!config.storageAuthorize || !!config.storagePolicies?.length,
|
|
11003
12511
|
publicRead: config.storagePublicRead === true,
|
|
11004
12512
|
allowAnyAuthenticated: config.storageInsecureAllowAnyAuthenticated === true
|
|
11005
12513
|
}, isProduction);
|
|
@@ -11010,7 +12518,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
11010
12518
|
requireAuth: resolveRequireAuth(config.auth),
|
|
11011
12519
|
publicRead: config.storagePublicRead === true,
|
|
11012
12520
|
authAdapter,
|
|
11013
|
-
authorize:
|
|
12521
|
+
authorize: storageAccessControl,
|
|
11014
12522
|
authorizeData: () => storageAuthorizeData.current
|
|
11015
12523
|
});
|
|
11016
12524
|
const storageRouter = new Hono();
|
|
@@ -11147,9 +12655,9 @@ async function _initializeRebaseBackend(config) {
|
|
|
11147
12655
|
if (driver && "client" in driver) driver.client = serverClient;
|
|
11148
12656
|
}
|
|
11149
12657
|
if (config.functionsDir && surfaces.functions) {
|
|
11150
|
-
const { loadFunctionsWithDiagnostics } = await import("./function-loader-
|
|
12658
|
+
const { loadFunctionsWithDiagnostics } = await import("./function-loader-BrLmC_-y.js").then((n) => n.t);
|
|
11151
12659
|
const { createFunctionRoutes } = await import("./function-routes-Btcez1T-.js").then((n) => n.n);
|
|
11152
|
-
const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-
|
|
12660
|
+
const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-OofPCHQT.js").then((n) => n.t);
|
|
11153
12661
|
const { functions: allFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
|
|
11154
12662
|
const { selectFunctions } = await import("./selection-_z6TM1DB.js").then((n) => n.n);
|
|
11155
12663
|
const loadedFunctions = selectFunctions(allFunctions, config.functionsSelection);
|
|
@@ -11190,7 +12698,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
11190
12698
|
else logger.warn(`Function routes mounted at ${basePath}/functions, but no functions loaded from ${config.functionsDir}. ` + (problems.length > 0 ? `${problems.length} file(s) were skipped — see the messages above.` : "The directory holds no .ts/.js function files."));
|
|
11191
12699
|
}
|
|
11192
12700
|
if (config.functionsUpstream && !surfaces.functions) {
|
|
11193
|
-
const { createFunctionsProxy } = await import("./proxy-
|
|
12701
|
+
const { createFunctionsProxy } = await import("./proxy-QJKSS-CV.js").then((n) => n.t);
|
|
11194
12702
|
config.app.route(`${basePath}/functions`, createFunctionsProxy({
|
|
11195
12703
|
upstream: config.functionsUpstream,
|
|
11196
12704
|
basePath: `${basePath}/functions`
|
|
@@ -11202,10 +12710,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
11202
12710
|
}
|
|
11203
12711
|
let cronScheduler;
|
|
11204
12712
|
if (config.cronsDir && (surfaces.cron || ownership.cronScheduler)) {
|
|
11205
|
-
const { loadCronJobsWithDiagnostics } = await import("./cron-loader-
|
|
11206
|
-
const { CronScheduler } = await import("./cron-scheduler-
|
|
11207
|
-
const { createCronRoutes } = await import("./cron-routes-
|
|
11208
|
-
const { createCronStore } = await import("./cron-store-
|
|
12713
|
+
const { loadCronJobsWithDiagnostics } = await import("./cron-loader-BMvtW6-J.js").then((n) => n.t);
|
|
12714
|
+
const { CronScheduler } = await import("./cron-scheduler-Buf-uVam.js").then((n) => n.n);
|
|
12715
|
+
const { createCronRoutes } = await import("./cron-routes-BvYk-Kmi.js").then((n) => n.n);
|
|
12716
|
+
const { createCronStore } = await import("./cron-store-CyF-oInV.js").then((n) => n.n);
|
|
11209
12717
|
const { jobs: loadedCronJobs, problems: cronProblems } = await loadCronJobsWithDiagnostics(config.cronsDir);
|
|
11210
12718
|
cronScheduler = new CronScheduler();
|
|
11211
12719
|
cronScheduler.setClient(serverSingleton);
|
|
@@ -11220,7 +12728,11 @@ async function _initializeRebaseBackend(config) {
|
|
|
11220
12728
|
const cronRouter = new Hono();
|
|
11221
12729
|
applyAdminGate(cronRouter, "Cron");
|
|
11222
12730
|
cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
|
|
11223
|
-
if (surfaces.cron) config.app
|
|
12731
|
+
if (surfaces.cron) mountWithLegacyAlias(config.app, cronRouter, {
|
|
12732
|
+
canonical: `${basePath}/admin/cron`,
|
|
12733
|
+
legacy: `${basePath}/cron`,
|
|
12734
|
+
surface: "Cron"
|
|
12735
|
+
});
|
|
11224
12736
|
if (loadedCronJobs.length > 0 && !ownership.cronScheduler) logger.info("Cron jobs registered but NOT scheduled in this process", {
|
|
11225
12737
|
count: loadedCronJobs.length,
|
|
11226
12738
|
reason: "this process does not own the cron scheduler"
|
|
@@ -11235,7 +12747,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
11235
12747
|
}
|
|
11236
12748
|
let jobQueue;
|
|
11237
12749
|
if (config.jobs?.enabled) {
|
|
11238
|
-
const { createJobStore, createJobQueue } = await import("./jobs-
|
|
12750
|
+
const { createJobStore, createJobQueue } = await import("./jobs-Bn6nhY8J.js").then((n) => n.t);
|
|
11239
12751
|
const store = createJobStore(defaultDriver);
|
|
11240
12752
|
if (store) {
|
|
11241
12753
|
await store.ensureTable();
|
|
@@ -11246,8 +12758,9 @@ async function _initializeRebaseBackend(config) {
|
|
|
11246
12758
|
} else logger.info("Job queue available for enqueue, workers NOT started in this process", { tasks: Object.keys(config.jobs.tasks ?? {}).length });
|
|
11247
12759
|
} else logger.warn("Job queue requested but unavailable on this driver — `rebase.jobs.enqueue` will throw, and webhook deliveries fall back to the in-memory queue.");
|
|
11248
12760
|
}
|
|
12761
|
+
let rlsAudit;
|
|
11249
12762
|
if (surfaces.admin) {
|
|
11250
|
-
const { createBackupRoutes, parseBackupDestination } = await import("./backup-
|
|
12763
|
+
const { createBackupRoutes, parseBackupDestination } = await import("./backup-BJ86ah4T.js").then((n) => n.t);
|
|
11251
12764
|
const backupRouter = new Hono();
|
|
11252
12765
|
applyAdminGate(backupRouter, "Backup");
|
|
11253
12766
|
backupRouter.route("/", createBackupRoutes({
|
|
@@ -11260,16 +12773,29 @@ async function _initializeRebaseBackend(config) {
|
|
|
11260
12773
|
config.app.route(`${basePath}/admin/backups`, backupRouter);
|
|
11261
12774
|
logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
|
|
11262
12775
|
}
|
|
12776
|
+
if (surfaces.admin) {
|
|
12777
|
+
const rlsAuditRouter = new Hono();
|
|
12778
|
+
applyAdminGate(rlsAuditRouter, "RLS audit");
|
|
12779
|
+
rlsAuditRouter.get("/", (c) => c.json(rlsAudit ? rlsAudit.status() : {
|
|
12780
|
+
enabled: false,
|
|
12781
|
+
reason: config.rlsAudit?.enabled ? "This process serves the admin surface but does not own the RLS audit." : "The scheduled RLS audit is off. Set `rlsAudit.enabled` to turn it on."
|
|
12782
|
+
}));
|
|
12783
|
+
config.app.route(`${basePath}/admin/rls-audit`, rlsAuditRouter);
|
|
12784
|
+
logger.debug("RLS audit admin routes mounted", { path: `${basePath}/admin/rls-audit` });
|
|
12785
|
+
}
|
|
11263
12786
|
if (surfaces.admin) {
|
|
11264
12787
|
const { default: logsRoutes } = await import("./logs-routes-CWBLQj2l.js").then((n) => n.n);
|
|
11265
12788
|
const logsRouter = new Hono();
|
|
11266
12789
|
applyAdminGate(logsRouter, "Logs");
|
|
11267
12790
|
logsRouter.route("/", logsRoutes);
|
|
11268
|
-
config.app
|
|
11269
|
-
|
|
12791
|
+
mountWithLegacyAlias(config.app, logsRouter, {
|
|
12792
|
+
canonical: `${basePath}/admin/logs`,
|
|
12793
|
+
legacy: `${basePath}/logs`,
|
|
12794
|
+
surface: "Logs"
|
|
12795
|
+
});
|
|
11270
12796
|
}
|
|
11271
12797
|
if (surfaces.meta) {
|
|
11272
|
-
const { createContractRoutes } = await import("./contract-routes-
|
|
12798
|
+
const { createContractRoutes } = await import("./contract-routes-BEq7euZg.js").then((n) => n.t);
|
|
11273
12799
|
const contractRouter = new Hono();
|
|
11274
12800
|
if (adminSurfacesGated) {
|
|
11275
12801
|
if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
|
|
@@ -11298,10 +12824,15 @@ async function _initializeRebaseBackend(config) {
|
|
|
11298
12824
|
logger.debug("Rebase Backend Initialized");
|
|
11299
12825
|
const authSchemaCheck = authConfigResult?.schemaHealthCheck;
|
|
11300
12826
|
const healthCheck = createHealthCheck(defaultDriver, authSchemaCheck ? () => authSchemaCheck.call(authConfigResult) : void 0);
|
|
12827
|
+
if (config.rlsAudit?.enabled && ownership.rlsAudit) {
|
|
12828
|
+
rlsAudit = createRlsAudit(config.rlsAudit);
|
|
12829
|
+
rlsAudit.start();
|
|
12830
|
+
}
|
|
11301
12831
|
const shutdown = createShutdown({
|
|
11302
12832
|
server: config.server,
|
|
11303
12833
|
cronScheduler,
|
|
11304
12834
|
jobQueue,
|
|
12835
|
+
rlsAudit,
|
|
11305
12836
|
realtimeServices
|
|
11306
12837
|
});
|
|
11307
12838
|
/**
|
|
@@ -11385,6 +12916,276 @@ function defineFunction(definition) {
|
|
|
11385
12916
|
return returned instanceof Hono ? returned : app;
|
|
11386
12917
|
}
|
|
11387
12918
|
//#endregion
|
|
12919
|
+
//#region src/functions/context.ts
|
|
12920
|
+
function read(c, key) {
|
|
12921
|
+
return c.get(key);
|
|
12922
|
+
}
|
|
12923
|
+
/**
|
|
12924
|
+
* The authenticated caller, or `undefined` for an anonymous request.
|
|
12925
|
+
*
|
|
12926
|
+
* **`undefined` is not a permission decision.** The functions router mounts its
|
|
12927
|
+
* auth middleware with `requireAuth: false` on purpose — a webhook receiver has
|
|
12928
|
+
* no token to send — so an anonymous caller reaches the handler and reads
|
|
12929
|
+
* `undefined` here while the handler runs on regardless. Use {@link requireAuth}
|
|
12930
|
+
* (or a `!user` branch that returns 401) to make it a decision.
|
|
12931
|
+
*
|
|
12932
|
+
* A caller who presented a *bad* token never gets this far: both auth
|
|
12933
|
+
* middlewares reject an unverifiable token with 401 before the router is
|
|
12934
|
+
* reached, precisely so an expired session cannot be silently downgraded to an
|
|
12935
|
+
* anonymous one.
|
|
12936
|
+
*/
|
|
12937
|
+
function getUser(c) {
|
|
12938
|
+
const raw = read(c, "user");
|
|
12939
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
12940
|
+
const record = raw;
|
|
12941
|
+
const uid = typeof record.uid === "string" ? record.uid : typeof record.userId === "string" ? record.userId : void 0;
|
|
12942
|
+
if (uid === void 0) return void 0;
|
|
12943
|
+
const roles = Array.isArray(record.roles) ? record.roles.filter((role) => typeof role === "string") : [];
|
|
12944
|
+
return {
|
|
12945
|
+
...record,
|
|
12946
|
+
uid,
|
|
12947
|
+
roles
|
|
12948
|
+
};
|
|
12949
|
+
}
|
|
12950
|
+
/** The caller's id, or `undefined` when nobody is signed in. */
|
|
12951
|
+
function getUserId(c) {
|
|
12952
|
+
return getUser(c)?.uid;
|
|
12953
|
+
}
|
|
12954
|
+
/** The caller's roles. Empty for an anonymous request — never `undefined`. */
|
|
12955
|
+
function getRoles(c) {
|
|
12956
|
+
return getUser(c)?.roles ?? [];
|
|
12957
|
+
}
|
|
12958
|
+
/**
|
|
12959
|
+
* Whether the caller holds **any** of the named roles.
|
|
12960
|
+
*
|
|
12961
|
+
* Any rather than all, because that is what a route guard means by a list of
|
|
12962
|
+
* roles; require several by calling this more than once.
|
|
12963
|
+
*/
|
|
12964
|
+
function hasRole(c, ...roles) {
|
|
12965
|
+
if (roles.length === 0) return false;
|
|
12966
|
+
const held = new Set(getRoles(c));
|
|
12967
|
+
return roles.some((role) => held.has(role));
|
|
12968
|
+
}
|
|
12969
|
+
/**
|
|
12970
|
+
* Whether the caller holds an administrative role.
|
|
12971
|
+
*
|
|
12972
|
+
* Delegates to the single definition in `auth/admin-roles.ts` — which is
|
|
12973
|
+
* `admin` **or** `schema-admin` — rather than comparing against `"admin"`.
|
|
12974
|
+
* Those two lists disagreed once, and the gap made every public registrant an
|
|
12975
|
+
* administrator; see that file.
|
|
12976
|
+
*/
|
|
12977
|
+
function isAdmin(c) {
|
|
12978
|
+
return hasAdministrativeRole(getRoles(c));
|
|
12979
|
+
}
|
|
12980
|
+
/** Whether the request carries an identity at all. */
|
|
12981
|
+
function isAuthenticated(c) {
|
|
12982
|
+
return getUser(c) !== void 0;
|
|
12983
|
+
}
|
|
12984
|
+
/**
|
|
12985
|
+
* The request-scoped data driver: reads and writes run as **the caller**, with
|
|
12986
|
+
* your row-level security policies evaluated against their identity.
|
|
12987
|
+
*
|
|
12988
|
+
* This is the accessor to reach for when a function serves user-facing data.
|
|
12989
|
+
* `rebase.dataAsAdmin` is the other one, and it is not the same thing — it runs
|
|
12990
|
+
* as `{ uid: "service", roles: ["admin"] }` for every caller alike, which is
|
|
12991
|
+
* correct for trusted background work and wrong for a request.
|
|
12992
|
+
*
|
|
12993
|
+
* `undefined` only when no Rebase auth middleware ran (see
|
|
12994
|
+
* {@link identityResolved}); inside a function mounted by the framework it is
|
|
12995
|
+
* always present, anonymous requests included — they get an anon-scoped driver
|
|
12996
|
+
* so policies still have an identity to evaluate.
|
|
12997
|
+
*/
|
|
12998
|
+
function getDriver(c) {
|
|
12999
|
+
return read(c, "driver");
|
|
13000
|
+
}
|
|
13001
|
+
/**
|
|
13002
|
+
* {@link getDriver}, but throws instead of handing back `undefined`.
|
|
13003
|
+
*
|
|
13004
|
+
* For the common case where a handler cannot proceed without it and would
|
|
13005
|
+
* otherwise write `c.get("driver")!` — an assertion that turns a wiring problem
|
|
13006
|
+
* into `Cannot read properties of undefined (reading 'fetchCollection')` twenty
|
|
13007
|
+
* lines away from the cause.
|
|
13008
|
+
*/
|
|
13009
|
+
function requireDriver(c) {
|
|
13010
|
+
const driver = getDriver(c);
|
|
13011
|
+
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.");
|
|
13012
|
+
return driver;
|
|
13013
|
+
}
|
|
13014
|
+
/**
|
|
13015
|
+
* The API key this request authenticated with, masked, or `undefined` when it
|
|
13016
|
+
* did not use one.
|
|
13017
|
+
*
|
|
13018
|
+
* Useful for attribution and for per-key behaviour. The permission check itself
|
|
13019
|
+
* has already happened — reaching a handler means the key was allowed to.
|
|
13020
|
+
*/
|
|
13021
|
+
function getApiKey(c) {
|
|
13022
|
+
return read(c, "apiKey");
|
|
13023
|
+
}
|
|
13024
|
+
/**
|
|
13025
|
+
* The correlation id for this request — generated, or taken from an inbound
|
|
13026
|
+
* `X-Request-ID`.
|
|
13027
|
+
*
|
|
13028
|
+
* Log it. It is the only thing that ties a line written inside a function to
|
|
13029
|
+
* the framework's own lines for the same request.
|
|
13030
|
+
*/
|
|
13031
|
+
function getRequestId(c) {
|
|
13032
|
+
return read(c, "requestId");
|
|
13033
|
+
}
|
|
13034
|
+
/**
|
|
13035
|
+
* Whether a Rebase auth middleware has run on this request.
|
|
13036
|
+
*
|
|
13037
|
+
* Both middlewares populate `driver` for *every* outcome, anonymous included,
|
|
13038
|
+
* and populate `user` whenever there is one. So "neither is set" does not mean
|
|
13039
|
+
* "anonymous" — it means nothing resolved the identity, and treating that as
|
|
13040
|
+
* anonymous is the dangerous reading. The guards use this to tell a genuinely
|
|
13041
|
+
* anonymous caller (401) from a misconfigured mount (500), because answering
|
|
13042
|
+
* 401 to the second sends whoever is debugging it to look at the token.
|
|
13043
|
+
*/
|
|
13044
|
+
function identityResolved(c) {
|
|
13045
|
+
return read(c, "user") !== void 0 || read(c, "driver") !== void 0;
|
|
13046
|
+
}
|
|
13047
|
+
//#endregion
|
|
13048
|
+
//#region src/functions/guards.ts
|
|
13049
|
+
/**
|
|
13050
|
+
* The answer to "a guard ran, but no middleware had resolved anything".
|
|
13051
|
+
*
|
|
13052
|
+
* Deliberately a 500 and not a 401. A 401 tells the caller their credential is
|
|
13053
|
+
* the problem, and here the caller's credential was never looked at — sending
|
|
13054
|
+
* them to check their token is sending them to the one place the answer is not.
|
|
13055
|
+
*/
|
|
13056
|
+
function unresolvedIdentity() {
|
|
13057
|
+
return { error: {
|
|
13058
|
+
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.",
|
|
13059
|
+
code: "AUTH_MIDDLEWARE_MISSING"
|
|
13060
|
+
} };
|
|
13061
|
+
}
|
|
13062
|
+
/**
|
|
13063
|
+
* Reject callers holding none of the named roles with 403.
|
|
13064
|
+
*
|
|
13065
|
+
* Any of them, not all — require several by chaining the guard twice. Naming no
|
|
13066
|
+
* role at all is a programming error and throws at module load rather than at
|
|
13067
|
+
* request time, because `requireRole()` with an empty list would otherwise read
|
|
13068
|
+
* as a guard while admitting everyone.
|
|
13069
|
+
*
|
|
13070
|
+
* @example
|
|
13071
|
+
* ```ts
|
|
13072
|
+
* app.post("/publish", requireAuth, requireRole("editor", "admin"), handler);
|
|
13073
|
+
* ```
|
|
13074
|
+
*/
|
|
13075
|
+
function requireRole(...roles) {
|
|
13076
|
+
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.");
|
|
13077
|
+
const allowed = new Set(roles);
|
|
13078
|
+
return async (c, next) => {
|
|
13079
|
+
if (!getUser(c)) {
|
|
13080
|
+
if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);
|
|
13081
|
+
return c.json({ error: {
|
|
13082
|
+
message: "Authentication required",
|
|
13083
|
+
code: "UNAUTHORIZED"
|
|
13084
|
+
} }, 401);
|
|
13085
|
+
}
|
|
13086
|
+
if (!getRoles(c).some((role) => allowed.has(role))) return c.json({ error: {
|
|
13087
|
+
message: `This operation requires one of these roles: ${roles.join(", ")}`,
|
|
13088
|
+
code: "FORBIDDEN"
|
|
13089
|
+
} }, 403);
|
|
13090
|
+
return next();
|
|
13091
|
+
};
|
|
13092
|
+
}
|
|
13093
|
+
//#endregion
|
|
13094
|
+
//#region src/functions/runtime-env.ts
|
|
13095
|
+
/**
|
|
13096
|
+
* Every environment variable visible to this request.
|
|
13097
|
+
*
|
|
13098
|
+
* `c.env` on workerd, `Deno.env` on Deno, `process.env` on Node, Bun and
|
|
13099
|
+
* Vercel's edge runtime. Reading it through here rather than through `process`
|
|
13100
|
+
* is the whole of what makes a function's configuration portable.
|
|
13101
|
+
*/
|
|
13102
|
+
function getEnv(c) {
|
|
13103
|
+
return env(c);
|
|
13104
|
+
}
|
|
13105
|
+
/**
|
|
13106
|
+
* One environment variable, or `undefined`.
|
|
13107
|
+
*
|
|
13108
|
+
* Trimmed, and a blank value counts as absent — declaring a variable and
|
|
13109
|
+
* leaving it empty is the ordinary way to write a compose file or a `.env`
|
|
13110
|
+
* line, and nobody has ever meant `""` by it.
|
|
13111
|
+
*/
|
|
13112
|
+
function env$1(c, name) {
|
|
13113
|
+
const raw = getEnv(c)[name];
|
|
13114
|
+
if (typeof raw !== "string") return void 0;
|
|
13115
|
+
const trimmed = raw.trim();
|
|
13116
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
13117
|
+
}
|
|
13118
|
+
/**
|
|
13119
|
+
* One environment variable, or a thrown error naming it.
|
|
13120
|
+
*
|
|
13121
|
+
* For configuration a handler cannot run without. Throwing here — inside the
|
|
13122
|
+
* request — is deliberately better than the module-scope `process.env.KEY!`
|
|
13123
|
+
* it replaces: that one takes the whole *file* down at load time, and the
|
|
13124
|
+
* loader reports it as "this function could not be imported", which names the
|
|
13125
|
+
* file but not the variable. This fails one request, with the name in the
|
|
13126
|
+
* message, while every other route in the file keeps serving.
|
|
13127
|
+
*/
|
|
13128
|
+
function requireEnv(c, name) {
|
|
13129
|
+
const value = env$1(c, name);
|
|
13130
|
+
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.`);
|
|
13131
|
+
return value;
|
|
13132
|
+
}
|
|
13133
|
+
/**
|
|
13134
|
+
* Which host this is: `"node"`, `"workerd"`, `"deno"`, `"bun"`, `"edge-light"`,
|
|
13135
|
+
* `"fastly"` or `"other"`.
|
|
13136
|
+
*
|
|
13137
|
+
* Use it to *degrade*, not to branch a whole implementation — a function that
|
|
13138
|
+
* needs two implementations is two functions. It is honest about one thing in
|
|
13139
|
+
* particular: a Node-only capability, such as `rebase.sql()`, can check this
|
|
13140
|
+
* and say so, instead of failing at the call.
|
|
13141
|
+
*/
|
|
13142
|
+
function runtimeKey() {
|
|
13143
|
+
return getRuntimeKey();
|
|
13144
|
+
}
|
|
13145
|
+
/** Whether this is a Node-like host — Node itself, or Bun's Node compatibility. */
|
|
13146
|
+
function isNodeRuntime() {
|
|
13147
|
+
const key = getRuntimeKey();
|
|
13148
|
+
return key === "node" || key === "bun";
|
|
13149
|
+
}
|
|
13150
|
+
/**
|
|
13151
|
+
* Build something expensive once per environment, on first use, from inside a
|
|
13152
|
+
* request.
|
|
13153
|
+
*
|
|
13154
|
+
* This is the sanctioned replacement for the module-scope client:
|
|
13155
|
+
*
|
|
13156
|
+
* ```ts
|
|
13157
|
+
* // Don't: runs at import time, before bindings exist on some hosts.
|
|
13158
|
+
* const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
13159
|
+
*
|
|
13160
|
+
* // Do: runs on first request, reads that request's configuration.
|
|
13161
|
+
* const stripe = lazyResource(env => new Stripe(env.STRIPE_SECRET_KEY!));
|
|
13162
|
+
* app.post("/", async (c) => { await stripe(c).charges.list(); });
|
|
13163
|
+
* ```
|
|
13164
|
+
*
|
|
13165
|
+
* The factory runs once per distinct environment object and the result is
|
|
13166
|
+
* cached against it. On Node that is once per process, because `process.env` is
|
|
13167
|
+
* one object for the life of the process — identical to the module-scope
|
|
13168
|
+
* version in cost, and unlike it, deferred until configuration exists. On a
|
|
13169
|
+
* host that hands each request a fresh binding object it is once per object,
|
|
13170
|
+
* which is the only correct answer there: caching across bindings would serve
|
|
13171
|
+
* one tenant's client to another.
|
|
13172
|
+
*
|
|
13173
|
+
* A {@link WeakMap} holds the cache, so an environment object the host has
|
|
13174
|
+
* finished with takes the resource with it.
|
|
13175
|
+
*/
|
|
13176
|
+
function lazyResource(factory) {
|
|
13177
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
13178
|
+
return (c) => {
|
|
13179
|
+
const bindings = getEnv(c);
|
|
13180
|
+
if (!bindings || typeof bindings !== "object") return factory(bindings ?? {});
|
|
13181
|
+
const existing = cache.get(bindings);
|
|
13182
|
+
if (existing !== void 0) return existing;
|
|
13183
|
+
const created = factory(bindings);
|
|
13184
|
+
cache.set(bindings, created);
|
|
13185
|
+
return created;
|
|
13186
|
+
};
|
|
13187
|
+
}
|
|
13188
|
+
//#endregion
|
|
11388
13189
|
//#region src/cron/define-cron.ts
|
|
11389
13190
|
/**
|
|
11390
13191
|
* Typed authoring helper for a cron job file. Identity at runtime —
|
|
@@ -11469,6 +13270,130 @@ var authJwt = () => {
|
|
|
11469
13270
|
return sql.raw(RLS_JWT_SQL);
|
|
11470
13271
|
};
|
|
11471
13272
|
//#endregion
|
|
13273
|
+
//#region src/dev-secrets.ts
|
|
13274
|
+
/**
|
|
13275
|
+
* Development secrets that survive a restart.
|
|
13276
|
+
*
|
|
13277
|
+
* `loadEnv` generates `JWT_SECRET` and `REBASE_SERVICE_KEY` when they are unset
|
|
13278
|
+
* outside production, so a developer can start without setting anything up. It
|
|
13279
|
+
* generated them **freshly on every boot**, which meant every restart of the dev
|
|
13280
|
+
* server silently invalidated the previous run's tokens: you were logged out of
|
|
13281
|
+
* your own app because you edited a file, and the API key you had just made
|
|
13282
|
+
* stopped working.
|
|
13283
|
+
*
|
|
13284
|
+
* The CLI already worked around it for scaffolded projects by writing a stable
|
|
13285
|
+
* `JWT_SECRET` into `.env` at `rebase init` — the comment there says exactly
|
|
13286
|
+
* why. That leaves anyone who did not scaffold, and anyone running from a
|
|
13287
|
+
* container with no `.env`, with the logout-on-restart behaviour.
|
|
13288
|
+
*
|
|
13289
|
+
* So the generated values are cached in a file beside the other dev-state
|
|
13290
|
+
* files — `.rebase-dev-port` and `.rebase-dev-url` follow the same convention
|
|
13291
|
+
* and are gitignored in the same place.
|
|
13292
|
+
*
|
|
13293
|
+
* ## What this does not change
|
|
13294
|
+
*
|
|
13295
|
+
* Production. `loadEnv` refuses to start when a secret was auto-generated and
|
|
13296
|
+
* `NODE_ENV=production`, and that is untouched: this only makes the
|
|
13297
|
+
* already-generated development values stable. There is no path by which a
|
|
13298
|
+
* cached secret becomes a production one.
|
|
13299
|
+
*
|
|
13300
|
+
* ## Failing soft
|
|
13301
|
+
*
|
|
13302
|
+
* A read-only filesystem, a container without a writable working directory, a
|
|
13303
|
+
* permissions problem — none of these should stop a development server from
|
|
13304
|
+
* starting. Every failure here falls back to the previous behaviour, an
|
|
13305
|
+
* ephemeral secret, and says so once at debug level.
|
|
13306
|
+
*/
|
|
13307
|
+
/** Beside `.rebase-dev-port` and `.rebase-dev-url`, and gitignored with them. */
|
|
13308
|
+
var DEV_SECRETS_FILENAME = ".rebase-dev-secrets.json";
|
|
13309
|
+
/** Owner read/write only. These are secrets, even development ones. */
|
|
13310
|
+
var FILE_MODE = 384;
|
|
13311
|
+
/** Whether these environment variables describe a test runner. */
|
|
13312
|
+
function detectTestRunner(env) {
|
|
13313
|
+
return Boolean(env.JEST_WORKER_ID || env.VITEST || env.VITEST_WORKER_ID || env.NODE_ENV === "test");
|
|
13314
|
+
}
|
|
13315
|
+
/**
|
|
13316
|
+
* Whether a test runner started this process, decided **once at import**.
|
|
13317
|
+
*
|
|
13318
|
+
* Read at call time it would be wrong, and provably so: `env.test.ts` does
|
|
13319
|
+
* `process.env = {}` in its `beforeEach`, which erases `NODE_ENV` and
|
|
13320
|
+
* `JEST_WORKER_ID` alike. A predicate consulting the live environment therefore
|
|
13321
|
+
* sees a bare object and concludes "development", which is how the first version
|
|
13322
|
+
* of this dropped a `.rebase-dev-secrets.json` into `packages/server` on every
|
|
13323
|
+
* test run.
|
|
13324
|
+
*
|
|
13325
|
+
* Whether a test runner is hosting the process is a fact about the process, not
|
|
13326
|
+
* about whatever a test has since done to `process.env`, so it is captured
|
|
13327
|
+
* before any test can rewrite it.
|
|
13328
|
+
*/
|
|
13329
|
+
var TEST_RUNNER_DETECTED = detectTestRunner(process.env);
|
|
13330
|
+
/**
|
|
13331
|
+
* Whether generated secrets should be cached at all.
|
|
13332
|
+
*
|
|
13333
|
+
* Not in production — `loadEnv` refuses to boot there on a generated secret, so
|
|
13334
|
+
* there is nothing to cache and no path by which a cached one becomes a
|
|
13335
|
+
* production one.
|
|
13336
|
+
*
|
|
13337
|
+
* Not under a test runner: a test must not inherit a secret a previous run left
|
|
13338
|
+
* behind, and a library call has no business writing into a suite's working
|
|
13339
|
+
* directory. `inTestRunner` is a parameter only so this predicate can be tested
|
|
13340
|
+
* on its own; callers pass nothing.
|
|
13341
|
+
*/
|
|
13342
|
+
function shouldCacheDevSecrets(env = process.env, inTestRunner = TEST_RUNNER_DETECTED) {
|
|
13343
|
+
if (inTestRunner) return false;
|
|
13344
|
+
return env.NODE_ENV !== "production";
|
|
13345
|
+
}
|
|
13346
|
+
/** `REBASE_DEV_SECRETS_FILE`, or the conventional name in the working directory. */
|
|
13347
|
+
function resolveDevSecretsFile(env = process.env) {
|
|
13348
|
+
const override = env.REBASE_DEV_SECRETS_FILE?.trim();
|
|
13349
|
+
if (override) return path$3.resolve(override);
|
|
13350
|
+
return path$3.join(process.cwd(), DEV_SECRETS_FILENAME);
|
|
13351
|
+
}
|
|
13352
|
+
/**
|
|
13353
|
+
* Open the cache.
|
|
13354
|
+
*
|
|
13355
|
+
* Reading a malformed file is treated as an empty cache rather than an error:
|
|
13356
|
+
* the file is ours, it holds regenerable values, and refusing to boot over a
|
|
13357
|
+
* corrupted convenience cache would be worse than rewriting it.
|
|
13358
|
+
*/
|
|
13359
|
+
function openDevSecretStore(file = resolveDevSecretsFile()) {
|
|
13360
|
+
let cache = {};
|
|
13361
|
+
let usable = true;
|
|
13362
|
+
try {
|
|
13363
|
+
if (fs$3.existsSync(file)) {
|
|
13364
|
+
const parsed = JSON.parse(fs$3.readFileSync(file, "utf-8"));
|
|
13365
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
13366
|
+
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string" && value.length > 0) cache[key] = value;
|
|
13367
|
+
}
|
|
13368
|
+
}
|
|
13369
|
+
} catch {
|
|
13370
|
+
cache = {};
|
|
13371
|
+
}
|
|
13372
|
+
const flush = () => {
|
|
13373
|
+
try {
|
|
13374
|
+
fs$3.mkdirSync(path$3.dirname(file), { recursive: true });
|
|
13375
|
+
fs$3.writeFileSync(file, `${JSON.stringify(cache, null, 2)}\n`, { mode: FILE_MODE });
|
|
13376
|
+
fs$3.chmodSync(file, FILE_MODE);
|
|
13377
|
+
} catch (err) {
|
|
13378
|
+
if (usable) {
|
|
13379
|
+
usable = false;
|
|
13380
|
+
logger.debug(`[dev-secrets] Could not write ${file}, so generated development secrets will not survive a restart: ${err instanceof Error ? err.message : String(err)}`);
|
|
13381
|
+
}
|
|
13382
|
+
}
|
|
13383
|
+
};
|
|
13384
|
+
return {
|
|
13385
|
+
file,
|
|
13386
|
+
get usable() {
|
|
13387
|
+
return usable;
|
|
13388
|
+
},
|
|
13389
|
+
get: (name) => cache[name],
|
|
13390
|
+
set: (name, value) => {
|
|
13391
|
+
cache[name] = value;
|
|
13392
|
+
flush();
|
|
13393
|
+
}
|
|
13394
|
+
};
|
|
13395
|
+
}
|
|
13396
|
+
//#endregion
|
|
11472
13397
|
//#region src/env.ts
|
|
11473
13398
|
/**
|
|
11474
13399
|
* Generate a cryptographically secure random secret (hex-encoded).
|
|
@@ -11586,14 +13511,24 @@ function loadEnv(options) {
|
|
|
11586
13511
|
const isProduction = process.env.NODE_ENV === "production";
|
|
11587
13512
|
const autoGeneratedSecrets = [];
|
|
11588
13513
|
if (!isProduction) {
|
|
11589
|
-
|
|
11590
|
-
|
|
11591
|
-
|
|
11592
|
-
|
|
11593
|
-
|
|
11594
|
-
|
|
11595
|
-
|
|
11596
|
-
|
|
13514
|
+
const devSecrets = shouldCacheDevSecrets() ? openDevSecretStore() : void 0;
|
|
13515
|
+
const reused = [];
|
|
13516
|
+
const ensure = (name) => {
|
|
13517
|
+
if (process.env[name]) return;
|
|
13518
|
+
const cached = devSecrets?.get(name);
|
|
13519
|
+
if (cached) {
|
|
13520
|
+
process.env[name] = cached;
|
|
13521
|
+
reused.push(name);
|
|
13522
|
+
} else {
|
|
13523
|
+
const generated = generateSecret();
|
|
13524
|
+
process.env[name] = generated;
|
|
13525
|
+
devSecrets?.set(name, generated);
|
|
13526
|
+
}
|
|
13527
|
+
autoGeneratedSecrets.push(name);
|
|
13528
|
+
};
|
|
13529
|
+
ensure("JWT_SECRET");
|
|
13530
|
+
ensure("REBASE_SERVICE_KEY");
|
|
13531
|
+
if (autoGeneratedSecrets.length > 0 && reused.length !== autoGeneratedSecrets.length && devSecrets?.usable) logger.info(`Generated development ${autoGeneratedSecrets.length > 1 ? "secrets" : "secret"} (${autoGeneratedSecrets.join(", ")}) and cached them in ${DEV_SECRETS_FILENAME} so a restart does not log you out. Set them explicitly for anything that is not a development machine.`);
|
|
11597
13532
|
}
|
|
11598
13533
|
const env = (options?.extend ? rebaseEnvSchema.merge(options.extend) : rebaseEnvSchema).superRefine((data, ctx) => {
|
|
11599
13534
|
const d = data;
|
|
@@ -12338,7 +14273,7 @@ function serveSPA(app, config) {
|
|
|
12338
14273
|
const rawBase = config.basePath ?? "/";
|
|
12339
14274
|
const basePath = rawBase !== "/" ? rawBase.replace(/\/+$/, "") : "/";
|
|
12340
14275
|
const isRoot = basePath === "/";
|
|
12341
|
-
if (!fs$
|
|
14276
|
+
if (!fs$3.existsSync(frontendPath)) {
|
|
12342
14277
|
logger.warn(`⚠️ Frontend build path does not exist: ${frontendPath}`);
|
|
12343
14278
|
logger.warn(" SPA serving is disabled. Build your frontend first.");
|
|
12344
14279
|
return;
|
|
@@ -12352,7 +14287,7 @@ function serveSPA(app, config) {
|
|
|
12352
14287
|
});
|
|
12353
14288
|
app.use(scope, responseCompression());
|
|
12354
14289
|
app.use(scope, serveStatic({
|
|
12355
|
-
root: path$
|
|
14290
|
+
root: path$3.relative(process.cwd(), frontendPath),
|
|
12356
14291
|
precompressed: true,
|
|
12357
14292
|
...isRoot ? {} : { rewriteRequestPath: (p) => p.slice(basePath.length) || "/" }
|
|
12358
14293
|
}));
|
|
@@ -12365,7 +14300,7 @@ function serveSPA(app, config) {
|
|
|
12365
14300
|
app.get(scope, async (c, next) => {
|
|
12366
14301
|
if (allExcludePaths.some((p) => isUnderPath(c.req.path, p))) return next();
|
|
12367
14302
|
if (isAssetRequest(c.req.path, c.req.header("sec-fetch-dest"))) return c.text("Not found", 404);
|
|
12368
|
-
const indexPath = path$
|
|
14303
|
+
const indexPath = path$3.join(frontendPath, indexFile);
|
|
12369
14304
|
if (!cachedHtml) try {
|
|
12370
14305
|
cachedHtml = await fsp.readFile(indexPath, "utf-8");
|
|
12371
14306
|
} catch {
|
|
@@ -12774,6 +14709,18 @@ var bootEnvExtension = object({
|
|
|
12774
14709
|
"false",
|
|
12775
14710
|
""
|
|
12776
14711
|
]).default("false").transform((v) => v === "true"),
|
|
14712
|
+
/**
|
|
14713
|
+
* Bot protection. Enabled by setting both — a provider without a secret
|
|
14714
|
+
* cannot verify anything, and is refused at boot rather than ignored.
|
|
14715
|
+
*/
|
|
14716
|
+
CAPTCHA_PROVIDER: _enum([
|
|
14717
|
+
"turnstile",
|
|
14718
|
+
"hcaptcha",
|
|
14719
|
+
""
|
|
14720
|
+
]).optional(),
|
|
14721
|
+
CAPTCHA_SECRET: string().optional(),
|
|
14722
|
+
/** Comma-separated: register, login, forgotPassword, magicLink. */
|
|
14723
|
+
CAPTCHA_ROUTES: string().optional(),
|
|
12777
14724
|
AUTH_COOKIE_SAME_SITE: _enum([
|
|
12778
14725
|
"Strict",
|
|
12779
14726
|
"Lax",
|
|
@@ -12874,6 +14821,19 @@ var bootEnvExtension = object({
|
|
|
12874
14821
|
""
|
|
12875
14822
|
]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
|
|
12876
14823
|
/**
|
|
14824
|
+
* Override whether this process runs the scheduled RLS audit. Unset follows
|
|
14825
|
+
* the role.
|
|
14826
|
+
*
|
|
14827
|
+
* Unlike cron and the job workers this is not claim-protected — every owner
|
|
14828
|
+
* scans on its own timer, which is redundant rather than unsafe — so a split
|
|
14829
|
+
* deployment sets it false everywhere but one process.
|
|
14830
|
+
*/
|
|
14831
|
+
REBASE_RLS_AUDIT: _enum([
|
|
14832
|
+
"true",
|
|
14833
|
+
"false",
|
|
14834
|
+
""
|
|
14835
|
+
]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
|
|
14836
|
+
/**
|
|
12877
14837
|
* Comma-separated function names this process serves. Unset means all.
|
|
12878
14838
|
*
|
|
12879
14839
|
* `functions` role only. A name that is not in the bundle is a boot failure,
|
|
@@ -13023,7 +14983,8 @@ var ROLES = {
|
|
|
13023
14983
|
},
|
|
13024
14984
|
ownership: {
|
|
13025
14985
|
cronScheduler: false,
|
|
13026
|
-
jobWorkers: false
|
|
14986
|
+
jobWorkers: false,
|
|
14987
|
+
rlsAudit: false
|
|
13027
14988
|
},
|
|
13028
14989
|
provisionSchema: false
|
|
13029
14990
|
},
|
|
@@ -13067,6 +15028,7 @@ function resolveRole(env) {
|
|
|
13067
15028
|
const ownership = { ...base.ownership };
|
|
13068
15029
|
if (env.REBASE_CRON_SCHEDULER !== void 0) ownership.cronScheduler = env.REBASE_CRON_SCHEDULER;
|
|
13069
15030
|
if (env.REBASE_JOB_WORKERS !== void 0) ownership.jobWorkers = env.REBASE_JOB_WORKERS;
|
|
15031
|
+
if (env.REBASE_RLS_AUDIT !== void 0) ownership.rlsAudit = env.REBASE_RLS_AUDIT;
|
|
13070
15032
|
return {
|
|
13071
15033
|
role,
|
|
13072
15034
|
surfaces: { ...base.surfaces },
|
|
@@ -13504,13 +15466,51 @@ async function initializeDataSources(sources, schema, resolveFrom = []) {
|
|
|
13504
15466
|
//#endregion
|
|
13505
15467
|
//#region src/boot/options.ts
|
|
13506
15468
|
/**
|
|
13507
|
-
*
|
|
15469
|
+
* True when an emailed link would be followable — i.e. when there is an
|
|
15470
|
+
* absolute base to build one from. Mirrors `assertEmailLinkBases`, which
|
|
15471
|
+
* refuses the boot otherwise.
|
|
15472
|
+
*/
|
|
15473
|
+
function hasAbsoluteLinkBase(value) {
|
|
15474
|
+
if (!value) return false;
|
|
15475
|
+
try {
|
|
15476
|
+
const url = new URL(value);
|
|
15477
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
15478
|
+
} catch {
|
|
15479
|
+
return false;
|
|
15480
|
+
}
|
|
15481
|
+
}
|
|
15482
|
+
/**
|
|
15483
|
+
* Build the email configuration.
|
|
13508
15484
|
*
|
|
13509
|
-
*
|
|
13510
|
-
*
|
|
15485
|
+
* With `SMTP_HOST`, that is a real SMTP transport. Without it, outside
|
|
15486
|
+
* production, it is the development sink: mail is captured and its links are
|
|
15487
|
+
* printed, so `POST /auth/magic-link` and `POST /auth/forgot-password` complete
|
|
15488
|
+
* on a fresh project instead of answering `503 EMAIL_NOT_CONFIGURED`. The token
|
|
15489
|
+
* was always minted and valid; only delivery was missing.
|
|
15490
|
+
*
|
|
15491
|
+
* Three conditions, all required, none configurable:
|
|
15492
|
+
*
|
|
15493
|
+
* - no `SMTP_HOST` — a configured mail server always wins;
|
|
15494
|
+
* - `NODE_ENV` is not `production` — a captured reset mail carries a working
|
|
15495
|
+
* token, so this must not be reachable there (see `dev-sink.ts`);
|
|
15496
|
+
* - an absolute `FRONTEND_URL`, or the emailed link has no base and is dead on
|
|
15497
|
+
* arrival — which is the condition `assertEmailLinkBases` fails the boot on.
|
|
15498
|
+
*
|
|
15499
|
+
* When any of those does not hold this returns `undefined`, exactly as before,
|
|
15500
|
+
* and the auth routes report the missing service for themselves.
|
|
13511
15501
|
*/
|
|
13512
15502
|
function resolveEmailOptions(env) {
|
|
13513
|
-
if (!env.SMTP_HOST)
|
|
15503
|
+
if (!env.SMTP_HOST) {
|
|
15504
|
+
if (env.NODE_ENV === "production") return void 0;
|
|
15505
|
+
if (!hasAbsoluteLinkBase(env.FRONTEND_URL)) return void 0;
|
|
15506
|
+
return {
|
|
15507
|
+
from: env.SMTP_FROM || `${env.APP_NAME} <noreply@rebase.pro>`,
|
|
15508
|
+
sendEmail: createDevEmailSink().sendEmail,
|
|
15509
|
+
appName: env.APP_NAME,
|
|
15510
|
+
logoUrl: env.EMAIL_LOGO_URL,
|
|
15511
|
+
resetPasswordUrl: env.FRONTEND_URL
|
|
15512
|
+
};
|
|
15513
|
+
}
|
|
13514
15514
|
return {
|
|
13515
15515
|
from: env.SMTP_FROM || `${env.APP_NAME} <noreply@rebase.pro>`,
|
|
13516
15516
|
smtp: {
|
|
@@ -13529,6 +15529,24 @@ function resolveEmailOptions(env) {
|
|
|
13529
15529
|
};
|
|
13530
15530
|
}
|
|
13531
15531
|
/**
|
|
15532
|
+
* Bot protection from the environment, or `undefined` when it is not configured.
|
|
15533
|
+
*
|
|
15534
|
+
* Both halves are required together. A provider with no secret cannot verify
|
|
15535
|
+
* anything, so it is left off here and `resolveCaptchaVerifier` refuses the boot
|
|
15536
|
+
* if something else turns it on — the one failure this feature must not have is
|
|
15537
|
+
* being silently absent while the config says otherwise.
|
|
15538
|
+
*/
|
|
15539
|
+
function resolveCaptchaOptions(env) {
|
|
15540
|
+
if (!env.CAPTCHA_PROVIDER || !env.CAPTCHA_SECRET) return void 0;
|
|
15541
|
+
const routes = env.CAPTCHA_ROUTES?.split(",").map((part) => part.trim()).filter(Boolean);
|
|
15542
|
+
return {
|
|
15543
|
+
enabled: true,
|
|
15544
|
+
provider: env.CAPTCHA_PROVIDER,
|
|
15545
|
+
secret: env.CAPTCHA_SECRET,
|
|
15546
|
+
...routes?.length ? { routes } : {}
|
|
15547
|
+
};
|
|
15548
|
+
}
|
|
15549
|
+
/**
|
|
13532
15550
|
* Build the auth configuration from the environment and the bundle's users
|
|
13533
15551
|
* collection.
|
|
13534
15552
|
*
|
|
@@ -13552,6 +15570,7 @@ function resolveAuthOptions(env, usersCollection) {
|
|
|
13552
15570
|
disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
|
|
13553
15571
|
allowAnonymous: env.ALLOW_ANONYMOUS,
|
|
13554
15572
|
allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
|
|
15573
|
+
captcha: resolveCaptchaOptions(env),
|
|
13555
15574
|
email: resolveEmailOptions(env),
|
|
13556
15575
|
cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
|
|
13557
15576
|
};
|
|
@@ -14322,6 +16341,6 @@ function warnOnUnusableBundleShape(bundle) {
|
|
|
14322
16341
|
logger.warn(`This bundle declares a config package at "${bundle.manifest.entry.config}", but no collections directory resolved inside it, so no collections were loaded and no tables will be created. Rebuild with \`rebase build\` and check the manifest's \`entry.collections\`.`);
|
|
14323
16342
|
}
|
|
14324
16343
|
//#endregion
|
|
14325
|
-
export { ApiError, BlockedUrlError, BundleError, CONCURRENT_DDL_SQLSTATES, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RawHtml, S3StorageController, SMTPEmailService, TransformCache, TusHandler, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertWriteRequestValid, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDdlBootstrapper, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createJobQueue, createJobStore, createJwksRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defaultBackoff, defineCron, defineFunction, envSuffixForKey, errorHandler, escapeHtml, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getJwks, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hasAsymmetricSigningKey, hashPassword, html, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isConcurrentDdlRace, isDatabaseAdapter, isDuplicateObjectRace, isLocalhostOrigin, isOperationAllowed, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailBranding, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword };
|
|
16344
|
+
export { ApiError, BlockedUrlError, BundleError, CONCURRENT_DDL_SQLSTATES, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RawHtml, S3StorageController, SMTPEmailService, StoragePolicyError, TransformCache, TusHandler, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertWriteRequestValid, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, compileStoragePolicies, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDdlBootstrapper, createDevEmailSink, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createJobQueue, createJobStore, createJwksRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createRlsAudit, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defaultBackoff, defineCron, defineFunction, drainBackgroundWork, envSuffixForKey, errorHandler, escapeHtml, extractLinks, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getApiKey, getDriver, getEmailVerificationTemplate, getEnv, getJwks, getMagicLinkTemplate, getPasswordResetTemplate, getRequestId, getRoles, getUser, getUserId, getUserInvitationTemplate, getWelcomeEmailTemplate, hasAsymmetricSigningKey, hasRole, hashPassword, html, httpMethodToOperation, identityResolved, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isAdmin, isApiKeyToken, isAuthAdapter, isAuthenticated, isConcurrentDdlRace, isDatabaseAdapter, isDuplicateObjectRace, isLocalhostOrigin, isNodeRuntime, isOperationAllowed, isTransformableImage, lazyResource, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadFunctionsWithDiagnostics, loadUsersCollection, logger, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, pendingBackgroundWork, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, requireDriver, requireEnv, requireRole, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailBranding, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageAccessControl, resolveStorageBackend, resolveStorageSources, runFromBundle, runtimeKey, safeCompare, serveSPA, summarize as summarizeRlsScan, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword, waitUntil };
|
|
14326
16345
|
|
|
14327
16346
|
//# sourceMappingURL=index.es.js.map
|