@rebasepro/server 0.16.0 → 0.16.1-canary.g041c925
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{GCSStorageController-BEmDYFKc.js → GCSStorageController-Bl7nmhdv.js} +7 -6
- package/dist/{GCSStorageController-BEmDYFKc.js.map → GCSStorageController-Bl7nmhdv.js.map} +1 -1
- package/dist/{S3StorageController-B2EDXXMH.js → S3StorageController-CWvvrRpF.js} +7 -6
- package/dist/{S3StorageController-B2EDXXMH.js.map → S3StorageController-CWvvrRpF.js.map} +1 -1
- package/dist/{admin_block-DLILvzle.js → admin_block-BeypnEfb.js} +6 -6
- package/dist/admin_block-BeypnEfb.js.map +1 -0
- package/dist/api/ast-schema-editor.d.ts +23 -0
- 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 +122 -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 +12 -4
- package/dist/{schema-editor-routes-CV9k0w3G.js → ast-schema-editor-CDGgrIn7.js} +37 -45
- package/dist/ast-schema-editor-CDGgrIn7.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 -6
- 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 +4 -4
- package/dist/auth/bitbucket-oauth.d.ts +2 -2
- package/dist/auth/builtin-auth-adapter.d.ts +8 -4
- package/dist/auth/capabilities.d.ts +51 -0
- 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 +58 -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 +6 -7
- package/dist/auth/otp-routes.d.ts +89 -0
- 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 +18 -10
- 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-DzarZhLS.js} +603 -4376
- package/dist/auth-DzarZhLS.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-CRdZkA6c.js} +7 -6
- package/dist/{backup-C6ljYVTp.js.map → backup-CRdZkA6c.js.map} +1 -1
- package/dist/boot/bindings.d.ts +81 -0
- package/dist/boot/boot.d.ts +5 -5
- package/dist/boot/bundle.d.ts +10 -1
- package/dist/boot/driver.d.ts +1 -1
- package/dist/boot/env.d.ts +28 -1
- package/dist/boot/fetch-bundle.d.ts +144 -1
- package/dist/boot/local-provisioner.d.ts +30 -0
- package/dist/boot/options.d.ts +30 -6
- package/dist/boot/resource-adapters.d.ts +31 -0
- package/dist/boot/resource-loading.d.ts +10 -0
- package/dist/boot/role.d.ts +3 -2
- package/dist/boot/sources.d.ts +1 -1
- package/dist/boot/version-skew.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-DDSRyGSl.js} +8 -7
- package/dist/contract-routes-DDSRyGSl.js.map +1 -0
- package/dist/cron/cron-routes.d.ts +2 -2
- package/dist/cron/cron-scheduler.d.ts +3 -3
- package/dist/cron/index.d.ts +8 -8
- package/dist/{cron-loader-YhhQeVBM.js → cron-loader-d9WMFENB.js} +8 -7
- package/dist/{cron-loader-YhhQeVBM.js.map → cron-loader-d9WMFENB.js.map} +1 -1
- package/dist/{cron-routes-maM_RlUu.js → cron-routes-B_GKclr5.js} +9 -8
- package/dist/cron-routes-B_GKclr5.js.map +1 -0
- package/dist/{cron-scheduler-DIpYBmZP.js → cron-scheduler-BB82dWuU.js} +9 -9
- package/dist/cron-scheduler-BB82dWuU.js.map +1 -0
- package/dist/{cron-store-DfH_4Cd9.js → cron-store-BTZqwkM1.js} +10 -9
- package/dist/{cron-store-DfH_4Cd9.js.map → cron-store-BTZqwkM1.js.map} +1 -1
- package/dist/{ddl-bootstrap-Cywoj8Ta.js → ddl-bootstrap-euwN8FOy.js} +21 -6
- package/dist/ddl-bootstrap-euwN8FOy.js.map +1 -0
- package/dist/deploy/pod-contract.d.ts +139 -0
- package/dist/dev-secrets.d.ts +52 -0
- package/dist/{dynamic-import-Dvh-K5fl.js → dynamic-import-X40pTZUQ.js} +5 -4
- package/dist/{dynamic-import-Dvh-K5fl.js.map → dynamic-import-X40pTZUQ.js.map} +1 -1
- package/dist/email/dev-sink.d.ts +88 -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 +2 -2
- package/dist/email/templates.d.ts +12 -0
- package/dist/email/types.d.ts +18 -6
- package/dist/env.d.ts +1 -1
- package/dist/{errors-EBYiaJ2E.js → errors-DBwpj9N8.js} +8 -7
- package/dist/errors-DBwpj9N8.js.map +1 -0
- package/dist/{function-loader-DDS1v7YX.js → function-loader-LLdmBFoL.js} +9 -8
- package/dist/{function-loader-DDS1v7YX.js.map → function-loader-LLdmBFoL.js.map} +1 -1
- package/dist/{function-routes-Btcez1T-.js → function-routes-ClT6UQpD.js} +8 -7
- package/dist/function-routes-ClT6UQpD.js.map +1 -0
- package/dist/functions/context.d.ts +141 -0
- package/dist/functions/define-function.d.ts +1 -1
- package/dist/functions/function-routes.d.ts +9 -3
- 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 +65 -54
- package/dist/index.es.js +3498 -305
- 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 +171 -22
- package/dist/jobs/index.d.ts +5 -5
- package/dist/{jobs-CyOKXXlu.js → jobs-CeZzdeR8.js} +10 -9
- package/dist/{jobs-CyOKXXlu.js.map → jobs-CeZzdeR8.js.map} +1 -1
- package/dist/{jwt-DxH9fLPt.js → jwt-CpMqxVct.js} +9 -8
- package/dist/{jwt-DxH9fLPt.js.map → jwt-CpMqxVct.js.map} +1 -1
- package/dist/{logger-DfvF_8r-.js → logger-DS03e908.js} +104 -10
- package/dist/logger-DS03e908.js.map +1 -0
- package/dist/{logs-routes-CWBLQj2l.js → logs-routes-DB72iQSr.js} +36 -9
- package/dist/logs-routes-DB72iQSr.js.map +1 -0
- package/dist/metrics/index.d.ts +1 -1
- package/dist/{openapi-generator-BCKJRUS4.js → openapi-generator-wFJo22gg.js} +7 -11
- package/dist/{openapi-generator-BCKJRUS4.js.map → openapi-generator-wFJo22gg.js.map} +1 -1
- package/dist/{proxy-Bj5DVllb.js → proxy-CMymhnwG.js} +9 -6
- package/dist/{proxy-Bj5DVllb.js.map → proxy-CMymhnwG.js.map} +1 -1
- package/dist/query-parser-C3hHu17g.js +355 -0
- package/dist/query-parser-C3hHu17g.js.map +1 -0
- package/dist/{request-timeout-BuFoEKwT.js → request-timeout-C8gkc-j7.js} +21 -6
- package/dist/request-timeout-C8gkc-j7.js.map +1 -0
- package/dist/rls-audit/index.d.ts +111 -0
- package/dist/{rolldown-runtime-DSJWtz9O.js → rolldown-runtime-dW7B1o5h.js} +4 -3
- 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 +43 -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-CRKMZnXu.js +87 -0
- package/dist/schema-editor-routes-CRKMZnXu.js.map +1 -0
- package/dist/schemas-DBxgjM9A.js +4153 -0
- package/dist/schemas-DBxgjM9A.js.map +1 -0
- package/dist/{selection-_z6TM1DB.js → selection-CRpqKUbt.js} +6 -5
- package/dist/{selection-_z6TM1DB.js.map → selection-CRpqKUbt.js.map} +1 -1
- package/dist/services/webhook-service.d.ts +1 -1
- package/dist/singleton.d.ts +7 -0
- package/dist/{src-CrCxd8km.js → src-6VqGNeDB.js} +122 -5
- package/dist/src-6VqGNeDB.js.map +1 -0
- package/dist/{src-BPfYOeN4.js → src-C5vFEj-t.js} +8 -6
- package/dist/src-C5vFEj-t.js.map +1 -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/path-pattern.d.ts +58 -0
- package/dist/storage/policies.d.ts +88 -0
- package/dist/storage/range.d.ts +63 -0
- package/dist/storage/rendition-cache.d.ts +45 -0
- package/dist/storage/routes.d.ts +17 -5
- package/dist/storage/storage-registry.d.ts +1 -1
- package/dist/storage/triggers.d.ts +66 -0
- package/dist/storage/tus-handler.d.ts +34 -3
- package/dist/topics/runtime.d.ts +68 -0
- package/dist/{types-DSnOC4mF.js → types-BfKcm9do.js} +5 -4
- package/dist/{types-DSnOC4mF.js.map → types-BfKcm9do.js.map} +1 -1
- 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/admin_block-DLILvzle.js.map +0 -1
- package/dist/auth-5Et5mnUA.js.map +0 -1
- package/dist/contract-routes-DZ-LBpSL.js.map +0 -1
- package/dist/cron-routes-maM_RlUu.js.map +0 -1
- package/dist/cron-scheduler-DIpYBmZP.js.map +0 -1
- package/dist/ddl-bootstrap-Cywoj8Ta.js.map +0 -1
- package/dist/errors-EBYiaJ2E.js.map +0 -1
- package/dist/function-routes-Btcez1T-.js.map +0 -1
- package/dist/logger-DfvF_8r-.js.map +0 -1
- package/dist/logs-routes-CWBLQj2l.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/src-BPfYOeN4.js.map +0 -1
- package/dist/src-CrCxd8km.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -1,46 +1,56 @@
|
|
|
1
|
-
import { createRequire as
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import { c as
|
|
6
|
-
import { n as
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { n as
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { t as
|
|
14
|
-
import { n as
|
|
15
|
-
import { t as
|
|
16
|
-
import { t as
|
|
17
|
-
import { n as
|
|
18
|
-
import { t as
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import { t as
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import
|
|
1
|
+
import { createRequire as __rebaseCreateRequire } from "module";
|
|
2
|
+
import __rebaseProcess from "process";
|
|
3
|
+
globalThis.process ??= __rebaseProcess;
|
|
4
|
+
__rebaseCreateRequire(import.meta.url);
|
|
5
|
+
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-C5vFEj-t.js";
|
|
6
|
+
import { a as RLS_JWT_SQL, b as toCanonicalOp, c as getCollectionDataPath, f as DEFAULT_DATA_SOURCE_KEY, i as buildResourceGraph, n as setTopicRuntime, o as RLS_ROLES_SQL, s as RLS_UID_SQL, t as declaredSubscriptions, u as isPostgresCollectionConfig, v as parseRelationAggregateSort } from "./src-6VqGNeDB.js";
|
|
7
|
+
import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-BeypnEfb.js";
|
|
8
|
+
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-euwN8FOy.js";
|
|
9
|
+
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-CpMqxVct.js";
|
|
10
|
+
import { n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-DDSRyGSl.js";
|
|
11
|
+
import { $ as html, A as activeDevEmailSink, B as hashPassword, C as providerVerifiedEmail, Ct as PUBLIC_STORAGE_PREFIX, D as createDataRateLimiter, E as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, F as SMTPEmailService, G as getEmailVerificationTemplate, H as verifyPassword, I as createEmailService, J as getUserInvitationTemplate, K as getMagicLinkTemplate, L as assertEmailLinkBases, M as createDevEmailSink, N as extractLinks, O as defaultAuthLimiter, P as registerDevEmailSink, Q as escapeHtml, R as resolveEmailLinkBase, S as pkceTokenParams, St as isOperationAllowed, T as createBuiltinAuthAdapter, U as generateSecurePassword, V as validatePasswordStrength, W as getEmailOtpTemplate, 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 clearActiveDevEmailSink, k as MemoryRateLimitStore, 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 isPublicStoragePath, x as oauthCodeFlowSchema, xt as httpMethodToOperation, y as createLinkedinProvider, yt as scopeDataDriver, z as resolveAuthHooks } from "./auth-DzarZhLS.js";
|
|
12
|
+
import { r as hostEnv, t as logger } from "./logger-DS03e908.js";
|
|
13
|
+
import { n as errorHandler, t as ApiError } from "./errors-DBwpj9N8.js";
|
|
14
|
+
import { a as resolveListLimitParam, i as parseQueryOptions, n as parseAggregateSelect, r as parseGroupBy, t as orderByEntriesToTuples } from "./query-parser-C3hHu17g.js";
|
|
15
|
+
import { c as _coercedNumber, i as object, n as _enum, o as string, t as ZodNumber } from "./schemas-DBxgjM9A.js";
|
|
16
|
+
import { t as logMiddleware } from "./logs-routes-DB72iQSr.js";
|
|
17
|
+
import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-BfKcm9do.js";
|
|
18
|
+
import { t as S3StorageController } from "./S3StorageController-CWvvrRpF.js";
|
|
19
|
+
import { t as GCSStorageController } from "./GCSStorageController-Bl7nmhdv.js";
|
|
20
|
+
import { n as loadFunctionsFromDirectory, r as loadFunctionsWithDiagnostics } from "./function-loader-LLdmBFoL.js";
|
|
21
|
+
import { t as createFunctionRoutes } from "./function-routes-ClT6UQpD.js";
|
|
22
|
+
import "./proxy-CMymhnwG.js";
|
|
23
|
+
import "./request-timeout-C8gkc-j7.js";
|
|
24
|
+
import { t as FunctionSelectionError } from "./selection-CRpqKUbt.js";
|
|
25
|
+
import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-d9WMFENB.js";
|
|
26
|
+
import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-BB82dWuU.js";
|
|
27
|
+
import { t as createCronRoutes } from "./cron-routes-B_GKclr5.js";
|
|
28
|
+
import { t as createCronStore } from "./cron-store-BTZqwkM1.js";
|
|
29
|
+
import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-CRdZkA6c.js";
|
|
30
|
+
import { i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-CeZzdeR8.js";
|
|
31
|
+
import { createHash, createSign, randomBytes, randomUUID } from "node:crypto";
|
|
32
|
+
import * as fs$3 from "fs";
|
|
28
33
|
import fs, { existsSync } from "fs";
|
|
29
|
-
import * as path$
|
|
34
|
+
import * as path$3 from "path";
|
|
30
35
|
import path, { join } from "path";
|
|
31
36
|
import { pathToFileURL } from "url";
|
|
32
37
|
import { Hono } from "hono";
|
|
33
38
|
import { promisify } from "util";
|
|
34
39
|
import * as crypto$1 from "crypto";
|
|
35
40
|
import { createHmac, randomUUID as randomUUID$1 } from "crypto";
|
|
41
|
+
import * as path$2 from "node:path";
|
|
36
42
|
import path$1 from "node:path";
|
|
37
43
|
import { bodyLimit } from "hono/body-limit";
|
|
38
44
|
import { csrf } from "hono/csrf";
|
|
39
45
|
import { compress } from "hono/compress";
|
|
40
|
-
import fs$1 from "node:fs";
|
|
46
|
+
import fs$1, { createWriteStream } from "node:fs";
|
|
47
|
+
import * as fs$2 from "node:fs/promises";
|
|
41
48
|
import fsp from "node:fs/promises";
|
|
42
49
|
import os from "node:os";
|
|
43
50
|
import { mkdir, open, unlink, writeFile } from "fs/promises";
|
|
51
|
+
import { execFile } from "node:child_process";
|
|
52
|
+
import { promisify as promisify$1 } from "node:util";
|
|
53
|
+
import { env, getRuntimeKey } from "hono/adapter";
|
|
44
54
|
import { sql } from "drizzle-orm";
|
|
45
55
|
import { lookup } from "node:dns/promises";
|
|
46
56
|
import { isIP } from "node:net";
|
|
@@ -49,8 +59,16 @@ import { createServer } from "http";
|
|
|
49
59
|
import { cors } from "hono/cors";
|
|
50
60
|
import { secureHeaders } from "hono/secure-headers";
|
|
51
61
|
import { getRequestListener } from "@hono/node-server";
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
62
|
+
import { Readable } from "node:stream";
|
|
63
|
+
import { pipeline } from "node:stream/promises";
|
|
64
|
+
//#region ../types/src/types/schema_editing.ts
|
|
65
|
+
var DEFAULT_COMMIT_PATHS = {
|
|
66
|
+
schemaFile: "backend/src/schema.generated.ts",
|
|
67
|
+
ddlFile: "drizzle/schema.sql",
|
|
68
|
+
policiesFile: "drizzle/policies.sql",
|
|
69
|
+
searchFile: "drizzle/search.sql"
|
|
70
|
+
};
|
|
71
|
+
//#endregion
|
|
54
72
|
//#region src/collections/BackendCollectionRegistry.ts
|
|
55
73
|
/**
|
|
56
74
|
* Backend-agnostic collection registry.
|
|
@@ -136,7 +154,28 @@ var BASE_PROPERTY_KEYS = [
|
|
|
136
154
|
"metadata",
|
|
137
155
|
"admin"
|
|
138
156
|
];
|
|
139
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* Keys valid at the TOP LEVEL of a property, by type.
|
|
159
|
+
*
|
|
160
|
+
* Presentation is not among them. It moved into the property's `admin` block in
|
|
161
|
+
* 0.11, and `PROPERTY_MIGRATIONS` carries a hint for every key in
|
|
162
|
+
* `ADMIN_PROPERTY_KEYS` telling an author to move it — generated from that list,
|
|
163
|
+
* so it cannot fall behind.
|
|
164
|
+
*
|
|
165
|
+
* Seven of those keys were nevertheless listed here, on the four types they used
|
|
166
|
+
* to live on: `fixedFilter`, `includeId`, `includeEntityLink` (reference and
|
|
167
|
+
* relation), `widget` (relation), `sortable`, `canAddElements` (array) and
|
|
168
|
+
* `previewProperties` (map). This allowlist is consulted first, so on exactly
|
|
169
|
+
* those types the key was accepted, the migration hint was never reached, and
|
|
170
|
+
* nothing read the value — a config written against the pre-0.11 shape booted
|
|
171
|
+
* clean with the option silently dropped, while the identical key on any other
|
|
172
|
+
* type failed with a helpful message. The inconsistency is what made it hard to
|
|
173
|
+
* see: it looked like the feature worked on the types it was documented for.
|
|
174
|
+
*
|
|
175
|
+
* The rule this file now keeps: nothing in `ADMIN_PROPERTY_KEYS` belongs in any
|
|
176
|
+
* list here. `admin-keys-are-not-top-level.test.ts` asserts it for every key and
|
|
177
|
+
* every type, so a key added to the admin block later cannot be added here too.
|
|
178
|
+
*/
|
|
140
179
|
var PROPERTY_KEYS_BY_TYPE = {
|
|
141
180
|
string: [
|
|
142
181
|
"columnType",
|
|
@@ -161,35 +200,22 @@ var PROPERTY_KEYS_BY_TYPE = {
|
|
|
161
200
|
],
|
|
162
201
|
geopoint: [],
|
|
163
202
|
binary: [],
|
|
164
|
-
vector: ["dimensions"],
|
|
165
|
-
reference: [
|
|
166
|
-
"isId",
|
|
167
|
-
"path",
|
|
168
|
-
"fixedFilter",
|
|
169
|
-
"includeId",
|
|
170
|
-
"includeEntityLink"
|
|
171
|
-
],
|
|
203
|
+
vector: ["dimensions", "index"],
|
|
204
|
+
reference: ["isId", "path"],
|
|
172
205
|
relation: [
|
|
173
206
|
"isId",
|
|
174
207
|
"relation",
|
|
175
|
-
"resolvedRelation"
|
|
176
|
-
"fixedFilter",
|
|
177
|
-
"includeId",
|
|
178
|
-
"includeEntityLink",
|
|
179
|
-
"widget"
|
|
208
|
+
"resolvedRelation"
|
|
180
209
|
],
|
|
181
210
|
array: [
|
|
182
211
|
"columnType",
|
|
183
212
|
"of",
|
|
184
|
-
"oneOf"
|
|
185
|
-
"sortable",
|
|
186
|
-
"canAddElements"
|
|
213
|
+
"oneOf"
|
|
187
214
|
],
|
|
188
215
|
map: [
|
|
189
216
|
"columnType",
|
|
190
217
|
"properties",
|
|
191
218
|
"propertiesOrder",
|
|
192
|
-
"previewProperties",
|
|
193
219
|
"keyValue"
|
|
194
220
|
]
|
|
195
221
|
};
|
|
@@ -233,11 +259,20 @@ var RELATION_LINK_FIELDS = [
|
|
|
233
259
|
"joinPath",
|
|
234
260
|
"cardinality"
|
|
235
261
|
];
|
|
262
|
+
/**
|
|
263
|
+
* Keys that no longer exist *inside* the `admin` block.
|
|
264
|
+
*
|
|
265
|
+
* Checked by name, not by completeness — see `checkCollection`.
|
|
266
|
+
*/
|
|
267
|
+
var ADMIN_BLOCK_MIGRATIONS = { titleProperty: { fix: "`titleProperty` was replaced by `admin.display.title` — the same string works there, and `display.title` also takes a resolver for a title the record does not carry" } };
|
|
236
268
|
/** Collection-level keys that no longer exist at the top level. */
|
|
237
|
-
var COLLECTION_MIGRATIONS = {
|
|
269
|
+
var COLLECTION_MIGRATIONS = {
|
|
270
|
+
editable: { fix: "`editable` was removed in 0.10 — collections are editable by default. Delete it, or use `admin.disableDefaultActions` to take actions away" },
|
|
271
|
+
...ADMIN_BLOCK_MIGRATIONS
|
|
272
|
+
};
|
|
238
273
|
for (const key of ADMIN_COLLECTION_KEYS) COLLECTION_MIGRATIONS[key] = {
|
|
239
274
|
fix: `\`${key}\` moved into the collection's \`admin\` block in 0.11 — write \`admin: { ${key}: … }\``,
|
|
240
|
-
codemod: "node scripts/codemod/collections-admin-block.mjs"
|
|
275
|
+
codemod: "node tooling/scripts/codemod/collections-admin-block.mjs"
|
|
241
276
|
};
|
|
242
277
|
/** Property-level keys that no longer exist at the top level of a property. */
|
|
243
278
|
var PROPERTY_MIGRATIONS = {
|
|
@@ -267,7 +302,7 @@ var RELATION_PROPERTY_MIGRATIONS = {
|
|
|
267
302
|
overrides: { fix: "move `overrides` inside `relation`" },
|
|
268
303
|
relationName: { fix: "move `relationName` inside `relation` — `relation: { kind: …, relationName: … }`" }
|
|
269
304
|
};
|
|
270
|
-
var RELATION_UNION_CODEMOD = "node scripts/codemod/relations-tagged-union.mjs";
|
|
305
|
+
var RELATION_UNION_CODEMOD = "node tooling/scripts/codemod/relations-tagged-union.mjs";
|
|
271
306
|
/** Fields the old flat `Relation` carried that the tagged union does not. */
|
|
272
307
|
var RELATION_MIGRATIONS = {
|
|
273
308
|
direction: {
|
|
@@ -365,7 +400,7 @@ function checkProperty(property, path, collect) {
|
|
|
365
400
|
const type = property.type;
|
|
366
401
|
if (typeof type !== "string") collect.error(path, "a property has no `type`.");
|
|
367
402
|
else if (!PROPERTY_TYPES.includes(type)) collect.error(path, `\`type: "${type}"\` is not a property type. Expected one of ${PROPERTY_TYPES.join(", ")}.`);
|
|
368
|
-
const allowed = /* @__PURE__ */ new Set([...BASE_PROPERTY_KEYS, ...typeof type === "string" ? PROPERTY_KEYS_BY_TYPE[type]
|
|
403
|
+
const allowed = /* @__PURE__ */ new Set([...BASE_PROPERTY_KEYS, ...typeof type === "string" && type in PROPERTY_KEYS_BY_TYPE ? PROPERTY_KEYS_BY_TYPE[type] : []]);
|
|
369
404
|
for (const key of Object.keys(property)) {
|
|
370
405
|
if (allowed.has(key)) continue;
|
|
371
406
|
if (type === "relation" && RELATION_PROPERTY_MIGRATIONS[key]) {
|
|
@@ -413,6 +448,9 @@ function checkCollection(collection, index, collect) {
|
|
|
413
448
|
}
|
|
414
449
|
collect.unknown(`${at}.${key}`, key, "collection");
|
|
415
450
|
}
|
|
451
|
+
if (isPlainObject$1(collection.admin)) {
|
|
452
|
+
for (const [key, migration] of Object.entries(ADMIN_BLOCK_MIGRATIONS)) if (key in collection.admin) collect.migrated(`${at}.admin.${key}`, key, migration);
|
|
453
|
+
}
|
|
416
454
|
if (isPlainObject$1(collection.properties)) checkProperties(collection.properties, `${at}.properties`, collect);
|
|
417
455
|
else if (collection.properties !== void 0) collect.error(`${at}.properties`, "`properties` must be an object keyed by property name.");
|
|
418
456
|
if (Array.isArray(collection.relations)) collection.relations.forEach((relation, i) => {
|
|
@@ -464,8 +502,8 @@ async function importModule(filePath) {
|
|
|
464
502
|
/** Read `defaultSecurityRules` from a collections directory's index module. */
|
|
465
503
|
async function readDefaults(directory) {
|
|
466
504
|
for (const name of ["index.ts", "index.js"]) {
|
|
467
|
-
const indexPath = path$
|
|
468
|
-
if (!fs$
|
|
505
|
+
const indexPath = path$3.join(directory, name);
|
|
506
|
+
if (!fs$3.existsSync(indexPath)) continue;
|
|
469
507
|
try {
|
|
470
508
|
return { defaultSecurityRules: (await importModule(indexPath)).defaultSecurityRules };
|
|
471
509
|
} catch (err) {
|
|
@@ -502,23 +540,23 @@ function applyCollectionDefaults(collections, defaults) {
|
|
|
502
540
|
* rejects.
|
|
503
541
|
*/
|
|
504
542
|
async function loadCollectionsFromDirectory(source, options = {}) {
|
|
505
|
-
const resolved = path$
|
|
543
|
+
const resolved = path$3.resolve(source);
|
|
506
544
|
const validate = (collections) => {
|
|
507
545
|
if (options.validate !== false) assertCollectionConfigs(collections, options.validate ?? {});
|
|
508
546
|
return collections;
|
|
509
547
|
};
|
|
510
|
-
if (!fs$
|
|
548
|
+
if (!fs$3.existsSync(resolved)) {
|
|
511
549
|
logger.warn(`[collections] Not found: ${resolved}`);
|
|
512
550
|
return [];
|
|
513
551
|
}
|
|
514
|
-
if (!fs$
|
|
552
|
+
if (!fs$3.statSync(resolved).isDirectory()) {
|
|
515
553
|
const mod = await importModule(resolved);
|
|
516
554
|
return validate(applyCollectionDefaults([...mod.backendCollections || mod.collections || []], { defaultSecurityRules: mod.defaultSecurityRules }));
|
|
517
555
|
}
|
|
518
556
|
const collections = [];
|
|
519
557
|
const failures = [];
|
|
520
|
-
for (const file of fs$
|
|
521
|
-
const mod = await importModule(path$
|
|
558
|
+
for (const file of fs$3.readdirSync(resolved).filter(isCollectionFile)) try {
|
|
559
|
+
const mod = await importModule(path$3.join(resolved, file));
|
|
522
560
|
if (mod?.default) collections.push(mod.default);
|
|
523
561
|
else failures.push(`${file}: no default export`);
|
|
524
562
|
} catch (err) {
|
|
@@ -533,7 +571,7 @@ async function loadCollectionsFromDirectory(source, options = {}) {
|
|
|
533
571
|
* Telling a *stale* driver apart from a driver that never had the feature.
|
|
534
572
|
*
|
|
535
573
|
* The managed runtime is two halves that version independently. The image
|
|
536
|
-
* supplies `@rebasepro/server` — `docker/entrypoint.mjs` symlinks it over
|
|
574
|
+
* supplies `@rebasepro/server` — `infra/docker/entrypoint.mjs` symlinks it over
|
|
537
575
|
* whatever the bundle installed, so the boot harness is always the image's. The
|
|
538
576
|
* **driver** is not redirected: `@rebasepro/server-postgres` comes from the
|
|
539
577
|
* bundle's `deps.declared`, pinned to whatever the project's `package.json`
|
|
@@ -705,7 +743,7 @@ function readPackageVersion(packageDir) {
|
|
|
705
743
|
* The version of `@rebasepro/server` this deployment is actually running.
|
|
706
744
|
*
|
|
707
745
|
* Resolved from the same roots the drivers are, and that is the point rather
|
|
708
|
-
* than a convenience. In a managed container `docker/entrypoint.mjs` replaces
|
|
746
|
+
* than a convenience. In a managed container `infra/docker/entrypoint.mjs` replaces
|
|
709
747
|
* `/bundle/node_modules/@rebasepro/server` with a link to the image's copy, so
|
|
710
748
|
* reading the manifest *through the bundle* reports the version that will
|
|
711
749
|
* really execute — which is exactly the number the driver has to be compared
|
|
@@ -2215,15 +2253,14 @@ var RestApiGenerator = class {
|
|
|
2215
2253
|
* gateway in front of this API would reject partial updates the server
|
|
2216
2254
|
* would have accepted.
|
|
2217
2255
|
*
|
|
2218
|
-
* PUT
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
2221
|
-
*
|
|
2222
|
-
*
|
|
2223
|
-
*
|
|
2256
|
+
* PUT was mounted on the same handler for a while so that published
|
|
2257
|
+
* SDKs kept working. It is gone: two verbs for one operation meant the
|
|
2258
|
+
* spec had to describe the same route twice, one of them marked
|
|
2259
|
+
* deprecated, and a merge served under a verb that means replace is a
|
|
2260
|
+
* contract nobody can generate against. `update()` in the SDK sends
|
|
2261
|
+
* PATCH.
|
|
2224
2262
|
*/
|
|
2225
2263
|
this.router.patch(`${basePath}/:id`, updateEntity);
|
|
2226
|
-
this.router.put(`${basePath}/:id`, updateEntity);
|
|
2227
2264
|
this.router.delete(`${basePath}/:id`, async (c) => {
|
|
2228
2265
|
this.enforceApiKeyPermission(c, collection.slug);
|
|
2229
2266
|
const id = c.req.param("id");
|
|
@@ -2393,7 +2430,6 @@ var RestApiGenerator = class {
|
|
|
2393
2430
|
return c.json(response);
|
|
2394
2431
|
};
|
|
2395
2432
|
this.router.patch("/:parent/:parentId/:rest{.+}", updateNested);
|
|
2396
|
-
this.router.put("/:parent/:parentId/:rest{.+}", updateNested);
|
|
2397
2433
|
this.router.delete("/:parent/:parentId/:rest{.+}", async (c, next) => {
|
|
2398
2434
|
const rest = c.req.param("rest");
|
|
2399
2435
|
if (!rest || rest === "undefined") return next();
|
|
@@ -2632,13 +2668,13 @@ function configureMiddlewares(app, basePath, isProduction, config) {
|
|
|
2632
2668
|
/**
|
|
2633
2669
|
* Local filesystem storage controller
|
|
2634
2670
|
*/
|
|
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$
|
|
2671
|
+
var mkdir$1 = promisify(fs$3.mkdir);
|
|
2672
|
+
var writeFile$1 = promisify(fs$3.writeFile);
|
|
2673
|
+
var readFile = promisify(fs$3.readFile);
|
|
2674
|
+
var unlink$1 = promisify(fs$3.unlink);
|
|
2675
|
+
var readdir = promisify(fs$3.readdir);
|
|
2676
|
+
var stat = promisify(fs$3.stat);
|
|
2677
|
+
var access = promisify(fs$3.access);
|
|
2642
2678
|
/**
|
|
2643
2679
|
* Remove initial and trailing slashes from a path.
|
|
2644
2680
|
* Handles paths like "/images/", "images/", "/images" → "images"
|
|
@@ -2658,7 +2694,7 @@ var LocalStorageController = class {
|
|
|
2658
2694
|
basePath;
|
|
2659
2695
|
constructor(config) {
|
|
2660
2696
|
this.config = config;
|
|
2661
|
-
this.basePath = path$
|
|
2697
|
+
this.basePath = path$3.resolve(config.basePath);
|
|
2662
2698
|
}
|
|
2663
2699
|
getType() {
|
|
2664
2700
|
return "local";
|
|
@@ -2699,10 +2735,10 @@ var LocalStorageController = class {
|
|
|
2699
2735
|
* depend on it having run.
|
|
2700
2736
|
*/
|
|
2701
2737
|
getFullPath(storagePath, bucket) {
|
|
2702
|
-
const bucketPath = path$
|
|
2703
|
-
if (!bucketPath.startsWith(this.basePath + path$
|
|
2704
|
-
const resolved = path$
|
|
2705
|
-
if (!resolved.startsWith(bucketPath + path$
|
|
2738
|
+
const bucketPath = path$3.resolve(this.basePath, bucket || "default");
|
|
2739
|
+
if (!bucketPath.startsWith(this.basePath + path$3.sep)) throw new Error("Path traversal detected: bucket resolves outside the storage root.");
|
|
2740
|
+
const resolved = path$3.resolve(bucketPath, storagePath);
|
|
2741
|
+
if (!resolved.startsWith(bucketPath + path$3.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
|
|
2706
2742
|
return resolved;
|
|
2707
2743
|
}
|
|
2708
2744
|
/**
|
|
@@ -2720,7 +2756,7 @@ var LocalStorageController = class {
|
|
|
2720
2756
|
const usedBucket = bucket || "default";
|
|
2721
2757
|
const fullStoragePath = key;
|
|
2722
2758
|
const fullPath = this.getFullPath(fullStoragePath, usedBucket);
|
|
2723
|
-
await this.ensureDir(path$
|
|
2759
|
+
await this.ensureDir(path$3.dirname(fullPath));
|
|
2724
2760
|
const arrayBuffer = await file.arrayBuffer();
|
|
2725
2761
|
await writeFile$1(fullPath, Buffer.from(arrayBuffer));
|
|
2726
2762
|
await writeFile$1(`${fullPath}.metadata.json`, JSON.stringify({
|
|
@@ -2749,7 +2785,7 @@ var LocalStorageController = class {
|
|
|
2749
2785
|
resolvedPath = normalizeStoragePath(resolvedPath);
|
|
2750
2786
|
const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
|
|
2751
2787
|
try {
|
|
2752
|
-
await access(fullPath, fs$
|
|
2788
|
+
await access(fullPath, fs$3.constants.R_OK);
|
|
2753
2789
|
} catch {
|
|
2754
2790
|
return {
|
|
2755
2791
|
url: null,
|
|
@@ -2765,7 +2801,7 @@ var LocalStorageController = class {
|
|
|
2765
2801
|
metadata = {
|
|
2766
2802
|
bucket: resolvedBucket ?? "default",
|
|
2767
2803
|
fullPath: resolvedPath,
|
|
2768
|
-
name: path$
|
|
2804
|
+
name: path$3.basename(resolvedPath),
|
|
2769
2805
|
size: fileStat.size,
|
|
2770
2806
|
contentType: savedMetadata.contentType || "application/octet-stream",
|
|
2771
2807
|
customMetadata: savedMetadata
|
|
@@ -2776,7 +2812,7 @@ var LocalStorageController = class {
|
|
|
2776
2812
|
metadata = {
|
|
2777
2813
|
bucket: resolvedBucket ?? "default",
|
|
2778
2814
|
fullPath: resolvedPath,
|
|
2779
|
-
name: path$
|
|
2815
|
+
name: path$3.basename(resolvedPath),
|
|
2780
2816
|
size: fileStat.size,
|
|
2781
2817
|
contentType: "application/octet-stream",
|
|
2782
2818
|
customMetadata: {}
|
|
@@ -2802,7 +2838,7 @@ var LocalStorageController = class {
|
|
|
2802
2838
|
resolvedPath = normalizeStoragePath(resolvedPath);
|
|
2803
2839
|
const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
|
|
2804
2840
|
try {
|
|
2805
|
-
await access(fullPath, fs$
|
|
2841
|
+
await access(fullPath, fs$3.constants.R_OK);
|
|
2806
2842
|
const buffer = await readFile(fullPath);
|
|
2807
2843
|
let contentType = "application/octet-stream";
|
|
2808
2844
|
try {
|
|
@@ -2810,7 +2846,7 @@ var LocalStorageController = class {
|
|
|
2810
2846
|
contentType = JSON.parse(metadataContent).contentType || contentType;
|
|
2811
2847
|
} catch {}
|
|
2812
2848
|
const blob = new Blob([buffer], { type: contentType });
|
|
2813
|
-
return new File([blob], path$
|
|
2849
|
+
return new File([blob], path$3.basename(resolvedPath), { type: contentType });
|
|
2814
2850
|
} catch {
|
|
2815
2851
|
return null;
|
|
2816
2852
|
}
|
|
@@ -2830,12 +2866,12 @@ var LocalStorageController = class {
|
|
|
2830
2866
|
if (!resolvedPath) return;
|
|
2831
2867
|
const fullPath = this.getFullPath(resolvedPath, resolvedBucket);
|
|
2832
2868
|
try {
|
|
2833
|
-
await access(fullPath, fs$
|
|
2869
|
+
await access(fullPath, fs$3.constants.F_OK);
|
|
2834
2870
|
} catch {
|
|
2835
2871
|
return;
|
|
2836
2872
|
}
|
|
2837
2873
|
try {
|
|
2838
|
-
if ((await stat(fullPath)).isDirectory()) await fs$
|
|
2874
|
+
if ((await stat(fullPath)).isDirectory()) await fs$3.promises.rmdir(fullPath);
|
|
2839
2875
|
else {
|
|
2840
2876
|
await unlink$1(fullPath);
|
|
2841
2877
|
try {
|
|
@@ -2856,7 +2892,7 @@ var LocalStorageController = class {
|
|
|
2856
2892
|
const items = [];
|
|
2857
2893
|
const prefixes = [];
|
|
2858
2894
|
try {
|
|
2859
|
-
await access(fullPath, fs$
|
|
2895
|
+
await access(fullPath, fs$3.constants.R_OK);
|
|
2860
2896
|
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
2861
2897
|
let count = 0;
|
|
2862
2898
|
const maxResults = options?.maxResults ?? 1e3;
|
|
@@ -3032,6 +3068,138 @@ function isStorageController(obj) {
|
|
|
3032
3068
|
return typeof controller.putObject === "function" && typeof controller.getSignedUrl === "function" && typeof controller.deleteObject === "function" && typeof controller.listObjects === "function" && typeof controller.getType === "function";
|
|
3033
3069
|
}
|
|
3034
3070
|
//#endregion
|
|
3071
|
+
//#region src/storage/cache-headers.ts
|
|
3072
|
+
/**
|
|
3073
|
+
* A weak validator built from size and modification time.
|
|
3074
|
+
*
|
|
3075
|
+
* Weak (`W/`) because it is not a digest of the body: two different bodies of
|
|
3076
|
+
* the same length written in the same millisecond would collide. That is the
|
|
3077
|
+
* same trade nginx makes for static files, and the alternative — hashing every
|
|
3078
|
+
* object on every request — costs more than it saves for content this server is
|
|
3079
|
+
* already streaming from somewhere else.
|
|
3080
|
+
*/
|
|
3081
|
+
function buildEntityTag(size, lastModifiedMs) {
|
|
3082
|
+
return `W/"${size.toString(16)}-${Math.floor(lastModifiedMs).toString(16)}"`;
|
|
3083
|
+
}
|
|
3084
|
+
function objectValidators(size, lastModifiedMs) {
|
|
3085
|
+
const seconds = Math.floor(lastModifiedMs / 1e3) * 1e3;
|
|
3086
|
+
return {
|
|
3087
|
+
etag: buildEntityTag(size, lastModifiedMs),
|
|
3088
|
+
lastModified: new Date(seconds).toUTCString(),
|
|
3089
|
+
lastModifiedMs: seconds
|
|
3090
|
+
};
|
|
3091
|
+
}
|
|
3092
|
+
function cacheControl(options) {
|
|
3093
|
+
const parts = [options.isPublic ? "public" : "private", `max-age=${options.maxAgeSeconds}`];
|
|
3094
|
+
if (options.isPublic && options.staleWhileRevalidateSeconds) parts.push(`stale-while-revalidate=${options.staleWhileRevalidateSeconds}`);
|
|
3095
|
+
parts.push("must-revalidate");
|
|
3096
|
+
return parts.join(", ");
|
|
3097
|
+
}
|
|
3098
|
+
/** Every `ETag` in an `If-None-Match` header, unquoted comparison-ready. */
|
|
3099
|
+
function parseIfNoneMatch(header) {
|
|
3100
|
+
return header.split(",").map((part) => part.trim()).filter(Boolean);
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* Weak comparison, which is the only comparison `If-None-Match` on a GET is
|
|
3104
|
+
* allowed to use (RFC 9110 §8.8.3.2). `W/"x"` and `"x"` are the same entity for
|
|
3105
|
+
* this purpose, so the prefix is stripped from both sides before comparing.
|
|
3106
|
+
*/
|
|
3107
|
+
function weakMatch(a, b) {
|
|
3108
|
+
const strip = (v) => v.startsWith("W/") ? v.slice(2) : v;
|
|
3109
|
+
return strip(a) === strip(b);
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* Whether the request already holds this exact representation.
|
|
3113
|
+
*
|
|
3114
|
+
* `If-None-Match` wins outright when present: it is an exact validator, whereas
|
|
3115
|
+
* `If-Modified-Since` has second resolution and is only consulted in its
|
|
3116
|
+
* absence — again the order RFC 9110 requires.
|
|
3117
|
+
*/
|
|
3118
|
+
function isNotModified(c, validators) {
|
|
3119
|
+
const ifNoneMatch = c.req.header("if-none-match");
|
|
3120
|
+
if (ifNoneMatch) {
|
|
3121
|
+
const tags = parseIfNoneMatch(ifNoneMatch);
|
|
3122
|
+
return tags.includes("*") || tags.some((tag) => weakMatch(tag, validators.etag));
|
|
3123
|
+
}
|
|
3124
|
+
const ifModifiedSince = c.req.header("if-modified-since");
|
|
3125
|
+
if (ifModifiedSince) {
|
|
3126
|
+
const since = Date.parse(ifModifiedSince);
|
|
3127
|
+
if (!Number.isNaN(since)) return validators.lastModifiedMs <= since;
|
|
3128
|
+
}
|
|
3129
|
+
return false;
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Put the validators and the cache policy on a response.
|
|
3133
|
+
*
|
|
3134
|
+
* Applied to the 304 as well as the 200: a 304 that omits them tells the client
|
|
3135
|
+
* nothing about how long the answer holds, so the next load revalidates again
|
|
3136
|
+
* immediately and the round trip repeats forever.
|
|
3137
|
+
*/
|
|
3138
|
+
function applyCacheHeaders(c, validators, options) {
|
|
3139
|
+
c.header("ETag", validators.etag);
|
|
3140
|
+
c.header("Last-Modified", validators.lastModified);
|
|
3141
|
+
c.header("Cache-Control", cacheControl(options));
|
|
3142
|
+
c.header("Vary", "Authorization");
|
|
3143
|
+
}
|
|
3144
|
+
//#endregion
|
|
3145
|
+
//#region src/storage/range.ts
|
|
3146
|
+
/**
|
|
3147
|
+
* Parse a `Range` header against an object of `size` bytes.
|
|
3148
|
+
*
|
|
3149
|
+
* Deliberately conservative: anything malformed, multi-range, or in a unit other
|
|
3150
|
+
* than bytes resolves to `none`, which serves the whole object. A range request
|
|
3151
|
+
* answered with `200` is always legal; a range answered wrongly is a corrupted
|
|
3152
|
+
* download.
|
|
3153
|
+
*/
|
|
3154
|
+
function parseRange(header, size) {
|
|
3155
|
+
if (!header) return { kind: "none" };
|
|
3156
|
+
const match = /^bytes=(.*)$/i.exec(header.trim());
|
|
3157
|
+
if (!match) return { kind: "none" };
|
|
3158
|
+
const spec = match[1].trim();
|
|
3159
|
+
if (spec.includes(",")) return { kind: "none" };
|
|
3160
|
+
const parts = /^(\d*)-(\d*)$/.exec(spec);
|
|
3161
|
+
if (!parts) return { kind: "none" };
|
|
3162
|
+
const [, rawStart, rawEnd] = parts;
|
|
3163
|
+
if (size === 0) return { kind: "unsatisfiable" };
|
|
3164
|
+
let start;
|
|
3165
|
+
let end;
|
|
3166
|
+
if (rawStart === "") {
|
|
3167
|
+
if (rawEnd === "") return { kind: "none" };
|
|
3168
|
+
const suffix = Number(rawEnd);
|
|
3169
|
+
if (!Number.isSafeInteger(suffix)) return { kind: "none" };
|
|
3170
|
+
if (suffix === 0) return { kind: "unsatisfiable" };
|
|
3171
|
+
start = Math.max(0, size - suffix);
|
|
3172
|
+
end = size - 1;
|
|
3173
|
+
} else {
|
|
3174
|
+
start = Number(rawStart);
|
|
3175
|
+
if (!Number.isSafeInteger(start)) return { kind: "none" };
|
|
3176
|
+
if (start >= size) return { kind: "unsatisfiable" };
|
|
3177
|
+
if (rawEnd === "") end = size - 1;
|
|
3178
|
+
else {
|
|
3179
|
+
end = Number(rawEnd);
|
|
3180
|
+
if (!Number.isSafeInteger(end)) return { kind: "none" };
|
|
3181
|
+
end = Math.min(end, size - 1);
|
|
3182
|
+
}
|
|
3183
|
+
if (end < start) return { kind: "unsatisfiable" };
|
|
3184
|
+
}
|
|
3185
|
+
return {
|
|
3186
|
+
kind: "range",
|
|
3187
|
+
range: {
|
|
3188
|
+
start,
|
|
3189
|
+
end,
|
|
3190
|
+
length: end - start + 1
|
|
3191
|
+
}
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
/** The `Content-Range` for a served range. */
|
|
3195
|
+
function contentRange(range, size) {
|
|
3196
|
+
return `bytes ${range.start}-${range.end}/${size}`;
|
|
3197
|
+
}
|
|
3198
|
+
/** The `Content-Range` for a `416`, which names the size and no range. */
|
|
3199
|
+
function unsatisfiableContentRange(size) {
|
|
3200
|
+
return `bytes */${size}`;
|
|
3201
|
+
}
|
|
3202
|
+
//#endregion
|
|
3035
3203
|
//#region src/storage/image-transform.ts
|
|
3036
3204
|
/**
|
|
3037
3205
|
* Image Transformation Service
|
|
@@ -3292,6 +3460,221 @@ var TransformCache = class {
|
|
|
3292
3460
|
}
|
|
3293
3461
|
};
|
|
3294
3462
|
//#endregion
|
|
3463
|
+
//#region src/storage/path-pattern.ts
|
|
3464
|
+
/**
|
|
3465
|
+
* The storage key pattern language, in one place.
|
|
3466
|
+
*
|
|
3467
|
+
* `storagePolicies` invented it and `storageTriggers` needs exactly the same
|
|
3468
|
+
* thing: match a key segment by segment, capture the named segments, and refuse
|
|
3469
|
+
* a pattern that would silently do something other than it says. Two copies of
|
|
3470
|
+
* a matcher used for access control is one copy too many — the day they drift
|
|
3471
|
+
* is the day a policy and a trigger disagree about what `users/:uid/**` means,
|
|
3472
|
+
* and only one of them is enforcing anything.
|
|
3473
|
+
*
|
|
3474
|
+
* The rules:
|
|
3475
|
+
*
|
|
3476
|
+
* - a literal segment matches itself
|
|
3477
|
+
* - `*` matches exactly one segment
|
|
3478
|
+
* - `:name` matches one segment and captures it
|
|
3479
|
+
* - `**` matches the rest of the key, including nothing, and is only allowed as
|
|
3480
|
+
* the final segment
|
|
3481
|
+
*
|
|
3482
|
+
* Compilation is strict and happens at boot: a `**` in the middle, a `:` with
|
|
3483
|
+
* no name, or the same capture twice are all errors, because each of them is a
|
|
3484
|
+
* pattern whose author believed something the matcher does not do.
|
|
3485
|
+
*/
|
|
3486
|
+
/** A pattern that cannot be compiled. Thrown at boot, never per request. */
|
|
3487
|
+
var StoragePatternError = class extends Error {
|
|
3488
|
+
constructor(message) {
|
|
3489
|
+
super(message);
|
|
3490
|
+
this.name = "StoragePatternError";
|
|
3491
|
+
}
|
|
3492
|
+
};
|
|
3493
|
+
/**
|
|
3494
|
+
* Split a key into segments, dropping empty ones.
|
|
3495
|
+
*
|
|
3496
|
+
* Empty segments come from a leading slash or a `//`, and treating them as real
|
|
3497
|
+
* would let `users//x` match a pattern expecting a captured segment with the
|
|
3498
|
+
* empty string. Keys reaching here are already sanitized against traversal; this
|
|
3499
|
+
* is about matching, not safety.
|
|
3500
|
+
*/
|
|
3501
|
+
function segmentsOf(value) {
|
|
3502
|
+
return value.split("/").filter((segment) => segment.length > 0);
|
|
3503
|
+
}
|
|
3504
|
+
/**
|
|
3505
|
+
* Compile a pattern, or throw {@link StoragePatternError}.
|
|
3506
|
+
*
|
|
3507
|
+
* `label` names the thing being compiled in any error — `storagePolicies[2]`,
|
|
3508
|
+
* `storageTriggers[0]` — so a boot failure points at the line to edit.
|
|
3509
|
+
*/
|
|
3510
|
+
function compileStoragePattern(path, label) {
|
|
3511
|
+
if (typeof path !== "string" || path.trim() === "") throw new StoragePatternError(`${label}: \`path\` must be a non-empty string.`);
|
|
3512
|
+
const segments = segmentsOf(path);
|
|
3513
|
+
if (segments.length === 0) throw new StoragePatternError(`${label}: \`path\` "${path}" names no segments. Use "**" to match every key.`);
|
|
3514
|
+
const starIndex = segments.indexOf("**");
|
|
3515
|
+
if (starIndex !== -1 && starIndex !== segments.length - 1) throw new StoragePatternError(`${label}: "**" is only allowed as the last segment of \`path\`, and "${path}" puts it in the middle. Use "*" for a single segment.`);
|
|
3516
|
+
const captures = segments.filter((s) => s.startsWith(":")).map((s) => s.slice(1));
|
|
3517
|
+
for (const name of captures) {
|
|
3518
|
+
if (name === "") throw new StoragePatternError(`${label}: a ":" placeholder in "${path}" has no name.`);
|
|
3519
|
+
if (captures.filter((c) => c === name).length > 1) throw new StoragePatternError(`${label}: "${path}" captures ":${name}" twice, so one would silently win.`);
|
|
3520
|
+
}
|
|
3521
|
+
return {
|
|
3522
|
+
segments,
|
|
3523
|
+
trailing: starIndex !== -1,
|
|
3524
|
+
source: path
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
/**
|
|
3528
|
+
* Match a key against a compiled pattern.
|
|
3529
|
+
*
|
|
3530
|
+
* Returns the captured segments on a match — an empty object for a pattern that
|
|
3531
|
+
* captures nothing — and `undefined` on a miss. The distinction matters: a
|
|
3532
|
+
* pattern with no `:name` still matches, and `{}` is a match while `undefined`
|
|
3533
|
+
* is not.
|
|
3534
|
+
*/
|
|
3535
|
+
function matchStoragePattern(compiled, key) {
|
|
3536
|
+
const keySegments = segmentsOf(key);
|
|
3537
|
+
const pattern = compiled.trailing ? compiled.segments.slice(0, -1) : compiled.segments;
|
|
3538
|
+
if (compiled.trailing ? keySegments.length < pattern.length : keySegments.length !== pattern.length) return;
|
|
3539
|
+
const params = {};
|
|
3540
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
3541
|
+
const expected = pattern[i];
|
|
3542
|
+
const actual = keySegments[i];
|
|
3543
|
+
if (expected === "*") continue;
|
|
3544
|
+
if (expected.startsWith(":")) {
|
|
3545
|
+
params[expected.slice(1)] = actual;
|
|
3546
|
+
continue;
|
|
3547
|
+
}
|
|
3548
|
+
if (expected !== actual) return void 0;
|
|
3549
|
+
}
|
|
3550
|
+
return params;
|
|
3551
|
+
}
|
|
3552
|
+
//#endregion
|
|
3553
|
+
//#region src/storage/triggers.ts
|
|
3554
|
+
/**
|
|
3555
|
+
* Storage triggers: run something when an object lands, or when one goes.
|
|
3556
|
+
*
|
|
3557
|
+
* Every other write in the product can be reacted to — a row has `beforeSave`
|
|
3558
|
+
* and `afterSave`, a schedule has a cron job, an outbound event has a webhook —
|
|
3559
|
+
* and an upload had nothing. So the ordinary things an upload implies (thumbnail
|
|
3560
|
+
* a photo, index a document, write a row recording what arrived, tell somebody)
|
|
3561
|
+
* had to be done by the client after the upload returned, which means they are
|
|
3562
|
+
* not done at all when the client goes away between the two calls.
|
|
3563
|
+
*
|
|
3564
|
+
* ```ts
|
|
3565
|
+
* storageTriggers: [
|
|
3566
|
+
* {
|
|
3567
|
+
* path: "uploads/:uid/**",
|
|
3568
|
+
* events: ["finalize"],
|
|
3569
|
+
* handler: async ({ key, params, size }) => {
|
|
3570
|
+
* await jobs.enqueue("index-upload", { key, uid: params.uid, size });
|
|
3571
|
+
* }
|
|
3572
|
+
* }
|
|
3573
|
+
* ]
|
|
3574
|
+
* ```
|
|
3575
|
+
*
|
|
3576
|
+
* The pattern language is `storagePolicies`', to the letter — see
|
|
3577
|
+
* `path-pattern.ts`, which both compile through.
|
|
3578
|
+
*
|
|
3579
|
+
* ## What a handler can rely on
|
|
3580
|
+
*
|
|
3581
|
+
* **`finalize` fires after the object is durably written**, never before, and
|
|
3582
|
+
* never for a write that failed. **`delete` fires after the object is gone.**
|
|
3583
|
+
* Both carry the key exactly as the storage controller saw it — the canonical
|
|
3584
|
+
* one, the same string `storageAuthorize` was asked about.
|
|
3585
|
+
*
|
|
3586
|
+
* ## What a handler must not rely on
|
|
3587
|
+
*
|
|
3588
|
+
* **Delivery is best-effort, and a throwing handler does not fail the request.**
|
|
3589
|
+
* The object is already stored by the time a `finalize` handler runs; answering
|
|
3590
|
+
* the client with an error would say the upload failed when it did not, and
|
|
3591
|
+
* clients retry uploads. So a handler that throws is logged and the response is
|
|
3592
|
+
* unchanged. If the work must happen, the handler should enqueue a job — that
|
|
3593
|
+
* is durable, retried, and observable — rather than being the durable step
|
|
3594
|
+
* itself.
|
|
3595
|
+
*
|
|
3596
|
+
* **Handlers are awaited**, in declaration order, before the response is sent.
|
|
3597
|
+
* That is deliberate: firing and forgetting would leave a promise running after
|
|
3598
|
+
* the response, which a serverless runtime is free to freeze mid-flight. The
|
|
3599
|
+
* cost is that a slow handler is a slow upload, which is the other reason to
|
|
3600
|
+
* enqueue rather than to work here.
|
|
3601
|
+
*
|
|
3602
|
+
* **Renditions and other internal writes do not fire triggers.** Only a write
|
|
3603
|
+
* that came through the API does. The image-transform cache writes derived
|
|
3604
|
+
* objects straight to the controller, and a trigger firing on those would
|
|
3605
|
+
* recurse.
|
|
3606
|
+
*/
|
|
3607
|
+
/**
|
|
3608
|
+
* A request's principal, in the shape a trigger is documented to receive.
|
|
3609
|
+
*
|
|
3610
|
+
* Hono's `user` variable is typed `AuthResult`, which admits `true` — some
|
|
3611
|
+
* middleware sets a bare boolean to mean "authenticated, with no identity
|
|
3612
|
+
* attached". A trigger asking "who did this?" is better told `null` than
|
|
3613
|
+
* `true`, and better told `null` than a `{}` with no `uid`.
|
|
3614
|
+
*/
|
|
3615
|
+
function triggerUser(raw) {
|
|
3616
|
+
if (!raw || typeof raw !== "object") return null;
|
|
3617
|
+
const user = raw;
|
|
3618
|
+
if (typeof user.uid !== "string") return null;
|
|
3619
|
+
return {
|
|
3620
|
+
uid: user.uid,
|
|
3621
|
+
...typeof user.email === "string" ? { email: user.email } : {},
|
|
3622
|
+
...Array.isArray(user.roles) ? { roles: user.roles } : {}
|
|
3623
|
+
};
|
|
3624
|
+
}
|
|
3625
|
+
var StorageTriggerError = class extends Error {
|
|
3626
|
+
constructor(message) {
|
|
3627
|
+
super(message);
|
|
3628
|
+
this.name = "StorageTriggerError";
|
|
3629
|
+
}
|
|
3630
|
+
};
|
|
3631
|
+
var ALL_EVENTS = ["finalize", "delete"];
|
|
3632
|
+
/**
|
|
3633
|
+
* Compile a trigger list into a dispatcher, or throw
|
|
3634
|
+
* {@link StorageTriggerError}.
|
|
3635
|
+
*
|
|
3636
|
+
* Compiled at boot so a malformed pattern fails the start rather than the first
|
|
3637
|
+
* upload — and, worse, rather than failing *silently* by matching nothing.
|
|
3638
|
+
*/
|
|
3639
|
+
function compileStorageTriggers(triggers) {
|
|
3640
|
+
if (!Array.isArray(triggers)) throw new StorageTriggerError("`storageTriggers` must be an array.");
|
|
3641
|
+
const compiled = triggers.map((trigger, index) => {
|
|
3642
|
+
const label = `storageTriggers[${index}]`;
|
|
3643
|
+
if (typeof trigger?.handler !== "function") throw new StorageTriggerError(`${label}: \`handler\` must be a function. A trigger with no handler runs nothing, which is never what was meant.`);
|
|
3644
|
+
if (trigger.events !== void 0) {
|
|
3645
|
+
if (!Array.isArray(trigger.events) || trigger.events.length === 0) throw new StorageTriggerError(`${label}: \`events\` is empty, so this trigger never fires. Remove it to take the default, or name the events.`);
|
|
3646
|
+
for (const event of trigger.events) if (!ALL_EVENTS.includes(event)) throw new StorageTriggerError(`${label}: \`events\` names "${event}", which is not a storage event. Use ${ALL_EVENTS.map((e) => `"${e}"`).join(" or ")}.`);
|
|
3647
|
+
}
|
|
3648
|
+
let pattern;
|
|
3649
|
+
try {
|
|
3650
|
+
pattern = compileStoragePattern(trigger.path, label);
|
|
3651
|
+
} catch (err) {
|
|
3652
|
+
throw err instanceof StoragePatternError ? new StorageTriggerError(err.message) : err;
|
|
3653
|
+
}
|
|
3654
|
+
return {
|
|
3655
|
+
pattern,
|
|
3656
|
+
events: new Set(trigger.events ?? ["finalize"]),
|
|
3657
|
+
name: trigger.name ?? trigger.path,
|
|
3658
|
+
handler: trigger.handler
|
|
3659
|
+
};
|
|
3660
|
+
});
|
|
3661
|
+
return async (ctx) => {
|
|
3662
|
+
for (const trigger of compiled) {
|
|
3663
|
+
if (!trigger.events.has(ctx.event)) continue;
|
|
3664
|
+
const params = matchStoragePattern(trigger.pattern, ctx.key);
|
|
3665
|
+
if (!params) continue;
|
|
3666
|
+
try {
|
|
3667
|
+
await trigger.handler({
|
|
3668
|
+
...ctx,
|
|
3669
|
+
params
|
|
3670
|
+
});
|
|
3671
|
+
} catch (err) {
|
|
3672
|
+
logger.error(`[storage] Trigger "${trigger.name}" threw on ${ctx.event} of "${ctx.key}". The object is unaffected and the request was not failed.`, { error: err instanceof Error ? err.message : String(err) });
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
3677
|
+
//#endregion
|
|
3295
3678
|
//#region src/storage/tus-handler.ts
|
|
3296
3679
|
/**
|
|
3297
3680
|
* TUS Protocol Handler
|
|
@@ -3317,13 +3700,15 @@ var TusHandler = class {
|
|
|
3317
3700
|
storageController;
|
|
3318
3701
|
storageRegistry;
|
|
3319
3702
|
authorizeUpload;
|
|
3703
|
+
onFinalized;
|
|
3320
3704
|
uploads = /* @__PURE__ */ new Map();
|
|
3321
3705
|
tusDir;
|
|
3322
3706
|
cleanupTimer;
|
|
3323
|
-
constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload) {
|
|
3707
|
+
constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload, onFinalized) {
|
|
3324
3708
|
this.storageController = storageController;
|
|
3325
3709
|
this.storageRegistry = storageRegistry;
|
|
3326
3710
|
this.authorizeUpload = authorizeUpload;
|
|
3711
|
+
this.onFinalized = onFinalized;
|
|
3327
3712
|
this.tusDir = join(storageBaseDir, ".tus-uploads");
|
|
3328
3713
|
}
|
|
3329
3714
|
/** Ensure the temp directory exists. */
|
|
@@ -3417,7 +3802,8 @@ var TusHandler = class {
|
|
|
3417
3802
|
bucket,
|
|
3418
3803
|
storageId,
|
|
3419
3804
|
key,
|
|
3420
|
-
completed: false
|
|
3805
|
+
completed: false,
|
|
3806
|
+
user: triggerUser(c.get("user"))
|
|
3421
3807
|
};
|
|
3422
3808
|
this.uploads.set(id, upload);
|
|
3423
3809
|
const reqUrl = new URL(c.req.url);
|
|
@@ -3536,6 +3922,14 @@ var TusHandler = class {
|
|
|
3536
3922
|
} catch {}
|
|
3537
3923
|
this.uploads.delete(upload.id);
|
|
3538
3924
|
logger.info(`[TUS] Upload ${upload.id} finalized → ${fileName}`, storageId ? { storageId } : {});
|
|
3925
|
+
await this.onFinalized?.({
|
|
3926
|
+
key: fileName,
|
|
3927
|
+
bucket: upload.bucket,
|
|
3928
|
+
storageId,
|
|
3929
|
+
size: data.byteLength,
|
|
3930
|
+
contentType: mimeType,
|
|
3931
|
+
user: upload.user
|
|
3932
|
+
});
|
|
3539
3933
|
} catch (err) {
|
|
3540
3934
|
logger.error(`[TUS] Failed to finalize upload ${upload.id}`, { error: err });
|
|
3541
3935
|
if (err instanceof ApiError) throw err;
|
|
@@ -3544,6 +3938,142 @@ var TusHandler = class {
|
|
|
3544
3938
|
}
|
|
3545
3939
|
};
|
|
3546
3940
|
//#endregion
|
|
3941
|
+
//#region src/storage/rendition-cache.ts
|
|
3942
|
+
/**
|
|
3943
|
+
* Derived image renditions, kept in the storage source rather than in memory.
|
|
3944
|
+
*
|
|
3945
|
+
* ## The problem this solves
|
|
3946
|
+
*
|
|
3947
|
+
* A transform — `?width=400&format=webp` — is a decode, a resize and an encode.
|
|
3948
|
+
* The result is held in an in-process LRU, which makes it free for the second
|
|
3949
|
+
* request that asks the same instance for the same variant, and free for
|
|
3950
|
+
* nothing else. Two consequences follow, and both are the ordinary case rather
|
|
3951
|
+
* than an edge one:
|
|
3952
|
+
*
|
|
3953
|
+
* - **per-instance.** Two replicas behind a load balancer each compute every
|
|
3954
|
+
* variant, and a third replica added under load starts by recomputing all of
|
|
3955
|
+
* them — at exactly the moment there is least CPU to spare;
|
|
3956
|
+
* - **cold after a deploy.** The cache does not survive a restart, so every
|
|
3957
|
+
* release re-does the entire catalogue's worth of work.
|
|
3958
|
+
*
|
|
3959
|
+
* Writing the rendition back to the storage source fixes both, because the
|
|
3960
|
+
* storage source is the one thing every instance already shares and no deploy
|
|
3961
|
+
* touches.
|
|
3962
|
+
*
|
|
3963
|
+
* ## Why it is opt-in
|
|
3964
|
+
*
|
|
3965
|
+
* This makes a `GET` write to the operator's bucket. That costs money per
|
|
3966
|
+
* object, it puts files somebody did not upload into a place they own, and on a
|
|
3967
|
+
* bucket whose credentials are read-only it cannot work at all. None of those
|
|
3968
|
+
* is a decision to make on an operator's behalf while they are not looking, so
|
|
3969
|
+
* it is off unless asked for — and when a write does fail, the request still
|
|
3970
|
+
* succeeds from memory and the failure is reported once rather than per request.
|
|
3971
|
+
*
|
|
3972
|
+
* ## What is stored, and where
|
|
3973
|
+
*
|
|
3974
|
+
* One object per (source object, transform, source version), under a reserved
|
|
3975
|
+
* prefix in the same bucket as the source. The key is the SHA-256 of the same
|
|
3976
|
+
* cache key the in-memory cache uses, so it already carries the source's
|
|
3977
|
+
* version: replacing an image changes its key, and the old rendition is
|
|
3978
|
+
* abandoned rather than served. Abandoned renditions are not collected — set a
|
|
3979
|
+
* lifecycle rule on the prefix, which is why the prefix is a single fixed one.
|
|
3980
|
+
*
|
|
3981
|
+
* The prefix is refused as a caller-supplied key at the route layer. Without
|
|
3982
|
+
* that, a rendition is an object at a path nobody authorized: `storageAuthorize`
|
|
3983
|
+
* and the declarative policies both reason about the *source* key, and a
|
|
3984
|
+
* rendition sits under a different one.
|
|
3985
|
+
*/
|
|
3986
|
+
/**
|
|
3987
|
+
* Where renditions live inside the source bucket.
|
|
3988
|
+
*
|
|
3989
|
+
* Fixed rather than configurable: it is the target of a lifecycle rule and of
|
|
3990
|
+
* the route-layer refusal below, and both of those are worth being able to
|
|
3991
|
+
* write down once, in the documentation, for every deployment.
|
|
3992
|
+
*/
|
|
3993
|
+
var RENDITION_PREFIX = "_rebase/renditions/";
|
|
3994
|
+
/** True for a key that names the reserved rendition space. */
|
|
3995
|
+
function isRenditionKey(key) {
|
|
3996
|
+
return key.startsWith(RENDITION_PREFIX);
|
|
3997
|
+
}
|
|
3998
|
+
/**
|
|
3999
|
+
* Extensions for what `transformImage` can emit.
|
|
4000
|
+
*
|
|
4001
|
+
* The extension is cosmetic — the content type is read back from the object's
|
|
4002
|
+
* own metadata — but an unreadable bucket listing is a bucket nobody can reason
|
|
4003
|
+
* about, and `…-a3f9.webp` is the difference.
|
|
4004
|
+
*/
|
|
4005
|
+
var EXTENSIONS = {
|
|
4006
|
+
"image/webp": "webp",
|
|
4007
|
+
"image/avif": "avif",
|
|
4008
|
+
"image/jpeg": "jpg",
|
|
4009
|
+
"image/png": "png"
|
|
4010
|
+
};
|
|
4011
|
+
/**
|
|
4012
|
+
* The object key for a rendition.
|
|
4013
|
+
*
|
|
4014
|
+
* Hashed rather than derived from the source path: the source key can be up to
|
|
4015
|
+
* 1024 characters and the transform options are a JSON object, so a readable
|
|
4016
|
+
* composition would exceed key limits on exactly the paths people use — deep
|
|
4017
|
+
* per-user prefixes. The source key is kept in the object's metadata, where it
|
|
4018
|
+
* is available to anyone auditing the prefix and to nothing on the hot path.
|
|
4019
|
+
*/
|
|
4020
|
+
function renditionKey(cacheKey, contentType) {
|
|
4021
|
+
const digest = createHash("sha256").update(cacheKey).digest("hex");
|
|
4022
|
+
const extension = EXTENSIONS[contentType];
|
|
4023
|
+
return `${RENDITION_PREFIX}${digest}${extension ? `.${extension}` : ""}`;
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* A rendition's key when reading, where the content type is not yet known.
|
|
4027
|
+
*
|
|
4028
|
+
* Reads try the known extensions rather than storing an index: four `getObject`
|
|
4029
|
+
* calls at worst is still one round trip in the common case — the first
|
|
4030
|
+
* candidate is the format the request asked for — and an index is a second
|
|
4031
|
+
* thing to keep consistent with the objects it describes.
|
|
4032
|
+
*/
|
|
4033
|
+
function renditionKeyCandidates(cacheKey, preferred) {
|
|
4034
|
+
const digest = createHash("sha256").update(cacheKey).digest("hex");
|
|
4035
|
+
const extensions = Object.values(EXTENSIONS);
|
|
4036
|
+
return (preferred && EXTENSIONS[preferred] ? [EXTENSIONS[preferred], ...extensions.filter((e) => e !== EXTENSIONS[preferred])] : extensions).map((extension) => `${RENDITION_PREFIX}${digest}.${extension}`);
|
|
4037
|
+
}
|
|
4038
|
+
function createDurableRenditionCache() {
|
|
4039
|
+
let writeFailureAnnounced = false;
|
|
4040
|
+
return {
|
|
4041
|
+
async get(controller, cacheKey, bucket) {
|
|
4042
|
+
for (const key of renditionKeyCandidates(cacheKey)) try {
|
|
4043
|
+
const object = await controller.getObject(key, bucket);
|
|
4044
|
+
if (!object) continue;
|
|
4045
|
+
return {
|
|
4046
|
+
data: Buffer.from(await object.arrayBuffer()),
|
|
4047
|
+
contentType: object.type || "application/octet-stream"
|
|
4048
|
+
};
|
|
4049
|
+
} catch {
|
|
4050
|
+
continue;
|
|
4051
|
+
}
|
|
4052
|
+
return null;
|
|
4053
|
+
},
|
|
4054
|
+
async put(controller, cacheKey, bucket, rendition) {
|
|
4055
|
+
const key = renditionKey(cacheKey, rendition.contentType);
|
|
4056
|
+
try {
|
|
4057
|
+
await controller.putObject({
|
|
4058
|
+
file: new File([new Uint8Array(rendition.data)], key.split("/").pop(), { type: rendition.contentType }),
|
|
4059
|
+
key,
|
|
4060
|
+
bucket,
|
|
4061
|
+
metadata: {
|
|
4062
|
+
contentType: rendition.contentType,
|
|
4063
|
+
rebaseRendition: "1"
|
|
4064
|
+
},
|
|
4065
|
+
public: false
|
|
4066
|
+
});
|
|
4067
|
+
} catch (err) {
|
|
4068
|
+
if (!writeFailureAnnounced) {
|
|
4069
|
+
writeFailureAnnounced = true;
|
|
4070
|
+
logger.warn(`[storage] Could not write an image rendition to the storage source, so transforms stay in the per-instance memory cache. Check that the bucket credentials permit writes under "${RENDITION_PREFIX}".`, { error: err instanceof Error ? err.message : String(err) });
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
};
|
|
4075
|
+
}
|
|
4076
|
+
//#endregion
|
|
3547
4077
|
//#region src/storage/routes.ts
|
|
3548
4078
|
/**
|
|
3549
4079
|
* Storage REST API routes using Hono
|
|
@@ -3553,43 +4083,44 @@ var TusHandler = class {
|
|
|
3553
4083
|
* to target a named storage backend. When omitted, the default backend
|
|
3554
4084
|
* is used.
|
|
3555
4085
|
*/
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
*
|
|
3561
|
-
* Without it, N concurrent requests for one uncached variant run N full
|
|
3562
|
-
* decode+encode pipelines — the cache only helps the requests that arrive
|
|
3563
|
-
* after the first one has finished. Joining the in-flight promise makes a
|
|
3564
|
-
* thundering herd cost one decode.
|
|
3565
|
-
*/
|
|
3566
|
-
var transformsInFlight = /* @__PURE__ */ new Map();
|
|
4086
|
+
var createTransformMemory = () => ({
|
|
4087
|
+
cache: new TransformCache(),
|
|
4088
|
+
inFlight: /* @__PURE__ */ new Map()
|
|
4089
|
+
});
|
|
3567
4090
|
/**
|
|
3568
4091
|
* Compute a transform, or join the one already running for this key.
|
|
3569
4092
|
*
|
|
3570
4093
|
* `loadSource` is only called on a real miss, so the concurrent requests that
|
|
3571
4094
|
* join an in-flight transform do not each read the source object either.
|
|
3572
4095
|
*/
|
|
3573
|
-
async function transformOnce(cacheKey, options, loadSource) {
|
|
3574
|
-
const cached =
|
|
4096
|
+
async function transformOnce(memory, cacheKey, options, loadSource, durable) {
|
|
4097
|
+
const cached = memory.cache.get(cacheKey);
|
|
3575
4098
|
if (cached) return cached;
|
|
3576
|
-
const running =
|
|
4099
|
+
const running = memory.inFlight.get(cacheKey);
|
|
3577
4100
|
if (running) return running;
|
|
3578
4101
|
const pending = (async () => {
|
|
3579
4102
|
try {
|
|
4103
|
+
if (durable) {
|
|
4104
|
+
const stored = await durable.cache.get(durable.controller, cacheKey, durable.bucket);
|
|
4105
|
+
if (stored) {
|
|
4106
|
+
memory.cache.set(cacheKey, stored.data, stored.contentType);
|
|
4107
|
+
return stored;
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
3580
4110
|
const result = await transformImage(await loadSource(), options);
|
|
3581
|
-
|
|
4111
|
+
memory.cache.set(cacheKey, result.data, result.contentType);
|
|
4112
|
+
if (durable) await durable.cache.put(durable.controller, cacheKey, durable.bucket, result);
|
|
3582
4113
|
return result;
|
|
3583
4114
|
} catch (err) {
|
|
3584
4115
|
if (err instanceof TransformOverloadedError) throw ApiError.serviceUnavailable(err.message, "TRANSFORM_OVERLOADED");
|
|
3585
4116
|
throw err;
|
|
3586
4117
|
}
|
|
3587
4118
|
})();
|
|
3588
|
-
|
|
4119
|
+
memory.inFlight.set(cacheKey, pending);
|
|
3589
4120
|
try {
|
|
3590
4121
|
return await pending;
|
|
3591
4122
|
} finally {
|
|
3592
|
-
|
|
4123
|
+
memory.inFlight.delete(cacheKey);
|
|
3593
4124
|
}
|
|
3594
4125
|
}
|
|
3595
4126
|
/**
|
|
@@ -3662,7 +4193,9 @@ function extractWildcardPath(c) {
|
|
|
3662
4193
|
*/
|
|
3663
4194
|
function canonicalKeyOrBadRequest(key) {
|
|
3664
4195
|
try {
|
|
3665
|
-
|
|
4196
|
+
const canonical = canonicalStorageKey(key);
|
|
4197
|
+
if (isRenditionKey(canonical)) throw new InvalidStorageKeyError(`"${RENDITION_PREFIX}" is reserved for derived image renditions and cannot be read or written directly.`);
|
|
4198
|
+
return canonical;
|
|
3666
4199
|
} catch (err) {
|
|
3667
4200
|
throw new ApiError(400, "INVALID_STORAGE_KEY", err instanceof InvalidStorageKeyError ? err.message : "Invalid storage key");
|
|
3668
4201
|
}
|
|
@@ -3745,6 +4278,9 @@ function createStorageRoutes(config) {
|
|
|
3745
4278
|
const router = new Hono();
|
|
3746
4279
|
router.onError(errorHandler);
|
|
3747
4280
|
const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize, authorizeData } = config;
|
|
4281
|
+
const durableRenditions = config.renditionCache?.enabled ? createDurableRenditionCache() : void 0;
|
|
4282
|
+
const transformMemory = createTransformMemory();
|
|
4283
|
+
const fireTrigger = config.triggers?.length ? compileStorageTriggers(config.triggers) : void 0;
|
|
3748
4284
|
/**
|
|
3749
4285
|
* Run the per-object authorization hook, if one is configured.
|
|
3750
4286
|
*
|
|
@@ -3873,6 +4409,16 @@ function createStorageRoutes(config) {
|
|
|
3873
4409
|
metadata: Object.keys(metadata).length > 0 ? metadata : void 0,
|
|
3874
4410
|
bucket
|
|
3875
4411
|
});
|
|
4412
|
+
await fireTrigger?.({
|
|
4413
|
+
event: "finalize",
|
|
4414
|
+
key: finalKey,
|
|
4415
|
+
bucket,
|
|
4416
|
+
storageId: canonicalStorageId(storageId),
|
|
4417
|
+
size: uploadedFile.size,
|
|
4418
|
+
contentType: uploadedFile.type || void 0,
|
|
4419
|
+
user: triggerUser(c.get("user")),
|
|
4420
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4421
|
+
});
|
|
3876
4422
|
return c.json({
|
|
3877
4423
|
success: true,
|
|
3878
4424
|
data: result
|
|
@@ -3893,11 +4439,20 @@ function createStorageRoutes(config) {
|
|
|
3893
4439
|
await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
|
|
3894
4440
|
c.header("X-Content-Type-Options", "nosniff");
|
|
3895
4441
|
const transformKeyPrefix = `${storageId || "(default)"}/${bucket}/${resolvedPath}`;
|
|
4442
|
+
const sharedCacheable = publicRead === true || isPublicStoragePath(resolvedPath);
|
|
4443
|
+
const cachePolicy = (maxAgeSeconds) => ({
|
|
4444
|
+
isPublic: sharedCacheable,
|
|
4445
|
+
maxAgeSeconds,
|
|
4446
|
+
staleWhileRevalidateSeconds: 86400
|
|
4447
|
+
});
|
|
4448
|
+
const OBJECT_MAX_AGE = 60;
|
|
4449
|
+
const TRANSFORM_MAX_AGE = 3600;
|
|
3896
4450
|
const transformOpts = transformOptionsOrBadRequest(c.req.query());
|
|
3897
4451
|
if (resolved.getType() === "local") {
|
|
3898
4452
|
const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket ?? "default");
|
|
4453
|
+
let localStat;
|
|
3899
4454
|
try {
|
|
3900
|
-
await fsp.
|
|
4455
|
+
localStat = await fsp.stat(absolutePath);
|
|
3901
4456
|
} catch {
|
|
3902
4457
|
throw ApiError.notFound("File not found");
|
|
3903
4458
|
}
|
|
@@ -3908,31 +4463,85 @@ function createStorageRoutes(config) {
|
|
|
3908
4463
|
contentType = JSON.parse(metadataRaw).contentType || contentType;
|
|
3909
4464
|
} catch {}
|
|
3910
4465
|
if (transformOpts && isTransformableImage(contentType)) {
|
|
3911
|
-
const
|
|
4466
|
+
const sourceVersion = buildEntityTag(localStat.size, localStat.mtimeMs);
|
|
4467
|
+
const cacheKey = transformMemory.cache.buildKey(`${transformKeyPrefix}@${sourceVersion}`, transformOpts);
|
|
4468
|
+
const transformed = await transformOnce(transformMemory, cacheKey, transformOpts, async () => Buffer.from(await fsp.readFile(absolutePath)), durableRenditions ? {
|
|
4469
|
+
cache: durableRenditions,
|
|
4470
|
+
controller: resolved,
|
|
4471
|
+
bucket
|
|
4472
|
+
} : void 0);
|
|
4473
|
+
const validators = objectValidators(transformed.data.byteLength, localStat.mtimeMs);
|
|
4474
|
+
applyCacheHeaders(c, validators, cachePolicy(TRANSFORM_MAX_AGE));
|
|
4475
|
+
if (isNotModified(c, validators)) return c.body(null, 304);
|
|
3912
4476
|
c.header("Content-Type", transformed.contentType);
|
|
3913
|
-
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
3914
4477
|
return c.body(new Uint8Array(transformed.data));
|
|
3915
4478
|
}
|
|
3916
|
-
const
|
|
4479
|
+
const validators = objectValidators(localStat.size, localStat.mtimeMs);
|
|
4480
|
+
applyCacheHeaders(c, validators, cachePolicy(OBJECT_MAX_AGE));
|
|
3917
4481
|
const served = resolveServedContentType(contentType);
|
|
3918
4482
|
c.header("Content-Type", served.contentType);
|
|
3919
4483
|
if (served.attachment) c.header("Content-Disposition", "attachment");
|
|
4484
|
+
c.header("Accept-Ranges", "bytes");
|
|
4485
|
+
if (isNotModified(c, validators)) return c.body(null, 304);
|
|
4486
|
+
const localRange = parseRange(c.req.header("range"), localStat.size);
|
|
4487
|
+
if (localRange.kind === "unsatisfiable") {
|
|
4488
|
+
c.header("Content-Range", unsatisfiableContentRange(localStat.size));
|
|
4489
|
+
return c.body(null, 416);
|
|
4490
|
+
}
|
|
4491
|
+
if (localRange.kind === "range") {
|
|
4492
|
+
const { start, length } = localRange.range;
|
|
4493
|
+
const handle = await fsp.open(absolutePath, "r");
|
|
4494
|
+
try {
|
|
4495
|
+
const buffer = Buffer.alloc(length);
|
|
4496
|
+
await handle.read(buffer, 0, length, start);
|
|
4497
|
+
c.header("Content-Range", contentRange(localRange.range, localStat.size));
|
|
4498
|
+
c.header("Content-Length", String(length));
|
|
4499
|
+
return c.body(new Uint8Array(buffer), 206);
|
|
4500
|
+
} finally {
|
|
4501
|
+
await handle.close();
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
c.header("Content-Length", String(localStat.size));
|
|
4505
|
+
const fileContent = await fsp.readFile(absolutePath);
|
|
3920
4506
|
return c.body(new Uint8Array(fileContent));
|
|
3921
4507
|
}
|
|
3922
4508
|
const fileObject = await resolved.getObject(resolvedPath, bucket);
|
|
3923
4509
|
if (!fileObject) throw ApiError.notFound("File not found");
|
|
3924
4510
|
const remoteContentType = fileObject.type || "application/octet-stream";
|
|
3925
4511
|
if (transformOpts && isTransformableImage(remoteContentType)) {
|
|
3926
|
-
const
|
|
4512
|
+
const sourceVersion = buildEntityTag(fileObject.size, fileObject.lastModified);
|
|
4513
|
+
const cacheKey = transformMemory.cache.buildKey(`${transformKeyPrefix}@${sourceVersion}`, transformOpts);
|
|
4514
|
+
const transformed = await transformOnce(transformMemory, cacheKey, transformOpts, async () => Buffer.from(await fileObject.arrayBuffer()), durableRenditions ? {
|
|
4515
|
+
cache: durableRenditions,
|
|
4516
|
+
controller: resolved,
|
|
4517
|
+
bucket
|
|
4518
|
+
} : void 0);
|
|
4519
|
+
const validators = objectValidators(transformed.data.byteLength, fileObject.lastModified);
|
|
4520
|
+
applyCacheHeaders(c, validators, cachePolicy(TRANSFORM_MAX_AGE));
|
|
4521
|
+
if (isNotModified(c, validators)) return c.body(null, 304);
|
|
3927
4522
|
c.header("Content-Type", transformed.contentType);
|
|
3928
|
-
c.header("Cache-Control", "public, max-age=31536000, immutable");
|
|
3929
4523
|
return c.body(new Uint8Array(transformed.data));
|
|
3930
4524
|
}
|
|
3931
4525
|
const servedRemote = resolveServedContentType(remoteContentType);
|
|
3932
4526
|
c.header("Content-Type", servedRemote.contentType);
|
|
3933
4527
|
if (servedRemote.attachment) c.header("Content-Disposition", "attachment");
|
|
3934
|
-
|
|
4528
|
+
const remoteValidators = objectValidators(fileObject.size, fileObject.lastModified);
|
|
4529
|
+
applyCacheHeaders(c, remoteValidators, cachePolicy(OBJECT_MAX_AGE));
|
|
4530
|
+
c.header("Accept-Ranges", "bytes");
|
|
4531
|
+
if (isNotModified(c, remoteValidators)) return c.body(null, 304);
|
|
4532
|
+
const remoteRange = parseRange(c.req.header("range"), fileObject.size);
|
|
4533
|
+
if (remoteRange.kind === "unsatisfiable") {
|
|
4534
|
+
c.header("Content-Range", unsatisfiableContentRange(fileObject.size));
|
|
4535
|
+
return c.body(null, 416);
|
|
4536
|
+
}
|
|
3935
4537
|
const buf = await fileObject.arrayBuffer();
|
|
4538
|
+
if (remoteRange.kind === "range") {
|
|
4539
|
+
const { start, end, length } = remoteRange.range;
|
|
4540
|
+
c.header("Content-Range", contentRange(remoteRange.range, fileObject.size));
|
|
4541
|
+
c.header("Content-Length", String(length));
|
|
4542
|
+
return c.body(new Uint8Array(buf.slice(start, end + 1)), 206);
|
|
4543
|
+
}
|
|
4544
|
+
c.header("Content-Length", String(fileObject.size));
|
|
3936
4545
|
return c.body(new Uint8Array(buf));
|
|
3937
4546
|
});
|
|
3938
4547
|
/**
|
|
@@ -3980,6 +4589,14 @@ function createStorageRoutes(config) {
|
|
|
3980
4589
|
const { bucket, resolvedPath } = parseBucketAndPath(filePath);
|
|
3981
4590
|
await checkAuthorized(c, "delete", resolvedPath, bucket, storageId);
|
|
3982
4591
|
await resolved.deleteObject(resolvedPath, bucket);
|
|
4592
|
+
await fireTrigger?.({
|
|
4593
|
+
event: "delete",
|
|
4594
|
+
key: resolvedPath,
|
|
4595
|
+
bucket,
|
|
4596
|
+
storageId: canonicalStorageId(storageId),
|
|
4597
|
+
user: triggerUser(c.get("user")),
|
|
4598
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4599
|
+
});
|
|
3983
4600
|
return c.json({
|
|
3984
4601
|
success: true,
|
|
3985
4602
|
message: "File deleted"
|
|
@@ -4040,6 +4657,17 @@ function createStorageRoutes(config) {
|
|
|
4040
4657
|
const defaultCtrl = getDefaultController();
|
|
4041
4658
|
const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket, storageId) => {
|
|
4042
4659
|
await checkAuthorized(c, "write", key, bucket, storageId);
|
|
4660
|
+
} : void 0, fireTrigger ? async (event) => {
|
|
4661
|
+
await fireTrigger({
|
|
4662
|
+
event: "finalize",
|
|
4663
|
+
key: event.key,
|
|
4664
|
+
bucket: event.bucket,
|
|
4665
|
+
storageId: canonicalStorageId(event.storageId),
|
|
4666
|
+
size: event.size,
|
|
4667
|
+
contentType: event.contentType,
|
|
4668
|
+
user: event.user,
|
|
4669
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
4670
|
+
});
|
|
4043
4671
|
} : void 0);
|
|
4044
4672
|
tusHandler.startCleanup();
|
|
4045
4673
|
router.options("/tus", (_c) => tusHandler.options());
|
|
@@ -4091,11 +4719,11 @@ async function createStorageController(config) {
|
|
|
4091
4719
|
switch (config.type) {
|
|
4092
4720
|
case "local": return new LocalStorageController(config);
|
|
4093
4721
|
case "s3": {
|
|
4094
|
-
const { S3StorageController } = await import("./S3StorageController-
|
|
4722
|
+
const { S3StorageController } = await import("./S3StorageController-CWvvrRpF.js").then((n) => n.n);
|
|
4095
4723
|
return new S3StorageController(config);
|
|
4096
4724
|
}
|
|
4097
4725
|
case "gcs": {
|
|
4098
|
-
const { GCSStorageController } = await import("./GCSStorageController-
|
|
4726
|
+
const { GCSStorageController } = await import("./GCSStorageController-Bl7nmhdv.js").then((n) => n.n);
|
|
4099
4727
|
return new GCSStorageController(config);
|
|
4100
4728
|
}
|
|
4101
4729
|
default: throw new Error(`Unknown storage type: ${config.type}. Built-in types: local, s3, gcs. For other providers, implement the StorageController interface directly.`);
|
|
@@ -4161,6 +4789,79 @@ function assertStorageAccessControlConfigured(state, isProduction) {
|
|
|
4161
4789
|
logger.warn(STORAGE_NO_ACCESS_CONTROL_MESSAGE);
|
|
4162
4790
|
}
|
|
4163
4791
|
//#endregion
|
|
4792
|
+
//#region src/storage/policies.ts
|
|
4793
|
+
var StoragePolicyError = class extends Error {
|
|
4794
|
+
constructor(message) {
|
|
4795
|
+
super(message);
|
|
4796
|
+
this.name = "StoragePolicyError";
|
|
4797
|
+
}
|
|
4798
|
+
};
|
|
4799
|
+
var ALL_OPERATIONS = [
|
|
4800
|
+
"read",
|
|
4801
|
+
"write",
|
|
4802
|
+
"delete",
|
|
4803
|
+
"list"
|
|
4804
|
+
];
|
|
4805
|
+
function compile(policy, index) {
|
|
4806
|
+
const label = `storagePolicies[${index}]`;
|
|
4807
|
+
let pattern;
|
|
4808
|
+
try {
|
|
4809
|
+
pattern = compileStoragePattern(policy.path, label);
|
|
4810
|
+
} catch (err) {
|
|
4811
|
+
throw err instanceof StoragePatternError ? new StoragePolicyError(err.message) : err;
|
|
4812
|
+
}
|
|
4813
|
+
if (policy.operations !== void 0) {
|
|
4814
|
+
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.`);
|
|
4815
|
+
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(", ")}.`);
|
|
4816
|
+
}
|
|
4817
|
+
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.`);
|
|
4818
|
+
return {
|
|
4819
|
+
pattern,
|
|
4820
|
+
operations: new Set(policy.operations ?? ALL_OPERATIONS),
|
|
4821
|
+
allow: policy.allow
|
|
4822
|
+
};
|
|
4823
|
+
}
|
|
4824
|
+
/**
|
|
4825
|
+
* Compile a policy list into a {@link StorageAuthorize}.
|
|
4826
|
+
*
|
|
4827
|
+
* Compiled once, at boot, so a malformed pattern fails the start rather than the
|
|
4828
|
+
* first upload. `fallback` is an explicit `storageAuthorize`, consulted only
|
|
4829
|
+
* when no policy matched.
|
|
4830
|
+
*/
|
|
4831
|
+
function compileStoragePolicies(policies, fallback) {
|
|
4832
|
+
if (!Array.isArray(policies)) throw new StoragePolicyError("`storagePolicies` must be an array.");
|
|
4833
|
+
const compiled = policies.map(compile);
|
|
4834
|
+
return async (ctx) => {
|
|
4835
|
+
for (const policy of compiled) {
|
|
4836
|
+
if (!policy.operations.has(ctx.operation)) continue;
|
|
4837
|
+
const params = matchStoragePattern(policy.pattern, ctx.key);
|
|
4838
|
+
if (!params) continue;
|
|
4839
|
+
if (policy.allow === "public") return true;
|
|
4840
|
+
if (policy.allow === "authenticated") {
|
|
4841
|
+
if (ctx.user?.uid) return true;
|
|
4842
|
+
continue;
|
|
4843
|
+
}
|
|
4844
|
+
if (await policy.allow({
|
|
4845
|
+
...ctx,
|
|
4846
|
+
params
|
|
4847
|
+
})) return true;
|
|
4848
|
+
}
|
|
4849
|
+
if (fallback) return await fallback(ctx);
|
|
4850
|
+
return false;
|
|
4851
|
+
};
|
|
4852
|
+
}
|
|
4853
|
+
/**
|
|
4854
|
+
* The access-control function for a configuration, or `undefined` when it
|
|
4855
|
+
* declares none.
|
|
4856
|
+
*
|
|
4857
|
+
* Kept separate from {@link compileStoragePolicies} so `init` has one call to
|
|
4858
|
+
* make and one thing to reason about: policies, a hook, both, or neither.
|
|
4859
|
+
*/
|
|
4860
|
+
function resolveStorageAccessControl(config) {
|
|
4861
|
+
if (config.storagePolicies?.length) return compileStoragePolicies(config.storagePolicies, config.storageAuthorize);
|
|
4862
|
+
return config.storageAuthorize;
|
|
4863
|
+
}
|
|
4864
|
+
//#endregion
|
|
4164
4865
|
//#region src/init/docs.ts
|
|
4165
4866
|
/**
|
|
4166
4867
|
* Serve the OpenAPI document, and Swagger UI outside production.
|
|
@@ -4173,7 +4874,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
|
|
|
4173
4874
|
*/
|
|
4174
4875
|
async function mountOpenApiDocs(app, basePath, enableSwagger, serverCollections, requireAuth) {
|
|
4175
4876
|
if (enableSwagger === false || serverCollections.length === 0) return;
|
|
4176
|
-
const { generateOpenApiSpec } = await import("./openapi-generator-
|
|
4877
|
+
const { generateOpenApiSpec } = await import("./openapi-generator-wFJo22gg.js");
|
|
4177
4878
|
app.get(`${basePath}/docs`, (c) => {
|
|
4178
4879
|
const spec = generateOpenApiSpec(serverCollections, {
|
|
4179
4880
|
basePath,
|
|
@@ -4256,6 +4957,101 @@ function createHealthCheck(defaultDriver, authSchemaCheck) {
|
|
|
4256
4957
|
};
|
|
4257
4958
|
}
|
|
4258
4959
|
//#endregion
|
|
4960
|
+
//#region src/functions/wait-until.ts
|
|
4961
|
+
/**
|
|
4962
|
+
* Pending background work, on a process-global slot for the reason given in
|
|
4963
|
+
* `../singleton.ts`: two copies of this module in one process must not each
|
|
4964
|
+
* hold half the work, or shutdown drains one half and drops the other.
|
|
4965
|
+
*/
|
|
4966
|
+
var PENDING_SLOT = Symbol.for("@rebasepro/server:pending-background-work");
|
|
4967
|
+
function pending() {
|
|
4968
|
+
const global = globalThis;
|
|
4969
|
+
if (!global[PENDING_SLOT]) global[PENDING_SLOT] = /* @__PURE__ */ new Set();
|
|
4970
|
+
return global[PENDING_SLOT];
|
|
4971
|
+
}
|
|
4972
|
+
/**
|
|
4973
|
+
* Keep `work` alive past the response, on any runtime.
|
|
4974
|
+
*
|
|
4975
|
+
* Hand it a promise or a function returning one. Nothing is awaited: the
|
|
4976
|
+
* response goes out immediately, exactly as an un-awaited call would.
|
|
4977
|
+
*
|
|
4978
|
+
* What it buys, per host:
|
|
4979
|
+
*
|
|
4980
|
+
* - **Isolate hosts** (Workers, Deno Deploy, Vercel Edge): the host is told to
|
|
4981
|
+
* keep the isolate alive until the promise settles. Without this the work is
|
|
4982
|
+
* dropped when the response resolves.
|
|
4983
|
+
* - **Node**: the promise is tracked so {@link drainBackgroundWork} can wait for
|
|
4984
|
+
* it during graceful shutdown instead of the process exiting out from under
|
|
4985
|
+
* it.
|
|
4986
|
+
*
|
|
4987
|
+
* On every host a rejection is logged rather than left to the unhandled-rejection
|
|
4988
|
+
* handler, so the failure names the function it came from.
|
|
4989
|
+
*
|
|
4990
|
+
* @example
|
|
4991
|
+
* ```ts
|
|
4992
|
+
* app.post("/orders", requireAuth, async (c) => {
|
|
4993
|
+
* const order = await createOrder(c);
|
|
4994
|
+
* waitUntil(c, notifyWarehouse(order)); // caller does not wait for this
|
|
4995
|
+
* return c.json({ id: order.id });
|
|
4996
|
+
* });
|
|
4997
|
+
* ```
|
|
4998
|
+
*/
|
|
4999
|
+
function waitUntil(c, work) {
|
|
5000
|
+
let promise;
|
|
5001
|
+
try {
|
|
5002
|
+
promise = typeof work === "function" ? Promise.resolve(work()) : work;
|
|
5003
|
+
} catch (error) {
|
|
5004
|
+
logBackgroundFailure(c, error);
|
|
5005
|
+
return;
|
|
5006
|
+
}
|
|
5007
|
+
const tracked = promise.catch((error) => {
|
|
5008
|
+
logBackgroundFailure(c, error);
|
|
5009
|
+
});
|
|
5010
|
+
const set = pending();
|
|
5011
|
+
set.add(tracked);
|
|
5012
|
+
tracked.finally(() => set.delete(tracked));
|
|
5013
|
+
try {
|
|
5014
|
+
c.executionCtx?.waitUntil?.(tracked);
|
|
5015
|
+
} catch {}
|
|
5016
|
+
}
|
|
5017
|
+
function logBackgroundFailure(c, error) {
|
|
5018
|
+
logger.error("[functions] Background work failed after the response was sent", {
|
|
5019
|
+
method: c.req?.method,
|
|
5020
|
+
path: c.req?.path,
|
|
5021
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
5022
|
+
});
|
|
5023
|
+
}
|
|
5024
|
+
/** How many background promises are still in flight. Diagnostics and tests. */
|
|
5025
|
+
function pendingBackgroundWork() {
|
|
5026
|
+
return pending().size;
|
|
5027
|
+
}
|
|
5028
|
+
/**
|
|
5029
|
+
* Wait for tracked background work to finish, up to `timeoutMs`.
|
|
5030
|
+
*
|
|
5031
|
+
* Called by the shutdown path after the server stops accepting connections. It
|
|
5032
|
+
* resolves to the number of promises still outstanding when it returned — `0`
|
|
5033
|
+
* for a clean drain, more than that for a timeout, which is worth a log line
|
|
5034
|
+
* because it means work was dropped.
|
|
5035
|
+
*
|
|
5036
|
+
* Rejections do not propagate: every tracked promise has already had a `catch`
|
|
5037
|
+
* attached by {@link waitUntil}.
|
|
5038
|
+
*/
|
|
5039
|
+
async function drainBackgroundWork(timeoutMs = 5e3) {
|
|
5040
|
+
const set = pending();
|
|
5041
|
+
if (set.size === 0) return 0;
|
|
5042
|
+
let timer;
|
|
5043
|
+
const expired = new Promise((resolve) => {
|
|
5044
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
5045
|
+
timer.unref?.();
|
|
5046
|
+
});
|
|
5047
|
+
try {
|
|
5048
|
+
await Promise.race([Promise.allSettled([...set]), expired]);
|
|
5049
|
+
} finally {
|
|
5050
|
+
if (timer) clearTimeout(timer);
|
|
5051
|
+
}
|
|
5052
|
+
return set.size;
|
|
5053
|
+
}
|
|
5054
|
+
//#endregion
|
|
4259
5055
|
//#region src/init/shutdown.ts
|
|
4260
5056
|
/**
|
|
4261
5057
|
* Install graceful-shutdown signal handlers for a Rebase backend.
|
|
@@ -4326,10 +5122,14 @@ function createShutdown(config) {
|
|
|
4326
5122
|
config.cronScheduler.stop();
|
|
4327
5123
|
logger.info("Cron scheduler stopped");
|
|
4328
5124
|
}
|
|
5125
|
+
if (config.rlsAudit) config.rlsAudit.stop();
|
|
4329
5126
|
if (config.jobQueue) {
|
|
4330
5127
|
await config.jobQueue.stop();
|
|
4331
5128
|
logger.info("Job queue stopped");
|
|
4332
5129
|
}
|
|
5130
|
+
const drainBudget = timeoutMs > 0 ? Math.min(5e3, timeoutMs) : 5e3;
|
|
5131
|
+
const stillPending = await drainBackgroundWork(drainBudget);
|
|
5132
|
+
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.`);
|
|
4333
5133
|
for (const [key, rt] of Object.entries(config.realtimeServices)) try {
|
|
4334
5134
|
if (typeof rt.destroy === "function") {
|
|
4335
5135
|
await rt.destroy();
|
|
@@ -4354,18 +5154,1053 @@ function createShutdown(config) {
|
|
|
4354
5154
|
};
|
|
4355
5155
|
}
|
|
4356
5156
|
//#endregion
|
|
4357
|
-
//#region src/
|
|
4358
|
-
/**
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
5157
|
+
//#region src/schema-edit/apply-schema-change.ts
|
|
5158
|
+
/**
|
|
5159
|
+
* The plan says the change is not applicable.
|
|
5160
|
+
*
|
|
5161
|
+
* Re-checked here rather than trusted from the planner: this module is the one
|
|
5162
|
+
* that writes and runs things, so the guarantee belongs where the consequence
|
|
5163
|
+
* is. A caller that built a plan by hand cannot route around it.
|
|
5164
|
+
*/
|
|
5165
|
+
var UnapplicableChangeError = class extends Error {
|
|
5166
|
+
classified;
|
|
5167
|
+
constructor(message, classified) {
|
|
5168
|
+
super(message);
|
|
5169
|
+
this.classified = classified;
|
|
5170
|
+
this.name = "UnapplicableChangeError";
|
|
5171
|
+
}
|
|
5172
|
+
};
|
|
5173
|
+
var DirtyWorkingTreeError = class extends Error {
|
|
5174
|
+
paths;
|
|
5175
|
+
constructor(paths) {
|
|
5176
|
+
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.");
|
|
5177
|
+
this.paths = paths;
|
|
5178
|
+
this.name = "DirtyWorkingTreeError";
|
|
5179
|
+
}
|
|
5180
|
+
};
|
|
5181
|
+
/**
|
|
5182
|
+
* Generate the commit, land it, then run the DDL.
|
|
5183
|
+
*
|
|
5184
|
+
* Throws only for things that mean *nothing happened*: a change the ensure path
|
|
5185
|
+
* cannot express, a dirty tree, or a failed commit. Once the commit lands,
|
|
5186
|
+
* every outcome is a result.
|
|
5187
|
+
*/
|
|
5188
|
+
async function applySchemaChange(input) {
|
|
5189
|
+
const commit = input.plan;
|
|
5190
|
+
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);
|
|
5191
|
+
const plannedPaths = [...commit.files.map((file) => file.path), ...input.sourcePaths ?? []];
|
|
5192
|
+
const dirty = (await input.repository.dirtyPaths()).filter((path) => plannedPaths.includes(path));
|
|
5193
|
+
if (dirty.length > 0) throw new DirtyWorkingTreeError(dirty);
|
|
5194
|
+
const branch = await input.repository.currentBranch();
|
|
5195
|
+
const files = [...input.writeSource ? await input.writeSource() : [], ...commit.files];
|
|
5196
|
+
const paths = files.map((file) => file.path);
|
|
5197
|
+
await input.repository.writeFiles(files);
|
|
5198
|
+
const sha = await input.repository.commit(paths, commit.message);
|
|
5199
|
+
const committed = {
|
|
5200
|
+
sha,
|
|
5201
|
+
branch,
|
|
5202
|
+
files: paths
|
|
5203
|
+
};
|
|
5204
|
+
if (commit.statements.length === 0) return {
|
|
5205
|
+
committed,
|
|
5206
|
+
applied: true,
|
|
5207
|
+
statements: [],
|
|
5208
|
+
classified: commit.classified,
|
|
5209
|
+
summary: `Committed ${sha.slice(0, 9)} on ${branch}. No DDL was needed.`
|
|
5210
|
+
};
|
|
5211
|
+
try {
|
|
5212
|
+
await input.apply(commit.statements);
|
|
5213
|
+
return {
|
|
5214
|
+
committed,
|
|
5215
|
+
applied: true,
|
|
5216
|
+
statements: commit.statements,
|
|
5217
|
+
classified: commit.classified,
|
|
5218
|
+
summary: `Committed ${sha.slice(0, 9)} on ${branch} and applied ${commit.statements.length} statement(s).`
|
|
5219
|
+
};
|
|
5220
|
+
} catch (err) {
|
|
5221
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
5222
|
+
return {
|
|
5223
|
+
committed,
|
|
5224
|
+
applied: false,
|
|
5225
|
+
applyError: detail,
|
|
5226
|
+
statements: commit.statements,
|
|
5227
|
+
classified: commit.classified,
|
|
5228
|
+
summary: `Committed ${sha.slice(0, 9)} on ${branch}, but the database was not changed: ${detail}. The change will be applied on the next boot.`
|
|
5229
|
+
};
|
|
5230
|
+
}
|
|
5231
|
+
}
|
|
5232
|
+
//#endregion
|
|
5233
|
+
//#region src/schema-edit/schema-edit-permissions.ts
|
|
5234
|
+
/**
|
|
5235
|
+
* A caller whose identity is a credential rather than a person.
|
|
5236
|
+
*
|
|
5237
|
+
* Two shapes, both set by the auth middleware:
|
|
5238
|
+
*
|
|
5239
|
+
* - the service key resolves to `uid: "service"` with `roles: ["admin"]`;
|
|
5240
|
+
* - an API key resolves to `uid: "api-key:<id>"`, with `service` among its
|
|
5241
|
+
* roles and `admin` too when the key is an admin key.
|
|
5242
|
+
*
|
|
5243
|
+
* Detected by uid rather than by role, because `service` is also a role a
|
|
5244
|
+
* person could be granted, and a person with an unfortunate role name should
|
|
5245
|
+
* not lose the ability to commit under their own name.
|
|
5246
|
+
*/
|
|
5247
|
+
function machineKindOf(uid) {
|
|
5248
|
+
if (uid === "service") return "service-key";
|
|
5249
|
+
if (uid.startsWith("api-key:")) return "api-key";
|
|
5250
|
+
}
|
|
5251
|
+
/** Read the caller off the request context's `user`. */
|
|
5252
|
+
function classifyPrincipal(user) {
|
|
5253
|
+
if (!user || typeof user !== "object") return {
|
|
5254
|
+
kind: "anonymous",
|
|
5255
|
+
uid: "",
|
|
5256
|
+
roles: []
|
|
5257
|
+
};
|
|
5258
|
+
const { uid, roles } = user;
|
|
5259
|
+
if (typeof uid !== "string" || uid.length === 0) return {
|
|
5260
|
+
kind: "anonymous",
|
|
5261
|
+
uid: "",
|
|
5262
|
+
roles: []
|
|
5263
|
+
};
|
|
5264
|
+
const roleList = Array.isArray(roles) ? roles.filter((r) => typeof r === "string") : [];
|
|
5265
|
+
const machineKind = machineKindOf(uid);
|
|
5266
|
+
return {
|
|
5267
|
+
kind: machineKind ? "machine" : "person",
|
|
5268
|
+
uid,
|
|
5269
|
+
roles: roleList,
|
|
5270
|
+
machineKind
|
|
5271
|
+
};
|
|
5272
|
+
}
|
|
5273
|
+
var MACHINE_REFUSAL = {
|
|
5274
|
+
"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.",
|
|
5275
|
+
"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."
|
|
5276
|
+
};
|
|
5277
|
+
/**
|
|
5278
|
+
* What this caller may do.
|
|
5279
|
+
*
|
|
5280
|
+
* Assumes the admin gate has already run — this refines that decision rather
|
|
5281
|
+
* than replacing it, and an anonymous caller should never have reached here.
|
|
5282
|
+
* Handled anyway, because a capability function that trusts its caller to have
|
|
5283
|
+
* checked is one bad refactor away from granting everything.
|
|
5284
|
+
*/
|
|
5285
|
+
function schemaEditCapabilities(principal, policy = {}) {
|
|
5286
|
+
if (principal.kind === "anonymous") return {
|
|
5287
|
+
plan: false,
|
|
5288
|
+
apply: false,
|
|
5289
|
+
code: "UNAUTHORIZED",
|
|
5290
|
+
reason: "This request carries no identity."
|
|
5291
|
+
};
|
|
5292
|
+
if (principal.kind === "machine" && !policy.allowMachineApply) return {
|
|
5293
|
+
plan: true,
|
|
5294
|
+
apply: false,
|
|
5295
|
+
code: "SCHEMA_EDIT_REQUIRES_A_PERSON",
|
|
5296
|
+
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.`
|
|
5297
|
+
};
|
|
5298
|
+
return {
|
|
5299
|
+
plan: true,
|
|
5300
|
+
apply: true
|
|
5301
|
+
};
|
|
5302
|
+
}
|
|
5303
|
+
/**
|
|
5304
|
+
* The git identity for a machine that has been allowed to apply.
|
|
5305
|
+
*
|
|
5306
|
+
* Named for what it is. An operator reading `git log` a month from now should
|
|
5307
|
+
* be able to tell a change somebody made from one a pipeline made, and a
|
|
5308
|
+
* credential wearing a person-shaped name is how that distinction is lost.
|
|
5309
|
+
*/
|
|
5310
|
+
function machineCommitAuthor(principal) {
|
|
5311
|
+
return {
|
|
5312
|
+
name: principal.machineKind === "service-key" ? "Rebase service key" : `Rebase API key (${principal.uid.replace(/^api-key:/, "")})`,
|
|
5313
|
+
email: `${principal.uid.replace(/[^A-Za-z0-9._-]/g, "-")}@machines.noreply.rebase.pro`
|
|
5314
|
+
};
|
|
5315
|
+
}
|
|
5316
|
+
//#endregion
|
|
5317
|
+
//#region src/api/live-schema-routes.ts
|
|
5318
|
+
/**
|
|
5319
|
+
* Creating and altering collections against a running backend.
|
|
5320
|
+
*
|
|
5321
|
+
* The existing schema editor rewrites collection source and is off in
|
|
5322
|
+
* production, for an honest reason: a deployed server's files are rebuilt from
|
|
5323
|
+
* the repository on every deploy, so an edit there would be discarded. These
|
|
5324
|
+
* routes are the answer to that — they commit the change to the repository the
|
|
5325
|
+
* server is running in, and *then* apply the DDL, so the edit survives the next
|
|
5326
|
+
* deploy because the deploy is built from it.
|
|
5327
|
+
*
|
|
5328
|
+
* Two routes, and the first one is the important one.
|
|
5329
|
+
*
|
|
5330
|
+
* ## Why `plan` exists separately from `apply`
|
|
5331
|
+
*
|
|
5332
|
+
* `POST /plan` has no side effects and answers what would happen. That is not a
|
|
5333
|
+
* convenience: a schema change has three possible verdicts and two of them are
|
|
5334
|
+
* refusals, so a UI that could only try-and-see would be asking people to
|
|
5335
|
+
* discover `needs-migration` by pressing a button on a live database.
|
|
5336
|
+
*
|
|
5337
|
+
* It also surfaces the `diverges` cases, which are the ones nothing else in the
|
|
5338
|
+
* system reports — a required property that will arrive nullable, an enum value
|
|
5339
|
+
* that will never land. Those apply "successfully" today and leave a database
|
|
5340
|
+
* that does not match its configuration.
|
|
5341
|
+
*
|
|
5342
|
+
* ## What these routes will not do
|
|
5343
|
+
*
|
|
5344
|
+
* Anything the boot-time ensure path cannot express. No drops, no type changes,
|
|
5345
|
+
* no primary keys. The planner refuses them and this refuses to route around it
|
|
5346
|
+
* — `applySchemaChange` re-checks rather than trusting the plan it is handed.
|
|
5347
|
+
*/
|
|
5348
|
+
/**
|
|
5349
|
+
* The git identity for the caller.
|
|
5350
|
+
*
|
|
5351
|
+
* `undefined` when the request carries no user — a service key, say — which
|
|
5352
|
+
* lets the repository fall back to its own git config rather than inventing a
|
|
5353
|
+
* name for somebody.
|
|
5354
|
+
*/
|
|
5355
|
+
function commitAuthor(user) {
|
|
5356
|
+
if (!user || typeof user !== "object") return void 0;
|
|
5357
|
+
const { uid, email, displayName } = user;
|
|
5358
|
+
if (!uid) return void 0;
|
|
5359
|
+
return {
|
|
5360
|
+
name: displayName || email || uid,
|
|
5361
|
+
email: email || `${uid}@users.noreply.rebase.pro`
|
|
5362
|
+
};
|
|
5363
|
+
}
|
|
5364
|
+
/**
|
|
5365
|
+
* What a collection id may contain.
|
|
5366
|
+
*
|
|
5367
|
+
* The same alphabet the AST editor sanitises to, stated at the boundary
|
|
5368
|
+
* instead. The id becomes a *filename* — `<collectionsDir>/<id>.ts` — in two
|
|
5369
|
+
* places, and only one of them checked it: the editor refuses a traversal when
|
|
5370
|
+
* it writes, but the caller derives the same path first, to hand the dirty
|
|
5371
|
+
* check the paths it is about to touch. A refusal that arrives one layer in is
|
|
5372
|
+
* a refusal that the layer above already acted on.
|
|
5373
|
+
*/
|
|
5374
|
+
var SAFE_COLLECTION_ID = /^[A-Za-z0-9_-]+$/;
|
|
5375
|
+
var parseProposed = (body) => {
|
|
5376
|
+
const candidate = body;
|
|
5377
|
+
if (!candidate?.collectionId || typeof candidate.collectionId !== "string") throw ApiError.badRequest("`collectionId` is required.", "INVALID_CHANGE");
|
|
5378
|
+
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");
|
|
5379
|
+
if (!candidate.collection || typeof candidate.collection !== "object") throw ApiError.badRequest("`collection` is required.", "INVALID_CHANGE");
|
|
5380
|
+
return {
|
|
5381
|
+
collectionId: candidate.collectionId,
|
|
5382
|
+
collection: candidate.collection
|
|
5383
|
+
};
|
|
5384
|
+
};
|
|
5385
|
+
/**
|
|
5386
|
+
* The proposed collection set: everything as it is, with one collection
|
|
5387
|
+
* replaced or added.
|
|
5388
|
+
*/
|
|
5389
|
+
function proposedCollections(current, change) {
|
|
5390
|
+
const next = {
|
|
5391
|
+
...change.collection,
|
|
5392
|
+
slug: change.collectionId
|
|
5393
|
+
};
|
|
5394
|
+
const replaced = current.map((collection) => collection.slug === change.collectionId ? next : collection);
|
|
5395
|
+
return replaced.some((collection) => collection.slug === change.collectionId) ? replaced : [...replaced, next];
|
|
5396
|
+
}
|
|
5397
|
+
/**
|
|
5398
|
+
* What still has to happen after this change lands, if anything.
|
|
5399
|
+
*
|
|
5400
|
+
* Only one thing so far, and it is the one that would otherwise be silent: a
|
|
5401
|
+
* project that replays migrations needs this change recorded as one, or its
|
|
5402
|
+
* next environment is built without it.
|
|
5403
|
+
*/
|
|
5404
|
+
function followUpFor(config, statements) {
|
|
5405
|
+
if (statements.length === 0) return [];
|
|
5406
|
+
if (!config.usesVersionedMigrations?.()) return [];
|
|
5407
|
+
return ["This project keeps versioned migrations, and applying here does not write one — a migration needs Atlas and a throwaway database, which a running server does not have. `drizzle/schema.sql` has been committed and is what Atlas diffs against, so run `rebase db generate` to record this change. Without it, the next environment built by replaying migrations will not have it."];
|
|
5408
|
+
}
|
|
5409
|
+
function createLiveSchemaRoutes(config) {
|
|
5410
|
+
const router = new Hono();
|
|
5411
|
+
router.onError(errorHandler);
|
|
5412
|
+
/**
|
|
5413
|
+
* Whether this backend can edit its schema, and what is missing when it
|
|
5414
|
+
* cannot.
|
|
5415
|
+
*
|
|
5416
|
+
* Every optional surface in this API answers `GET …/status` — see
|
|
5417
|
+
* `docs/api-conventions.md`. The panel asks before it offers the control,
|
|
5418
|
+
* because the alternative is a button that looks available and fails on
|
|
5419
|
+
* press with a refusal the person could have been told about up front.
|
|
5420
|
+
*
|
|
5421
|
+
* Ordered by how fundamental the obstacle is: a driver that cannot plan is
|
|
5422
|
+
* a different conversation from a missing dependency, which is a different
|
|
5423
|
+
* conversation from a deployment with no source. Reporting the first one
|
|
5424
|
+
* that applies is what keeps the message actionable.
|
|
5425
|
+
*/
|
|
5426
|
+
router.get("/status", async (c) => {
|
|
5427
|
+
const admin = config.getAdmin();
|
|
5428
|
+
const repository = config.getRepository();
|
|
5429
|
+
if (!isSchemaEditingAdmin(admin)) return c.json({
|
|
5430
|
+
enabled: false,
|
|
5431
|
+
canPlan: false,
|
|
5432
|
+
code: "SCHEMA_EDITING_UNSUPPORTED",
|
|
5433
|
+
reason: "This backend's driver cannot plan schema changes. Live editing is available on Postgres."
|
|
5434
|
+
});
|
|
5435
|
+
if (!config.writeSource) return c.json({
|
|
5436
|
+
enabled: false,
|
|
5437
|
+
canPlan: true,
|
|
5438
|
+
code: "SCHEMA_EDITOR_MISSING_DEPENDENCY",
|
|
5439
|
+
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."
|
|
5440
|
+
});
|
|
5441
|
+
if (!repository) return c.json({
|
|
5442
|
+
enabled: false,
|
|
5443
|
+
canPlan: true,
|
|
5444
|
+
code: "SCHEMA_EDITING_NO_REPOSITORY",
|
|
5445
|
+
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."
|
|
5446
|
+
});
|
|
5447
|
+
const capabilities = schemaEditCapabilities(classifyPrincipal(c.get("user")), config.policy);
|
|
5448
|
+
let branch;
|
|
5449
|
+
try {
|
|
5450
|
+
branch = await repository.currentBranch();
|
|
5451
|
+
} catch {
|
|
5452
|
+
branch = void 0;
|
|
5453
|
+
}
|
|
5454
|
+
return c.json({
|
|
5455
|
+
enabled: true,
|
|
5456
|
+
canPlan: capabilities.plan,
|
|
5457
|
+
canApply: capabilities.apply,
|
|
5458
|
+
applyRefusedBecause: capabilities.reason,
|
|
5459
|
+
applyRefusedCode: capabilities.code,
|
|
5460
|
+
repository: repository.root,
|
|
5461
|
+
branch
|
|
5462
|
+
});
|
|
5463
|
+
});
|
|
5464
|
+
/** The admin, or a refusal naming what is missing rather than a 500. */
|
|
5465
|
+
const requirePlanner = () => {
|
|
5466
|
+
const admin = config.getAdmin();
|
|
5467
|
+
if (!isSchemaEditingAdmin(admin)) throw ApiError.serviceUnavailable("This backend's driver cannot plan schema changes. Live editing is available on Postgres.", "SCHEMA_EDITING_UNSUPPORTED");
|
|
5468
|
+
return admin;
|
|
5469
|
+
};
|
|
5470
|
+
const requireRepository = (author) => {
|
|
5471
|
+
const repository = config.getRepository(author);
|
|
5472
|
+
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");
|
|
5473
|
+
return repository;
|
|
5474
|
+
};
|
|
5475
|
+
/**
|
|
5476
|
+
* What would happen. No side effects, and deliberately reachable even when
|
|
5477
|
+
* there is no repository — knowing a change is blocked is useful before
|
|
5478
|
+
* knowing where it would be committed.
|
|
5479
|
+
*/
|
|
5480
|
+
router.post("/plan", async (c) => {
|
|
5481
|
+
const change = parseProposed(await c.req.json());
|
|
5482
|
+
const admin = requirePlanner();
|
|
5483
|
+
const capabilities = schemaEditCapabilities(classifyPrincipal(c.get("user")), config.policy);
|
|
5484
|
+
if (!capabilities.plan) throw ApiError.forbidden(capabilities.reason ?? "This caller may not plan schema changes.", capabilities.code ?? "FORBIDDEN");
|
|
5485
|
+
const before = config.getCollections();
|
|
5486
|
+
const after = proposedCollections(before, change);
|
|
5487
|
+
const plan = await admin.planSchemaChange(before, after, { paths: config.commitPaths });
|
|
5488
|
+
return c.json({
|
|
5489
|
+
applicable: plan.classified.applicable,
|
|
5490
|
+
verdict: plan.classified.verdict,
|
|
5491
|
+
changes: plan.classified.changes,
|
|
5492
|
+
statements: plan.statements,
|
|
5493
|
+
files: plan.files.map((file) => file.path),
|
|
5494
|
+
message: plan.message,
|
|
5495
|
+
withheldConstraints: plan.withheldConstraints ?? [],
|
|
5496
|
+
followUp: followUpFor(config, plan.statements)
|
|
5497
|
+
});
|
|
5498
|
+
});
|
|
5499
|
+
router.post("/apply", async (c) => {
|
|
5500
|
+
const change = parseProposed(await c.req.json());
|
|
5501
|
+
const admin = requirePlanner();
|
|
5502
|
+
const principal = classifyPrincipal(c.get("user"));
|
|
5503
|
+
const capabilities = schemaEditCapabilities(principal, config.policy);
|
|
5504
|
+
if (!capabilities.apply) throw ApiError.forbidden(capabilities.reason ?? "This caller may not apply schema changes.", capabilities.code ?? "FORBIDDEN");
|
|
5505
|
+
const author = principal.kind === "machine" ? machineCommitAuthor(principal) : commitAuthor(c.get("user"));
|
|
5506
|
+
const repository = requireRepository(author);
|
|
5507
|
+
const before = config.getCollections();
|
|
5508
|
+
const after = proposedCollections(before, change);
|
|
5509
|
+
const plan = await admin.planSchemaChange(before, after, { paths: config.commitPaths });
|
|
5510
|
+
if (!plan.classified.applicable) {
|
|
5511
|
+
const blocking = plan.classified.changes.filter((c) => c.verdict !== "safe");
|
|
5512
|
+
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 });
|
|
5513
|
+
}
|
|
5514
|
+
try {
|
|
5515
|
+
const result = await applySchemaChange({
|
|
5516
|
+
plan,
|
|
5517
|
+
repository,
|
|
5518
|
+
sourcePaths: config.sourcePathsFor?.(change) ?? [],
|
|
5519
|
+
writeSource: config.writeSource ? () => config.writeSource(change) : void 0,
|
|
5520
|
+
apply: async (statements) => {
|
|
5521
|
+
const sql = config.getAdmin();
|
|
5522
|
+
if (!isSQLAdmin(sql)) throw new Error("The driver cannot run SQL, so the change cannot be applied here.");
|
|
5523
|
+
for (const statement of statements) await sql.executeSql(statement);
|
|
5524
|
+
}
|
|
5525
|
+
});
|
|
5526
|
+
logger.info(`[schema-edit] ${result.summary}`);
|
|
5527
|
+
return c.json({
|
|
5528
|
+
...result,
|
|
5529
|
+
withheldConstraints: plan.withheldConstraints ?? [],
|
|
5530
|
+
followUp: followUpFor(config, plan.statements)
|
|
5531
|
+
});
|
|
5532
|
+
} catch (err) {
|
|
5533
|
+
if (err instanceof UnapplicableChangeError) throw ApiError.badRequest(err.message, "SCHEMA_CHANGE_UNAPPLICABLE", { changes: err.classified.changes });
|
|
5534
|
+
if (err instanceof DirtyWorkingTreeError) throw ApiError.conflict(err.message, "SCHEMA_EDIT_DIRTY_TREE");
|
|
5535
|
+
throw ApiError.badRequest(err instanceof Error ? err.message : String(err), "SCHEMA_CHANGE_FAILED");
|
|
5536
|
+
}
|
|
5537
|
+
});
|
|
5538
|
+
return router;
|
|
5539
|
+
}
|
|
5540
|
+
//#endregion
|
|
5541
|
+
//#region src/api/mount.ts
|
|
5542
|
+
/**
|
|
5543
|
+
* Mounting a surface at its canonical path, and at the path it used to have.
|
|
5544
|
+
*
|
|
5545
|
+
* Several admin-only surfaces grew up at the top level — `/api/cron`,
|
|
5546
|
+
* `/api/logs`, `/api/schema-editor` — while the ones added later went under
|
|
5547
|
+
* `/api/admin`. Nothing was wrong with either choice on its own; what was wrong
|
|
5548
|
+
* was having both, because the shape of the path stopped predicting whether a
|
|
5549
|
+
* caller needed to be an admin. `docs/api-conventions.md` states the rule the
|
|
5550
|
+
* repo now follows and this module is how existing callers survive it.
|
|
5551
|
+
*
|
|
5552
|
+
* ## Why an alias rather than a redirect
|
|
5553
|
+
*
|
|
5554
|
+
* A 308 would be correct and would break things anyway: `fetch` follows
|
|
5555
|
+
* redirects but drops an `Authorization` header on a cross-origin hop, and a
|
|
5556
|
+
* good deal of client code posts to these paths with one attached. An alias
|
|
5557
|
+
* serves the same handler at both paths, so nothing observable changes for a
|
|
5558
|
+
* caller that has not moved yet.
|
|
5559
|
+
*
|
|
5560
|
+
* The alias is not silent. Every response through it carries `Deprecation` and
|
|
5561
|
+
* a `Link` naming the successor, which is what makes the old path removable
|
|
5562
|
+
* later: an operator can find the callers in their own logs rather than
|
|
5563
|
+
* discovering them when the path is gone.
|
|
5564
|
+
*/
|
|
5565
|
+
/**
|
|
5566
|
+
* Mount `router` at its canonical path, and at its legacy path when it has one.
|
|
5567
|
+
*
|
|
5568
|
+
* The same router instance serves both, so the two paths cannot drift: there is
|
|
5569
|
+
* one set of handlers and one gate, and an alias that forgot to apply the admin
|
|
5570
|
+
* gate is not a state this can reach.
|
|
5571
|
+
*/
|
|
5572
|
+
function mountWithLegacyAlias(app, router, options) {
|
|
5573
|
+
app.route(options.canonical, router);
|
|
5574
|
+
if (!options.legacy) {
|
|
5575
|
+
logger.debug(`${options.surface} routes mounted`, { path: options.canonical });
|
|
5576
|
+
return;
|
|
5577
|
+
}
|
|
5578
|
+
const alias = new Hono();
|
|
5579
|
+
alias.use("/*", async (c, next) => {
|
|
5580
|
+
await next();
|
|
5581
|
+
c.header("Deprecation", "true");
|
|
5582
|
+
c.header("Link", `<${options.canonical}>; rel="successor-version"`);
|
|
5583
|
+
});
|
|
5584
|
+
alias.route("/", router);
|
|
5585
|
+
app.route(options.legacy, alias);
|
|
5586
|
+
logger.debug(`${options.surface} routes mounted`, {
|
|
5587
|
+
path: options.canonical,
|
|
5588
|
+
deprecatedAlias: options.legacy
|
|
5589
|
+
});
|
|
5590
|
+
}
|
|
5591
|
+
function findProjectRoot(startDir, levels = 5) {
|
|
5592
|
+
let dir = path$1.resolve(startDir);
|
|
5593
|
+
for (let i = 0; i <= levels; i++) {
|
|
5594
|
+
if (fs$1.existsSync(path$1.join(dir, "rebase.json"))) return dir;
|
|
5595
|
+
const parent = path$1.dirname(dir);
|
|
5596
|
+
if (parent === dir) return void 0;
|
|
5597
|
+
dir = parent;
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
5600
|
+
/**
|
|
5601
|
+
* The generated-artifact paths for a project, expressed relative to the
|
|
5602
|
+
* repository that will hold the commit.
|
|
5603
|
+
*
|
|
5604
|
+
* Returns `undefined` when there is nothing to correct — no project marker, or
|
|
5605
|
+
* a project that *is* the repository — so the caller passes no override and the
|
|
5606
|
+
* defaults stand. Saying "no change needed" is different from saying
|
|
5607
|
+
* "the change is a no-op prefix", and only the first leaves the existing
|
|
5608
|
+
* behaviour untouched for every project that already worked.
|
|
5609
|
+
*/
|
|
5610
|
+
function commitPathsFor(collectionsDir, repositoryRoot) {
|
|
5611
|
+
const projectRoot = findProjectRoot(collectionsDir);
|
|
5612
|
+
if (!projectRoot) return void 0;
|
|
5613
|
+
const prefix = path$1.relative(path$1.resolve(repositoryRoot), projectRoot);
|
|
5614
|
+
if (prefix === "" || prefix.startsWith("..")) return void 0;
|
|
5615
|
+
const relocate = (p) => path$1.posix.join(...prefix.split(path$1.sep), p);
|
|
5616
|
+
return {
|
|
5617
|
+
schemaFile: relocate(DEFAULT_COMMIT_PATHS.schemaFile),
|
|
5618
|
+
ddlFile: relocate(DEFAULT_COMMIT_PATHS.ddlFile),
|
|
5619
|
+
policiesFile: relocate(DEFAULT_COMMIT_PATHS.policiesFile),
|
|
5620
|
+
searchFile: relocate(DEFAULT_COMMIT_PATHS.searchFile)
|
|
5621
|
+
};
|
|
5622
|
+
}
|
|
5623
|
+
/**
|
|
5624
|
+
* Does this project keep versioned migrations?
|
|
5625
|
+
*
|
|
5626
|
+
* It matters because live schema editing does not write one, and cannot: a
|
|
5627
|
+
* migration is Atlas's format with an integrity file, minted by an external
|
|
5628
|
+
* binary against a throwaway database. A server process has neither.
|
|
5629
|
+
*
|
|
5630
|
+
* What it *does* write is `drizzle/schema.sql`, which is exactly the input
|
|
5631
|
+
* `rebase db generate` diffs against — so the migration is one command away.
|
|
5632
|
+
* The hazard is nobody saying so. A project that deploys by replaying
|
|
5633
|
+
* migrations would build its next environment without this change, having been
|
|
5634
|
+
* told the change was applied.
|
|
5635
|
+
*/
|
|
5636
|
+
function usesVersionedMigrations(collectionsDir) {
|
|
5637
|
+
const projectRoot = findProjectRoot(collectionsDir);
|
|
5638
|
+
if (!projectRoot) return false;
|
|
5639
|
+
const dir = path$1.join(projectRoot, "drizzle", "migrations");
|
|
5640
|
+
try {
|
|
5641
|
+
return fs$1.existsSync(dir) && fs$1.readdirSync(dir).some((f) => f.endsWith(".sql"));
|
|
5642
|
+
} catch {
|
|
5643
|
+
return true;
|
|
5644
|
+
}
|
|
5645
|
+
}
|
|
5646
|
+
//#endregion
|
|
5647
|
+
//#region src/schema-edit/github-repository.ts
|
|
5648
|
+
/**
|
|
5649
|
+
* A {@link SchemaEditRepository} backed by a repository on GitHub.
|
|
5650
|
+
*
|
|
5651
|
+
* This is the implementation for a deployment whose source is not on the
|
|
5652
|
+
* machine — a Cloud tenant runs a built bundle, and its repository lives
|
|
5653
|
+
* somewhere else entirely. The local one commits with `git`; this one has no
|
|
5654
|
+
* working tree at all and uses the Git Data API, which creates a blob, a tree,
|
|
5655
|
+
* a commit and moves a ref in four calls. Nothing is cloned.
|
|
5656
|
+
*
|
|
5657
|
+
* ## Authentication
|
|
5658
|
+
*
|
|
5659
|
+
* A GitHub App, not a token per project. The app holds one private key
|
|
5660
|
+
* globally; each project stores an `installationId`, which is not a secret. A
|
|
5661
|
+
* per-project personal access token would mean a credential to hold, rotate and
|
|
5662
|
+
* leak for every customer.
|
|
5663
|
+
*
|
|
5664
|
+
* Three steps, and the middle one is the one people miss:
|
|
5665
|
+
*
|
|
5666
|
+
* 1. sign a JWT with the private key — RS256, `iss` the app's id, ten minutes
|
|
5667
|
+
* at most, which authenticates *the app* and can touch no repository;
|
|
5668
|
+
* 2. exchange it for an **installation access token**, which expires in an hour
|
|
5669
|
+
* and is what can actually write;
|
|
5670
|
+
* 3. use that token as the bearer for the Git Data calls.
|
|
5671
|
+
*
|
|
5672
|
+
* The token is cached until shortly before it expires, because minting one is a
|
|
5673
|
+
* round trip and a schema edit makes several calls.
|
|
5674
|
+
*
|
|
5675
|
+
* ## What `dirtyPaths` means without a working tree
|
|
5676
|
+
*
|
|
5677
|
+
* Nothing, directly — there is no tree to be dirty. The equivalent hazard is the
|
|
5678
|
+
* branch moving under us between reading its head and writing a commit onto it,
|
|
5679
|
+
* and that is handled where it belongs: the commit is created against the head
|
|
5680
|
+
* read at the start, and updating the ref is **not** forced, so GitHub rejects
|
|
5681
|
+
* it if anything else landed meanwhile. A lost race is a refusal, never a
|
|
5682
|
+
* silent overwrite of somebody's commit.
|
|
5683
|
+
*/
|
|
5684
|
+
var GITHUB_API = "https://api.github.com";
|
|
5685
|
+
/** GitHub caps app JWTs at ten minutes; nine leaves room for clock skew. */
|
|
5686
|
+
var APP_JWT_TTL_SECONDS = 540;
|
|
5687
|
+
/** Renew an installation token this long before it lapses. */
|
|
5688
|
+
var TOKEN_REFRESH_MARGIN_MS = 6e4;
|
|
5689
|
+
var GitHubApiError = class extends Error {
|
|
5690
|
+
status;
|
|
5691
|
+
path;
|
|
5692
|
+
constructor(status, path, detail) {
|
|
5693
|
+
super(`GitHub ${path} failed with ${status}: ${detail}`);
|
|
5694
|
+
this.status = status;
|
|
5695
|
+
this.path = path;
|
|
5696
|
+
this.name = "GitHubApiError";
|
|
5697
|
+
}
|
|
5698
|
+
};
|
|
5699
|
+
/** base64url, which JWT requires and `Buffer` does not produce by default. */
|
|
5700
|
+
var b64url = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5701
|
+
/**
|
|
5702
|
+
* A JWT authenticating the app itself.
|
|
5703
|
+
*
|
|
5704
|
+
* `iat` is backdated by a minute: GitHub rejects a token whose `iat` is in its
|
|
5705
|
+
* future, and a server clock a few seconds fast is enough to trigger it.
|
|
5706
|
+
*/
|
|
5707
|
+
function createAppJwt(appId, privateKey, nowMs) {
|
|
5708
|
+
const issued = Math.floor(nowMs / 1e3) - 60;
|
|
5709
|
+
const header = b64url(JSON.stringify({
|
|
5710
|
+
alg: "RS256",
|
|
5711
|
+
typ: "JWT"
|
|
5712
|
+
}));
|
|
5713
|
+
const payload = b64url(JSON.stringify({
|
|
5714
|
+
iat: issued,
|
|
5715
|
+
exp: issued + APP_JWT_TTL_SECONDS,
|
|
5716
|
+
iss: appId
|
|
5717
|
+
}));
|
|
5718
|
+
const signer = createSign("RSA-SHA256");
|
|
5719
|
+
signer.update(`${header}.${payload}`);
|
|
5720
|
+
signer.end();
|
|
5721
|
+
return `${header}.${payload}.${b64url(signer.sign(privateKey))}`;
|
|
5722
|
+
}
|
|
5723
|
+
function createGitHubRepository(options) {
|
|
5724
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
5725
|
+
const now = options.now ?? (() => Date.now());
|
|
5726
|
+
const { owner, repo, branch } = options;
|
|
5727
|
+
/** Files staged by `writeFiles`, held until `commit`. */
|
|
5728
|
+
let staged = [];
|
|
5729
|
+
let token;
|
|
5730
|
+
const call = async (path, init) => {
|
|
5731
|
+
const { auth, ...rest } = init;
|
|
5732
|
+
const response = await doFetch(`${GITHUB_API}${path}`, {
|
|
5733
|
+
...rest,
|
|
5734
|
+
headers: {
|
|
5735
|
+
accept: "application/vnd.github+json",
|
|
5736
|
+
"x-github-api-version": "2022-11-28",
|
|
5737
|
+
authorization: `Bearer ${auth}`,
|
|
5738
|
+
"content-type": "application/json",
|
|
5739
|
+
...rest.headers ?? {}
|
|
5740
|
+
}
|
|
5741
|
+
});
|
|
5742
|
+
if (!response.ok) throw new GitHubApiError(response.status, path, (await response.text()).slice(0, 400));
|
|
5743
|
+
return await response.json();
|
|
5744
|
+
};
|
|
5745
|
+
/**
|
|
5746
|
+
* The bearer for the Git Data calls.
|
|
5747
|
+
*
|
|
5748
|
+
* A token is already one. An app has to mint one: sign a JWT with the
|
|
5749
|
+
* private key, which authenticates the *app* and can touch no repository,
|
|
5750
|
+
* then exchange it for an installation token, which expires in an hour and
|
|
5751
|
+
* is what can actually write. Cached until shortly before it lapses,
|
|
5752
|
+
* because a schema edit makes several calls and minting is a round trip.
|
|
5753
|
+
*/
|
|
5754
|
+
const bearer = async () => {
|
|
5755
|
+
if (options.auth.kind === "token") return options.auth.token;
|
|
5756
|
+
if (token && token.expiresAtMs - TOKEN_REFRESH_MARGIN_MS > now()) return token.value;
|
|
5757
|
+
const jwt = createAppJwt(options.auth.appId, options.auth.privateKey, now());
|
|
5758
|
+
const minted = await call(`/app/installations/${options.auth.installationId}/access_tokens`, {
|
|
5759
|
+
method: "POST",
|
|
5760
|
+
auth: jwt
|
|
5761
|
+
});
|
|
5762
|
+
token = {
|
|
5763
|
+
value: minted.token,
|
|
5764
|
+
expiresAtMs: Date.parse(minted.expires_at)
|
|
5765
|
+
};
|
|
5766
|
+
return token.value;
|
|
5767
|
+
};
|
|
5768
|
+
return {
|
|
5769
|
+
root: `${owner}/${repo}`,
|
|
5770
|
+
async currentBranch() {
|
|
5771
|
+
return branch;
|
|
5772
|
+
},
|
|
5773
|
+
async dirtyPaths() {
|
|
5774
|
+
return [];
|
|
5775
|
+
},
|
|
5776
|
+
async writeFiles(files) {
|
|
5777
|
+
staged = files;
|
|
5778
|
+
},
|
|
5779
|
+
/**
|
|
5780
|
+
* One file's current contents, read from the branch this commits onto.
|
|
5781
|
+
*
|
|
5782
|
+
* `undefined` for a file that is not there — a collection being created
|
|
5783
|
+
* has no source yet — and that is a normal answer rather than a
|
|
5784
|
+
* failure. Anything else is surfaced: a 403 from a token without
|
|
5785
|
+
* `contents:read` must not be mistaken for an empty repository, or the
|
|
5786
|
+
* editor would rewrite from nothing and the commit would delete
|
|
5787
|
+
* somebody's collection.
|
|
5788
|
+
*/
|
|
5789
|
+
async readFile(filePath) {
|
|
5790
|
+
const auth = await bearer();
|
|
5791
|
+
const response = await doFetch(`${GITHUB_API}/repos/${owner}/${repo}/contents/${filePath.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(branch)}`, { headers: {
|
|
5792
|
+
accept: "application/vnd.github+json",
|
|
5793
|
+
"x-github-api-version": "2022-11-28",
|
|
5794
|
+
authorization: `Bearer ${auth}`
|
|
5795
|
+
} });
|
|
5796
|
+
if (response.status === 404) return void 0;
|
|
5797
|
+
if (!response.ok) throw new GitHubApiError(response.status, `/repos/${owner}/${repo}/contents/${filePath}`, (await response.text()).slice(0, 400));
|
|
5798
|
+
const body = await response.json();
|
|
5799
|
+
if (typeof body.content !== "string") return void 0;
|
|
5800
|
+
if (body.encoding && body.encoding !== "base64") return void 0;
|
|
5801
|
+
return Buffer.from(body.content, "base64").toString("utf8");
|
|
5802
|
+
},
|
|
5803
|
+
async commit(paths, message) {
|
|
5804
|
+
if (staged.length === 0) throw new Error("Refusing to commit with nothing staged.");
|
|
5805
|
+
const auth = await bearer();
|
|
5806
|
+
const base = `/repos/${owner}/${repo}`;
|
|
5807
|
+
const headSha = (await call(`${base}/git/ref/heads/${encodeURIComponent(branch)}`, { auth })).object.sha;
|
|
5808
|
+
const head = await call(`${base}/git/commits/${headSha}`, { auth });
|
|
5809
|
+
const tree = await Promise.all(staged.filter((file) => paths.includes(file.path)).map(async (file) => {
|
|
5810
|
+
const blob = await call(`${base}/git/blobs`, {
|
|
5811
|
+
method: "POST",
|
|
5812
|
+
auth,
|
|
5813
|
+
body: JSON.stringify({
|
|
5814
|
+
content: file.contents,
|
|
5815
|
+
encoding: "utf-8"
|
|
5816
|
+
})
|
|
5817
|
+
});
|
|
5818
|
+
return {
|
|
5819
|
+
path: file.path,
|
|
5820
|
+
mode: "100644",
|
|
5821
|
+
type: "blob",
|
|
5822
|
+
sha: blob.sha
|
|
5823
|
+
};
|
|
5824
|
+
}));
|
|
5825
|
+
const created = await call(`${base}/git/trees`, {
|
|
5826
|
+
method: "POST",
|
|
5827
|
+
auth,
|
|
5828
|
+
body: JSON.stringify({
|
|
5829
|
+
base_tree: head.tree.sha,
|
|
5830
|
+
tree
|
|
5831
|
+
})
|
|
5832
|
+
});
|
|
5833
|
+
const commit = await call(`${base}/git/commits`, {
|
|
5834
|
+
method: "POST",
|
|
5835
|
+
auth,
|
|
5836
|
+
body: JSON.stringify({
|
|
5837
|
+
message,
|
|
5838
|
+
tree: created.sha,
|
|
5839
|
+
parents: [headSha],
|
|
5840
|
+
...options.author ? { author: {
|
|
5841
|
+
...options.author,
|
|
5842
|
+
date: new Date(now()).toISOString()
|
|
5843
|
+
} } : {}
|
|
5844
|
+
})
|
|
5845
|
+
});
|
|
5846
|
+
await call(`${base}/git/refs/heads/${encodeURIComponent(branch)}`, {
|
|
5847
|
+
method: "PATCH",
|
|
5848
|
+
auth,
|
|
5849
|
+
body: JSON.stringify({
|
|
5850
|
+
sha: commit.sha,
|
|
5851
|
+
force: false
|
|
5852
|
+
})
|
|
5853
|
+
});
|
|
5854
|
+
staged = [];
|
|
5855
|
+
return commit.sha;
|
|
5856
|
+
}
|
|
5857
|
+
};
|
|
5858
|
+
}
|
|
5859
|
+
//#endregion
|
|
5860
|
+
//#region src/schema-edit/remote-source.ts
|
|
5861
|
+
/**
|
|
5862
|
+
* Rewriting a collection whose source is not on this machine.
|
|
5863
|
+
*
|
|
5864
|
+
* A bundle deployment — every Cloud tenant, and any self-host running built
|
|
5865
|
+
* output — ships *compiled* collections. The AST editor rewrites the
|
|
5866
|
+
* TypeScript, opening it from a directory on disk, so there is nothing for it
|
|
5867
|
+
* to open. That single fact is why live schema editing was local-only: not
|
|
5868
|
+
* authentication, not the Git Data API, which have worked for a while, but the
|
|
5869
|
+
* editor needing a file.
|
|
5870
|
+
*
|
|
5871
|
+
* So the file is brought to it. Read the current source from the repository,
|
|
5872
|
+
* put it in a scratch directory shaped the way the editor expects, let the
|
|
5873
|
+
* editor do exactly what it does locally, and read the result back. The editor
|
|
5874
|
+
* is unchanged and untouched — which matters, because it is the part that knows
|
|
5875
|
+
* how to preserve a collection file's imports, comments and formatting, and a
|
|
5876
|
+
* second implementation of that for the remote case would be a second thing to
|
|
5877
|
+
* get wrong.
|
|
5878
|
+
*
|
|
5879
|
+
* ## What the scratch directory holds
|
|
5880
|
+
*
|
|
5881
|
+
* One file: the collection being edited. Not the whole directory.
|
|
5882
|
+
*
|
|
5883
|
+
* The editor resolves `<dir>/<id>.ts` and rewrites it in place; it does not
|
|
5884
|
+
* read its siblings to do that. Fetching them all would be a request per
|
|
5885
|
+
* collection on every save, for files nothing reads. A collection that does not
|
|
5886
|
+
* exist yet is simply absent, which is the same state the editor sees locally
|
|
5887
|
+
* when somebody adds one.
|
|
5888
|
+
*/
|
|
5889
|
+
/**
|
|
5890
|
+
* Fetch, rewrite, and return the changed file — without leaving anything behind.
|
|
5891
|
+
*
|
|
5892
|
+
* The scratch directory is removed on every path, including the failing one. It
|
|
5893
|
+
* holds a copy of the customer's collection source, and a temp directory that
|
|
5894
|
+
* accumulates those on a long-running server is a slow leak of exactly the
|
|
5895
|
+
* thing worth not leaking.
|
|
5896
|
+
*/
|
|
5897
|
+
async function rewriteRemoteCollection(options, collectionId, collection) {
|
|
5898
|
+
const { repository, collectionsPath, edit } = options;
|
|
5899
|
+
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.");
|
|
5900
|
+
const repoPath = `${collectionsPath.replace(/\/+$/, "")}/${collectionId}.ts`;
|
|
5901
|
+
const existing = await repository.readFile(repoPath);
|
|
5902
|
+
const scratch = await fsp.mkdtemp(path$1.join(os.tmpdir(), "rebase-remote-source-"));
|
|
5903
|
+
try {
|
|
5904
|
+
if (existing !== void 0) await fsp.writeFile(path$1.join(scratch, `${collectionId}.ts`), existing, "utf8");
|
|
5905
|
+
await edit(scratch, collectionId, collection);
|
|
5906
|
+
return [{
|
|
5907
|
+
path: repoPath,
|
|
5908
|
+
contents: await fsp.readFile(path$1.join(scratch, `${collectionId}.ts`), "utf8")
|
|
5909
|
+
}];
|
|
5910
|
+
} finally {
|
|
5911
|
+
await fsp.rm(scratch, {
|
|
5912
|
+
recursive: true,
|
|
5913
|
+
force: true
|
|
5914
|
+
});
|
|
5915
|
+
}
|
|
5916
|
+
}
|
|
5917
|
+
//#endregion
|
|
5918
|
+
//#region src/schema-edit/local-git-repository.ts
|
|
5919
|
+
/**
|
|
5920
|
+
* A {@link SchemaEditRepository} backed by the working tree the server is
|
|
5921
|
+
* running in.
|
|
5922
|
+
*
|
|
5923
|
+
* This is the implementation for the two deployment shapes that have their
|
|
5924
|
+
* source on disk — `rebase dev` on a developer's machine, and a self-host with
|
|
5925
|
+
* the project mounted. Both have a `.git` next to the collections, so a commit
|
|
5926
|
+
* is a local `git commit`: nothing to authenticate, nothing to fetch, no token.
|
|
5927
|
+
*
|
|
5928
|
+
* The remote implementation, for a Cloud tenant whose repository lives on
|
|
5929
|
+
* GitHub, satisfies the same interface and is a separate file. Neither knows
|
|
5930
|
+
* about the other.
|
|
5931
|
+
*
|
|
5932
|
+
* ## Why it stages paths explicitly
|
|
5933
|
+
*
|
|
5934
|
+
* Never `git add -A`. A schema change runs while somebody may have unrelated
|
|
5935
|
+
* work in the tree, and sweeping that into a commit nobody wrote is worse than
|
|
5936
|
+
* refusing — it is a commit that cannot be reviewed because nobody knows what
|
|
5937
|
+
* is in it. `applySchemaChange` refuses up front when the tree already has
|
|
5938
|
+
* *our* files modified; this stages the exact list and nothing else.
|
|
5939
|
+
*/
|
|
5940
|
+
var run$1 = promisify$1(execFile);
|
|
5941
|
+
var GitCommandError = class extends Error {
|
|
5942
|
+
constructor(command, detail) {
|
|
5943
|
+
super(`git ${command} failed: ${detail}`);
|
|
5944
|
+
this.name = "GitCommandError";
|
|
5945
|
+
}
|
|
5946
|
+
};
|
|
5947
|
+
/**
|
|
5948
|
+
* `--` before paths, always: a file called `main` would otherwise be read as a
|
|
5949
|
+
* revision, and the failure mode is a command that does something else entirely.
|
|
5950
|
+
*/
|
|
5951
|
+
async function git(root, args) {
|
|
5952
|
+
try {
|
|
5953
|
+
const { stdout } = await run$1("git", [
|
|
5954
|
+
"-c",
|
|
5955
|
+
"core.fsmonitor=false",
|
|
5956
|
+
...args
|
|
5957
|
+
], {
|
|
5958
|
+
cwd: root,
|
|
5959
|
+
maxBuffer: 32 * 1024 * 1024
|
|
5960
|
+
});
|
|
5961
|
+
return stdout;
|
|
5962
|
+
} catch (err) {
|
|
5963
|
+
const detail = err instanceof Error ? err.stderr?.trim() || err.message : String(err);
|
|
5964
|
+
throw new GitCommandError(args[0] ?? "", detail);
|
|
5965
|
+
}
|
|
5966
|
+
}
|
|
5967
|
+
/**
|
|
5968
|
+
* The top of the working tree containing `startDir`, or undefined.
|
|
5969
|
+
*
|
|
5970
|
+
* Resolved by asking git rather than by walking for a `.git`, because a
|
|
5971
|
+
* worktree's `.git` is a file and a submodule's is neither where nor what a
|
|
5972
|
+
* naive walk expects.
|
|
5973
|
+
*/
|
|
5974
|
+
async function findRepositoryRoot(startDir) {
|
|
5975
|
+
try {
|
|
5976
|
+
const root = (await git(startDir, ["rev-parse", "--show-toplevel"])).trim();
|
|
5977
|
+
return root.length > 0 ? root : void 0;
|
|
5978
|
+
} catch {
|
|
5979
|
+
return;
|
|
5980
|
+
}
|
|
5981
|
+
}
|
|
5982
|
+
function createLocalGitRepository(options) {
|
|
5983
|
+
const { root } = options;
|
|
5984
|
+
return {
|
|
5985
|
+
root,
|
|
5986
|
+
async currentBranch() {
|
|
5987
|
+
const branch = (await git(root, [
|
|
5988
|
+
"rev-parse",
|
|
5989
|
+
"--abbrev-ref",
|
|
5990
|
+
"HEAD"
|
|
5991
|
+
])).trim();
|
|
5992
|
+
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.");
|
|
5993
|
+
return branch;
|
|
5994
|
+
},
|
|
5995
|
+
async dirtyPaths() {
|
|
5996
|
+
const out = await git(root, [
|
|
5997
|
+
"status",
|
|
5998
|
+
"--porcelain=v1",
|
|
5999
|
+
"-z"
|
|
6000
|
+
]);
|
|
6001
|
+
const paths = [];
|
|
6002
|
+
for (const entry of out.split("\0")) {
|
|
6003
|
+
if (!entry) continue;
|
|
6004
|
+
const file = entry.slice(3);
|
|
6005
|
+
if (file) paths.push(file);
|
|
6006
|
+
}
|
|
6007
|
+
return paths;
|
|
6008
|
+
},
|
|
6009
|
+
async writeFiles(files) {
|
|
6010
|
+
for (const file of files) {
|
|
6011
|
+
const absolute = path$2.resolve(root, file.path);
|
|
6012
|
+
if (!absolute.startsWith(path$2.resolve(root) + path$2.sep)) throw new Error(`Refusing to write outside the repository: ${file.path}`);
|
|
6013
|
+
await fs$2.mkdir(path$2.dirname(absolute), { recursive: true });
|
|
6014
|
+
await fs$2.writeFile(absolute, file.contents, "utf8");
|
|
6015
|
+
}
|
|
6016
|
+
},
|
|
6017
|
+
async commit(paths, message) {
|
|
6018
|
+
if (paths.length === 0) throw new Error("Refusing to commit an empty path list.");
|
|
6019
|
+
await git(root, [
|
|
6020
|
+
"add",
|
|
6021
|
+
"--",
|
|
6022
|
+
...paths
|
|
6023
|
+
]);
|
|
6024
|
+
if ((await git(root, [
|
|
6025
|
+
"diff",
|
|
6026
|
+
"--cached",
|
|
6027
|
+
"--name-only",
|
|
6028
|
+
"--",
|
|
6029
|
+
...paths
|
|
6030
|
+
])).trim() === "") return (await git(root, ["rev-parse", "HEAD"])).trim();
|
|
6031
|
+
const identity = options.author ? [
|
|
6032
|
+
"-c",
|
|
6033
|
+
`user.name=${options.author.name}`,
|
|
6034
|
+
"-c",
|
|
6035
|
+
`user.email=${options.author.email}`
|
|
6036
|
+
] : [];
|
|
6037
|
+
await git(root, [
|
|
6038
|
+
...identity,
|
|
6039
|
+
"commit",
|
|
6040
|
+
"--only",
|
|
6041
|
+
"-m",
|
|
6042
|
+
message,
|
|
6043
|
+
"--",
|
|
6044
|
+
...paths
|
|
6045
|
+
]);
|
|
6046
|
+
return (await git(root, ["rev-parse", "HEAD"])).trim();
|
|
6047
|
+
}
|
|
6048
|
+
};
|
|
6049
|
+
}
|
|
6050
|
+
//#endregion
|
|
6051
|
+
//#region src/rls-audit/index.ts
|
|
6052
|
+
/**
|
|
6053
|
+
* The row-level-security audit, on a schedule.
|
|
6054
|
+
*
|
|
6055
|
+
* `@rebasepro/rls-check` is a thorough, read-only audit of a database's RLS —
|
|
6056
|
+
* more thorough, on security specifically, than the dashboard advisors it
|
|
6057
|
+
* competes with. And nobody runs it, because it is a command, and a command is
|
|
6058
|
+
* something you have to remember. A check that has to be remembered is a check
|
|
6059
|
+
* that reports a clean database right up until the day it matters.
|
|
6060
|
+
*
|
|
6061
|
+
* This runs it on a timer against the database the server is already using, and
|
|
6062
|
+
* makes the result readable without a terminal. Nothing else about the checker
|
|
6063
|
+
* changes: same package, same checks, same output.
|
|
6064
|
+
*
|
|
6065
|
+
* ## Why it is off by default
|
|
6066
|
+
*
|
|
6067
|
+
* It opens its own connection and introspects the whole catalog. That is cheap
|
|
6068
|
+
* and read-only, but it is not nothing, and a background query nobody asked for
|
|
6069
|
+
* is a poor default for a library. Enabling it is one line, and the boot log
|
|
6070
|
+
* says so when it is off.
|
|
6071
|
+
*
|
|
6072
|
+
* ## Why the scanner is injected rather than imported
|
|
6073
|
+
*
|
|
6074
|
+
* `@rebasepro/server` is engine-agnostic — it has no `pg` and no other database
|
|
6075
|
+
* driver, deliberately. `@rebasepro/rls-check` has `pg`, because it connects to
|
|
6076
|
+
* Postgres. Importing it here would put a Postgres driver in the install of
|
|
6077
|
+
* every Mongo and Firebase user, for a feature none of them can use, and an
|
|
6078
|
+
* audit of row-level security is a Postgres concept living in the one package
|
|
6079
|
+
* that is supposed to know nothing about Postgres.
|
|
6080
|
+
*
|
|
6081
|
+
* So the scan function is handed in:
|
|
6082
|
+
*
|
|
6083
|
+
* ```ts
|
|
6084
|
+
* import { scan } from "@rebasepro/rls-check";
|
|
6085
|
+
* rlsAudit: { enabled: true, scan }
|
|
6086
|
+
* ```
|
|
6087
|
+
*
|
|
6088
|
+
* Everything else — the schedule, the summary, the status, the admin route —
|
|
6089
|
+
* lives here and is engine-agnostic in fact as well as in principle.
|
|
6090
|
+
*/
|
|
6091
|
+
var SEVERITY_ORDER = [
|
|
6092
|
+
"info",
|
|
6093
|
+
"low",
|
|
6094
|
+
"medium",
|
|
6095
|
+
"high",
|
|
6096
|
+
"critical"
|
|
6097
|
+
];
|
|
6098
|
+
/** Rank for comparison. Unknown severities sort lowest rather than throwing. */
|
|
6099
|
+
var rank = (severity) => {
|
|
6100
|
+
const index = SEVERITY_ORDER.indexOf(severity);
|
|
6101
|
+
return index === -1 ? 0 : index;
|
|
6102
|
+
};
|
|
6103
|
+
var DEFAULT_INTERVAL_MS = 1440 * 60 * 1e3;
|
|
6104
|
+
var DEFAULT_STATEMENT_TIMEOUT_MS = 3e4;
|
|
6105
|
+
/** One line naming what the run found, at the right level to be noticed. */
|
|
6106
|
+
function summarize(result, warnAt) {
|
|
6107
|
+
const counts = /* @__PURE__ */ new Map();
|
|
6108
|
+
for (const finding of result.findings) counts.set(finding.severity, (counts.get(finding.severity) ?? 0) + 1);
|
|
6109
|
+
const worst = result.findings.reduce((acc, f) => rank(f.severity) > rank(acc) ? f.severity : acc, "info");
|
|
6110
|
+
if (result.findings.length === 0) return {
|
|
6111
|
+
level: "info",
|
|
6112
|
+
message: `RLS audit clean: ${result.stats.tables} table(s), ${result.stats.policies} polic(ies), ${result.stats.checksRun} check(s), no findings.`
|
|
6113
|
+
};
|
|
6114
|
+
const breakdown = [...SEVERITY_ORDER].reverse().filter((severity) => counts.get(severity)).map((severity) => `${counts.get(severity)} ${severity}`).join(", ");
|
|
6115
|
+
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.`;
|
|
6116
|
+
return {
|
|
6117
|
+
level: rank(worst) >= rank(warnAt) ? "warn" : "info",
|
|
6118
|
+
message
|
|
6119
|
+
};
|
|
6120
|
+
}
|
|
6121
|
+
/**
|
|
6122
|
+
* Create the audit. Nothing runs until {@link RlsAudit.start}.
|
|
6123
|
+
*
|
|
6124
|
+
* Because the scanner is supplied by the caller, the schedule, the summary and
|
|
6125
|
+
* the failure handling are all testable without a database and without
|
|
6126
|
+
* `@rebasepro/rls-check` present.
|
|
6127
|
+
*/
|
|
6128
|
+
function createRlsAudit(config) {
|
|
6129
|
+
const enabled = config.enabled === true;
|
|
6130
|
+
const intervalMs = config.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
6131
|
+
const warnAt = config.warnAtSeverity ?? "high";
|
|
6132
|
+
const connectionString = config.connectionString ?? process.env.DATABASE_URL;
|
|
6133
|
+
let timer;
|
|
6134
|
+
let status = { enabled };
|
|
6135
|
+
if (enabled && !config.scan) status = {
|
|
6136
|
+
enabled: false,
|
|
6137
|
+
reason: "The scheduled RLS audit is enabled but no scanner was supplied. Pass `scan` from `@rebasepro/rls-check`: `rlsAudit: { enabled: true, scan }`."
|
|
6138
|
+
};
|
|
6139
|
+
else if (enabled && !connectionString) status = {
|
|
6140
|
+
enabled: false,
|
|
6141
|
+
reason: "The scheduled RLS audit is enabled but no connection string is available. Set DATABASE_URL, or pass `rlsAudit.connectionString`."
|
|
6142
|
+
};
|
|
6143
|
+
const runnable = enabled && !!connectionString && !!config.scan;
|
|
6144
|
+
const run = async () => {
|
|
6145
|
+
if (!runnable) return;
|
|
6146
|
+
try {
|
|
6147
|
+
const result = await config.scan({
|
|
6148
|
+
connectionString,
|
|
6149
|
+
schemas: config.schemas,
|
|
6150
|
+
statementTimeoutMs: config.statementTimeoutMs ?? DEFAULT_STATEMENT_TIMEOUT_MS
|
|
6151
|
+
});
|
|
6152
|
+
const { level, message } = summarize(result, warnAt);
|
|
6153
|
+
logger[level](`[rls-audit] ${message}`);
|
|
6154
|
+
status = {
|
|
6155
|
+
enabled: true,
|
|
6156
|
+
lastRunAt: result.scannedAt,
|
|
6157
|
+
result
|
|
6158
|
+
};
|
|
6159
|
+
} catch (err) {
|
|
6160
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
6161
|
+
logger.error(`[rls-audit] Scan failed: ${detail}`);
|
|
6162
|
+
status = {
|
|
6163
|
+
...status,
|
|
6164
|
+
enabled: true,
|
|
6165
|
+
lastRunAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6166
|
+
lastError: detail
|
|
6167
|
+
};
|
|
6168
|
+
}
|
|
6169
|
+
};
|
|
6170
|
+
return {
|
|
6171
|
+
start() {
|
|
6172
|
+
if (!runnable) {
|
|
6173
|
+
if (status.reason) logger.warn(`[rls-audit] ${status.reason}`);
|
|
6174
|
+
return;
|
|
6175
|
+
}
|
|
6176
|
+
if (timer) return;
|
|
6177
|
+
timer = setInterval(() => {
|
|
6178
|
+
run();
|
|
6179
|
+
}, intervalMs);
|
|
6180
|
+
timer.unref?.();
|
|
6181
|
+
if (config.runOnBoot !== false) run();
|
|
6182
|
+
},
|
|
6183
|
+
stop() {
|
|
6184
|
+
if (timer) clearInterval(timer);
|
|
6185
|
+
timer = void 0;
|
|
6186
|
+
},
|
|
6187
|
+
runNow: run,
|
|
6188
|
+
status: () => status
|
|
6189
|
+
};
|
|
6190
|
+
}
|
|
6191
|
+
//#endregion
|
|
6192
|
+
//#region src/init/surfaces.ts
|
|
6193
|
+
/** Every surface, in a stable order. */
|
|
6194
|
+
var ALL_RUNTIME_SURFACES = [
|
|
6195
|
+
"auth",
|
|
6196
|
+
"data",
|
|
6197
|
+
"storage",
|
|
6198
|
+
"admin",
|
|
6199
|
+
"functions",
|
|
6200
|
+
"cron",
|
|
6201
|
+
"meta",
|
|
6202
|
+
"realtime"
|
|
6203
|
+
];
|
|
4369
6204
|
/**
|
|
4370
6205
|
* Resolve a partial surface set against the default, which is everything on.
|
|
4371
6206
|
*
|
|
@@ -4383,7 +6218,8 @@ function resolveSurfaces(options) {
|
|
|
4383
6218
|
function resolveOwnership(options) {
|
|
4384
6219
|
return {
|
|
4385
6220
|
cronScheduler: options?.cronScheduler ?? true,
|
|
4386
|
-
jobWorkers: options?.jobWorkers ?? true
|
|
6221
|
+
jobWorkers: options?.jobWorkers ?? true,
|
|
6222
|
+
rlsAudit: options?.rlsAudit ?? true
|
|
4387
6223
|
};
|
|
4388
6224
|
}
|
|
4389
6225
|
/** The surfaces a process is not serving, for a boot log line. */
|
|
@@ -4516,7 +6352,7 @@ function isServerLikeEnvironment() {
|
|
|
4516
6352
|
* Emitted once per client. Kept as a constant so the wording is testable and
|
|
4517
6353
|
* greppable — this is the string a user will paste into a search.
|
|
4518
6354
|
*/
|
|
4519
|
-
var ANONYMOUS_SERVER_CLIENT_WARNING = "[rebase] This client was created outside a browser with no credential — no `token`, no auth token getter, and no cookie auth flow — so every request runs as an anonymous caller. Row-level security will return only publicly readable rows, which is usually nothing and occasionally the wrong thing. Inside a cron or function handler, use the `
|
|
6355
|
+
var ANONYMOUS_SERVER_CLIENT_WARNING = "[rebase] This client was created outside a browser with no credential — no `token`, no auth token getter, and no cookie auth flow — so every request runs as an anonymous caller. Row-level security will return only publicly readable rows, which is usually nothing and occasionally the wrong thing. Inside a cron or function handler, use the `rebase` you were handed instead of building a new one: its data plane is already admin-scoped. In a standalone script or job, pass the service key as `token`. If you really do want anonymous access, pass `anonymous: true` to silence this.";
|
|
4520
6356
|
/**
|
|
4521
6357
|
* Refuse a filter whose *value* is missing.
|
|
4522
6358
|
*
|
|
@@ -5402,6 +7238,49 @@ function createAuth(transport, options) {
|
|
|
5402
7238
|
refreshToken: session.refreshToken
|
|
5403
7239
|
};
|
|
5404
7240
|
}
|
|
7241
|
+
/**
|
|
7242
|
+
* Ask for a six-digit sign-in code by email.
|
|
7243
|
+
*
|
|
7244
|
+
* Answers the same thing whether or not the address has an account — do not
|
|
7245
|
+
* use the result to tell a person whether they are registered, because it
|
|
7246
|
+
* does not know.
|
|
7247
|
+
*/
|
|
7248
|
+
async function sendEmailOtp(email) {
|
|
7249
|
+
const res = await getFetch()(authUrl("/otp"), {
|
|
7250
|
+
method: "POST",
|
|
7251
|
+
headers: { "Content-Type": "application/json" },
|
|
7252
|
+
body: JSON.stringify({ email })
|
|
7253
|
+
});
|
|
7254
|
+
const body = await res.json().catch(() => ({}));
|
|
7255
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
7256
|
+
return body;
|
|
7257
|
+
}
|
|
7258
|
+
/**
|
|
7259
|
+
* Trade a code for a session.
|
|
7260
|
+
*
|
|
7261
|
+
* The address is sent with the code because the code is only valid for it:
|
|
7262
|
+
* that is what keeps a six-digit guess a guess against one account rather
|
|
7263
|
+
* than against every account at once.
|
|
7264
|
+
*/
|
|
7265
|
+
async function verifyEmailOtp(email, code) {
|
|
7266
|
+
const res = await getFetch()(authUrl("/otp/verify"), {
|
|
7267
|
+
method: "POST",
|
|
7268
|
+
headers: { "Content-Type": "application/json" },
|
|
7269
|
+
body: JSON.stringify({
|
|
7270
|
+
email,
|
|
7271
|
+
code
|
|
7272
|
+
}),
|
|
7273
|
+
credentials: authFlowMode === "cookie" ? "include" : void 0
|
|
7274
|
+
});
|
|
7275
|
+
const body = await res.json().catch(() => ({}));
|
|
7276
|
+
if (!res.ok) throwApiError(res.status, body, res.statusText);
|
|
7277
|
+
const session = handleAuthResponse(body, "SIGNED_IN");
|
|
7278
|
+
return {
|
|
7279
|
+
user: session.user,
|
|
7280
|
+
accessToken: session.accessToken,
|
|
7281
|
+
refreshToken: session.refreshToken
|
|
7282
|
+
};
|
|
7283
|
+
}
|
|
5405
7284
|
async function getSessions() {
|
|
5406
7285
|
return (await transport.request(authPath + "/sessions", { method: "GET" })).sessions;
|
|
5407
7286
|
}
|
|
@@ -5492,6 +7371,8 @@ function createAuth(transport, options) {
|
|
|
5492
7371
|
verifyEmail,
|
|
5493
7372
|
sendMagicLink,
|
|
5494
7373
|
verifyMagicLink,
|
|
7374
|
+
sendEmailOtp,
|
|
7375
|
+
verifyEmailOtp,
|
|
5495
7376
|
getSessions,
|
|
5496
7377
|
revokeSession,
|
|
5497
7378
|
revokeAllSessions,
|
|
@@ -5861,23 +7742,17 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
5861
7742
|
})).data || [];
|
|
5862
7743
|
},
|
|
5863
7744
|
/**
|
|
5864
|
-
*
|
|
5865
|
-
*
|
|
5866
|
-
*
|
|
5867
|
-
* The two are interchangeable server-side, so switching buys nothing at
|
|
5868
|
-
* runtime — and it costs compatibility in the direction that fails
|
|
5869
|
-
* quietly. A 0.14 client talking to a 0.13 server would send `PATCH` to
|
|
5870
|
-
* a route that does not exist and get a **404**, which is
|
|
5871
|
-
* indistinguishable from "that row is gone". Every write would look like
|
|
5872
|
-
* a missing record.
|
|
7745
|
+
* `PATCH`, the verb for a merge — `update(id, data: Partial<M>)` sends
|
|
7746
|
+
* only the keys the caller named and the server merges the rest.
|
|
5873
7747
|
*
|
|
5874
|
-
* `
|
|
5875
|
-
*
|
|
5876
|
-
*
|
|
7748
|
+
* It was `PUT` for a while, on a route that served both so older
|
|
7749
|
+
* clients kept working. The alias is gone: the OpenAPI spec, the SDK
|
|
7750
|
+
* and the route now name one verb, and a spec-validating gateway in
|
|
7751
|
+
* front of the API sees the operation the server actually implements.
|
|
5877
7752
|
*/
|
|
5878
7753
|
async update(id, data) {
|
|
5879
7754
|
return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
|
|
5880
|
-
method: "
|
|
7755
|
+
method: "PATCH",
|
|
5881
7756
|
body: JSON.stringify(data)
|
|
5882
7757
|
});
|
|
5883
7758
|
},
|
|
@@ -6021,7 +7896,8 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
6021
7896
|
offset: window.driverOffset,
|
|
6022
7897
|
orderBy: normalizeOrderBy(params?.orderBy),
|
|
6023
7898
|
searchString: params?.searchString,
|
|
6024
|
-
searchExplain: params?.searchExplain
|
|
7899
|
+
searchExplain: params?.searchExplain,
|
|
7900
|
+
vectorSearch: params?.vectorSearch
|
|
6025
7901
|
}, (incomingRows) => {
|
|
6026
7902
|
const currentUpdateId = ++lastUpdateId;
|
|
6027
7903
|
const requestedLimit = window.limit;
|
|
@@ -6097,6 +7973,27 @@ function createFunctionsClient(transport) {
|
|
|
6097
7973
|
* When set, it is forwarded to the server so the correct
|
|
6098
7974
|
* `StorageController` is resolved from the registry.
|
|
6099
7975
|
*/
|
|
7976
|
+
/**
|
|
7977
|
+
* A storage key, encoded for a URL path.
|
|
7978
|
+
*
|
|
7979
|
+
* Every call below interpolated the key raw, and the server decodes what it
|
|
7980
|
+
* receives — so three ordinary filenames did three different wrong things:
|
|
7981
|
+
*
|
|
7982
|
+
* `Invoice #12.pdf` the `#` began the fragment, the server saw
|
|
7983
|
+
* `default/Invoice ` and looked up a key that is not the
|
|
7984
|
+
* file — and a scoped `?token=` after it was swallowed too
|
|
7985
|
+
* `100% done.png` `decodeURIComponent` threw URIError → 500
|
|
7986
|
+
* `a%2Fb.png` decoded to `a/b.png`, silently resolving a DIFFERENT
|
|
7987
|
+
* object
|
|
7988
|
+
*
|
|
7989
|
+
* Per SEGMENT, because `/` is the key's own separator and must survive; every
|
|
7990
|
+
* other character is encoded, including a literal `%`. Verified to round-trip
|
|
7991
|
+
* through the server's decode for all of the above, plus `+`, accented
|
|
7992
|
+
* characters and a trailing `%`.
|
|
7993
|
+
*/
|
|
7994
|
+
function encodeStorageKey(key) {
|
|
7995
|
+
return key.split("/").map(encodeURIComponent).join("/");
|
|
7996
|
+
}
|
|
6100
7997
|
function createStorage(transport, storageId) {
|
|
6101
7998
|
const urlsCache = /* @__PURE__ */ new Map();
|
|
6102
7999
|
/**
|
|
@@ -6142,15 +8039,15 @@ function createStorage(transport, storageId) {
|
|
|
6142
8039
|
fileNotFound: true
|
|
6143
8040
|
};
|
|
6144
8041
|
if (isPublicStoragePath(filePath)) {
|
|
6145
|
-
const publicConfig = { url: withStorageId(`${fileUrlBase()}/storage/file/${filePath}`) };
|
|
8042
|
+
const publicConfig = { url: withStorageId(`${fileUrlBase()}/storage/file/${encodeStorageKey(filePath)}`) };
|
|
6146
8043
|
urlsCache.set(cacheKey, { config: publicConfig });
|
|
6147
8044
|
return publicConfig;
|
|
6148
8045
|
}
|
|
6149
8046
|
try {
|
|
6150
|
-
const result = await transport.request(withStorageId(`/storage/metadata/${filePath}`));
|
|
8047
|
+
const result = await transport.request(withStorageId(`/storage/metadata/${encodeStorageKey(filePath)}`));
|
|
6151
8048
|
if (result.data.public) {
|
|
6152
8049
|
const publicConfig = {
|
|
6153
|
-
url: withStorageId(`${fileUrlBase()}/storage/file/${filePath}`),
|
|
8050
|
+
url: withStorageId(`${fileUrlBase()}/storage/file/${encodeStorageKey(filePath)}`),
|
|
6154
8051
|
metadata: result.data
|
|
6155
8052
|
};
|
|
6156
8053
|
urlsCache.set(cacheKey, { config: publicConfig });
|
|
@@ -6159,7 +8056,7 @@ function createStorage(transport, storageId) {
|
|
|
6159
8056
|
const scopedToken = result.data.token;
|
|
6160
8057
|
const tokenQuery = scopedToken ? `?token=${scopedToken}` : "";
|
|
6161
8058
|
const downloadConfig = {
|
|
6162
|
-
url: withStorageId(`${fileUrlBase()}/storage/file/${filePath}${tokenQuery}`),
|
|
8059
|
+
url: withStorageId(`${fileUrlBase()}/storage/file/${encodeStorageKey(filePath)}${tokenQuery}`),
|
|
6163
8060
|
metadata: result.data
|
|
6164
8061
|
};
|
|
6165
8062
|
const expiresAt = result.data.tokenExpiresIn ? Date.now() + (result.data.tokenExpiresIn - 10) * 1e3 : void 0;
|
|
@@ -6192,7 +8089,7 @@ function createStorage(transport, storageId) {
|
|
|
6192
8089
|
if (bucket && filePath && !filePath.startsWith(bucket)) filePath = `${bucket}/${filePath}`;
|
|
6193
8090
|
if (!filePath || filePath.trim() === "" || filePath === "/") return;
|
|
6194
8091
|
try {
|
|
6195
|
-
await transport.request(withStorageId(`/storage/file/${filePath}`), { method: "DELETE" });
|
|
8092
|
+
await transport.request(withStorageId(`/storage/file/${encodeStorageKey(filePath)}`), { method: "DELETE" });
|
|
6196
8093
|
} catch (e) {
|
|
6197
8094
|
if (!(e instanceof Error && "status" in e && e.status === 404)) throw e;
|
|
6198
8095
|
}
|
|
@@ -6313,7 +8210,6 @@ var RebaseWebSocketClient = class {
|
|
|
6313
8210
|
websocketUrl;
|
|
6314
8211
|
ws = null;
|
|
6315
8212
|
getAuthToken;
|
|
6316
|
-
subscriptions = /* @__PURE__ */ new Map();
|
|
6317
8213
|
listeners = /* @__PURE__ */ new Map();
|
|
6318
8214
|
/** Channel-name → handlers, for broadcast and presence frames. */
|
|
6319
8215
|
channelHandlers = /* @__PURE__ */ new Map();
|
|
@@ -6808,17 +8704,6 @@ var RebaseWebSocketClient = class {
|
|
|
6808
8704
|
}
|
|
6809
8705
|
}
|
|
6810
8706
|
}
|
|
6811
|
-
if (subscriptionId && this.subscriptions.has(subscriptionId)) {
|
|
6812
|
-
const callback = this.subscriptions.get(subscriptionId);
|
|
6813
|
-
if (!callback) throw new Error(`Subscription callback not found for subscriptionId: ${subscriptionId}`);
|
|
6814
|
-
if (message.type === "ERROR" || message.error) {
|
|
6815
|
-
if (callback.onError) {
|
|
6816
|
-
const { errorMessage, errorCode } = extractMessageError(message);
|
|
6817
|
-
callback.onError(new RebaseApiError(errorMessage, { code: errorCode }));
|
|
6818
|
-
}
|
|
6819
|
-
} else callback.onUpdate(message);
|
|
6820
|
-
return;
|
|
6821
|
-
}
|
|
6822
8707
|
if (type === "ERROR" || type === "error" || message.error) {
|
|
6823
8708
|
const { errorMessage, errorCode } = extractMessageError(message);
|
|
6824
8709
|
console.warn(`[Rebase] Realtime error from the server${errorCode ? ` (${errorCode})` : ""}: ${errorMessage}`);
|
|
@@ -10385,8 +12270,35 @@ function createHistoryRoutes(params) {
|
|
|
10385
12270
|
* version or not — reads the same live client.
|
|
10386
12271
|
*/
|
|
10387
12272
|
var INSTANCE_SLOT = Symbol.for("@rebasepro/server:singleton-instance");
|
|
12273
|
+
/**
|
|
12274
|
+
* A *function* that answers "which client is this call for", for hosts where
|
|
12275
|
+
* one answer per process is the wrong shape.
|
|
12276
|
+
*
|
|
12277
|
+
* A Node server boots once and serves every request from the same client, so
|
|
12278
|
+
* {@link INSTANCE_SLOT} is the whole story. An isolate-based host is different
|
|
12279
|
+
* in a way that matters: the environment arrives attached to the **request**,
|
|
12280
|
+
* not to the module, so there is nothing to publish at import time, and an
|
|
12281
|
+
* isolate can be reused across requests that must not share state. Such a host
|
|
12282
|
+
* registers a resolver — typically reading an `AsyncLocalStorage` populated per
|
|
12283
|
+
* request — and it is consulted first.
|
|
12284
|
+
*
|
|
12285
|
+
* This exists now, before there is a host that needs it, because of what it
|
|
12286
|
+
* protects: `rebase` is a lazy Proxy, so every property access already goes
|
|
12287
|
+
* through `getInstance()`. Backing that lookup with a resolver is therefore the
|
|
12288
|
+
* entire porting story for the singleton — **no function anyone has already
|
|
12289
|
+
* written changes**. Take the resolver away and the only alternative is asking
|
|
12290
|
+
* users to thread a client through their handlers, which is a rewrite of every
|
|
12291
|
+
* function file in existence.
|
|
12292
|
+
*/
|
|
12293
|
+
var RESOLVER_SLOT = Symbol.for("@rebasepro/server:singleton-resolver");
|
|
10388
12294
|
function getInstance() {
|
|
10389
|
-
|
|
12295
|
+
const global = globalThis;
|
|
12296
|
+
const resolver = global[RESOLVER_SLOT];
|
|
12297
|
+
if (resolver) {
|
|
12298
|
+
const resolved = resolver();
|
|
12299
|
+
if (resolved) return resolved;
|
|
12300
|
+
}
|
|
12301
|
+
return global[INSTANCE_SLOT] ?? null;
|
|
10390
12302
|
}
|
|
10391
12303
|
function setInstance(client) {
|
|
10392
12304
|
globalThis[INSTANCE_SLOT] = client;
|
|
@@ -10403,7 +12315,7 @@ function _initRebase(client) {
|
|
|
10403
12315
|
* Throws an error if used in a non-test environment to prevent production abuse.
|
|
10404
12316
|
*/
|
|
10405
12317
|
function _setRebaseMock(mockInstance) {
|
|
10406
|
-
if (
|
|
12318
|
+
if (hostEnv().NODE_ENV !== "test") throw new Error("_setRebaseMock can only be called in a test environment (NODE_ENV=test).");
|
|
10407
12319
|
setInstance({
|
|
10408
12320
|
...getInstance() || {},
|
|
10409
12321
|
...mockInstance
|
|
@@ -10413,7 +12325,7 @@ function _setRebaseMock(mockInstance) {
|
|
|
10413
12325
|
* @internal Resets the singleton instance, useful for afterEach() in test suites.
|
|
10414
12326
|
*/
|
|
10415
12327
|
function _resetRebaseMock() {
|
|
10416
|
-
if (
|
|
12328
|
+
if (hostEnv().NODE_ENV !== "test") throw new Error("_resetRebaseMock can only be called in a test environment.");
|
|
10417
12329
|
setInstance(null);
|
|
10418
12330
|
}
|
|
10419
12331
|
/**
|
|
@@ -10526,6 +12438,199 @@ function resolveRequireAuth(auth) {
|
|
|
10526
12438
|
return auth.requireAuth !== false;
|
|
10527
12439
|
}
|
|
10528
12440
|
//#endregion
|
|
12441
|
+
//#region src/boot/resource-adapters.ts
|
|
12442
|
+
/**
|
|
12443
|
+
* The graph, expressed as the shapes the existing subsystems already take.
|
|
12444
|
+
*
|
|
12445
|
+
* The data layer consumes `DataSourceDefinition[]` and the storage layer
|
|
12446
|
+
* consumes `StorageSourceDefinition[]`, and both work. Nothing about the
|
|
12447
|
+
* resource graph requires rewriting them: the graph is a better *declaration*,
|
|
12448
|
+
* not a different runtime. So it is translated at the boundary, once, and
|
|
12449
|
+
* everything downstream is untouched.
|
|
12450
|
+
*
|
|
12451
|
+
* Doing it the other way round — teaching each subsystem to read the graph —
|
|
12452
|
+
* would have meant touching the pool manager, the storage registry and the
|
|
12453
|
+
* collection resolver to land a config change, which is how a config change
|
|
12454
|
+
* becomes a rewrite and then does not ship.
|
|
12455
|
+
*/
|
|
12456
|
+
/**
|
|
12457
|
+
* The graph and the two subsystems spell "the unnamed one" identically today,
|
|
12458
|
+
* but they are three separate constants and nothing stops them drifting. Mapped
|
|
12459
|
+
* explicitly so a future divergence is a compile error here rather than a
|
|
12460
|
+
* default database that silently fails to bind.
|
|
12461
|
+
*/
|
|
12462
|
+
function toDataSourceKey(key) {
|
|
12463
|
+
return key === "(default)" ? DEFAULT_DATA_SOURCE_KEY : key;
|
|
12464
|
+
}
|
|
12465
|
+
function toStorageSourceKey(key) {
|
|
12466
|
+
return key === "(default)" ? DEFAULT_STORAGE_SOURCE_KEY : key;
|
|
12467
|
+
}
|
|
12468
|
+
/** Databases in the graph, as the data layer's definitions. */
|
|
12469
|
+
function graphToDataSources(graph) {
|
|
12470
|
+
return graph.resources.filter((r) => r.kind === "database").map((r) => ({
|
|
12471
|
+
key: toDataSourceKey(r.key),
|
|
12472
|
+
engine: r.engine,
|
|
12473
|
+
transport: r.transport,
|
|
12474
|
+
...typeof r.options.databaseId === "string" ? { databaseId: r.options.databaseId } : {},
|
|
12475
|
+
...r.label !== void 0 ? { label: r.label } : {}
|
|
12476
|
+
}));
|
|
12477
|
+
}
|
|
12478
|
+
/** Buckets in the graph, as the storage layer's definitions. */
|
|
12479
|
+
function graphToStorageSources(graph) {
|
|
12480
|
+
return graph.resources.filter((r) => r.kind === "bucket").map((r) => ({
|
|
12481
|
+
key: toStorageSourceKey(r.key),
|
|
12482
|
+
engine: r.engine,
|
|
12483
|
+
transport: r.transport,
|
|
12484
|
+
...r.label !== void 0 ? { label: r.label } : {}
|
|
12485
|
+
}));
|
|
12486
|
+
}
|
|
12487
|
+
/** Topics in the graph, for the runtime to publish through and the worker to wire. */
|
|
12488
|
+
function graphTopics(graph) {
|
|
12489
|
+
return graph.resources.filter((r) => r.kind === "topic");
|
|
12490
|
+
}
|
|
12491
|
+
/**
|
|
12492
|
+
* Configuration keys the resource graph replaced.
|
|
12493
|
+
*
|
|
12494
|
+
* Each was a way to declare a resource somewhere other than a declaration, and
|
|
12495
|
+
* every one of them is gone. There is no compatibility mode: a config still
|
|
12496
|
+
* carrying one is refused at boot, by name, with the replacement in the
|
|
12497
|
+
* message.
|
|
12498
|
+
*
|
|
12499
|
+
* Silence here would be the exact failure this model was built to remove. The
|
|
12500
|
+
* old storage path accepted a `storageSources` export, merged it with
|
|
12501
|
+
* `rebase.json`, and discarded whichever engine lost — a declaration accepted
|
|
12502
|
+
* and then ignored. Leaving the key readable but inert would reproduce that
|
|
12503
|
+
* with better intentions.
|
|
12504
|
+
*/
|
|
12505
|
+
var REPLACED_CONFIG_KEYS = {
|
|
12506
|
+
dataSources: "declare each one with database(\"<key>\") in your config's resources.ts, then run `rebase resources --write`",
|
|
12507
|
+
storageSources: "declare each one with bucket(\"<key>\") in your config's resources.ts, then run `rebase resources --write`"
|
|
12508
|
+
};
|
|
12509
|
+
/**
|
|
12510
|
+
* Refuse a config still carrying a replaced key.
|
|
12511
|
+
*
|
|
12512
|
+
* Throws on the first one rather than collecting: unlike a manifest, where a
|
|
12513
|
+
* reader wants every problem at once, this is a single edit per key and the
|
|
12514
|
+
* message is long enough that three of them would bury each other.
|
|
12515
|
+
*/
|
|
12516
|
+
function assertNoReplacedResourceConfig(config) {
|
|
12517
|
+
for (const [key, remedy] of Object.entries(REPLACED_CONFIG_KEYS)) {
|
|
12518
|
+
if (config[key] === void 0) continue;
|
|
12519
|
+
throw new Error(`\`${key}\` is no longer read. Resources are declared in config code now, so that there is one place to look and nothing to merge.\n\n To migrate: ${remedy}.\n\nRefused rather than ignored: a key that is accepted and then does nothing is the failure this replaced.`);
|
|
12520
|
+
}
|
|
12521
|
+
}
|
|
12522
|
+
//#endregion
|
|
12523
|
+
//#region src/topics/runtime.ts
|
|
12524
|
+
/**
|
|
12525
|
+
* Topics, on the durable job queue.
|
|
12526
|
+
*
|
|
12527
|
+
* ## Why fan out at publish time
|
|
12528
|
+
*
|
|
12529
|
+
* Publishing writes **one job row per subscription**, not one row for the
|
|
12530
|
+
* event. That is the shape SNS→SQS has and the reason is the same: each
|
|
12531
|
+
* subscriber then retries on its own schedule, and a subscription that is
|
|
12532
|
+
* broken or slow neither blocks the others nor causes them to run again. A
|
|
12533
|
+
* single row per event forces one retry policy across every consumer, so the
|
|
12534
|
+
* fourth attempt of a failing audit log re-delivers a welcome email that
|
|
12535
|
+
* already went out.
|
|
12536
|
+
*
|
|
12537
|
+
* It also makes "did this subscriber get it" a row somebody can look at, which
|
|
12538
|
+
* is the property the job queue was built for: work that gives up is *kept*,
|
|
12539
|
+
* because a queue that silently drops what it could not deliver is
|
|
12540
|
+
* indistinguishable from one with nothing to do.
|
|
12541
|
+
*
|
|
12542
|
+
* ## What at-least-once means here
|
|
12543
|
+
*
|
|
12544
|
+
* A handler can see the same event twice — a worker that dies holding a job
|
|
12545
|
+
* releases it after the visibility timeout, and the next worker starts the
|
|
12546
|
+
* handler again from the top. Handlers must tolerate that. It is the honest
|
|
12547
|
+
* name for what a retrying queue does, which is why `at-most-once` is refused
|
|
12548
|
+
* at declaration rather than quietly given the other guarantee.
|
|
12549
|
+
*
|
|
12550
|
+
* ## Transactional publish
|
|
12551
|
+
*
|
|
12552
|
+
* `enqueue` is a row insert, so a publish inside a transaction that rolls back
|
|
12553
|
+
* was never published — the difference between "email sent for an order that
|
|
12554
|
+
* does not exist" and nothing having happened at all.
|
|
12555
|
+
*/
|
|
12556
|
+
/** The job task name a subscription's deliveries are recorded under. */
|
|
12557
|
+
function topicTaskName(topic, subscription) {
|
|
12558
|
+
return `topic:${topic}:${subscription}`;
|
|
12559
|
+
}
|
|
12560
|
+
/**
|
|
12561
|
+
* Build the job handlers every declared subscription needs.
|
|
12562
|
+
*
|
|
12563
|
+
* Returned rather than registered, because the queue takes its whole task map
|
|
12564
|
+
* at construction — and a task map assembled in one place is one somebody can
|
|
12565
|
+
* print.
|
|
12566
|
+
*/
|
|
12567
|
+
function topicJobHandlers() {
|
|
12568
|
+
const tasks = {};
|
|
12569
|
+
for (const sub of declaredSubscriptions()) {
|
|
12570
|
+
const task = topicTaskName(sub.topic, sub.name);
|
|
12571
|
+
tasks[task] = (async (ctx) => {
|
|
12572
|
+
await sub.handler(ctx.payload, {
|
|
12573
|
+
attempt: ctx.attempt,
|
|
12574
|
+
topic: sub.topic,
|
|
12575
|
+
subscription: sub.name
|
|
12576
|
+
});
|
|
12577
|
+
});
|
|
12578
|
+
}
|
|
12579
|
+
return tasks;
|
|
12580
|
+
}
|
|
12581
|
+
/**
|
|
12582
|
+
* Topics warned about having no subscriber.
|
|
12583
|
+
*
|
|
12584
|
+
* Once per topic rather than per event: a topic nobody subscribes to is a
|
|
12585
|
+
* legitimate intermediate state while somebody builds the other half, but "I
|
|
12586
|
+
* published and nothing happened" is expensive to debug in silence, and a
|
|
12587
|
+
* per-event warning would bury the real log under a loop.
|
|
12588
|
+
*/
|
|
12589
|
+
var warnedEmpty = /* @__PURE__ */ new Set();
|
|
12590
|
+
/**
|
|
12591
|
+
* Build the runtime topics publish through.
|
|
12592
|
+
*
|
|
12593
|
+
* Publishing to a topic nothing declared throws. The handle API makes it hard
|
|
12594
|
+
* to reach, but a string name from an ejected project or a dynamic caller can —
|
|
12595
|
+
* and enqueueing under a task no worker handles would write rows nobody ever
|
|
12596
|
+
* runs.
|
|
12597
|
+
*/
|
|
12598
|
+
function createTopicRuntime(options) {
|
|
12599
|
+
const { queue, topics } = options;
|
|
12600
|
+
const declared = new Map(topics.map((t) => [t.key, t]));
|
|
12601
|
+
return { async publish(topic, event) {
|
|
12602
|
+
const declaration = declared.get(topic);
|
|
12603
|
+
if (!declaration) {
|
|
12604
|
+
const known = [...declared.keys()].sort().join(", ") || "none";
|
|
12605
|
+
throw new Error(`Cannot publish to topic "${topic}": nothing declares it. Declared topics: ${known}. Enqueueing it anyway would write rows no worker handles.`);
|
|
12606
|
+
}
|
|
12607
|
+
const subs = declaredSubscriptions(topic);
|
|
12608
|
+
if (subs.length === 0) {
|
|
12609
|
+
if (!warnedEmpty.has(topic)) {
|
|
12610
|
+
warnedEmpty.add(topic);
|
|
12611
|
+
logger.warn(`[topics] Published to "${topic}", which has no subscriptions — the event is dropped. Declare one with topic.subscription(name, handler).`);
|
|
12612
|
+
}
|
|
12613
|
+
return;
|
|
12614
|
+
}
|
|
12615
|
+
const topicAttempts = typeof declaration.options.maxAttempts === "number" ? declaration.options.maxAttempts : void 0;
|
|
12616
|
+
for (const sub of subs) await queue.enqueue(topicTaskName(topic, sub.name), event, { maxAttempts: sub.maxAttempts ?? topicAttempts ?? 5 });
|
|
12617
|
+
} };
|
|
12618
|
+
}
|
|
12619
|
+
/**
|
|
12620
|
+
* Install the runtime, and hand back a teardown.
|
|
12621
|
+
*
|
|
12622
|
+
* Publishing without one throws rather than resolving — an event that
|
|
12623
|
+
* disappears into a resolved promise is the failure a queue exists to
|
|
12624
|
+
* prevent — so it is installed for exactly as long as a backend is up.
|
|
12625
|
+
*/
|
|
12626
|
+
function installTopicRuntime(options) {
|
|
12627
|
+
setTopicRuntime(createTopicRuntime(options));
|
|
12628
|
+
return () => {
|
|
12629
|
+
setTopicRuntime(null);
|
|
12630
|
+
warnedEmpty.clear();
|
|
12631
|
+
};
|
|
12632
|
+
}
|
|
12633
|
+
//#endregion
|
|
10529
12634
|
//#region src/init.ts
|
|
10530
12635
|
/**
|
|
10531
12636
|
* Type guard to detect whether the `auth` config is an `AuthAdapter`
|
|
@@ -10581,6 +12686,10 @@ async function initializeRebaseBackend(config) {
|
|
|
10581
12686
|
async function _initializeRebaseBackend(config) {
|
|
10582
12687
|
if (config.logging?.level) configureLogLevel(config.logging.level);
|
|
10583
12688
|
else configureLogLevel();
|
|
12689
|
+
assertNoReplacedResourceConfig(config);
|
|
12690
|
+
const resourceGraph = buildResourceGraph();
|
|
12691
|
+
const declaredDataSources = graphToDataSources(resourceGraph);
|
|
12692
|
+
const declaredStorageSources = graphToStorageSources(resourceGraph);
|
|
10584
12693
|
logger.debug("Initializing Rebase Backend");
|
|
10585
12694
|
installUnhandledRejectionHandler();
|
|
10586
12695
|
const basePath = config.basePath || "/api";
|
|
@@ -10594,7 +12703,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10594
12703
|
});
|
|
10595
12704
|
configureMiddlewares(config.app, basePath, isProduction, config);
|
|
10596
12705
|
const collectionRegistry = new BackendCollectionRegistry();
|
|
10597
|
-
const dataSourceRegistry = createDataSourceRegistry(
|
|
12706
|
+
const dataSourceRegistry = createDataSourceRegistry(declaredDataSources.length > 0 ? declaredDataSources : void 0);
|
|
10598
12707
|
collectionRegistry.setDataSources(dataSourceRegistry);
|
|
10599
12708
|
if (config.callbacks) collectionRegistry.setGlobalCallbacks(config.callbacks);
|
|
10600
12709
|
let activeCollections = config.collections || [];
|
|
@@ -10621,7 +12730,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10621
12730
|
let defaultDriverId = DEFAULT_DRIVER_ID;
|
|
10622
12731
|
let defaultDriverResult = void 0;
|
|
10623
12732
|
const provisionTarget = provisionTargetFor(bootstrappers, config.database, config.provisioningDriverResult);
|
|
10624
|
-
const provisionable = collectionsStoredBy(activeCollections, { engine: provisionTarget.engine },
|
|
12733
|
+
const provisionable = collectionsStoredBy(activeCollections, { engine: provisionTarget.engine }, declaredDataSources.map((source) => ({
|
|
10625
12734
|
key: source.key,
|
|
10626
12735
|
engine: source.engine ?? provisionTarget.engine
|
|
10627
12736
|
})));
|
|
@@ -10789,7 +12898,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10789
12898
|
} : void 0;
|
|
10790
12899
|
const { storageRegistry, storageController } = await initializeStorage(config.storage, isProduction);
|
|
10791
12900
|
if (config.auth) {
|
|
10792
|
-
config.app.get(`${basePath}/auth/config`, async (c) => {
|
|
12901
|
+
config.app.get(`${basePath}/auth/config`, defaultAuthLimiter, async (c) => {
|
|
10793
12902
|
const capabilities = await authAdapter.getCapabilities();
|
|
10794
12903
|
return c.json(capabilities);
|
|
10795
12904
|
});
|
|
@@ -10865,7 +12974,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10865
12974
|
]) {
|
|
10866
12975
|
const providerConfig = safeAuthConfig[key];
|
|
10867
12976
|
if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
|
|
10868
|
-
const createFn = (await import("./auth-
|
|
12977
|
+
const createFn = (await import("./auth-DzarZhLS.js").then((n) => n.t))[factory];
|
|
10869
12978
|
oauthProviders.push(createFn(providerConfig));
|
|
10870
12979
|
}
|
|
10871
12980
|
}
|
|
@@ -10879,6 +12988,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10879
12988
|
disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
|
|
10880
12989
|
allowAnonymous: safeAuthConfig.allowAnonymous ?? false,
|
|
10881
12990
|
allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
|
|
12991
|
+
captcha: safeAuthConfig.captcha,
|
|
10882
12992
|
defaultRole: safeAuthConfig.defaultRole,
|
|
10883
12993
|
oauthProviders,
|
|
10884
12994
|
allowedRedirectUris: safeAuthConfig.allowedRedirectUris,
|
|
@@ -10886,6 +12996,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10886
12996
|
authHooks: safeAuthConfig.hooks,
|
|
10887
12997
|
collectionAuthConfig,
|
|
10888
12998
|
enableMagicLink: safeAuthConfig.magicLink ?? false,
|
|
12999
|
+
enableEmailOtp: safeAuthConfig.emailOtp ?? false,
|
|
10889
13000
|
cookieAuth: safeAuthConfig.cookieAuth
|
|
10890
13001
|
});
|
|
10891
13002
|
if (safeAuthConfig.cookieAuth) {
|
|
@@ -10965,7 +13076,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
10965
13076
|
let schemaEditorOff = schemaEditorUnavailable();
|
|
10966
13077
|
let schemaEditorRoutes;
|
|
10967
13078
|
if (!schemaEditorOff && config.collectionsDir) try {
|
|
10968
|
-
schemaEditorRoutes = (await import("./schema-editor-routes-
|
|
13079
|
+
schemaEditorRoutes = (await import("./schema-editor-routes-CRKMZnXu.js")).createSchemaEditorRoutes(config.collectionsDir);
|
|
10969
13080
|
} catch (err) {
|
|
10970
13081
|
if (err?.code === "ERR_MODULE_NOT_FOUND") {
|
|
10971
13082
|
schemaEditorOff = {
|
|
@@ -10988,30 +13099,116 @@ async function _initializeRebaseBackend(config) {
|
|
|
10988
13099
|
code: schemaEditorOff.code,
|
|
10989
13100
|
message: schemaEditorOff.message
|
|
10990
13101
|
} }, 501));
|
|
10991
|
-
config.
|
|
10992
|
-
|
|
10993
|
-
|
|
10994
|
-
|
|
10995
|
-
|
|
13102
|
+
const remoteRepo = config.liveSchema?.repository;
|
|
13103
|
+
const canEditSchema = Boolean(config.collectionsDir) || Boolean(remoteRepo);
|
|
13104
|
+
if (!canEditSchema) {
|
|
13105
|
+
const unconfigured = new Hono();
|
|
13106
|
+
applyAdminGate(unconfigured, "Live schema editing");
|
|
13107
|
+
unconfigured.all("/*", (c) => c.json({ error: {
|
|
13108
|
+
code: "SCHEMA_EDITING_NO_COLLECTIONS_DIR",
|
|
13109
|
+
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."
|
|
13110
|
+
} }, 501));
|
|
13111
|
+
mountWithLegacyAlias(config.app, unconfigured, {
|
|
13112
|
+
canonical: `${basePath}/admin/schema`,
|
|
13113
|
+
surface: "Live schema editing (unconfigured)"
|
|
13114
|
+
});
|
|
13115
|
+
}
|
|
13116
|
+
if (canEditSchema) {
|
|
13117
|
+
const collectionsDir = config.collectionsDir;
|
|
13118
|
+
const repositoryRoot = collectionsDir ? await findRepositoryRoot(collectionsDir) : void 0;
|
|
13119
|
+
const liveSchemaRouter = new Hono();
|
|
13120
|
+
applyAdminGate(liveSchemaRouter, "Live schema editing");
|
|
13121
|
+
const commitPaths = remoteRepo ? commitPathsFor(path$1.join("/", remoteRepo.collectionsPath ?? "config/collections"), "/") : collectionsDir && repositoryRoot ? commitPathsFor(collectionsDir, repositoryRoot) : void 0;
|
|
13122
|
+
if (commitPaths) logger.debug("Live schema editing commit paths relocated", { commitPaths });
|
|
13123
|
+
liveSchemaRouter.route("/", createLiveSchemaRoutes({
|
|
13124
|
+
commitPaths,
|
|
13125
|
+
usesVersionedMigrations: collectionsDir ? () => usesVersionedMigrations(collectionsDir) : void 0,
|
|
13126
|
+
policy: { allowMachineApply: config.liveSchema?.allowMachineApply ?? process.env.REBASE_LIVE_SCHEMA_ALLOW_MACHINE_APPLY === "true" },
|
|
13127
|
+
getCollections: () => collectionRegistry.getRawCollections(),
|
|
13128
|
+
getAdmin: () => defaultDriver.admin,
|
|
13129
|
+
getRepository: (author) => {
|
|
13130
|
+
if (remoteRepo) return createGitHubRepository({
|
|
13131
|
+
auth: remoteRepo.auth,
|
|
13132
|
+
owner: remoteRepo.owner,
|
|
13133
|
+
repo: remoteRepo.repo,
|
|
13134
|
+
branch: remoteRepo.branch ?? "main",
|
|
13135
|
+
author
|
|
13136
|
+
});
|
|
13137
|
+
if (!repositoryRoot) return void 0;
|
|
13138
|
+
return createLocalGitRepository({
|
|
13139
|
+
root: repositoryRoot,
|
|
13140
|
+
author
|
|
13141
|
+
});
|
|
13142
|
+
},
|
|
13143
|
+
writeSource: schemaEditorRoutes ? async (change) => {
|
|
13144
|
+
const applyEdit = async (dir, collectionId, collection) => {
|
|
13145
|
+
const { AstSchemaEditor } = await import("./ast-schema-editor-CDGgrIn7.js");
|
|
13146
|
+
await new AstSchemaEditor(dir).saveCollection(collectionId, collection, { partial: false });
|
|
13147
|
+
};
|
|
13148
|
+
if (remoteRepo) {
|
|
13149
|
+
const repository = config.liveSchema?.repository ? createGitHubRepository({
|
|
13150
|
+
auth: remoteRepo.auth,
|
|
13151
|
+
owner: remoteRepo.owner,
|
|
13152
|
+
repo: remoteRepo.repo,
|
|
13153
|
+
branch: remoteRepo.branch ?? "main"
|
|
13154
|
+
}) : void 0;
|
|
13155
|
+
if (!repository) return [];
|
|
13156
|
+
return rewriteRemoteCollection({
|
|
13157
|
+
repository,
|
|
13158
|
+
collectionsPath: remoteRepo.collectionsPath ?? "config/collections",
|
|
13159
|
+
edit: applyEdit
|
|
13160
|
+
}, change.collectionId, change.collection);
|
|
13161
|
+
}
|
|
13162
|
+
await applyEdit(collectionsDir, change.collectionId, change.collection);
|
|
13163
|
+
const file = path$1.join(collectionsDir, `${change.collectionId}.ts`);
|
|
13164
|
+
const contents = await fsp.readFile(file, "utf8");
|
|
13165
|
+
return [{
|
|
13166
|
+
path: path$1.relative(repositoryRoot, file),
|
|
13167
|
+
contents
|
|
13168
|
+
}];
|
|
13169
|
+
} : void 0,
|
|
13170
|
+
sourcePathsFor: (change) => {
|
|
13171
|
+
if (remoteRepo) return [`${(remoteRepo.collectionsPath ?? "config/collections").replace(/\/+$/, "")}/${change.collectionId}.ts`];
|
|
13172
|
+
if (!collectionsDir || !repositoryRoot) return [];
|
|
13173
|
+
return [path$1.relative(repositoryRoot, path$1.join(collectionsDir, `${change.collectionId}.ts`))];
|
|
13174
|
+
}
|
|
13175
|
+
}));
|
|
13176
|
+
mountWithLegacyAlias(config.app, liveSchemaRouter, {
|
|
13177
|
+
canonical: `${basePath}/admin/schema`,
|
|
13178
|
+
surface: "Live schema editing"
|
|
13179
|
+
});
|
|
13180
|
+
logger.debug("Live schema editing repository", { repository: repositoryRoot ?? "(none — refusals only)" });
|
|
13181
|
+
}
|
|
13182
|
+
mountWithLegacyAlias(config.app, schemaEditorRouter, {
|
|
13183
|
+
canonical: `${basePath}/admin/schema-editor`,
|
|
13184
|
+
legacy: `${basePath}/schema-editor`,
|
|
13185
|
+
surface: "Schema editor"
|
|
10996
13186
|
});
|
|
13187
|
+
if (!schemaEditorRoutes) logger.debug("Schema Editor unavailable", { code: schemaEditorOff.code });
|
|
10997
13188
|
}
|
|
10998
13189
|
const storageAuthorizeData = {};
|
|
10999
13190
|
if (storageController) {
|
|
11000
13191
|
const storageMaxSize = (config.storage && typeof config.storage === "object" && "type" in config.storage ? config.storage.maxFileSize : void 0) ?? 50 * 1024 * 1024;
|
|
13192
|
+
const storageAccessControl = resolveStorageAccessControl({
|
|
13193
|
+
storagePolicies: config.storagePolicies,
|
|
13194
|
+
storageAuthorize: config.storageAuthorize
|
|
13195
|
+
});
|
|
11001
13196
|
assertStorageAccessControlConfigured({
|
|
11002
|
-
hasAuthorize: !!config.storageAuthorize,
|
|
13197
|
+
hasAuthorize: !!config.storageAuthorize || !!config.storagePolicies?.length,
|
|
11003
13198
|
publicRead: config.storagePublicRead === true,
|
|
11004
13199
|
allowAnyAuthenticated: config.storageInsecureAllowAnyAuthenticated === true
|
|
11005
13200
|
}, isProduction);
|
|
11006
13201
|
const storageRoutes = createStorageRoutes({
|
|
11007
13202
|
controller: storageController,
|
|
11008
13203
|
registry: storageRegistry,
|
|
11009
|
-
sources:
|
|
13204
|
+
sources: declaredStorageSources.length > 0 ? declaredStorageSources : void 0,
|
|
11010
13205
|
requireAuth: resolveRequireAuth(config.auth),
|
|
11011
13206
|
publicRead: config.storagePublicRead === true,
|
|
11012
13207
|
authAdapter,
|
|
11013
|
-
authorize:
|
|
11014
|
-
authorizeData: () => storageAuthorizeData.current
|
|
13208
|
+
authorize: storageAccessControl,
|
|
13209
|
+
authorizeData: () => storageAuthorizeData.current,
|
|
13210
|
+
renditionCache: config.storageRenditionCache,
|
|
13211
|
+
triggers: config.storageTriggers
|
|
11015
13212
|
});
|
|
11016
13213
|
const storageRouter = new Hono();
|
|
11017
13214
|
if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
|
|
@@ -11147,11 +13344,11 @@ async function _initializeRebaseBackend(config) {
|
|
|
11147
13344
|
if (driver && "client" in driver) driver.client = serverClient;
|
|
11148
13345
|
}
|
|
11149
13346
|
if (config.functionsDir && surfaces.functions) {
|
|
11150
|
-
const { loadFunctionsWithDiagnostics } = await import("./function-loader-
|
|
11151
|
-
const { createFunctionRoutes } = await import("./function-routes-
|
|
11152
|
-
const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-
|
|
13347
|
+
const { loadFunctionsWithDiagnostics } = await import("./function-loader-LLdmBFoL.js").then((n) => n.t);
|
|
13348
|
+
const { createFunctionRoutes } = await import("./function-routes-ClT6UQpD.js").then((n) => n.n);
|
|
13349
|
+
const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-C8gkc-j7.js").then((n) => n.t);
|
|
11153
13350
|
const { functions: allFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
|
|
11154
|
-
const { selectFunctions } = await import("./selection-
|
|
13351
|
+
const { selectFunctions } = await import("./selection-CRpqKUbt.js").then((n) => n.n);
|
|
11155
13352
|
const loadedFunctions = selectFunctions(allFunctions, config.functionsSelection);
|
|
11156
13353
|
if (loadedFunctions.length !== allFunctions.length) logger.info("Serving a subset of this bundle's functions", {
|
|
11157
13354
|
serving: loadedFunctions.map((fn) => fn.name),
|
|
@@ -11179,7 +13376,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
11179
13376
|
...rateLimitConfig,
|
|
11180
13377
|
anonymous: rateLimitConfig.anonymousFunctions !== void 0 ? rateLimitConfig.anonymousFunctions : DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT
|
|
11181
13378
|
}));
|
|
11182
|
-
const fnRoutes = createFunctionRoutes(loadedFunctions, problems.length);
|
|
13379
|
+
const fnRoutes = createFunctionRoutes(loadedFunctions, problems.length, `${basePath}/functions`);
|
|
11183
13380
|
functionsRouter.route("/", fnRoutes);
|
|
11184
13381
|
config.app.route(`${basePath}/functions`, functionsRouter);
|
|
11185
13382
|
if (loadedFunctions.length > 0) logger.info("Mounted custom functions", {
|
|
@@ -11190,7 +13387,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
11190
13387
|
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
13388
|
}
|
|
11192
13389
|
if (config.functionsUpstream && !surfaces.functions) {
|
|
11193
|
-
const { createFunctionsProxy } = await import("./proxy-
|
|
13390
|
+
const { createFunctionsProxy } = await import("./proxy-CMymhnwG.js").then((n) => n.t);
|
|
11194
13391
|
config.app.route(`${basePath}/functions`, createFunctionsProxy({
|
|
11195
13392
|
upstream: config.functionsUpstream,
|
|
11196
13393
|
basePath: `${basePath}/functions`
|
|
@@ -11202,10 +13399,10 @@ async function _initializeRebaseBackend(config) {
|
|
|
11202
13399
|
}
|
|
11203
13400
|
let cronScheduler;
|
|
11204
13401
|
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-
|
|
13402
|
+
const { loadCronJobsWithDiagnostics } = await import("./cron-loader-d9WMFENB.js").then((n) => n.t);
|
|
13403
|
+
const { CronScheduler } = await import("./cron-scheduler-BB82dWuU.js").then((n) => n.n);
|
|
13404
|
+
const { createCronRoutes } = await import("./cron-routes-B_GKclr5.js").then((n) => n.n);
|
|
13405
|
+
const { createCronStore } = await import("./cron-store-BTZqwkM1.js").then((n) => n.n);
|
|
11209
13406
|
const { jobs: loadedCronJobs, problems: cronProblems } = await loadCronJobsWithDiagnostics(config.cronsDir);
|
|
11210
13407
|
cronScheduler = new CronScheduler();
|
|
11211
13408
|
cronScheduler.setClient(serverSingleton);
|
|
@@ -11220,7 +13417,11 @@ async function _initializeRebaseBackend(config) {
|
|
|
11220
13417
|
const cronRouter = new Hono();
|
|
11221
13418
|
applyAdminGate(cronRouter, "Cron");
|
|
11222
13419
|
cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
|
|
11223
|
-
if (surfaces.cron) config.app
|
|
13420
|
+
if (surfaces.cron) mountWithLegacyAlias(config.app, cronRouter, {
|
|
13421
|
+
canonical: `${basePath}/admin/cron`,
|
|
13422
|
+
legacy: `${basePath}/cron`,
|
|
13423
|
+
surface: "Cron"
|
|
13424
|
+
});
|
|
11224
13425
|
if (loadedCronJobs.length > 0 && !ownership.cronScheduler) logger.info("Cron jobs registered but NOT scheduled in this process", {
|
|
11225
13426
|
count: loadedCronJobs.length,
|
|
11226
13427
|
reason: "this process does not own the cron scheduler"
|
|
@@ -11233,21 +13434,45 @@ async function _initializeRebaseBackend(config) {
|
|
|
11233
13434
|
});
|
|
11234
13435
|
} else logger.warn(`Cron routes mounted at ${basePath}/cron, but no jobs loaded from ${config.cronsDir}. ` + (cronProblems.length > 0 ? `Nothing is scheduled — ${cronProblems.length} file(s) were skipped, see the messages above.` : "The directory holds no .ts/.js cron files, so nothing is scheduled."));
|
|
11235
13436
|
}
|
|
13437
|
+
const declaredTopics = graphTopics(buildResourceGraph());
|
|
13438
|
+
const topicTasks = declaredTopics.length > 0 ? topicJobHandlers() : {};
|
|
13439
|
+
if (declaredTopics.length > 0) {
|
|
13440
|
+
config = {
|
|
13441
|
+
...config,
|
|
13442
|
+
jobs: {
|
|
13443
|
+
...config.jobs,
|
|
13444
|
+
enabled: true,
|
|
13445
|
+
tasks: {
|
|
13446
|
+
...config.jobs?.tasks ?? {},
|
|
13447
|
+
...topicTasks
|
|
13448
|
+
}
|
|
13449
|
+
}
|
|
13450
|
+
};
|
|
13451
|
+
logger.debug(`[topics] ${declaredTopics.length} topic(s), ${Object.keys(topicTasks).length} subscription(s) wired onto the job queue`);
|
|
13452
|
+
}
|
|
11236
13453
|
let jobQueue;
|
|
11237
13454
|
if (config.jobs?.enabled) {
|
|
11238
|
-
const { createJobStore, createJobQueue } = await import("./jobs-
|
|
13455
|
+
const { createJobStore, createJobQueue } = await import("./jobs-CeZzdeR8.js").then((n) => n.t);
|
|
11239
13456
|
const store = createJobStore(defaultDriver);
|
|
11240
13457
|
if (store) {
|
|
11241
13458
|
await store.ensureTable();
|
|
11242
13459
|
jobQueue = createJobQueue(store, config.jobs);
|
|
13460
|
+
if (declaredTopics.length > 0) installTopicRuntime({
|
|
13461
|
+
queue: jobQueue,
|
|
13462
|
+
topics: declaredTopics
|
|
13463
|
+
});
|
|
11243
13464
|
if (ownership.jobWorkers) {
|
|
11244
13465
|
jobQueue.start();
|
|
11245
13466
|
logger.info("Job queue started", { tasks: Object.keys(config.jobs.tasks ?? {}).length });
|
|
11246
13467
|
} else logger.info("Job queue available for enqueue, workers NOT started in this process", { tasks: Object.keys(config.jobs.tasks ?? {}).length });
|
|
11247
|
-
} else
|
|
13468
|
+
} else if (declaredTopics.length > 0) throw new Error(`This project declares ${declaredTopics.length} topic(s) — ${declaredTopics.map((t) => t.key).join(", ")} — but the durable job queue is not available on this database driver, and topics are delivered as job rows.
|
|
13469
|
+
|
|
13470
|
+
Use a driver that supports the job queue, or remove the topic declarations. Refusing to boot rather than starting a backend where every publish throws.`);
|
|
13471
|
+
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
13472
|
}
|
|
13473
|
+
let rlsAudit;
|
|
11249
13474
|
if (surfaces.admin) {
|
|
11250
|
-
const { createBackupRoutes, parseBackupDestination } = await import("./backup-
|
|
13475
|
+
const { createBackupRoutes, parseBackupDestination } = await import("./backup-CRdZkA6c.js").then((n) => n.t);
|
|
11251
13476
|
const backupRouter = new Hono();
|
|
11252
13477
|
applyAdminGate(backupRouter, "Backup");
|
|
11253
13478
|
backupRouter.route("/", createBackupRoutes({
|
|
@@ -11261,15 +13486,52 @@ async function _initializeRebaseBackend(config) {
|
|
|
11261
13486
|
logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
|
|
11262
13487
|
}
|
|
11263
13488
|
if (surfaces.admin) {
|
|
11264
|
-
const
|
|
13489
|
+
const rlsAuditRouter = new Hono();
|
|
13490
|
+
applyAdminGate(rlsAuditRouter, "RLS audit");
|
|
13491
|
+
rlsAuditRouter.get("/", (c) => c.json(rlsAudit ? rlsAudit.status() : {
|
|
13492
|
+
enabled: false,
|
|
13493
|
+
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."
|
|
13494
|
+
}));
|
|
13495
|
+
config.app.route(`${basePath}/admin/rls-audit`, rlsAuditRouter);
|
|
13496
|
+
logger.debug("RLS audit admin routes mounted", { path: `${basePath}/admin/rls-audit` });
|
|
13497
|
+
}
|
|
13498
|
+
if (surfaces.admin) {
|
|
13499
|
+
const devMailRouter = new Hono();
|
|
13500
|
+
applyAdminGate(devMailRouter, "Development mailbox");
|
|
13501
|
+
const unavailable = { error: {
|
|
13502
|
+
code: "DEV_MAILBOX_UNAVAILABLE",
|
|
13503
|
+
message: "No development mailbox is active. Mail is captured only when SMTP_HOST is unset and NODE_ENV is not production; with a mail server configured, messages are delivered rather than held here."
|
|
13504
|
+
} };
|
|
13505
|
+
devMailRouter.get("/", (c) => {
|
|
13506
|
+
const sink = activeDevEmailSink();
|
|
13507
|
+
if (!sink) return c.json(unavailable, 501);
|
|
13508
|
+
return c.json({
|
|
13509
|
+
enabled: true,
|
|
13510
|
+
messages: sink.list()
|
|
13511
|
+
});
|
|
13512
|
+
});
|
|
13513
|
+
devMailRouter.delete("/", (c) => {
|
|
13514
|
+
const sink = activeDevEmailSink();
|
|
13515
|
+
if (!sink) return c.json(unavailable, 501);
|
|
13516
|
+
sink.clear();
|
|
13517
|
+
return c.json({ cleared: true });
|
|
13518
|
+
});
|
|
13519
|
+
config.app.route(`${basePath}/admin/dev/emails`, devMailRouter);
|
|
13520
|
+
logger.debug("Development mailbox admin routes mounted", { path: `${basePath}/admin/dev/emails` });
|
|
13521
|
+
}
|
|
13522
|
+
if (surfaces.admin) {
|
|
13523
|
+
const { default: logsRoutes } = await import("./logs-routes-DB72iQSr.js").then((n) => n.n);
|
|
11265
13524
|
const logsRouter = new Hono();
|
|
11266
13525
|
applyAdminGate(logsRouter, "Logs");
|
|
11267
13526
|
logsRouter.route("/", logsRoutes);
|
|
11268
|
-
config.app
|
|
11269
|
-
|
|
13527
|
+
mountWithLegacyAlias(config.app, logsRouter, {
|
|
13528
|
+
canonical: `${basePath}/admin/logs`,
|
|
13529
|
+
legacy: `${basePath}/logs`,
|
|
13530
|
+
surface: "Logs"
|
|
13531
|
+
});
|
|
11270
13532
|
}
|
|
11271
13533
|
if (surfaces.meta) {
|
|
11272
|
-
const { createContractRoutes } = await import("./contract-routes-
|
|
13534
|
+
const { createContractRoutes } = await import("./contract-routes-DDSRyGSl.js").then((n) => n.t);
|
|
11273
13535
|
const contractRouter = new Hono();
|
|
11274
13536
|
if (adminSurfacesGated) {
|
|
11275
13537
|
if (apiKeyPreAuth) contractRouter.use("/contract", apiKeyPreAuth);
|
|
@@ -11298,10 +13560,15 @@ async function _initializeRebaseBackend(config) {
|
|
|
11298
13560
|
logger.debug("Rebase Backend Initialized");
|
|
11299
13561
|
const authSchemaCheck = authConfigResult?.schemaHealthCheck;
|
|
11300
13562
|
const healthCheck = createHealthCheck(defaultDriver, authSchemaCheck ? () => authSchemaCheck.call(authConfigResult) : void 0);
|
|
13563
|
+
if (config.rlsAudit?.enabled && ownership.rlsAudit) {
|
|
13564
|
+
rlsAudit = createRlsAudit(config.rlsAudit);
|
|
13565
|
+
rlsAudit.start();
|
|
13566
|
+
}
|
|
11301
13567
|
const shutdown = createShutdown({
|
|
11302
13568
|
server: config.server,
|
|
11303
13569
|
cronScheduler,
|
|
11304
13570
|
jobQueue,
|
|
13571
|
+
rlsAudit,
|
|
11305
13572
|
realtimeServices
|
|
11306
13573
|
});
|
|
11307
13574
|
/**
|
|
@@ -11326,63 +13593,333 @@ async function _initializeRebaseBackend(config) {
|
|
|
11326
13593
|
attached++;
|
|
11327
13594
|
}
|
|
11328
13595
|
}
|
|
11329
|
-
if (attached === 0) logger.warn(`[callbacks] Collection "${slug}" not found in any registry — callbacks not attached.`);
|
|
11330
|
-
};
|
|
11331
|
-
return {
|
|
11332
|
-
driverRegistry,
|
|
11333
|
-
driver: defaultDriver,
|
|
11334
|
-
setCollectionCallbacks,
|
|
11335
|
-
realtimeServices,
|
|
11336
|
-
realtimeService: effectiveRealtimeService,
|
|
11337
|
-
auth: authConfigResult,
|
|
11338
|
-
history: historyConfigResult,
|
|
11339
|
-
storageRegistry,
|
|
11340
|
-
storageController,
|
|
11341
|
-
collectionRegistry,
|
|
11342
|
-
cronScheduler,
|
|
11343
|
-
jobQueue,
|
|
11344
|
-
healthCheck,
|
|
11345
|
-
shutdown
|
|
13596
|
+
if (attached === 0) logger.warn(`[callbacks] Collection "${slug}" not found in any registry — callbacks not attached.`);
|
|
13597
|
+
};
|
|
13598
|
+
return {
|
|
13599
|
+
driverRegistry,
|
|
13600
|
+
driver: defaultDriver,
|
|
13601
|
+
setCollectionCallbacks,
|
|
13602
|
+
realtimeServices,
|
|
13603
|
+
realtimeService: effectiveRealtimeService,
|
|
13604
|
+
auth: authConfigResult,
|
|
13605
|
+
history: historyConfigResult,
|
|
13606
|
+
storageRegistry,
|
|
13607
|
+
storageController,
|
|
13608
|
+
collectionRegistry,
|
|
13609
|
+
cronScheduler,
|
|
13610
|
+
jobQueue,
|
|
13611
|
+
healthCheck,
|
|
13612
|
+
shutdown
|
|
13613
|
+
};
|
|
13614
|
+
}
|
|
13615
|
+
//#endregion
|
|
13616
|
+
//#region src/functions/define-function.ts
|
|
13617
|
+
/**
|
|
13618
|
+
* Typed authoring contract for a custom backend function.
|
|
13619
|
+
*
|
|
13620
|
+
* A custom function is a file in the `functionsDir` that default-exports a
|
|
13621
|
+
* Hono app; the loader mounts it at `/<filename>`. `defineFunction` is the
|
|
13622
|
+
* typed opt-in for that contract: it hands you a pre-typed `Hono<HonoEnv>`
|
|
13623
|
+
* app (so `c.var.user` / `c.var.driver` are typed) plus a
|
|
13624
|
+
* {@link RebaseFunctionContext}, and returns exactly the Hono app the loader
|
|
13625
|
+
* already accepts — so it is fully interchangeable with a plain
|
|
13626
|
+
* `export default new Hono()`.
|
|
13627
|
+
*
|
|
13628
|
+
* @example
|
|
13629
|
+
* ```ts
|
|
13630
|
+
* import { defineFunction, requireAuth } from "@rebasepro/server";
|
|
13631
|
+
*
|
|
13632
|
+
* export default defineFunction((app, { rebase }) => {
|
|
13633
|
+
* app.use("/*", requireAuth);
|
|
13634
|
+
* app.get("/home", async (c) => {
|
|
13635
|
+
* // `rebase.sql` runs on the owner connection: no RLS, no policies,
|
|
13636
|
+
* // every row. It is the most privileged thing in this context —
|
|
13637
|
+
* // more so than `dataAsAdmin`, which is merely admin-scoped.
|
|
13638
|
+
* const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
|
|
13639
|
+
* return c.json({ orders: Number(stats.n) });
|
|
13640
|
+
* });
|
|
13641
|
+
* });
|
|
13642
|
+
* ```
|
|
13643
|
+
*
|
|
13644
|
+
* @param definition Receives the function's Hono app and the typed context.
|
|
13645
|
+
* Register routes on the provided `app` and return nothing, or return your
|
|
13646
|
+
* own `Hono<HonoEnv>` app to use instead.
|
|
13647
|
+
* @returns The Hono app to default-export from the function file.
|
|
13648
|
+
*/
|
|
13649
|
+
function defineFunction(definition) {
|
|
13650
|
+
const app = new Hono();
|
|
13651
|
+
const returned = definition(app, { rebase });
|
|
13652
|
+
return returned instanceof Hono ? returned : app;
|
|
13653
|
+
}
|
|
13654
|
+
//#endregion
|
|
13655
|
+
//#region src/functions/context.ts
|
|
13656
|
+
function read(c, key) {
|
|
13657
|
+
return c.get(key);
|
|
13658
|
+
}
|
|
13659
|
+
/**
|
|
13660
|
+
* The authenticated caller, or `undefined` for an anonymous request.
|
|
13661
|
+
*
|
|
13662
|
+
* **`undefined` is not a permission decision.** The functions router mounts its
|
|
13663
|
+
* auth middleware with `requireAuth: false` on purpose — a webhook receiver has
|
|
13664
|
+
* no token to send — so an anonymous caller reaches the handler and reads
|
|
13665
|
+
* `undefined` here while the handler runs on regardless. Use {@link requireAuth}
|
|
13666
|
+
* (or a `!user` branch that returns 401) to make it a decision.
|
|
13667
|
+
*
|
|
13668
|
+
* A caller who presented a *bad* token never gets this far: both auth
|
|
13669
|
+
* middlewares reject an unverifiable token with 401 before the router is
|
|
13670
|
+
* reached, precisely so an expired session cannot be silently downgraded to an
|
|
13671
|
+
* anonymous one.
|
|
13672
|
+
*/
|
|
13673
|
+
function getUser(c) {
|
|
13674
|
+
const raw = read(c, "user");
|
|
13675
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
13676
|
+
const record = raw;
|
|
13677
|
+
const uid = typeof record.uid === "string" ? record.uid : void 0;
|
|
13678
|
+
if (uid === void 0) return void 0;
|
|
13679
|
+
const roles = Array.isArray(record.roles) ? record.roles.filter((role) => typeof role === "string") : [];
|
|
13680
|
+
return {
|
|
13681
|
+
...record,
|
|
13682
|
+
uid,
|
|
13683
|
+
roles
|
|
13684
|
+
};
|
|
13685
|
+
}
|
|
13686
|
+
/** The caller's id, or `undefined` when nobody is signed in. */
|
|
13687
|
+
function getUserId(c) {
|
|
13688
|
+
return getUser(c)?.uid;
|
|
13689
|
+
}
|
|
13690
|
+
/** The caller's roles. Empty for an anonymous request — never `undefined`. */
|
|
13691
|
+
function getRoles(c) {
|
|
13692
|
+
return getUser(c)?.roles ?? [];
|
|
13693
|
+
}
|
|
13694
|
+
/**
|
|
13695
|
+
* Whether the caller holds **any** of the named roles.
|
|
13696
|
+
*
|
|
13697
|
+
* Any rather than all, because that is what a route guard means by a list of
|
|
13698
|
+
* roles; require several by calling this more than once.
|
|
13699
|
+
*/
|
|
13700
|
+
function hasRole(c, ...roles) {
|
|
13701
|
+
if (roles.length === 0) return false;
|
|
13702
|
+
const held = new Set(getRoles(c));
|
|
13703
|
+
return roles.some((role) => held.has(role));
|
|
13704
|
+
}
|
|
13705
|
+
/**
|
|
13706
|
+
* Whether the caller holds an administrative role.
|
|
13707
|
+
*
|
|
13708
|
+
* Delegates to the single definition in `auth/admin-roles.ts` — which is
|
|
13709
|
+
* `admin` **or** `schema-admin` — rather than comparing against `"admin"`.
|
|
13710
|
+
* Those two lists disagreed once, and the gap made every public registrant an
|
|
13711
|
+
* administrator; see that file.
|
|
13712
|
+
*/
|
|
13713
|
+
function isAdmin(c) {
|
|
13714
|
+
return hasAdministrativeRole(getRoles(c));
|
|
13715
|
+
}
|
|
13716
|
+
/** Whether the request carries an identity at all. */
|
|
13717
|
+
function isAuthenticated(c) {
|
|
13718
|
+
return getUser(c) !== void 0;
|
|
13719
|
+
}
|
|
13720
|
+
/**
|
|
13721
|
+
* The request-scoped data driver: reads and writes run as **the caller**, with
|
|
13722
|
+
* your row-level security policies evaluated against their identity.
|
|
13723
|
+
*
|
|
13724
|
+
* This is the accessor to reach for when a function serves user-facing data.
|
|
13725
|
+
* `rebase.dataAsAdmin` is the other one, and it is not the same thing — it runs
|
|
13726
|
+
* as `{ uid: "service", roles: ["admin"] }` for every caller alike, which is
|
|
13727
|
+
* correct for trusted background work and wrong for a request.
|
|
13728
|
+
*
|
|
13729
|
+
* `undefined` only when no Rebase auth middleware ran (see
|
|
13730
|
+
* {@link identityResolved}); inside a function mounted by the framework it is
|
|
13731
|
+
* always present, anonymous requests included — they get an anon-scoped driver
|
|
13732
|
+
* so policies still have an identity to evaluate.
|
|
13733
|
+
*/
|
|
13734
|
+
function getDriver(c) {
|
|
13735
|
+
return read(c, "driver");
|
|
13736
|
+
}
|
|
13737
|
+
/**
|
|
13738
|
+
* {@link getDriver}, but throws instead of handing back `undefined`.
|
|
13739
|
+
*
|
|
13740
|
+
* For the common case where a handler cannot proceed without it and would
|
|
13741
|
+
* otherwise write `c.get("driver")!` — an assertion that turns a wiring problem
|
|
13742
|
+
* into `Cannot read properties of undefined (reading 'fetchCollection')` twenty
|
|
13743
|
+
* lines away from the cause.
|
|
13744
|
+
*/
|
|
13745
|
+
function requireDriver(c) {
|
|
13746
|
+
const driver = getDriver(c);
|
|
13747
|
+
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.");
|
|
13748
|
+
return driver;
|
|
13749
|
+
}
|
|
13750
|
+
/**
|
|
13751
|
+
* The API key this request authenticated with, masked, or `undefined` when it
|
|
13752
|
+
* did not use one.
|
|
13753
|
+
*
|
|
13754
|
+
* Useful for attribution and for per-key behaviour. The permission check itself
|
|
13755
|
+
* has already happened — reaching a handler means the key was allowed to.
|
|
13756
|
+
*/
|
|
13757
|
+
function getApiKey(c) {
|
|
13758
|
+
return read(c, "apiKey");
|
|
13759
|
+
}
|
|
13760
|
+
/**
|
|
13761
|
+
* The correlation id for this request — generated, or taken from an inbound
|
|
13762
|
+
* `X-Request-ID`.
|
|
13763
|
+
*
|
|
13764
|
+
* Log it. It is the only thing that ties a line written inside a function to
|
|
13765
|
+
* the framework's own lines for the same request.
|
|
13766
|
+
*/
|
|
13767
|
+
function getRequestId(c) {
|
|
13768
|
+
return read(c, "requestId");
|
|
13769
|
+
}
|
|
13770
|
+
/**
|
|
13771
|
+
* Whether a Rebase auth middleware has run on this request.
|
|
13772
|
+
*
|
|
13773
|
+
* Both middlewares populate `driver` for *every* outcome, anonymous included,
|
|
13774
|
+
* and populate `user` whenever there is one. So "neither is set" does not mean
|
|
13775
|
+
* "anonymous" — it means nothing resolved the identity, and treating that as
|
|
13776
|
+
* anonymous is the dangerous reading. The guards use this to tell a genuinely
|
|
13777
|
+
* anonymous caller (401) from a misconfigured mount (500), because answering
|
|
13778
|
+
* 401 to the second sends whoever is debugging it to look at the token.
|
|
13779
|
+
*/
|
|
13780
|
+
function identityResolved(c) {
|
|
13781
|
+
return read(c, "user") !== void 0 || read(c, "driver") !== void 0;
|
|
13782
|
+
}
|
|
13783
|
+
//#endregion
|
|
13784
|
+
//#region src/functions/guards.ts
|
|
13785
|
+
/**
|
|
13786
|
+
* The answer to "a guard ran, but no middleware had resolved anything".
|
|
13787
|
+
*
|
|
13788
|
+
* Deliberately a 500 and not a 401. A 401 tells the caller their credential is
|
|
13789
|
+
* the problem, and here the caller's credential was never looked at — sending
|
|
13790
|
+
* them to check their token is sending them to the one place the answer is not.
|
|
13791
|
+
*/
|
|
13792
|
+
function unresolvedIdentity() {
|
|
13793
|
+
return { error: {
|
|
13794
|
+
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.",
|
|
13795
|
+
code: "AUTH_MIDDLEWARE_MISSING"
|
|
13796
|
+
} };
|
|
13797
|
+
}
|
|
13798
|
+
/**
|
|
13799
|
+
* Reject callers holding none of the named roles with 403.
|
|
13800
|
+
*
|
|
13801
|
+
* Any of them, not all — require several by chaining the guard twice. Naming no
|
|
13802
|
+
* role at all is a programming error and throws at module load rather than at
|
|
13803
|
+
* request time, because `requireRole()` with an empty list would otherwise read
|
|
13804
|
+
* as a guard while admitting everyone.
|
|
13805
|
+
*
|
|
13806
|
+
* @example
|
|
13807
|
+
* ```ts
|
|
13808
|
+
* app.post("/publish", requireAuth, requireRole("editor", "admin"), handler);
|
|
13809
|
+
* ```
|
|
13810
|
+
*/
|
|
13811
|
+
function requireRole(...roles) {
|
|
13812
|
+
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.");
|
|
13813
|
+
const allowed = new Set(roles);
|
|
13814
|
+
return async (c, next) => {
|
|
13815
|
+
if (!getUser(c)) {
|
|
13816
|
+
if (!identityResolved(c)) return c.json(unresolvedIdentity(), 500);
|
|
13817
|
+
return c.json({ error: {
|
|
13818
|
+
message: "Authentication required",
|
|
13819
|
+
code: "UNAUTHORIZED"
|
|
13820
|
+
} }, 401);
|
|
13821
|
+
}
|
|
13822
|
+
if (!getRoles(c).some((role) => allowed.has(role))) return c.json({ error: {
|
|
13823
|
+
message: `This operation requires one of these roles: ${roles.join(", ")}`,
|
|
13824
|
+
code: "FORBIDDEN"
|
|
13825
|
+
} }, 403);
|
|
13826
|
+
return next();
|
|
11346
13827
|
};
|
|
11347
13828
|
}
|
|
11348
13829
|
//#endregion
|
|
11349
|
-
//#region src/functions/
|
|
13830
|
+
//#region src/functions/runtime-env.ts
|
|
11350
13831
|
/**
|
|
11351
|
-
*
|
|
13832
|
+
* Every environment variable visible to this request.
|
|
11352
13833
|
*
|
|
11353
|
-
*
|
|
11354
|
-
*
|
|
11355
|
-
*
|
|
11356
|
-
|
|
11357
|
-
|
|
11358
|
-
|
|
11359
|
-
|
|
13834
|
+
* `c.env` on workerd, `Deno.env` on Deno, `process.env` on Node, Bun and
|
|
13835
|
+
* Vercel's edge runtime. Reading it through here rather than through `process`
|
|
13836
|
+
* is the whole of what makes a function's configuration portable.
|
|
13837
|
+
*/
|
|
13838
|
+
function getEnv(c) {
|
|
13839
|
+
return env(c);
|
|
13840
|
+
}
|
|
13841
|
+
/**
|
|
13842
|
+
* One environment variable, or `undefined`.
|
|
13843
|
+
*
|
|
13844
|
+
* Trimmed, and a blank value counts as absent — declaring a variable and
|
|
13845
|
+
* leaving it empty is the ordinary way to write a compose file or a `.env`
|
|
13846
|
+
* line, and nobody has ever meant `""` by it.
|
|
13847
|
+
*/
|
|
13848
|
+
function env$1(c, name) {
|
|
13849
|
+
const raw = getEnv(c)[name];
|
|
13850
|
+
if (typeof raw !== "string") return void 0;
|
|
13851
|
+
const trimmed = raw.trim();
|
|
13852
|
+
return trimmed === "" ? void 0 : trimmed;
|
|
13853
|
+
}
|
|
13854
|
+
/**
|
|
13855
|
+
* One environment variable, or a thrown error naming it.
|
|
13856
|
+
*
|
|
13857
|
+
* For configuration a handler cannot run without. Throwing here — inside the
|
|
13858
|
+
* request — is deliberately better than the module-scope `process.env.KEY!`
|
|
13859
|
+
* it replaces: that one takes the whole *file* down at load time, and the
|
|
13860
|
+
* loader reports it as "this function could not be imported", which names the
|
|
13861
|
+
* file but not the variable. This fails one request, with the name in the
|
|
13862
|
+
* message, while every other route in the file keeps serving.
|
|
13863
|
+
*/
|
|
13864
|
+
function requireEnv(c, name) {
|
|
13865
|
+
const value = env$1(c, name);
|
|
13866
|
+
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.`);
|
|
13867
|
+
return value;
|
|
13868
|
+
}
|
|
13869
|
+
/**
|
|
13870
|
+
* Which host this is: `"node"`, `"workerd"`, `"deno"`, `"bun"`, `"edge-light"`,
|
|
13871
|
+
* `"fastly"` or `"other"`.
|
|
13872
|
+
*
|
|
13873
|
+
* Use it to *degrade*, not to branch a whole implementation — a function that
|
|
13874
|
+
* needs two implementations is two functions. It is honest about one thing in
|
|
13875
|
+
* particular: a Node-only capability, such as `rebase.sql()`, can check this
|
|
13876
|
+
* and say so, instead of failing at the call.
|
|
13877
|
+
*/
|
|
13878
|
+
function runtimeKey() {
|
|
13879
|
+
return getRuntimeKey();
|
|
13880
|
+
}
|
|
13881
|
+
/** Whether this is a Node-like host — Node itself, or Bun's Node compatibility. */
|
|
13882
|
+
function isNodeRuntime() {
|
|
13883
|
+
const key = getRuntimeKey();
|
|
13884
|
+
return key === "node" || key === "bun";
|
|
13885
|
+
}
|
|
13886
|
+
/**
|
|
13887
|
+
* Build something expensive once per environment, on first use, from inside a
|
|
13888
|
+
* request.
|
|
13889
|
+
*
|
|
13890
|
+
* This is the sanctioned replacement for the module-scope client:
|
|
11360
13891
|
*
|
|
11361
|
-
* @example
|
|
11362
13892
|
* ```ts
|
|
11363
|
-
* import
|
|
13893
|
+
* // Don't: runs at import time, before bindings exist on some hosts.
|
|
13894
|
+
* const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
|
|
11364
13895
|
*
|
|
11365
|
-
*
|
|
11366
|
-
*
|
|
11367
|
-
*
|
|
11368
|
-
* // `rebase.sql` runs on the owner connection: no RLS, no policies,
|
|
11369
|
-
* // every row. It is the most privileged thing in this context —
|
|
11370
|
-
* // more so than `dataAsAdmin`, which is merely admin-scoped.
|
|
11371
|
-
* const [stats] = await rebase.sql(`SELECT count(*) AS n FROM orders`);
|
|
11372
|
-
* return c.json({ orders: Number(stats.n) });
|
|
11373
|
-
* });
|
|
11374
|
-
* });
|
|
13896
|
+
* // Do: runs on first request, reads that request's configuration.
|
|
13897
|
+
* const stripe = lazyResource(env => new Stripe(env.STRIPE_SECRET_KEY!));
|
|
13898
|
+
* app.post("/", async (c) => { await stripe(c).charges.list(); });
|
|
11375
13899
|
* ```
|
|
11376
13900
|
*
|
|
11377
|
-
*
|
|
11378
|
-
*
|
|
11379
|
-
*
|
|
11380
|
-
*
|
|
13901
|
+
* The factory runs once per distinct environment object and the result is
|
|
13902
|
+
* cached against it. On Node that is once per process, because `process.env` is
|
|
13903
|
+
* one object for the life of the process — identical to the module-scope
|
|
13904
|
+
* version in cost, and unlike it, deferred until configuration exists. On a
|
|
13905
|
+
* host that hands each request a fresh binding object it is once per object,
|
|
13906
|
+
* which is the only correct answer there: caching across bindings would serve
|
|
13907
|
+
* one tenant's client to another.
|
|
13908
|
+
*
|
|
13909
|
+
* A {@link WeakMap} holds the cache, so an environment object the host has
|
|
13910
|
+
* finished with takes the resource with it.
|
|
11381
13911
|
*/
|
|
11382
|
-
function
|
|
11383
|
-
const
|
|
11384
|
-
|
|
11385
|
-
|
|
13912
|
+
function lazyResource(factory) {
|
|
13913
|
+
const cache = /* @__PURE__ */ new WeakMap();
|
|
13914
|
+
return (c) => {
|
|
13915
|
+
const bindings = getEnv(c);
|
|
13916
|
+
if (!bindings || typeof bindings !== "object") return factory(bindings ?? {});
|
|
13917
|
+
const existing = cache.get(bindings);
|
|
13918
|
+
if (existing !== void 0) return existing;
|
|
13919
|
+
const created = factory(bindings);
|
|
13920
|
+
cache.set(bindings, created);
|
|
13921
|
+
return created;
|
|
13922
|
+
};
|
|
11386
13923
|
}
|
|
11387
13924
|
//#endregion
|
|
11388
13925
|
//#region src/cron/define-cron.ts
|
|
@@ -11469,6 +14006,130 @@ var authJwt = () => {
|
|
|
11469
14006
|
return sql.raw(RLS_JWT_SQL);
|
|
11470
14007
|
};
|
|
11471
14008
|
//#endregion
|
|
14009
|
+
//#region src/dev-secrets.ts
|
|
14010
|
+
/**
|
|
14011
|
+
* Development secrets that survive a restart.
|
|
14012
|
+
*
|
|
14013
|
+
* `loadEnv` generates `JWT_SECRET` and `REBASE_SERVICE_KEY` when they are unset
|
|
14014
|
+
* outside production, so a developer can start without setting anything up. It
|
|
14015
|
+
* generated them **freshly on every boot**, which meant every restart of the dev
|
|
14016
|
+
* server silently invalidated the previous run's tokens: you were logged out of
|
|
14017
|
+
* your own app because you edited a file, and the API key you had just made
|
|
14018
|
+
* stopped working.
|
|
14019
|
+
*
|
|
14020
|
+
* The CLI already worked around it for scaffolded projects by writing a stable
|
|
14021
|
+
* `JWT_SECRET` into `.env` at `rebase init` — the comment there says exactly
|
|
14022
|
+
* why. That leaves anyone who did not scaffold, and anyone running from a
|
|
14023
|
+
* container with no `.env`, with the logout-on-restart behaviour.
|
|
14024
|
+
*
|
|
14025
|
+
* So the generated values are cached in a file beside the other dev-state
|
|
14026
|
+
* files — `.rebase-dev-port` and `.rebase-dev-url` follow the same convention
|
|
14027
|
+
* and are gitignored in the same place.
|
|
14028
|
+
*
|
|
14029
|
+
* ## What this does not change
|
|
14030
|
+
*
|
|
14031
|
+
* Production. `loadEnv` refuses to start when a secret was auto-generated and
|
|
14032
|
+
* `NODE_ENV=production`, and that is untouched: this only makes the
|
|
14033
|
+
* already-generated development values stable. There is no path by which a
|
|
14034
|
+
* cached secret becomes a production one.
|
|
14035
|
+
*
|
|
14036
|
+
* ## Failing soft
|
|
14037
|
+
*
|
|
14038
|
+
* A read-only filesystem, a container without a writable working directory, a
|
|
14039
|
+
* permissions problem — none of these should stop a development server from
|
|
14040
|
+
* starting. Every failure here falls back to the previous behaviour, an
|
|
14041
|
+
* ephemeral secret, and says so once at debug level.
|
|
14042
|
+
*/
|
|
14043
|
+
/** Beside `.rebase-dev-port` and `.rebase-dev-url`, and gitignored with them. */
|
|
14044
|
+
var DEV_SECRETS_FILENAME = ".rebase-dev-secrets.json";
|
|
14045
|
+
/** Owner read/write only. These are secrets, even development ones. */
|
|
14046
|
+
var FILE_MODE = 384;
|
|
14047
|
+
/** Whether these environment variables describe a test runner. */
|
|
14048
|
+
function detectTestRunner(env) {
|
|
14049
|
+
return Boolean(env.JEST_WORKER_ID || env.VITEST || env.VITEST_WORKER_ID || env.NODE_ENV === "test");
|
|
14050
|
+
}
|
|
14051
|
+
/**
|
|
14052
|
+
* Whether a test runner started this process, decided **once at import**.
|
|
14053
|
+
*
|
|
14054
|
+
* Read at call time it would be wrong, and provably so: `env.test.ts` does
|
|
14055
|
+
* `process.env = {}` in its `beforeEach`, which erases `NODE_ENV` and
|
|
14056
|
+
* `JEST_WORKER_ID` alike. A predicate consulting the live environment therefore
|
|
14057
|
+
* sees a bare object and concludes "development", which is how the first version
|
|
14058
|
+
* of this dropped a `.rebase-dev-secrets.json` into `packages/server` on every
|
|
14059
|
+
* test run.
|
|
14060
|
+
*
|
|
14061
|
+
* Whether a test runner is hosting the process is a fact about the process, not
|
|
14062
|
+
* about whatever a test has since done to `process.env`, so it is captured
|
|
14063
|
+
* before any test can rewrite it.
|
|
14064
|
+
*/
|
|
14065
|
+
var TEST_RUNNER_DETECTED = detectTestRunner(process.env);
|
|
14066
|
+
/**
|
|
14067
|
+
* Whether generated secrets should be cached at all.
|
|
14068
|
+
*
|
|
14069
|
+
* Not in production — `loadEnv` refuses to boot there on a generated secret, so
|
|
14070
|
+
* there is nothing to cache and no path by which a cached one becomes a
|
|
14071
|
+
* production one.
|
|
14072
|
+
*
|
|
14073
|
+
* Not under a test runner: a test must not inherit a secret a previous run left
|
|
14074
|
+
* behind, and a library call has no business writing into a suite's working
|
|
14075
|
+
* directory. `inTestRunner` is a parameter only so this predicate can be tested
|
|
14076
|
+
* on its own; callers pass nothing.
|
|
14077
|
+
*/
|
|
14078
|
+
function shouldCacheDevSecrets(env = process.env, inTestRunner = TEST_RUNNER_DETECTED) {
|
|
14079
|
+
if (inTestRunner) return false;
|
|
14080
|
+
return env.NODE_ENV !== "production";
|
|
14081
|
+
}
|
|
14082
|
+
/** `REBASE_DEV_SECRETS_FILE`, or the conventional name in the working directory. */
|
|
14083
|
+
function resolveDevSecretsFile(env = process.env) {
|
|
14084
|
+
const override = env.REBASE_DEV_SECRETS_FILE?.trim();
|
|
14085
|
+
if (override) return path$3.resolve(override);
|
|
14086
|
+
return path$3.join(process.cwd(), DEV_SECRETS_FILENAME);
|
|
14087
|
+
}
|
|
14088
|
+
/**
|
|
14089
|
+
* Open the cache.
|
|
14090
|
+
*
|
|
14091
|
+
* Reading a malformed file is treated as an empty cache rather than an error:
|
|
14092
|
+
* the file is ours, it holds regenerable values, and refusing to boot over a
|
|
14093
|
+
* corrupted convenience cache would be worse than rewriting it.
|
|
14094
|
+
*/
|
|
14095
|
+
function openDevSecretStore(file = resolveDevSecretsFile()) {
|
|
14096
|
+
let cache = {};
|
|
14097
|
+
let usable = true;
|
|
14098
|
+
try {
|
|
14099
|
+
if (fs$3.existsSync(file)) {
|
|
14100
|
+
const parsed = JSON.parse(fs$3.readFileSync(file, "utf-8"));
|
|
14101
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
14102
|
+
for (const [key, value] of Object.entries(parsed)) if (typeof value === "string" && value.length > 0) cache[key] = value;
|
|
14103
|
+
}
|
|
14104
|
+
}
|
|
14105
|
+
} catch {
|
|
14106
|
+
cache = {};
|
|
14107
|
+
}
|
|
14108
|
+
const flush = () => {
|
|
14109
|
+
try {
|
|
14110
|
+
fs$3.mkdirSync(path$3.dirname(file), { recursive: true });
|
|
14111
|
+
fs$3.writeFileSync(file, `${JSON.stringify(cache, null, 2)}\n`, { mode: FILE_MODE });
|
|
14112
|
+
fs$3.chmodSync(file, FILE_MODE);
|
|
14113
|
+
} catch (err) {
|
|
14114
|
+
if (usable) {
|
|
14115
|
+
usable = false;
|
|
14116
|
+
logger.debug(`[dev-secrets] Could not write ${file}, so generated development secrets will not survive a restart: ${err instanceof Error ? err.message : String(err)}`);
|
|
14117
|
+
}
|
|
14118
|
+
}
|
|
14119
|
+
};
|
|
14120
|
+
return {
|
|
14121
|
+
file,
|
|
14122
|
+
get usable() {
|
|
14123
|
+
return usable;
|
|
14124
|
+
},
|
|
14125
|
+
get: (name) => cache[name],
|
|
14126
|
+
set: (name, value) => {
|
|
14127
|
+
cache[name] = value;
|
|
14128
|
+
flush();
|
|
14129
|
+
}
|
|
14130
|
+
};
|
|
14131
|
+
}
|
|
14132
|
+
//#endregion
|
|
11472
14133
|
//#region src/env.ts
|
|
11473
14134
|
/**
|
|
11474
14135
|
* Generate a cryptographically secure random secret (hex-encoded).
|
|
@@ -11586,14 +14247,24 @@ function loadEnv(options) {
|
|
|
11586
14247
|
const isProduction = process.env.NODE_ENV === "production";
|
|
11587
14248
|
const autoGeneratedSecrets = [];
|
|
11588
14249
|
if (!isProduction) {
|
|
11589
|
-
|
|
11590
|
-
|
|
11591
|
-
|
|
11592
|
-
|
|
11593
|
-
|
|
11594
|
-
|
|
11595
|
-
|
|
11596
|
-
|
|
14250
|
+
const devSecrets = shouldCacheDevSecrets() ? openDevSecretStore() : void 0;
|
|
14251
|
+
const reused = [];
|
|
14252
|
+
const ensure = (name) => {
|
|
14253
|
+
if (process.env[name]) return;
|
|
14254
|
+
const cached = devSecrets?.get(name);
|
|
14255
|
+
if (cached) {
|
|
14256
|
+
process.env[name] = cached;
|
|
14257
|
+
reused.push(name);
|
|
14258
|
+
} else {
|
|
14259
|
+
const generated = generateSecret();
|
|
14260
|
+
process.env[name] = generated;
|
|
14261
|
+
devSecrets?.set(name, generated);
|
|
14262
|
+
}
|
|
14263
|
+
autoGeneratedSecrets.push(name);
|
|
14264
|
+
};
|
|
14265
|
+
ensure("JWT_SECRET");
|
|
14266
|
+
ensure("REBASE_SERVICE_KEY");
|
|
14267
|
+
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
14268
|
}
|
|
11598
14269
|
const env = (options?.extend ? rebaseEnvSchema.merge(options.extend) : rebaseEnvSchema).superRefine((data, ctx) => {
|
|
11599
14270
|
const d = data;
|
|
@@ -12338,7 +15009,7 @@ function serveSPA(app, config) {
|
|
|
12338
15009
|
const rawBase = config.basePath ?? "/";
|
|
12339
15010
|
const basePath = rawBase !== "/" ? rawBase.replace(/\/+$/, "") : "/";
|
|
12340
15011
|
const isRoot = basePath === "/";
|
|
12341
|
-
if (!fs$
|
|
15012
|
+
if (!fs$3.existsSync(frontendPath)) {
|
|
12342
15013
|
logger.warn(`⚠️ Frontend build path does not exist: ${frontendPath}`);
|
|
12343
15014
|
logger.warn(" SPA serving is disabled. Build your frontend first.");
|
|
12344
15015
|
return;
|
|
@@ -12352,7 +15023,7 @@ function serveSPA(app, config) {
|
|
|
12352
15023
|
});
|
|
12353
15024
|
app.use(scope, responseCompression());
|
|
12354
15025
|
app.use(scope, serveStatic({
|
|
12355
|
-
root: path$
|
|
15026
|
+
root: path$3.relative(process.cwd(), frontendPath),
|
|
12356
15027
|
precompressed: true,
|
|
12357
15028
|
...isRoot ? {} : { rewriteRequestPath: (p) => p.slice(basePath.length) || "/" }
|
|
12358
15029
|
}));
|
|
@@ -12365,7 +15036,7 @@ function serveSPA(app, config) {
|
|
|
12365
15036
|
app.get(scope, async (c, next) => {
|
|
12366
15037
|
if (allExcludePaths.some((p) => isUnderPath(c.req.path, p))) return next();
|
|
12367
15038
|
if (isAssetRequest(c.req.path, c.req.header("sec-fetch-dest"))) return c.text("Not found", 404);
|
|
12368
|
-
const indexPath = path$
|
|
15039
|
+
const indexPath = path$3.join(frontendPath, indexFile);
|
|
12369
15040
|
if (!cachedHtml) try {
|
|
12370
15041
|
cachedHtml = await fsp.readFile(indexPath, "utf-8");
|
|
12371
15042
|
} catch {
|
|
@@ -12387,6 +15058,14 @@ var BundleError = class extends Error {
|
|
|
12387
15058
|
this.name = "BundleError";
|
|
12388
15059
|
}
|
|
12389
15060
|
};
|
|
15061
|
+
/**
|
|
15062
|
+
* The file that makes a directory a bundle.
|
|
15063
|
+
*
|
|
15064
|
+
* Exported because `fetch-bundle.ts` has to recognise the same thing after
|
|
15065
|
+
* unpacking, and when it had its own idea of the name — `rebase-bundle.json`,
|
|
15066
|
+
* which nothing has ever written — every download was rejected as "not a Rebase
|
|
15067
|
+
* bundle". A marker with two spellings is a marker with none.
|
|
15068
|
+
*/
|
|
12390
15069
|
var MANIFEST_FILENAME = "manifest.json";
|
|
12391
15070
|
/**
|
|
12392
15071
|
* Bring a format-1 manifest up to the shape the rest of this runtime expects.
|
|
@@ -12652,6 +15331,55 @@ async function loadUsersCollection(bundle) {
|
|
|
12652
15331
|
}
|
|
12653
15332
|
}
|
|
12654
15333
|
//#endregion
|
|
15334
|
+
//#region src/boot/resource-loading.ts
|
|
15335
|
+
/**
|
|
15336
|
+
* Getting a bundle's resource declarations into the registry at boot.
|
|
15337
|
+
*
|
|
15338
|
+
* The graph could be read from the committed `rebase.resources.json`, and a
|
|
15339
|
+
* *host* does exactly that — it is the artifact that lets a console decide what
|
|
15340
|
+
* to provision without running anything. A running backend cannot, for one
|
|
15341
|
+
* reason: the file records that a subscription exists, and the backend needs
|
|
15342
|
+
* the *function*. Handlers only exist once the module declaring them has been
|
|
15343
|
+
* evaluated.
|
|
15344
|
+
*
|
|
15345
|
+
* So the runtime evaluates, and the file is for readers who do not. That the
|
|
15346
|
+
* two agree is what `rebase resources --check` gates.
|
|
15347
|
+
*
|
|
15348
|
+
* The config index is imported for its exports elsewhere, and re-exporting
|
|
15349
|
+
* `resources.ts` from it would be enough — but only if every project remembers
|
|
15350
|
+
* to. This imports it directly instead, so a project that forgets gets its
|
|
15351
|
+
* resources anyway rather than a backend that boots with none and says nothing.
|
|
15352
|
+
*/
|
|
15353
|
+
/** Where a project's declarations live, relative to its config directory. */
|
|
15354
|
+
var RESOURCE_ENTRIES = [
|
|
15355
|
+
"resources.js",
|
|
15356
|
+
"resources.ts",
|
|
15357
|
+
"resources/index.js",
|
|
15358
|
+
"resources/index.ts"
|
|
15359
|
+
];
|
|
15360
|
+
/**
|
|
15361
|
+
* Evaluate a bundle's resource declarations and return the graph.
|
|
15362
|
+
*
|
|
15363
|
+
* A project with no `resources.ts` is normal and returns whatever the config
|
|
15364
|
+
* index already registered — which for most projects is nothing, and a single
|
|
15365
|
+
* default database is then synthesised downstream exactly as before.
|
|
15366
|
+
*/
|
|
15367
|
+
async function loadBundleResourceGraph(bundle) {
|
|
15368
|
+
const configEntry = bundle.manifest.entry?.config;
|
|
15369
|
+
if (configEntry) {
|
|
15370
|
+
const configDir = resolveBundlePath(bundle.dir, configEntry, "config");
|
|
15371
|
+
const entry = RESOURCE_ENTRIES.map((name) => path.join(configDir, name)).find((candidate) => fs.existsSync(candidate));
|
|
15372
|
+
if (entry) try {
|
|
15373
|
+
await import(pathToFileURL(entry).href);
|
|
15374
|
+
} catch (err) {
|
|
15375
|
+
throw new Error(`Could not load resource declarations from ${entry}: ${err instanceof Error ? err.message : String(err)}. Refusing to boot: every database, bucket and topic this project declares lives in that module, and starting without them would answer requests against resources nobody bound.`);
|
|
15376
|
+
}
|
|
15377
|
+
}
|
|
15378
|
+
const graph = buildResourceGraph();
|
|
15379
|
+
if (graph.resources.length > 0) logger.debug(`[resources] ${graph.resources.length} declared: ` + graph.resources.map((r) => `${r.kind}:${r.key}`).join(", "));
|
|
15380
|
+
return graph;
|
|
15381
|
+
}
|
|
15382
|
+
//#endregion
|
|
12655
15383
|
//#region src/boot/env.ts
|
|
12656
15384
|
/**
|
|
12657
15385
|
* The environment a bundle-booted runtime understands.
|
|
@@ -12716,7 +15444,7 @@ var bootEnvExtension = object({
|
|
|
12716
15444
|
* `push` is in the enum and is **not** distinguished from `ensure` anywhere
|
|
12717
15445
|
* in the boot path; nothing implements "reconcile destructively" here. The
|
|
12718
15446
|
* published image goes further and refuses to start on it
|
|
12719
|
-
* (`docker/entrypoint.mjs`), because a full push computes a diff and will
|
|
15447
|
+
* (`infra/docker/entrypoint.mjs`), because a full push computes a diff and will
|
|
12720
15448
|
* happily `DROP COLUMN` — and a container restart must never be able to
|
|
12721
15449
|
* destroy a production column as a side effect of rescheduling. Reshaping
|
|
12722
15450
|
* and destructive changes stay a deliberate `rebase db generate` + `rebase
|
|
@@ -12755,6 +15483,16 @@ var bootEnvExtension = object({
|
|
|
12755
15483
|
""
|
|
12756
15484
|
]).default("false").transform((v) => v === "true"),
|
|
12757
15485
|
/**
|
|
15486
|
+
* Write derived image renditions back to the storage source, so a transform
|
|
15487
|
+
* survives a restart and is computed once across instances rather than once
|
|
15488
|
+
* per instance. Costs one PUT per new variant; see `rendition-cache.ts`.
|
|
15489
|
+
*/
|
|
15490
|
+
STORAGE_RENDITION_CACHE: _enum([
|
|
15491
|
+
"true",
|
|
15492
|
+
"false",
|
|
15493
|
+
""
|
|
15494
|
+
]).default("false").transform((v) => v === "true"),
|
|
15495
|
+
/**
|
|
12758
15496
|
* Opt out of the storage access-control boot guard, restoring the behaviour
|
|
12759
15497
|
* where any authenticated user may read, overwrite, delete or list any key.
|
|
12760
15498
|
* Only defensible when every signed-in user is trusted with every file.
|
|
@@ -12774,6 +15512,36 @@ var bootEnvExtension = object({
|
|
|
12774
15512
|
"false",
|
|
12775
15513
|
""
|
|
12776
15514
|
]).default("false").transform((v) => v === "true"),
|
|
15515
|
+
/**
|
|
15516
|
+
* Passwordless email sign-in, both shapes of it.
|
|
15517
|
+
*
|
|
15518
|
+
* Both were code-level flags only, so a bundle deployment — the shape every
|
|
15519
|
+
* self-hosted and managed project runs — could not turn either on without
|
|
15520
|
+
* rebuilding. That is the wrong place for the switch: whether a deployment
|
|
15521
|
+
* wants passwordless login is a property of the deployment.
|
|
15522
|
+
*/
|
|
15523
|
+
AUTH_MAGIC_LINK: _enum([
|
|
15524
|
+
"true",
|
|
15525
|
+
"false",
|
|
15526
|
+
""
|
|
15527
|
+
]).default("false").transform((v) => v === "true"),
|
|
15528
|
+
AUTH_EMAIL_OTP: _enum([
|
|
15529
|
+
"true",
|
|
15530
|
+
"false",
|
|
15531
|
+
""
|
|
15532
|
+
]).default("false").transform((v) => v === "true"),
|
|
15533
|
+
/**
|
|
15534
|
+
* Bot protection. Enabled by setting both — a provider without a secret
|
|
15535
|
+
* cannot verify anything, and is refused at boot rather than ignored.
|
|
15536
|
+
*/
|
|
15537
|
+
CAPTCHA_PROVIDER: _enum([
|
|
15538
|
+
"turnstile",
|
|
15539
|
+
"hcaptcha",
|
|
15540
|
+
""
|
|
15541
|
+
]).optional(),
|
|
15542
|
+
CAPTCHA_SECRET: string().optional(),
|
|
15543
|
+
/** Comma-separated: register, login, forgotPassword, magicLink, emailOtp. */
|
|
15544
|
+
CAPTCHA_ROUTES: string().optional(),
|
|
12777
15545
|
AUTH_COOKIE_SAME_SITE: _enum([
|
|
12778
15546
|
"Strict",
|
|
12779
15547
|
"Lax",
|
|
@@ -12874,6 +15642,19 @@ var bootEnvExtension = object({
|
|
|
12874
15642
|
""
|
|
12875
15643
|
]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
|
|
12876
15644
|
/**
|
|
15645
|
+
* Override whether this process runs the scheduled RLS audit. Unset follows
|
|
15646
|
+
* the role.
|
|
15647
|
+
*
|
|
15648
|
+
* Unlike cron and the job workers this is not claim-protected — every owner
|
|
15649
|
+
* scans on its own timer, which is redundant rather than unsafe — so a split
|
|
15650
|
+
* deployment sets it false everywhere but one process.
|
|
15651
|
+
*/
|
|
15652
|
+
REBASE_RLS_AUDIT: _enum([
|
|
15653
|
+
"true",
|
|
15654
|
+
"false",
|
|
15655
|
+
""
|
|
15656
|
+
]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
|
|
15657
|
+
/**
|
|
12877
15658
|
* Comma-separated function names this process serves. Unset means all.
|
|
12878
15659
|
*
|
|
12879
15660
|
* `functions` role only. A name that is not in the bundle is a boot failure,
|
|
@@ -13023,7 +15804,8 @@ var ROLES = {
|
|
|
13023
15804
|
},
|
|
13024
15805
|
ownership: {
|
|
13025
15806
|
cronScheduler: false,
|
|
13026
|
-
jobWorkers: false
|
|
15807
|
+
jobWorkers: false,
|
|
15808
|
+
rlsAudit: false
|
|
13027
15809
|
},
|
|
13028
15810
|
provisionSchema: false
|
|
13029
15811
|
},
|
|
@@ -13067,6 +15849,7 @@ function resolveRole(env) {
|
|
|
13067
15849
|
const ownership = { ...base.ownership };
|
|
13068
15850
|
if (env.REBASE_CRON_SCHEDULER !== void 0) ownership.cronScheduler = env.REBASE_CRON_SCHEDULER;
|
|
13069
15851
|
if (env.REBASE_JOB_WORKERS !== void 0) ownership.jobWorkers = env.REBASE_JOB_WORKERS;
|
|
15852
|
+
if (env.REBASE_RLS_AUDIT !== void 0) ownership.rlsAudit = env.REBASE_RLS_AUDIT;
|
|
13070
15853
|
return {
|
|
13071
15854
|
role,
|
|
13072
15855
|
surfaces: { ...base.surfaces },
|
|
@@ -13504,13 +16287,51 @@ async function initializeDataSources(sources, schema, resolveFrom = []) {
|
|
|
13504
16287
|
//#endregion
|
|
13505
16288
|
//#region src/boot/options.ts
|
|
13506
16289
|
/**
|
|
13507
|
-
*
|
|
16290
|
+
* True when an emailed link would be followable — i.e. when there is an
|
|
16291
|
+
* absolute base to build one from. Mirrors `assertEmailLinkBases`, which
|
|
16292
|
+
* refuses the boot otherwise.
|
|
16293
|
+
*/
|
|
16294
|
+
function hasAbsoluteLinkBase(value) {
|
|
16295
|
+
if (!value) return false;
|
|
16296
|
+
try {
|
|
16297
|
+
const url = new URL(value);
|
|
16298
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
16299
|
+
} catch {
|
|
16300
|
+
return false;
|
|
16301
|
+
}
|
|
16302
|
+
}
|
|
16303
|
+
/**
|
|
16304
|
+
* Build the email configuration.
|
|
16305
|
+
*
|
|
16306
|
+
* With `SMTP_HOST`, that is a real SMTP transport. Without it, outside
|
|
16307
|
+
* production, it is the development sink: mail is captured and its links are
|
|
16308
|
+
* printed, so `POST /auth/magic-link` and `POST /auth/forgot-password` complete
|
|
16309
|
+
* on a fresh project instead of answering `503 EMAIL_NOT_CONFIGURED`. The token
|
|
16310
|
+
* was always minted and valid; only delivery was missing.
|
|
13508
16311
|
*
|
|
13509
|
-
*
|
|
13510
|
-
*
|
|
16312
|
+
* Three conditions, all required, none configurable:
|
|
16313
|
+
*
|
|
16314
|
+
* - no `SMTP_HOST` — a configured mail server always wins;
|
|
16315
|
+
* - `NODE_ENV` is not `production` — a captured reset mail carries a working
|
|
16316
|
+
* token, so this must not be reachable there (see `dev-sink.ts`);
|
|
16317
|
+
* - an absolute `FRONTEND_URL`, or the emailed link has no base and is dead on
|
|
16318
|
+
* arrival — which is the condition `assertEmailLinkBases` fails the boot on.
|
|
16319
|
+
*
|
|
16320
|
+
* When any of those does not hold this returns `undefined`, exactly as before,
|
|
16321
|
+
* and the auth routes report the missing service for themselves.
|
|
13511
16322
|
*/
|
|
13512
16323
|
function resolveEmailOptions(env) {
|
|
13513
|
-
if (!env.SMTP_HOST)
|
|
16324
|
+
if (!env.SMTP_HOST) {
|
|
16325
|
+
if (env.NODE_ENV === "production") return void 0;
|
|
16326
|
+
if (!hasAbsoluteLinkBase(env.FRONTEND_URL)) return void 0;
|
|
16327
|
+
return {
|
|
16328
|
+
from: env.SMTP_FROM || `${env.APP_NAME} <noreply@rebase.pro>`,
|
|
16329
|
+
sendEmail: registerDevEmailSink(createDevEmailSink()).sendEmail,
|
|
16330
|
+
appName: env.APP_NAME,
|
|
16331
|
+
logoUrl: env.EMAIL_LOGO_URL,
|
|
16332
|
+
resetPasswordUrl: env.FRONTEND_URL
|
|
16333
|
+
};
|
|
16334
|
+
}
|
|
13514
16335
|
return {
|
|
13515
16336
|
from: env.SMTP_FROM || `${env.APP_NAME} <noreply@rebase.pro>`,
|
|
13516
16337
|
smtp: {
|
|
@@ -13529,6 +16350,24 @@ function resolveEmailOptions(env) {
|
|
|
13529
16350
|
};
|
|
13530
16351
|
}
|
|
13531
16352
|
/**
|
|
16353
|
+
* Bot protection from the environment, or `undefined` when it is not configured.
|
|
16354
|
+
*
|
|
16355
|
+
* Both halves are required together. A provider with no secret cannot verify
|
|
16356
|
+
* anything, so it is left off here and `resolveCaptchaVerifier` refuses the boot
|
|
16357
|
+
* if something else turns it on — the one failure this feature must not have is
|
|
16358
|
+
* being silently absent while the config says otherwise.
|
|
16359
|
+
*/
|
|
16360
|
+
function resolveCaptchaOptions(env) {
|
|
16361
|
+
if (!env.CAPTCHA_PROVIDER || !env.CAPTCHA_SECRET) return void 0;
|
|
16362
|
+
const routes = env.CAPTCHA_ROUTES?.split(",").map((part) => part.trim()).filter(Boolean);
|
|
16363
|
+
return {
|
|
16364
|
+
enabled: true,
|
|
16365
|
+
provider: env.CAPTCHA_PROVIDER,
|
|
16366
|
+
secret: env.CAPTCHA_SECRET,
|
|
16367
|
+
...routes?.length ? { routes } : {}
|
|
16368
|
+
};
|
|
16369
|
+
}
|
|
16370
|
+
/**
|
|
13532
16371
|
* Build the auth configuration from the environment and the bundle's users
|
|
13533
16372
|
* collection.
|
|
13534
16373
|
*
|
|
@@ -13552,6 +16391,9 @@ function resolveAuthOptions(env, usersCollection) {
|
|
|
13552
16391
|
disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
|
|
13553
16392
|
allowAnonymous: env.ALLOW_ANONYMOUS,
|
|
13554
16393
|
allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
|
|
16394
|
+
captcha: resolveCaptchaOptions(env),
|
|
16395
|
+
magicLink: env.AUTH_MAGIC_LINK,
|
|
16396
|
+
emailOtp: env.AUTH_EMAIL_OTP,
|
|
13555
16397
|
email: resolveEmailOptions(env),
|
|
13556
16398
|
cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
|
|
13557
16399
|
};
|
|
@@ -13820,25 +16662,44 @@ function createMetricsRoutes(registry, token) {
|
|
|
13820
16662
|
//#endregion
|
|
13821
16663
|
//#region src/boot/fetch-bundle.ts
|
|
13822
16664
|
/**
|
|
13823
|
-
* Fetching a bundle at boot
|
|
13824
|
-
*
|
|
13825
|
-
* A bundle
|
|
13826
|
-
*
|
|
13827
|
-
*
|
|
13828
|
-
*
|
|
13829
|
-
*
|
|
13830
|
-
*
|
|
13831
|
-
*
|
|
13832
|
-
*
|
|
13833
|
-
*
|
|
16665
|
+
* Fetching a bundle at boot.
|
|
16666
|
+
*
|
|
16667
|
+
* A bundle arrives one of two ways: already on disk (`rebase build` wrote one,
|
|
16668
|
+
* or a container image carries one at `/bundle`), or over HTTP from a stable
|
|
16669
|
+
* URL. This is the second, and it is now the *only* mechanism for fetching one
|
|
16670
|
+
* — Kubernetes deployments used to run a separate init container that did the
|
|
16671
|
+
* same three jobs into a shared volume, and it was deleted in favour of this.
|
|
16672
|
+
*
|
|
16673
|
+
* ## Why there was a second implementation, and why it is gone
|
|
16674
|
+
*
|
|
16675
|
+
* The init container existed because this path could not actually be used: it
|
|
16676
|
+
* looked for a marker file called `rebase-bundle.json` that nothing has ever
|
|
16677
|
+
* written — the CLI writes `manifest.json` — so every real bundle was rejected
|
|
16678
|
+
* as "not a Rebase bundle". The only thing that ever produced the file it
|
|
16679
|
+
* wanted was its own test. So `REBASE_BUNDLE_URL` had never worked against a
|
|
16680
|
+
* bundle from `rebase build`, on Cloud Run or under the Helm chart's
|
|
16681
|
+
* `bundle.mode: url`, and Kubernetes grew an init container instead.
|
|
16682
|
+
*
|
|
16683
|
+
* That init container is where the worst failure in the managed path lived. It
|
|
16684
|
+
* held three copies of the dependency tree at once (archive, npm cache,
|
|
16685
|
+
* extracted `node_modules`), and above its ephemeral-storage grant `npm
|
|
16686
|
+
* install` neither errored nor got evicted — it went to sleep in `epoll_wait`
|
|
16687
|
+
* and stayed there, with no log line, no event and no exit code anywhere. The
|
|
16688
|
+
* only symptom was the control plane reporting that the deploy did not become
|
|
16689
|
+
* ready.
|
|
16690
|
+
*
|
|
16691
|
+
* Doing the work here instead does not make dependency installation need less
|
|
16692
|
+
* disk. What it changes is that the work happens inside a process that has a
|
|
16693
|
+
* logger, so running out of room is reported as running out of room.
|
|
13834
16694
|
*
|
|
13835
16695
|
* ## Every start, not just the first
|
|
13836
16696
|
*
|
|
13837
|
-
* The fetch has to be cheap and repeatable because it runs on *every*
|
|
13838
|
-
*
|
|
13839
|
-
* revision. That is also why the platform's bundle URL is
|
|
13840
|
-
* endpoint rather than a signed expiring one —
|
|
13841
|
-
*
|
|
16697
|
+
* The fetch has to be cheap and repeatable because it runs on *every* start: a
|
|
16698
|
+
* scale-from-zero, an instance recycled after an hour idle, a node drain, an
|
|
16699
|
+
* eviction, a new revision. That is also why the platform's bundle URL is
|
|
16700
|
+
* deliberately a stable endpoint rather than a signed expiring one — a pod
|
|
16701
|
+
* rescheduled at 3am needs the same URL to work, and a URL that had expired
|
|
16702
|
+
* would mean a tenant stays down until a human triggers a fresh deploy.
|
|
13842
16703
|
*
|
|
13843
16704
|
* ## It refuses rather than half-unpacking
|
|
13844
16705
|
*
|
|
@@ -13883,11 +16744,144 @@ async function extractWithTar(tarball, destination) {
|
|
|
13883
16744
|
* refuse. Writing the whole tarball first means a truncated download is caught
|
|
13884
16745
|
* by `tar` as a corrupt archive, which is an error.
|
|
13885
16746
|
*/
|
|
13886
|
-
|
|
13887
|
-
|
|
13888
|
-
|
|
13889
|
-
|
|
13890
|
-
|
|
16747
|
+
/**
|
|
16748
|
+
* Install the bundle's declared dependencies, in place.
|
|
16749
|
+
*
|
|
16750
|
+
* `npm ci` when a lockfile is present and `npm install` otherwise — a bundle
|
|
16751
|
+
* that shipped a lockfile gets a reproducible install, one that did not still
|
|
16752
|
+
* boots.
|
|
16753
|
+
*
|
|
16754
|
+
* `--ignore-scripts` is not optional. The runtime image's entrypoint refuses to
|
|
16755
|
+
* run lifecycle scripts, and an install that ran them here would void that
|
|
16756
|
+
* guarantee at every pod start, executing arbitrary code from the dependency
|
|
16757
|
+
* tree before the process this is meant to be booting exists.
|
|
16758
|
+
*
|
|
16759
|
+
* The npm cache is dropped afterwards because it is, at that point, a
|
|
16760
|
+
* byte-for-byte duplicate of the tree beside it and nothing reads it again —
|
|
16761
|
+
* the runtime never installs twice in one process. Keeping it doubles the
|
|
16762
|
+
* high-water mark on a disk that is usually the binding constraint.
|
|
16763
|
+
*/
|
|
16764
|
+
async function installBundleDependencies(bundleRoot, exec = run) {
|
|
16765
|
+
if (!fs$1.existsSync(path$1.join(bundleRoot, "package.json"))) return;
|
|
16766
|
+
if (fs$1.existsSync(path$1.join(bundleRoot, "node_modules"))) {
|
|
16767
|
+
logger.debug("Bundle already carries node_modules; skipping install");
|
|
16768
|
+
return;
|
|
16769
|
+
}
|
|
16770
|
+
const args = fs$1.existsSync(path$1.join(bundleRoot, "package-lock.json")) ? [
|
|
16771
|
+
"ci",
|
|
16772
|
+
"--omit=dev",
|
|
16773
|
+
"--ignore-scripts",
|
|
16774
|
+
"--no-audit",
|
|
16775
|
+
"--no-fund"
|
|
16776
|
+
] : [
|
|
16777
|
+
"install",
|
|
16778
|
+
"--omit=dev",
|
|
16779
|
+
"--ignore-scripts",
|
|
16780
|
+
"--no-audit",
|
|
16781
|
+
"--no-fund"
|
|
16782
|
+
];
|
|
16783
|
+
logger.info("Installing bundle dependencies", {
|
|
16784
|
+
command: `npm ${args[0]}`,
|
|
16785
|
+
cwd: bundleRoot
|
|
16786
|
+
});
|
|
16787
|
+
const started = Date.now();
|
|
16788
|
+
try {
|
|
16789
|
+
await exec("npm", args, {
|
|
16790
|
+
cwd: bundleRoot,
|
|
16791
|
+
maxBuffer: 32 * 1024 * 1024
|
|
16792
|
+
});
|
|
16793
|
+
} catch (error) {
|
|
16794
|
+
fs$1.rmSync(path$1.join(bundleRoot, "node_modules"), {
|
|
16795
|
+
recursive: true,
|
|
16796
|
+
force: true
|
|
16797
|
+
});
|
|
16798
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
16799
|
+
const outOfSpace = /ENOSPC|no space left/i.test(detail);
|
|
16800
|
+
throw new Error(`Installing the bundle's dependencies failed: ${detail}` + (outOfSpace ? " — the volume ran out of space. An install needs room for the archive, npm's cache and the extracted tree at once; raise the pod's ephemeral-storage request, or vendor node_modules into the bundle at build time." : ""));
|
|
16801
|
+
}
|
|
16802
|
+
logger.info("Bundle dependencies installed", { ms: Date.now() - started });
|
|
16803
|
+
dedupeRuntimePackages(bundleRoot, imageModulesDir());
|
|
16804
|
+
await exec("npm", [
|
|
16805
|
+
"cache",
|
|
16806
|
+
"clean",
|
|
16807
|
+
"--force"
|
|
16808
|
+
], { cwd: bundleRoot }).catch(() => void 0);
|
|
16809
|
+
}
|
|
16810
|
+
function imageModulesDir() {
|
|
16811
|
+
return process.env["REBASE_RUNTIME_MODULES"] || void 0;
|
|
16812
|
+
}
|
|
16813
|
+
/**
|
|
16814
|
+
* The packages the runtime image supplies to a fetched bundle.
|
|
16815
|
+
*
|
|
16816
|
+
* Must match `RUNTIME_PROVIDED` in `packages/cli/src/bundle.ts` and in
|
|
16817
|
+
* `infra/docker/entrypoint.mjs`; `tooling/scripts/test/runtime-provided.test.mjs` fails if any
|
|
16818
|
+
* of the three drift. The bundler STRIPS these from a bundle's declared
|
|
16819
|
+
* dependencies on the promise that the image supplies them, so a shorter list
|
|
16820
|
+
* here means the builder removed a dependency that nothing then provides, and
|
|
16821
|
+
* every function and cron importing it fails to load with `Cannot find package`
|
|
16822
|
+
* behind a container that reports itself healthy.
|
|
16823
|
+
*
|
|
16824
|
+
* This list carried only `@rebasepro/server` while the bundler stripped five —
|
|
16825
|
+
* the same four-package gap that broke the non-fetch path, reproduced on the
|
|
16826
|
+
* fetch path because this list was written before that fix and no gate watched
|
|
16827
|
+
* this file.
|
|
16828
|
+
*/
|
|
16829
|
+
var RUNTIME_PROVIDED_PACKAGES = [
|
|
16830
|
+
"@rebasepro/server",
|
|
16831
|
+
"@rebasepro/types",
|
|
16832
|
+
"@rebasepro/client",
|
|
16833
|
+
"@rebasepro/common",
|
|
16834
|
+
"@rebasepro/utils"
|
|
16835
|
+
];
|
|
16836
|
+
/**
|
|
16837
|
+
* Collapse a duplicate framework copy in the bundle onto the image's.
|
|
16838
|
+
*
|
|
16839
|
+
* Node resolves a module's identity by its real path, so replacing the
|
|
16840
|
+
* duplicate with a symlink makes the two one instance again. It is also the
|
|
16841
|
+
* honest version of what was already true: the image's copy is the one that
|
|
16842
|
+
* booted and owns the process, so a bundle pinning a different version was
|
|
16843
|
+
* never running that version — it just carried a second, dead one alongside it.
|
|
16844
|
+
*
|
|
16845
|
+
* Non-fatal by design. A project with no custom functions is unaffected by the
|
|
16846
|
+
* duplicate, and refusing to boot over it would turn a degraded start into no
|
|
16847
|
+
* start at all.
|
|
16848
|
+
*
|
|
16849
|
+
* @param imageModules the runtime image's own `node_modules`. Absent, there is
|
|
16850
|
+
* nothing to dedupe against and this is a no-op — which is the case in every
|
|
16851
|
+
* context except a container built from the runtime image.
|
|
16852
|
+
*/
|
|
16853
|
+
function dedupeRuntimePackages(bundleRoot, imageModules) {
|
|
16854
|
+
if (!imageModules || !fs$1.existsSync(imageModules)) return [];
|
|
16855
|
+
const deduped = [];
|
|
16856
|
+
for (const pkg of RUNTIME_PROVIDED_PACKAGES) {
|
|
16857
|
+
const provided = path$1.join(imageModules, pkg);
|
|
16858
|
+
const inBundle = path$1.join(bundleRoot, "node_modules", pkg);
|
|
16859
|
+
if (!fs$1.existsSync(provided)) continue;
|
|
16860
|
+
let stat = null;
|
|
16861
|
+
try {
|
|
16862
|
+
stat = fs$1.lstatSync(inBundle);
|
|
16863
|
+
} catch {}
|
|
16864
|
+
if (stat?.isSymbolicLink()) continue;
|
|
16865
|
+
try {
|
|
16866
|
+
fs$1.mkdirSync(path$1.dirname(inBundle), { recursive: true });
|
|
16867
|
+
if (stat) fs$1.rmSync(inBundle, {
|
|
16868
|
+
recursive: true,
|
|
16869
|
+
force: true
|
|
16870
|
+
});
|
|
16871
|
+
fs$1.symlinkSync(provided, inBundle, "dir");
|
|
16872
|
+
deduped.push(pkg);
|
|
16873
|
+
logger.info(stat ? "Deduped a bundle package onto the runtime's own copy" : "Linked the runtime's own copy into the bundle", { package: pkg });
|
|
16874
|
+
} catch (error) {
|
|
16875
|
+
logger.warn("Could not link a bundle package; custom functions may not see the runtime", {
|
|
16876
|
+
package: pkg,
|
|
16877
|
+
error: error instanceof Error ? error.message : String(error)
|
|
16878
|
+
});
|
|
16879
|
+
}
|
|
16880
|
+
}
|
|
16881
|
+
return deduped;
|
|
16882
|
+
}
|
|
16883
|
+
/** One download attempt, streamed to `destination`. */
|
|
16884
|
+
async function downloadTo(tarball, options, fetchImpl) {
|
|
13891
16885
|
const controller = new AbortController();
|
|
13892
16886
|
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 6e4);
|
|
13893
16887
|
let response;
|
|
@@ -13896,25 +16890,63 @@ async function fetchBundle(options) {
|
|
|
13896
16890
|
headers: options.token ? { authorization: `Bearer ${options.token}` } : {},
|
|
13897
16891
|
signal: controller.signal
|
|
13898
16892
|
});
|
|
13899
|
-
} catch (error) {
|
|
13900
|
-
throw new Error(`Could not download the bundle from ${options.url}: ` + (error instanceof Error ? error.message : String(error)));
|
|
13901
16893
|
} finally {
|
|
13902
16894
|
clearTimeout(timer);
|
|
13903
16895
|
}
|
|
13904
|
-
if (!response.ok) throw new Error(
|
|
16896
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
16897
|
+
if (!response.body) throw new Error("empty response body");
|
|
16898
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
|
|
16899
|
+
const { size } = fs$1.statSync(tarball);
|
|
16900
|
+
if (size === 0) throw new Error("the bundle is empty");
|
|
16901
|
+
}
|
|
16902
|
+
/**
|
|
16903
|
+
* Download, unpack and prepare a bundle, returning the directory it landed in.
|
|
16904
|
+
*
|
|
16905
|
+
* Downloads to a file rather than streaming into `tar`, deliberately. A stream
|
|
16906
|
+
* that dies mid-transfer leaves `tar` having successfully extracted a prefix of
|
|
16907
|
+
* the archive and exiting 0 — the half-unpacked bundle this module exists to
|
|
16908
|
+
* refuse. Writing the whole tarball first means a truncated download is caught
|
|
16909
|
+
* by `tar` as a corrupt archive, which is an error.
|
|
16910
|
+
*/
|
|
16911
|
+
async function fetchBundle(options) {
|
|
16912
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
16913
|
+
const extract = options.extract ?? extractWithTar;
|
|
16914
|
+
const attempts = Math.max(1, options.attempts ?? 6);
|
|
16915
|
+
const retryDelayMs = options.retryDelayMs ?? 3e3;
|
|
16916
|
+
const destination = options.destination ?? fs$1.mkdtempSync(path$1.join(os.tmpdir(), "rebase-bundle-"));
|
|
16917
|
+
fs$1.mkdirSync(destination, { recursive: true });
|
|
13905
16918
|
const tarball = path$1.join(destination, "bundle.tar.gz");
|
|
13906
|
-
|
|
13907
|
-
|
|
13908
|
-
|
|
16919
|
+
let lastError;
|
|
16920
|
+
for (let attempt = 1; attempt <= attempts; attempt++) try {
|
|
16921
|
+
await downloadTo(tarball, options, fetchImpl);
|
|
16922
|
+
lastError = void 0;
|
|
16923
|
+
break;
|
|
16924
|
+
} catch (error) {
|
|
16925
|
+
lastError = error;
|
|
16926
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
16927
|
+
if (/^4\d\d /.test(detail) || attempt === attempts) break;
|
|
16928
|
+
logger.warn("Bundle download failed; retrying", {
|
|
16929
|
+
attempt,
|
|
16930
|
+
of: attempts,
|
|
16931
|
+
error: detail
|
|
16932
|
+
});
|
|
16933
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
16934
|
+
}
|
|
16935
|
+
if (lastError) {
|
|
16936
|
+
fs$1.rmSync(tarball, { force: true });
|
|
16937
|
+
throw new Error(`Could not download the bundle from ${options.url}: ` + (lastError instanceof Error ? lastError.message : String(lastError)));
|
|
16938
|
+
}
|
|
16939
|
+
const { size } = fs$1.statSync(tarball);
|
|
13909
16940
|
try {
|
|
13910
16941
|
await extract(tarball, destination);
|
|
13911
16942
|
} catch (error) {
|
|
13912
|
-
throw new Error(`The bundle downloaded from ${options.url} could not be unpacked (${
|
|
16943
|
+
throw new Error(`The bundle downloaded from ${options.url} could not be unpacked (${size} bytes): ` + (error instanceof Error ? error.message : String(error)));
|
|
13913
16944
|
} finally {
|
|
13914
16945
|
fs$1.rmSync(tarball, { force: true });
|
|
13915
16946
|
}
|
|
13916
16947
|
const root = bundleRootIn(destination);
|
|
13917
|
-
if (!root) throw new Error(`The bundle downloaded from ${options.url} unpacked without a
|
|
16948
|
+
if (!root) throw new Error(`The bundle downloaded from ${options.url} unpacked without a ${MANIFEST_FILENAME}. It is not a Rebase bundle, or it was truncated.`);
|
|
16949
|
+
if (options.installDependencies ?? true) await (options.installImpl ?? installBundleDependencies)(root);
|
|
13918
16950
|
return root;
|
|
13919
16951
|
}
|
|
13920
16952
|
/**
|
|
@@ -13926,11 +16958,11 @@ async function fetchBundle(options) {
|
|
|
13926
16958
|
* files, and both are things a build script does.
|
|
13927
16959
|
*/
|
|
13928
16960
|
function bundleRootIn(directory) {
|
|
13929
|
-
if (fs$1.existsSync(path$1.join(directory, "
|
|
16961
|
+
if (fs$1.existsSync(path$1.join(directory, "manifest.json"))) return directory;
|
|
13930
16962
|
for (const entry of fs$1.readdirSync(directory, { withFileTypes: true })) {
|
|
13931
16963
|
if (!entry.isDirectory()) continue;
|
|
13932
16964
|
const nested = path$1.join(directory, entry.name);
|
|
13933
|
-
if (fs$1.existsSync(path$1.join(nested, "
|
|
16965
|
+
if (fs$1.existsSync(path$1.join(nested, "manifest.json"))) return nested;
|
|
13934
16966
|
}
|
|
13935
16967
|
return null;
|
|
13936
16968
|
}
|
|
@@ -13953,7 +16985,8 @@ function bundleRootIn(directory) {
|
|
|
13953
16985
|
async function bootFromBundle(options = {}) {
|
|
13954
16986
|
const fetchedDir = !options.bundleDir && !options.bundle && shouldFetchBundle() ? await fetchBundle({
|
|
13955
16987
|
url: process.env[BUNDLE_URL_ENV],
|
|
13956
|
-
token: process.env[BUNDLE_TOKEN_ENV]
|
|
16988
|
+
token: process.env[BUNDLE_TOKEN_ENV],
|
|
16989
|
+
destination: process.env["REBASE_BUNDLE_FETCH_DIR"] || void 0
|
|
13957
16990
|
}) : void 0;
|
|
13958
16991
|
const bundleDir = options.bundleDir || fetchedDir || process.env.REBASE_BUNDLE || path.resolve(process.cwd(), "dist-bundle");
|
|
13959
16992
|
const bundle = options.bundle ?? loadBundle(bundleDir);
|
|
@@ -13973,9 +17006,12 @@ async function bootFromBundle(options = {}) {
|
|
|
13973
17006
|
...runtimeRole.ownership
|
|
13974
17007
|
});
|
|
13975
17008
|
const configExports = await loadBundleConfigExports(bundle);
|
|
13976
|
-
|
|
13977
|
-
const
|
|
13978
|
-
const
|
|
17009
|
+
assertNoReplacedResourceConfig(configExports);
|
|
17010
|
+
const resourceGraph = await loadBundleResourceGraph(bundle);
|
|
17011
|
+
const graphDataSources = graphToDataSources(resourceGraph);
|
|
17012
|
+
const graphStorageSources = graphToStorageSources(resourceGraph);
|
|
17013
|
+
const dataSourceDefs = graphDataSources.length > 0 ? graphDataSources : void 0;
|
|
17014
|
+
const storageSourceDefs = graphStorageSources.length > 0 ? graphStorageSources : void 0;
|
|
13979
17015
|
const resolvedSources = resolveDataSources(process.env, dataSourceDefs);
|
|
13980
17016
|
const schema = await loadBundleSchema(bundle);
|
|
13981
17017
|
const driverRoots = bundleResolutionRoots(bundle.dir);
|
|
@@ -14010,11 +17046,10 @@ async function bootFromBundle(options = {}) {
|
|
|
14010
17046
|
functionsSelection: runtimeRole.functionsSelection,
|
|
14011
17047
|
functionsUpstream: runtimeRole.functionsUpstream,
|
|
14012
17048
|
bootstrappers: dataSources.map((s) => s.bootstrapper),
|
|
14013
|
-
dataSources: dataSourceDefs,
|
|
14014
17049
|
storage,
|
|
14015
|
-
storageSources: storageSourceDefs,
|
|
14016
17050
|
storageAuthorize: configExports.storageAuthorize,
|
|
14017
17051
|
storagePublicRead: env.STORAGE_PUBLIC_READ,
|
|
17052
|
+
storageRenditionCache: { enabled: env.STORAGE_RENDITION_CACHE },
|
|
14018
17053
|
storageInsecureAllowAnyAuthenticated: env.STORAGE_ALLOW_ANY_AUTHENTICATED,
|
|
14019
17054
|
callbacks: configExports.callbacks,
|
|
14020
17055
|
auth: resolveAuthOptions(env, usersCollection),
|
|
@@ -14192,9 +17227,13 @@ async function bootStaticApp(bundle, devRoot, options) {
|
|
|
14192
17227
|
port = await listenWithPortRetry(server, requestedPort, { portFileDir: devRoot });
|
|
14193
17228
|
logger.info(`Server running at http://localhost:${port}`);
|
|
14194
17229
|
}
|
|
14195
|
-
const
|
|
17230
|
+
const staticBackend = { shutdown: async () => {
|
|
17231
|
+
await new Promise((resolve) => {
|
|
17232
|
+
server.close(() => resolve());
|
|
17233
|
+
});
|
|
17234
|
+
} };
|
|
14196
17235
|
if (options.handleSignals !== false) {
|
|
14197
|
-
installShutdownHandlers(
|
|
17236
|
+
installShutdownHandlers(staticBackend, { onCleanup: async () => {
|
|
14198
17237
|
if (!isProduction) cleanupDevPortFile(devRoot);
|
|
14199
17238
|
} });
|
|
14200
17239
|
if (!isProduction) process.on("exit", () => cleanupDevPortFile(devRoot));
|
|
@@ -14202,7 +17241,7 @@ async function bootStaticApp(bundle, devRoot, options) {
|
|
|
14202
17241
|
return {
|
|
14203
17242
|
app,
|
|
14204
17243
|
server,
|
|
14205
|
-
backend:
|
|
17244
|
+
backend: staticBackend,
|
|
14206
17245
|
bundle,
|
|
14207
17246
|
env: {
|
|
14208
17247
|
NODE_ENV: process.env.NODE_ENV ?? "development",
|
|
@@ -14322,6 +17361,160 @@ function warnOnUnusableBundleShape(bundle) {
|
|
|
14322
17361
|
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
17362
|
}
|
|
14324
17363
|
//#endregion
|
|
14325
|
-
|
|
17364
|
+
//#region src/deploy/pod-contract.ts
|
|
17365
|
+
/**
|
|
17366
|
+
* What every deployment of this runtime must get right about the pod.
|
|
17367
|
+
*
|
|
17368
|
+
* There are two things that place a Rebase runtime in Kubernetes — the Helm
|
|
17369
|
+
* chart in `infra/charts/rebase`, for self-hosting, and the control plane's
|
|
17370
|
+
* `buildManagedContainer`, for cloud tenants — and they were written months
|
|
17371
|
+
* apart by people solving different problems. Rendering both for the same unit
|
|
17372
|
+
* and diffing them (2026-08-22) turned up four disagreements, and in three of
|
|
17373
|
+
* them the chart was doing the thing this runtime's own source warns against.
|
|
17374
|
+
*
|
|
17375
|
+
* The disagreements were not in the parts that *should* differ. A managed pod
|
|
17376
|
+
* takes its environment from a tenant Secret and a self-hosted one takes it from
|
|
17377
|
+
* a values file; a managed pod is scheduled onto spot capacity its plan sold it
|
|
17378
|
+
* and a self-hosted one goes where the operator says. Those are different jobs
|
|
17379
|
+
* and they produce different manifests, correctly.
|
|
17380
|
+
*
|
|
17381
|
+
* What must not differ is anything that is really a statement about *this
|
|
17382
|
+
* process*: which endpoint answers what, which variables decide the topology,
|
|
17383
|
+
* where the bundle is mounted. Those are not deployment preferences. They are
|
|
17384
|
+
* facts about the runtime, and a deployment that gets one wrong produces a
|
|
17385
|
+
* cluster that looks healthy — which is why they are stated here, in the
|
|
17386
|
+
* runtime, rather than twice in the things that deploy it.
|
|
17387
|
+
*
|
|
17388
|
+
* The chart cannot import this module. `tooling/scripts/check-chart.mjs` renders the
|
|
17389
|
+
* chart and asserts it against these values instead, so the chart conforms by
|
|
17390
|
+
* gate where the control plane conforms by construction.
|
|
17391
|
+
*/
|
|
17392
|
+
/**
|
|
17393
|
+
* Endpoints this runtime guarantees on every role, and what each one means.
|
|
17394
|
+
*
|
|
17395
|
+
* `/livez` is dependency-free — it answers "this process is running" and
|
|
17396
|
+
* nothing else. `/health` opens the default driver and every configured
|
|
17397
|
+
* secondary, and **answers 503 when any of them is unreachable**
|
|
17398
|
+
* (`boot.ts`, the `healthPaths` handler).
|
|
17399
|
+
*
|
|
17400
|
+
* That difference is the whole reason both exist, and it is stated at the
|
|
17401
|
+
* `/livez` registration in `boot.ts`: "`/health` touches the database, so a
|
|
17402
|
+
* database blip would make an orchestrator kill an otherwise healthy process."
|
|
17403
|
+
*/
|
|
17404
|
+
var RUNTIME_HEALTH_PATH = "/health";
|
|
17405
|
+
var RUNTIME_LIVENESS_PATH = "/livez";
|
|
17406
|
+
/**
|
|
17407
|
+
* Which endpoint each Kubernetes probe must target.
|
|
17408
|
+
*
|
|
17409
|
+
* The rule follows from what the probe *does* when it fails:
|
|
17410
|
+
*
|
|
17411
|
+
* - **liveness** restarts the container. A database outage must therefore never
|
|
17412
|
+
* fail it, or an unreachable database turns into a cluster-wide restart loop
|
|
17413
|
+
* that clears the moment the database returns and looks, in the logs, like
|
|
17414
|
+
* the application crashed. `/livez`.
|
|
17415
|
+
* - **readiness** removes the pod from its Service. That is exactly what should
|
|
17416
|
+
* happen when the database is unreachable — the pod cannot serve. `/health`.
|
|
17417
|
+
* - **startup** decides when the other two begin. Its job is "has this process
|
|
17418
|
+
* finished booting", and booting may include fetching a bundle, installing
|
|
17419
|
+
* dependencies and checking the schema. It must not also require the database
|
|
17420
|
+
* to be *up*, or a cold database is indistinguishable from a broken image:
|
|
17421
|
+
* the pod never passes startup, liveness never gets to run, and the pod
|
|
17422
|
+
* CrashLoops with nothing in its output about a database. `/livez`.
|
|
17423
|
+
*
|
|
17424
|
+
* A probe targeting the wrong one of these fails silently in the direction that
|
|
17425
|
+
* looks like an application bug, which is why this is a contract and not a
|
|
17426
|
+
* default.
|
|
17427
|
+
*/
|
|
17428
|
+
var RUNTIME_PROBE_PATHS = {
|
|
17429
|
+
liveness: RUNTIME_LIVENESS_PATH,
|
|
17430
|
+
readiness: RUNTIME_HEALTH_PATH,
|
|
17431
|
+
startup: RUNTIME_LIVENESS_PATH
|
|
17432
|
+
};
|
|
17433
|
+
/**
|
|
17434
|
+
* Environment variables that decide *topology* — which surfaces a process
|
|
17435
|
+
* mounts, which singletons it owns, where it forwards.
|
|
17436
|
+
*
|
|
17437
|
+
* These are the platform's or the operator's decision expressed by the thing
|
|
17438
|
+
* doing the deploying, and they must never be settable by whoever supplies the
|
|
17439
|
+
* project's own environment. The cloud learned this the expensive way: a tenant
|
|
17440
|
+
* who set `REBASE_ROLE=worker` in their project variables got a pod that served
|
|
17441
|
+
* no HTTP at all, and because `/health` answers on **every** role the readiness
|
|
17442
|
+
* probe passed, the rollout reported success, and every request 404'd.
|
|
17443
|
+
*
|
|
17444
|
+
* Kubernetes only lets `env` shadow `envFrom` for names it **lists**, so
|
|
17445
|
+
* neutralising one of these means naming it explicitly. An empty value is read
|
|
17446
|
+
* by the runtime as unset, which is why the cloud pins most of them to `""`
|
|
17447
|
+
* rather than to a value: the entry exists to take the decision away from the
|
|
17448
|
+
* project, not to make it here.
|
|
17449
|
+
*/
|
|
17450
|
+
var TOPOLOGY_ENV_VARS = [
|
|
17451
|
+
"REBASE_ROLE",
|
|
17452
|
+
"REBASE_FUNCTIONS_ONLY",
|
|
17453
|
+
"REBASE_FUNCTIONS_EXCLUDE",
|
|
17454
|
+
"REBASE_FUNCTIONS_UPSTREAM",
|
|
17455
|
+
"REBASE_CRON_SCHEDULER",
|
|
17456
|
+
"REBASE_JOB_WORKERS",
|
|
17457
|
+
"REBASE_RLS_AUDIT",
|
|
17458
|
+
"REBASE_MIGRATE_ON_BOOT",
|
|
17459
|
+
"TRUSTED_PROXY_HOPS",
|
|
17460
|
+
"REBASE_RATE_LIMIT_STORE",
|
|
17461
|
+
"REBASE_REQUIRE_SCHEMA_MATCH"
|
|
17462
|
+
];
|
|
17463
|
+
/** Whether a variable name is one the deployer owns rather than the project. */
|
|
17464
|
+
function isTopologyEnvVar(name) {
|
|
17465
|
+
return TOPOLOGY_ENV_VARS.includes(name);
|
|
17466
|
+
}
|
|
17467
|
+
/**
|
|
17468
|
+
* Seconds to keep answering after Kubernetes decides to remove this pod.
|
|
17469
|
+
*
|
|
17470
|
+
* `installShutdownHandlers` drains on SIGTERM — it stops the scheduler, tears
|
|
17471
|
+
* down realtime and closes the HTTP server, finishing what is in flight. What
|
|
17472
|
+
* it cannot do is stop *new* requests arriving: kubelet sends SIGTERM and the
|
|
17473
|
+
* endpoint controller removes the pod from its Service **concurrently**, so for
|
|
17474
|
+
* as long as endpoint removal takes to propagate, the ingress is still routing
|
|
17475
|
+
* to a process that has already stopped accepting connections.
|
|
17476
|
+
*
|
|
17477
|
+
* A `preStop` sleep is the only thing that orders those two. The pod keeps
|
|
17478
|
+
* serving for this long *before* SIGTERM is sent, which is time the ingress
|
|
17479
|
+
* uses to stop sending it anything.
|
|
17480
|
+
*
|
|
17481
|
+
* Anything greater than zero fixes the ordering; five seconds is what the
|
|
17482
|
+
* control plane has run in production.
|
|
17483
|
+
*/
|
|
17484
|
+
var RUNTIME_PRESTOP_DRAIN_SECONDS = 5;
|
|
17485
|
+
/**
|
|
17486
|
+
* The floor for `terminationGracePeriodSeconds`.
|
|
17487
|
+
*
|
|
17488
|
+
* The pod must outlive `preStop` *plus* the drain that follows it, or kubelet
|
|
17489
|
+
* SIGKILLs mid-drain and the graceful shutdown was decoration. The drain's own
|
|
17490
|
+
* default is 15s (`ShutdownHandlerOptions.timeoutMs`), and Kubernetes' default
|
|
17491
|
+
* grace period is 30s — so the stock configuration has room, and this exists to
|
|
17492
|
+
* say how much before someone raises the drain.
|
|
17493
|
+
*/
|
|
17494
|
+
var RUNTIME_MIN_TERMINATION_GRACE_SECONDS = 20;
|
|
17495
|
+
/**
|
|
17496
|
+
* How long a deployment must let this runtime take to answer for the first time.
|
|
17497
|
+
*
|
|
17498
|
+
* Nothing answers until boot is finished. `runFromBundle` binds the socket
|
|
17499
|
+
* **last** — after the bundle is read, the drivers connect and, on the process
|
|
17500
|
+
* that owns it, the schema DDL runs (`boot.ts`, "schema DDL happens during
|
|
17501
|
+
* boot, above"). So the gap between the container starting and `/livez`
|
|
17502
|
+
* answering is a whole provisioning run, not a framework warming up.
|
|
17503
|
+
*
|
|
17504
|
+
* A deployment with no startup probe measures that gap with its *liveness*
|
|
17505
|
+
* probe instead, and liveness restarts the container. On the control plane's
|
|
17506
|
+
* settings that is 20s of grace plus three 20s failures — 80 seconds — after
|
|
17507
|
+
* which a slow first boot is killed and retried, and each retry starts from the
|
|
17508
|
+
* beginning. It does not converge; it loops, and the logs show a pod that never
|
|
17509
|
+
* finished starting rather than a database that was slow.
|
|
17510
|
+
*
|
|
17511
|
+
* So a startup probe is not optional here, and its budget is this. A healthy
|
|
17512
|
+
* pod passes it on the first check and nothing is spent.
|
|
17513
|
+
*/
|
|
17514
|
+
var RUNTIME_STARTUP_BUDGET_SECONDS = 300;
|
|
17515
|
+
/** Where a bundle is mounted, and what the runtime is told to read. */
|
|
17516
|
+
var RUNTIME_BUNDLE_MOUNT = "/bundle";
|
|
17517
|
+
//#endregion
|
|
17518
|
+
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, RUNTIME_BUNDLE_MOUNT, RUNTIME_HEALTH_PATH, RUNTIME_LIVENESS_PATH, RUNTIME_MIN_TERMINATION_GRACE_SECONDS, RUNTIME_PRESTOP_DRAIN_SECONDS, RUNTIME_PROBE_PATHS, RUNTIME_STARTUP_BUDGET_SECONDS, RawHtml, S3StorageController, SMTPEmailService, StoragePolicyError, TOPOLOGY_ENV_VARS, TransformCache, TusHandler, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, activeDevEmailSink, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertWriteRequestValid, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, clearActiveDevEmailSink, 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, getEmailOtpTemplate, 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, isTopologyEnvVar, 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, registerDevEmailSink, 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
17519
|
|
|
14327
17520
|
//# sourceMappingURL=index.es.js.map
|