@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.
Files changed (210) hide show
  1. package/dist/{GCSStorageController-BEmDYFKc.js → GCSStorageController-Bl7nmhdv.js} +7 -6
  2. package/dist/{GCSStorageController-BEmDYFKc.js.map → GCSStorageController-Bl7nmhdv.js.map} +1 -1
  3. package/dist/{S3StorageController-B2EDXXMH.js → S3StorageController-CWvvrRpF.js} +7 -6
  4. package/dist/{S3StorageController-B2EDXXMH.js.map → S3StorageController-CWvvrRpF.js.map} +1 -1
  5. package/dist/{admin_block-DLILvzle.js → admin_block-BeypnEfb.js} +6 -6
  6. package/dist/admin_block-BeypnEfb.js.map +1 -0
  7. package/dist/api/ast-schema-editor.d.ts +23 -0
  8. package/dist/api/contract-routes.d.ts +1 -1
  9. package/dist/api/errors.d.ts +1 -1
  10. package/dist/api/index.d.ts +3 -3
  11. package/dist/api/live-schema-routes.d.ts +122 -0
  12. package/dist/api/logs-routes.d.ts +1 -1
  13. package/dist/api/mount.d.ts +41 -0
  14. package/dist/api/rest/api-generator.d.ts +2 -2
  15. package/dist/api/rest/index.d.ts +1 -1
  16. package/dist/api/rest/query-parser.d.ts +1 -1
  17. package/dist/api/schema-editor-routes.d.ts +1 -1
  18. package/dist/api/types.d.ts +12 -4
  19. package/dist/{schema-editor-routes-CV9k0w3G.js → ast-schema-editor-CDGgrIn7.js} +37 -45
  20. package/dist/ast-schema-editor-CDGgrIn7.js.map +1 -0
  21. package/dist/auth/adapter-middleware.d.ts +2 -2
  22. package/dist/auth/admin-roles-route.d.ts +2 -2
  23. package/dist/auth/admin-user-ops.d.ts +3 -3
  24. package/dist/auth/admin-users-route.d.ts +4 -6
  25. package/dist/auth/api-keys/api-key-middleware.d.ts +2 -2
  26. package/dist/auth/api-keys/api-key-permission-guard.d.ts +1 -1
  27. package/dist/auth/api-keys/api-key-routes.d.ts +2 -2
  28. package/dist/auth/api-keys/api-key-store.d.ts +1 -1
  29. package/dist/auth/api-keys/index.d.ts +9 -9
  30. package/dist/auth/apple-oauth.d.ts +2 -2
  31. package/dist/auth/auth-hooks.d.ts +4 -4
  32. package/dist/auth/bitbucket-oauth.d.ts +2 -2
  33. package/dist/auth/builtin-auth-adapter.d.ts +8 -4
  34. package/dist/auth/capabilities.d.ts +51 -0
  35. package/dist/auth/captcha.d.ts +86 -0
  36. package/dist/auth/cookie-utils.d.ts +2 -2
  37. package/dist/auth/discord-oauth.d.ts +2 -2
  38. package/dist/auth/facebook-oauth.d.ts +2 -2
  39. package/dist/auth/github-oauth.d.ts +2 -2
  40. package/dist/auth/gitlab-oauth.d.ts +2 -2
  41. package/dist/auth/google-oauth.d.ts +1 -1
  42. package/dist/auth/index.d.ts +58 -54
  43. package/dist/auth/jwks-routes.d.ts +1 -1
  44. package/dist/auth/jwt.d.ts +1 -1
  45. package/dist/auth/linkedin-oauth.d.ts +2 -2
  46. package/dist/auth/magic-link-routes.d.ts +10 -3
  47. package/dist/auth/mfa-gate.d.ts +1 -1
  48. package/dist/auth/mfa-routes.d.ts +3 -3
  49. package/dist/auth/microsoft-oauth.d.ts +2 -2
  50. package/dist/auth/middleware.d.ts +6 -7
  51. package/dist/auth/otp-routes.d.ts +89 -0
  52. package/dist/auth/rate-limiter.d.ts +2 -2
  53. package/dist/auth/require-auth.d.ts +1 -1
  54. package/dist/auth/reset-password-admin.d.ts +4 -4
  55. package/dist/auth/routes.d.ts +18 -10
  56. package/dist/auth/session-routes.d.ts +3 -3
  57. package/dist/auth/slack-oauth.d.ts +2 -2
  58. package/dist/auth/spotify-oauth.d.ts +2 -2
  59. package/dist/auth/token-revocation.d.ts +2 -2
  60. package/dist/auth/twitter-oauth.d.ts +2 -2
  61. package/dist/{auth-5Et5mnUA.js → auth-DzarZhLS.js} +603 -4376
  62. package/dist/auth-DzarZhLS.js.map +1 -0
  63. package/dist/backup/backup-common.d.ts +1 -1
  64. package/dist/backup/backup-routes.d.ts +3 -3
  65. package/dist/backup/index.d.ts +3 -3
  66. package/dist/{backup-C6ljYVTp.js → backup-CRdZkA6c.js} +7 -6
  67. package/dist/{backup-C6ljYVTp.js.map → backup-CRdZkA6c.js.map} +1 -1
  68. package/dist/boot/bindings.d.ts +81 -0
  69. package/dist/boot/boot.d.ts +5 -5
  70. package/dist/boot/bundle.d.ts +10 -1
  71. package/dist/boot/driver.d.ts +1 -1
  72. package/dist/boot/env.d.ts +28 -1
  73. package/dist/boot/fetch-bundle.d.ts +144 -1
  74. package/dist/boot/local-provisioner.d.ts +30 -0
  75. package/dist/boot/options.d.ts +30 -6
  76. package/dist/boot/resource-adapters.d.ts +31 -0
  77. package/dist/boot/resource-loading.d.ts +10 -0
  78. package/dist/boot/role.d.ts +3 -2
  79. package/dist/boot/sources.d.ts +1 -1
  80. package/dist/boot/version-skew.d.ts +1 -1
  81. package/dist/collections/BackendCollectionRegistry.d.ts +1 -1
  82. package/dist/collections/loader.d.ts +1 -1
  83. package/dist/{contract-routes-DZ-LBpSL.js → contract-routes-DDSRyGSl.js} +8 -7
  84. package/dist/contract-routes-DDSRyGSl.js.map +1 -0
  85. package/dist/cron/cron-routes.d.ts +2 -2
  86. package/dist/cron/cron-scheduler.d.ts +3 -3
  87. package/dist/cron/index.d.ts +8 -8
  88. package/dist/{cron-loader-YhhQeVBM.js → cron-loader-d9WMFENB.js} +8 -7
  89. package/dist/{cron-loader-YhhQeVBM.js.map → cron-loader-d9WMFENB.js.map} +1 -1
  90. package/dist/{cron-routes-maM_RlUu.js → cron-routes-B_GKclr5.js} +9 -8
  91. package/dist/cron-routes-B_GKclr5.js.map +1 -0
  92. package/dist/{cron-scheduler-DIpYBmZP.js → cron-scheduler-BB82dWuU.js} +9 -9
  93. package/dist/cron-scheduler-BB82dWuU.js.map +1 -0
  94. package/dist/{cron-store-DfH_4Cd9.js → cron-store-BTZqwkM1.js} +10 -9
  95. package/dist/{cron-store-DfH_4Cd9.js.map → cron-store-BTZqwkM1.js.map} +1 -1
  96. package/dist/{ddl-bootstrap-Cywoj8Ta.js → ddl-bootstrap-euwN8FOy.js} +21 -6
  97. package/dist/ddl-bootstrap-euwN8FOy.js.map +1 -0
  98. package/dist/deploy/pod-contract.d.ts +139 -0
  99. package/dist/dev-secrets.d.ts +52 -0
  100. package/dist/{dynamic-import-Dvh-K5fl.js → dynamic-import-X40pTZUQ.js} +5 -4
  101. package/dist/{dynamic-import-Dvh-K5fl.js.map → dynamic-import-X40pTZUQ.js.map} +1 -1
  102. package/dist/email/dev-sink.d.ts +88 -0
  103. package/dist/email/index.d.ts +9 -7
  104. package/dist/email/link-base.d.ts +1 -1
  105. package/dist/email/smtp-email-service.d.ts +2 -2
  106. package/dist/email/templates.d.ts +12 -0
  107. package/dist/email/types.d.ts +18 -6
  108. package/dist/env.d.ts +1 -1
  109. package/dist/{errors-EBYiaJ2E.js → errors-DBwpj9N8.js} +8 -7
  110. package/dist/errors-DBwpj9N8.js.map +1 -0
  111. package/dist/{function-loader-DDS1v7YX.js → function-loader-LLdmBFoL.js} +9 -8
  112. package/dist/{function-loader-DDS1v7YX.js.map → function-loader-LLdmBFoL.js.map} +1 -1
  113. package/dist/{function-routes-Btcez1T-.js → function-routes-ClT6UQpD.js} +8 -7
  114. package/dist/function-routes-ClT6UQpD.js.map +1 -0
  115. package/dist/functions/context.d.ts +141 -0
  116. package/dist/functions/define-function.d.ts +1 -1
  117. package/dist/functions/function-routes.d.ts +9 -3
  118. package/dist/functions/guards.d.ts +76 -0
  119. package/dist/functions/index.d.ts +96 -5
  120. package/dist/functions/index.js +919 -0
  121. package/dist/functions/index.js.map +1 -0
  122. package/dist/functions/internal.d.ts +26 -0
  123. package/dist/functions/proxy.d.ts +1 -1
  124. package/dist/functions/request-timeout.d.ts +9 -1
  125. package/dist/functions/runtime-env.d.ts +92 -0
  126. package/dist/functions/wait-until.d.ts +74 -0
  127. package/dist/history/history-routes.d.ts +2 -2
  128. package/dist/history/index.d.ts +1 -1
  129. package/dist/index.d.ts +65 -54
  130. package/dist/index.es.js +3498 -305
  131. package/dist/index.es.js.map +1 -1
  132. package/dist/init/docs.d.ts +1 -1
  133. package/dist/init/middlewares.d.ts +1 -1
  134. package/dist/init/shutdown.d.ts +4 -0
  135. package/dist/init/storage.d.ts +1 -1
  136. package/dist/init/surfaces.d.ts +10 -0
  137. package/dist/init.d.ts +171 -22
  138. package/dist/jobs/index.d.ts +5 -5
  139. package/dist/{jobs-CyOKXXlu.js → jobs-CeZzdeR8.js} +10 -9
  140. package/dist/{jobs-CyOKXXlu.js.map → jobs-CeZzdeR8.js.map} +1 -1
  141. package/dist/{jwt-DxH9fLPt.js → jwt-CpMqxVct.js} +9 -8
  142. package/dist/{jwt-DxH9fLPt.js.map → jwt-CpMqxVct.js.map} +1 -1
  143. package/dist/{logger-DfvF_8r-.js → logger-DS03e908.js} +104 -10
  144. package/dist/logger-DS03e908.js.map +1 -0
  145. package/dist/{logs-routes-CWBLQj2l.js → logs-routes-DB72iQSr.js} +36 -9
  146. package/dist/logs-routes-DB72iQSr.js.map +1 -0
  147. package/dist/metrics/index.d.ts +1 -1
  148. package/dist/{openapi-generator-BCKJRUS4.js → openapi-generator-wFJo22gg.js} +7 -11
  149. package/dist/{openapi-generator-BCKJRUS4.js.map → openapi-generator-wFJo22gg.js.map} +1 -1
  150. package/dist/{proxy-Bj5DVllb.js → proxy-CMymhnwG.js} +9 -6
  151. package/dist/{proxy-Bj5DVllb.js.map → proxy-CMymhnwG.js.map} +1 -1
  152. package/dist/query-parser-C3hHu17g.js +355 -0
  153. package/dist/query-parser-C3hHu17g.js.map +1 -0
  154. package/dist/{request-timeout-BuFoEKwT.js → request-timeout-C8gkc-j7.js} +21 -6
  155. package/dist/request-timeout-C8gkc-j7.js.map +1 -0
  156. package/dist/rls-audit/index.d.ts +111 -0
  157. package/dist/{rolldown-runtime-DSJWtz9O.js → rolldown-runtime-dW7B1o5h.js} +4 -3
  158. package/dist/schema-edit/apply-schema-change.d.ts +141 -0
  159. package/dist/schema-edit/github-repository.d.ts +67 -0
  160. package/dist/schema-edit/local-git-repository.d.ts +28 -0
  161. package/dist/schema-edit/project-root.d.ts +43 -0
  162. package/dist/schema-edit/remote-source.d.ts +25 -0
  163. package/dist/schema-edit/schema-edit-permissions.d.ts +129 -0
  164. package/dist/schema-editor-routes-CRKMZnXu.js +87 -0
  165. package/dist/schema-editor-routes-CRKMZnXu.js.map +1 -0
  166. package/dist/schemas-DBxgjM9A.js +4153 -0
  167. package/dist/schemas-DBxgjM9A.js.map +1 -0
  168. package/dist/{selection-_z6TM1DB.js → selection-CRpqKUbt.js} +6 -5
  169. package/dist/{selection-_z6TM1DB.js.map → selection-CRpqKUbt.js.map} +1 -1
  170. package/dist/services/webhook-service.d.ts +1 -1
  171. package/dist/singleton.d.ts +7 -0
  172. package/dist/{src-CrCxd8km.js → src-6VqGNeDB.js} +122 -5
  173. package/dist/src-6VqGNeDB.js.map +1 -0
  174. package/dist/{src-BPfYOeN4.js → src-C5vFEj-t.js} +8 -6
  175. package/dist/src-C5vFEj-t.js.map +1 -0
  176. package/dist/storage/GCSStorageController.d.ts +1 -1
  177. package/dist/storage/LocalStorageController.d.ts +1 -1
  178. package/dist/storage/S3StorageController.d.ts +1 -1
  179. package/dist/storage/cache-headers.d.ts +87 -0
  180. package/dist/storage/index.d.ts +11 -11
  181. package/dist/storage/path-pattern.d.ts +58 -0
  182. package/dist/storage/policies.d.ts +88 -0
  183. package/dist/storage/range.d.ts +63 -0
  184. package/dist/storage/rendition-cache.d.ts +45 -0
  185. package/dist/storage/routes.d.ts +17 -5
  186. package/dist/storage/storage-registry.d.ts +1 -1
  187. package/dist/storage/triggers.d.ts +66 -0
  188. package/dist/storage/tus-handler.d.ts +34 -3
  189. package/dist/topics/runtime.d.ts +68 -0
  190. package/dist/{types-DSnOC4mF.js → types-BfKcm9do.js} +5 -4
  191. package/dist/{types-DSnOC4mF.js.map → types-BfKcm9do.js.map} +1 -1
  192. package/dist/utils/host.d.ts +58 -0
  193. package/dist/utils/logger.d.ts +0 -15
  194. package/dist/utils/request-id.d.ts +1 -1
  195. package/functions/package.json +24 -0
  196. package/package.json +13 -7
  197. package/dist/admin_block-DLILvzle.js.map +0 -1
  198. package/dist/auth-5Et5mnUA.js.map +0 -1
  199. package/dist/contract-routes-DZ-LBpSL.js.map +0 -1
  200. package/dist/cron-routes-maM_RlUu.js.map +0 -1
  201. package/dist/cron-scheduler-DIpYBmZP.js.map +0 -1
  202. package/dist/ddl-bootstrap-Cywoj8Ta.js.map +0 -1
  203. package/dist/errors-EBYiaJ2E.js.map +0 -1
  204. package/dist/function-routes-Btcez1T-.js.map +0 -1
  205. package/dist/logger-DfvF_8r-.js.map +0 -1
  206. package/dist/logs-routes-CWBLQj2l.js.map +0 -1
  207. package/dist/request-timeout-BuFoEKwT.js.map +0 -1
  208. package/dist/schema-editor-routes-CV9k0w3G.js.map +0 -1
  209. package/dist/src-BPfYOeN4.js.map +0 -1
  210. package/dist/src-CrCxd8km.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -1,46 +1,56 @@
1
- import { createRequire as __createRequire } from "module";
2
- import process from "process";
3
- __createRequire(import.meta.url);
4
- import { A as GeoPoint, M as RebaseApiError, N as RebaseClientError, O as EntityReference, S as toSnakeCase, _ as fieldKeyForColumn, b as buildCompositeId, c as collectAllPages, d as normalizeOrderBy, f as serializeOrderBy, g as getEffectiveSecurityRules, h as resolveDataSource, j as Vector, k as EntityRelation, l as paginateFind, m as createDataSourceRegistry, n as buildSdkData, o as serializeFilter, p as CollectionRegistry, s as serializeLogicalCondition, t as buildRoutedRebaseData, u as resolveFindWindow, v as findRelation, x as resolvePrimaryKeys, y as resolveCollectionRelations } from "./src-BPfYOeN4.js";
5
- import { c as DEFAULT_DATA_SOURCE_KEY, g as toCanonicalOp, i as getCollectionDataPath, m as parseRelationAggregateSort, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-CrCxd8km.js";
6
- import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-DLILvzle.js";
7
- import { a as isDuplicateObjectRace, i as isConcurrentDdlRace, n as createDdlBootstrapper, o as revokeInternalTableSql, s as isSQLAdmin, t as CONCURRENT_DDL_SQLSTATES } from "./ddl-bootstrap-Cywoj8Ta.js";
8
- import { C as canonicalStorageKey, D as findStorageSuffixCollision, E as DEFAULT_STORAGE_SOURCE_KEY, O as normalizeStorageSources, S as canonicalStorageId, b as InvalidStorageKeyError, c as getJwks, d as hasAsymmetricSigningKey, i as generateDownloadToken, k as storageEnvSuffix, n as configureJwt, p as isJwtConfigured, v as normalizePemFromEnv, x as canonicalStorageBucket, y as InvalidStorageBucketError } from "./jwt-DxH9fLPt.js";
9
- import { n as createContractRoutes, r as computeSchemaVersion } from "./contract-routes-DZ-LBpSL.js";
10
- import { $ as createAdapterAuthMiddleware, A as _coercedNumber, B as validatePasswordStrength, C as providerVerifiedEmail, Ct as parseQueryOptions, D as _enum, E as ZodNumber, Et as isPublicStoragePath, F as createEmailService, G as getPasswordResetTemplate, H as generateSecurePassword, I as assertEmailLinkBases, J as resolveEmailBranding, K as getUserInvitationTemplate, L as resolveEmailLinkBase, M as createDataRateLimiter, N as MemoryRateLimitStore, O as object, P as SMTPEmailService, Q as raw, R as resolveAuthHooks, S as pkceTokenParams, St as parseGroupBy, T as createBuiltinAuthAdapter, Tt as PUBLIC_STORAGE_PREFIX, U as getEmailVerificationTemplate, V as verifyPassword, W as getMagicLinkTemplate, X as escapeHtml, Y as RawHtml, Z as html, _ as verifyOidcIdToken, _t as scopeDataDriver, a as resolveRateLimitStoreKind, at as publicObjectAuth, b as createGoogleProvider, bt as orderByEntriesToTuples, c as createSlackProvider, ct as requireAuth, d as createDiscordProvider, dt as createStorageApiKeyGuard, et as createAuthMiddleware, f as createTwitterProvider, ft as isApiKeyToken, g as tryVerifyOidcIdToken, gt as SERVICE_IDENTITY, h as createMicrosoftProvider, ht as safeCompare, i as createApiKeyStore, it as optionalAuth, j as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, k as string, l as createBitbucketProvider, lt as createApiKeyPreAuth, m as createAppleProvider, mt as extractBearerToken, n as createCustomAuthAdapter, nt as extractUserFromToken, o as createSqlRateLimitStore, ot as queryTokenAuth, p as createFacebookProvider, pt as validateApiKey, q as getWelcomeEmailTemplate, r as createApiKeyRoutes, rt as fileTokenAuth, s as createSpotifyProvider, st as requireAdmin, tt as createRequireAuth, u as createGitLabProvider, ut as createFunctionApiKeyGuard, v as createGitHubProvider, vt as httpMethodToOperation, w as createJwksRoutes, wt as resolveListLimitParam, x as oauthCodeFlowSchema, xt as parseAggregateSelect, y as createLinkedinProvider, yt as isOperationAllowed, z as hashPassword } from "./auth-5Et5mnUA.js";
11
- import { t as logger } from "./logger-DfvF_8r-.js";
12
- import { n as errorHandler, t as ApiError } from "./errors-EBYiaJ2E.js";
13
- import { t as logMiddleware } from "./logs-routes-CWBLQj2l.js";
14
- import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-DSnOC4mF.js";
15
- import { t as S3StorageController } from "./S3StorageController-B2EDXXMH.js";
16
- import { t as GCSStorageController } from "./GCSStorageController-BEmDYFKc.js";
17
- import { n as loadFunctionsFromDirectory } from "./function-loader-DDS1v7YX.js";
18
- import { t as createFunctionRoutes } from "./function-routes-Btcez1T-.js";
19
- import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-YhhQeVBM.js";
20
- import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-DIpYBmZP.js";
21
- import { t as createCronRoutes } from "./cron-routes-maM_RlUu.js";
22
- import { t as createCronStore } from "./cron-store-DfH_4Cd9.js";
23
- import { a as parseBackupTimestamp, i as parseBackupDestination, n as createBackupRoutes, o as readBackupBytes, r as listBackupObjects } from "./backup-C6ljYVTp.js";
24
- import { i as createJobStore, n as createJobQueue, r as defaultBackoff } from "./jobs-CyOKXXlu.js";
25
- import { t as FunctionSelectionError } from "./selection-_z6TM1DB.js";
26
- import { createHash, randomBytes, randomUUID } from "node:crypto";
27
- import * as fs$2 from "fs";
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$2 from "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 { execFile } from "node:child_process";
53
- import { promisify as promisify$1 } from "node:util";
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
- /** The keys each property `type` adds on top of {@link BASE_PROPERTY_KEYS}. */
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 = { editable: { fix: "`editable` was removed in 0.10 — collections are editable by default. Delete it, or use `admin.disableDefaultActions` to take actions away" } };
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$2.join(directory, name);
468
- if (!fs$2.existsSync(indexPath)) continue;
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$2.resolve(source);
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$2.existsSync(resolved)) {
548
+ if (!fs$3.existsSync(resolved)) {
511
549
  logger.warn(`[collections] Not found: ${resolved}`);
512
550
  return [];
513
551
  }
514
- if (!fs$2.statSync(resolved).isDirectory()) {
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$2.readdirSync(resolved).filter(isCollectionFile)) try {
521
- const mod = await importModule(path$2.join(resolved, file));
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 stays mounted on the same handler rather than being changed or
2219
- * removed: every published SDK sends it, and altering its semantics to
2220
- * be a true replace would silently start nulling columns that callers
2221
- * had simply omitted for years — a data-loss change disguised as a
2222
- * standards fix. So PATCH is the honest name, PUT is the compatible one,
2223
- * and both do the documented thing.
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$2.mkdir);
2636
- var writeFile$1 = promisify(fs$2.writeFile);
2637
- var readFile = promisify(fs$2.readFile);
2638
- var unlink$1 = promisify(fs$2.unlink);
2639
- var readdir = promisify(fs$2.readdir);
2640
- var stat = promisify(fs$2.stat);
2641
- var access = promisify(fs$2.access);
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$2.resolve(config.basePath);
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$2.resolve(this.basePath, bucket || "default");
2703
- if (!bucketPath.startsWith(this.basePath + path$2.sep)) throw new Error("Path traversal detected: bucket resolves outside the storage root.");
2704
- const resolved = path$2.resolve(bucketPath, storagePath);
2705
- if (!resolved.startsWith(bucketPath + path$2.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
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$2.dirname(fullPath));
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$2.constants.R_OK);
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$2.basename(resolvedPath),
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$2.basename(resolvedPath),
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$2.constants.R_OK);
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$2.basename(resolvedPath), { type: contentType });
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$2.constants.F_OK);
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$2.promises.rmdir(fullPath);
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$2.constants.R_OK);
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
- /** Shared image transform cache (LRU, 500 entries, 1 hour TTL). */
3557
- var transformCache = new TransformCache();
3558
- /**
3559
- * Transforms currently being computed, keyed the same way the cache is.
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 = transformCache.get(cacheKey);
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 = transformsInFlight.get(cacheKey);
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
- transformCache.set(cacheKey, result.data, result.contentType);
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
- transformsInFlight.set(cacheKey, pending);
4119
+ memory.inFlight.set(cacheKey, pending);
3589
4120
  try {
3590
4121
  return await pending;
3591
4122
  } finally {
3592
- transformsInFlight.delete(cacheKey);
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
- return canonicalStorageKey(key);
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.access(absolutePath);
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 transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fsp.readFile(absolutePath)));
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 fileContent = await fsp.readFile(absolutePath);
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 transformed = await transformOnce(transformCache.buildKey(transformKeyPrefix, transformOpts), transformOpts, async () => Buffer.from(await fileObject.arrayBuffer()));
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
- c.header("Cache-Control", "public, max-age=3600, immutable");
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-B2EDXXMH.js").then((n) => n.n);
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-BEmDYFKc.js").then((n) => n.n);
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-BCKJRUS4.js");
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/init/surfaces.ts
4358
- /** Every surface, in a stable order. */
4359
- var ALL_RUNTIME_SURFACES = [
4360
- "auth",
4361
- "data",
4362
- "storage",
4363
- "admin",
4364
- "functions",
4365
- "cron",
4366
- "meta",
4367
- "realtime"
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 `client` 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.";
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
- * Still `PUT`, deliberately, even though the server now serves `PATCH`
5865
- * on the same handler and `PATCH` is the honest verb for a merge.
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
- * `PATCH` is what the OpenAPI spec advertises, so anyone generating a
5875
- * client gets the correct verb; this stays on `PUT` until the oldest
5876
- * supported server is one that serves both.
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: "PUT",
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
- return globalThis[INSTANCE_SLOT] ?? null;
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 (process.env.NODE_ENV !== "test") throw new Error("_setRebaseMock can only be called in a test environment (NODE_ENV=test).");
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 (process.env.NODE_ENV !== "test") throw new Error("_resetRebaseMock can only be called in a test environment.");
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(config.dataSources);
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 }, (config.dataSources ?? []).map((source) => ({
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-5Et5mnUA.js").then((n) => n.t))[factory];
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-CV9k0w3G.js")).createSchemaEditorRoutes(config.collectionsDir);
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.app.route(`${basePath}/schema-editor`, schemaEditorRouter);
10992
- if (schemaEditorRoutes) logger.debug("Schema Editor mounted", { path: `${basePath}/schema-editor` });
10993
- else logger.debug("Schema Editor unavailable", {
10994
- path: `${basePath}/schema-editor`,
10995
- code: schemaEditorOff.code
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: config.storageSources,
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: config.storageAuthorize,
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-DDS1v7YX.js").then((n) => n.t);
11151
- const { createFunctionRoutes } = await import("./function-routes-Btcez1T-.js").then((n) => n.n);
11152
- const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-BuFoEKwT.js");
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-_z6TM1DB.js").then((n) => n.n);
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-Bj5DVllb.js");
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-YhhQeVBM.js").then((n) => n.t);
11206
- const { CronScheduler } = await import("./cron-scheduler-DIpYBmZP.js").then((n) => n.n);
11207
- const { createCronRoutes } = await import("./cron-routes-maM_RlUu.js").then((n) => n.n);
11208
- const { createCronStore } = await import("./cron-store-DfH_4Cd9.js").then((n) => n.n);
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.route(`${basePath}/cron`, cronRouter);
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-CyOKXXlu.js").then((n) => n.t);
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 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.");
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-C6ljYVTp.js").then((n) => n.t);
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 { default: logsRoutes } = await import("./logs-routes-CWBLQj2l.js").then((n) => n.n);
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.route(`${basePath}/logs`, logsRouter);
11269
- logger.debug("Logs routes mounted", { path: `${basePath}/logs` });
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-DZ-LBpSL.js").then((n) => n.t);
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/define-function.ts
13830
+ //#region src/functions/runtime-env.ts
11350
13831
  /**
11351
- * Typed authoring contract for a custom backend function.
13832
+ * Every environment variable visible to this request.
11352
13833
  *
11353
- * A custom function is a file in the `functionsDir` that default-exports a
11354
- * Hono app; the loader mounts it at `/<filename>`. `defineFunction` is the
11355
- * typed opt-in for that contract: it hands you a pre-typed `Hono<HonoEnv>`
11356
- * app (so `c.var.user` / `c.var.driver` are typed) plus a
11357
- * {@link RebaseFunctionContext}, and returns exactly the Hono app the loader
11358
- * already accepts — so it is fully interchangeable with a plain
11359
- * `export default new Hono()`.
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 { defineFunction, requireAuth } from "@rebasepro/server";
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
- * export default defineFunction((app, { rebase }) => {
11366
- * app.use("/*", requireAuth);
11367
- * app.get("/home", async (c) => {
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
- * @param definition Receives the function's Hono app and the typed context.
11378
- * Register routes on the provided `app` and return nothing, or return your
11379
- * own `Hono<HonoEnv>` app to use instead.
11380
- * @returns The Hono app to default-export from the function file.
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 defineFunction(definition) {
11383
- const app = new Hono();
11384
- const returned = definition(app, { rebase });
11385
- return returned instanceof Hono ? returned : app;
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
- if (!process.env.JWT_SECRET) {
11590
- process.env.JWT_SECRET = generateSecret();
11591
- autoGeneratedSecrets.push("JWT_SECRET");
11592
- }
11593
- if (!process.env.REBASE_SERVICE_KEY) {
11594
- process.env.REBASE_SERVICE_KEY = generateSecret();
11595
- autoGeneratedSecrets.push("REBASE_SERVICE_KEY");
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$2.existsSync(frontendPath)) {
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$2.relative(process.cwd(), frontendPath),
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$2.join(frontendPath, indexFile);
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
- * Build the email configuration, or `undefined` when no SMTP host is set.
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
- * Without it the auth adapter still works; password-reset and verification mail
13510
- * simply has nowhere to go, which the auth routes report for themselves.
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) return void 0;
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, for platforms with no init container.
13824
- *
13825
- * A bundle normally arrives on disk before the process starts: `rebase build`
13826
- * writes one, a container image carries one, and on Kubernetes an init container
13827
- * fetches one into a shared volume before the runtime container runs. All three
13828
- * mean `runFromBundle` can assume the files are already there.
13829
- *
13830
- * Serverless platforms have no init container. Cloud Run starts one container
13831
- * and nothing else, so the choice is between baking a per-tenant image — a build
13832
- * on every deploy and an image per tenant to garbage-collect — or fetching at
13833
- * boot. This is the second.
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* cold
13838
- * start: a scale-from-zero, an instance recycled after an hour idle, a new
13839
- * revision. That is also why the platform's bundle URL is deliberately a stable
13840
- * endpoint rather than a signed expiring one — an instance starting for the
13841
- * first time in three days needs the same URL to work.
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
- async function fetchBundle(options) {
13887
- const fetchImpl = options.fetchImpl ?? fetch;
13888
- const extract = options.extract ?? extractWithTar;
13889
- const destination = options.destination ?? fs$1.mkdtempSync(path$1.join(os.tmpdir(), "rebase-bundle-"));
13890
- fs$1.mkdirSync(destination, { recursive: true });
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(`Could not download the bundle from ${options.url}: ${response.status} ${response.statusText}`);
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
- const body = Buffer.from(await response.arrayBuffer());
13907
- if (body.length === 0) throw new Error(`The bundle at ${options.url} is empty.`);
13908
- fs$1.writeFileSync(tarball, body);
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 (${body.length} bytes): ` + (error instanceof Error ? error.message : String(error)));
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 rebase-bundle.json. It is not a Rebase bundle, or it was truncated.`);
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, "rebase-bundle.json"))) return 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, "rebase-bundle.json"))) return 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
- const dataSourceDefs = configExports.dataSources;
13977
- const declaredStorage = normalizeStorageSources(bundle.manifest.storage?.sources, configExports.storageSources);
13978
- const storageSourceDefs = declaredStorage.length > 0 ? declaredStorage : void 0;
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 noopBackend = { shutdown: async () => {} };
17230
+ const staticBackend = { shutdown: async () => {
17231
+ await new Promise((resolve) => {
17232
+ server.close(() => resolve());
17233
+ });
17234
+ } };
14196
17235
  if (options.handleSignals !== false) {
14197
- installShutdownHandlers(noopBackend, { onCleanup: async () => {
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: noopBackend,
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
- export { ApiError, BlockedUrlError, BundleError, CONCURRENT_DDL_SQLSTATES, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, RawHtml, S3StorageController, SMTPEmailService, TransformCache, TusHandler, UnknownStorageSourceError, WEBHOOK_DELIVERY_TASK, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, assertWriteRequestValid, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDdlBootstrapper, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createJobQueue, createJobStore, createJwksRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defaultBackoff, defineCron, defineFunction, envSuffixForKey, errorHandler, escapeHtml, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getJwks, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hasAsymmetricSigningKey, hashPassword, html, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isConcurrentDdlRace, isDatabaseAdapter, isDuplicateObjectRace, isLocalhostOrigin, isOperationAllowed, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadCronJobsWithDiagnostics, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, oauthCodeFlowSchema, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, pkceTokenParams, providerVerifiedEmail, queryTokenAuth, raw, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailBranding, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword };
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