@rebasepro/server 0.14.0 → 0.14.1

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 (58) hide show
  1. package/dist/api/rest/query-parser.d.ts +37 -1
  2. package/dist/api/rest/write-validation.d.ts +26 -0
  3. package/dist/auth/index.d.ts +3 -1
  4. package/dist/auth/interfaces.d.ts +14 -1
  5. package/dist/auth/jwks-routes.d.ts +17 -0
  6. package/dist/auth/jwt-keys.d.ts +108 -0
  7. package/dist/auth/jwt.d.ts +62 -2
  8. package/dist/{auth-CYoPVf-E.js → auth-BobZVd0j.js} +142 -167
  9. package/dist/auth-BobZVd0j.js.map +1 -0
  10. package/dist/boot/boot.d.ts +36 -50
  11. package/dist/boot/ddl-bootstrap.d.ts +15 -0
  12. package/dist/boot/env.d.ts +20 -0
  13. package/dist/boot/provision.d.ts +182 -0
  14. package/dist/boot/role.d.ts +88 -0
  15. package/dist/{cron-store-Dvr4Y1sZ.js → cron-store-CB1x-Ken.js} +3 -3
  16. package/dist/{cron-store-Dvr4Y1sZ.js.map → cron-store-CB1x-Ken.js.map} +1 -1
  17. package/dist/{ddl-bootstrap-BhXbTnBl.js → ddl-bootstrap-Cywoj8Ta.js} +40 -2
  18. package/dist/ddl-bootstrap-Cywoj8Ta.js.map +1 -0
  19. package/dist/env.d.ts +2 -0
  20. package/dist/functions/proxy.d.ts +41 -0
  21. package/dist/functions/selection.d.ts +45 -0
  22. package/dist/index.d.ts +8 -3
  23. package/dist/index.es.js +1113 -661
  24. package/dist/index.es.js.map +1 -1
  25. package/dist/init/shutdown.d.ts +4 -0
  26. package/dist/init/surfaces.d.ts +79 -0
  27. package/dist/init.d.ts +121 -1
  28. package/dist/jobs/index.d.ts +5 -0
  29. package/dist/jobs/job-queue.d.ts +14 -0
  30. package/dist/jobs/job-store.d.ts +22 -0
  31. package/dist/jobs/types.d.ts +125 -0
  32. package/dist/jobs-DR4SjGrD.js +326 -0
  33. package/dist/jobs-DR4SjGrD.js.map +1 -0
  34. package/dist/{jwt-_IFqfTOg.js → jwt-VJyXTdQQ.js} +447 -11
  35. package/dist/jwt-VJyXTdQQ.js.map +1 -0
  36. package/dist/{openapi-generator-DPKtUC9X.js → openapi-generator-DQeQ_q2f.js} +68 -3
  37. package/dist/openapi-generator-DQeQ_q2f.js.map +1 -0
  38. package/dist/proxy-Bj5DVllb.js +139 -0
  39. package/dist/proxy-Bj5DVllb.js.map +1 -0
  40. package/dist/{request-timeout-RivJsME0.js → request-timeout-BuFoEKwT.js} +6 -3
  41. package/dist/request-timeout-BuFoEKwT.js.map +1 -0
  42. package/dist/selection-_z6TM1DB.js +64 -0
  43. package/dist/selection-_z6TM1DB.js.map +1 -0
  44. package/dist/services/webhook-service.d.ts +43 -5
  45. package/dist/{src-C7rkDGxA.js → src-8XDWyDfR.js} +84 -13
  46. package/dist/src-8XDWyDfR.js.map +1 -0
  47. package/dist/src-Cz9nMgUR.js.map +1 -1
  48. package/dist/storage/keys.d.ts +17 -0
  49. package/dist/storage/routes.d.ts +1 -1
  50. package/dist/storage/storage-registry.d.ts +46 -4
  51. package/dist/storage/tus-handler.d.ts +1 -1
  52. package/package.json +5 -5
  53. package/dist/auth-CYoPVf-E.js.map +0 -1
  54. package/dist/ddl-bootstrap-BhXbTnBl.js.map +0 -1
  55. package/dist/jwt-_IFqfTOg.js.map +0 -1
  56. package/dist/openapi-generator-DPKtUC9X.js.map +0 -1
  57. package/dist/request-timeout-RivJsME0.js.map +0 -1
  58. package/dist/src-C7rkDGxA.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
- import { A as RebaseApiError, D as EntityRelation, E as EntityReference, O as GeoPoint, _ as resolveCollectionRelations, b as toSnakeCase, c as collectAllPages, d as CollectionRegistry, f as createDataSourceRegistry, g as findRelation, h as fieldKeyForColumn, j as RebaseClientError, k as Vector, l as paginateFind, m as getEffectiveSecurityRules, n as buildSdkData, o as serializeFilter, p as resolveDataSource, s as serializeLogicalCondition, t as buildRoutedRebaseData, u as resolveFindWindow, v as buildCompositeId, y as resolvePrimaryKeys } from "./src-C7rkDGxA.js";
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-8XDWyDfR.js";
5
5
  import { c as DEFAULT_DATA_SOURCE_KEY, i as getCollectionDataPath, m as toCanonicalOp, n as RLS_ROLES_SQL, o as isPostgresCollectionConfig, r as RLS_UID_SQL, t as RLS_JWT_SQL } from "./src-Cz9nMgUR.js";
6
6
  import { n as ADMIN_PROPERTY_KEYS, t as ADMIN_COLLECTION_KEYS } from "./admin_block-H86dCPsj.js";
7
- import { i as isSQLAdmin, r as revokeInternalTableSql } from "./ddl-bootstrap-BhXbTnBl.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-VJyXTdQQ.js";
8
9
  import { n as createContractRoutes } from "./contract-routes-Bet-eCNJ.js";
9
- import { $ as fileTokenAuth, A as MemoryRateLimitStore, B as getEmailVerificationTemplate, C as ZodNumber, Ct as isPublicStoragePath, D as _coercedNumber, E as string, F as resolveAuthHooks, G as RawHtml, H as getPasswordResetTemplate, I as hashPassword, J as raw, K as escapeHtml, L as validatePasswordStrength, M as createEmailService, N as assertEmailLinkBases, O as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, P as resolveEmailLinkBase, Q as extractUserFromToken, R as verifyPassword, S as createBuiltinAuthAdapter, St as PUBLIC_STORAGE_PREFIX, T as object, U as getUserInvitationTemplate, V as getMagicLinkTemplate, W as getWelcomeEmailTemplate, X as createAuthMiddleware, Y as createAdapterAuthMiddleware, Z as createRequireAuth, _ as createLinkedinProvider, _t as scopeDataDriver, a as createSpotifyProvider, at as InvalidStorageBucketError, b as pkceTokenParams, bt as parseQueryOptions, c as createGitLabProvider, ct as canonicalStorageKey, d as createFacebookProvider, dt as createStorageApiKeyGuard, et as optionalAuth, f as createAppleProvider, ft as isApiKeyToken, g as createGitHubProvider, gt as SERVICE_IDENTITY, h as verifyOidcIdToken, ht as safeCompare, i as createApiKeyStore, it as requireAuth, j as SMTPEmailService, k as createDataRateLimiter, l as createDiscordProvider, lt as createApiKeyPreAuth, m as tryVerifyOidcIdToken, mt as extractBearerToken, n as createCustomAuthAdapter, nt as queryTokenAuth, o as createSlackProvider, ot as InvalidStorageKeyError, p as createMicrosoftProvider, pt as validateApiKey, q as html, r as createApiKeyRoutes, rt as requireAdmin, s as createBitbucketProvider, st as canonicalStorageBucket, tt as publicObjectAuth, u as createTwitterProvider, ut as createFunctionApiKeyGuard, v as createGoogleProvider, vt as httpMethodToOperation, w as _enum, x as providerVerifiedEmail, xt as resolveListLimitParam, y as oauthCodeFlowSchema, yt as isOperationAllowed, z as generateSecurePassword } from "./auth-CYoPVf-E.js";
10
+ import { $ as extractUserFromToken, A as createDataRateLimiter, B as generateSecurePassword, C as createBuiltinAuthAdapter, Ct as isPublicStoragePath, D as string, E as object, F as resolveEmailLinkBase, G as getWelcomeEmailTemplate, H as getMagicLinkTemplate, I as resolveAuthHooks, J as html, K as RawHtml, L as hashPassword, M as SMTPEmailService, N as createEmailService, O as _coercedNumber, P as assertEmailLinkBases, Q as createRequireAuth, R as validatePasswordStrength, S as createJwksRoutes, St as PUBLIC_STORAGE_PREFIX, T as _enum, U as getPasswordResetTemplate, V as getEmailVerificationTemplate, W as getUserInvitationTemplate, X as createAdapterAuthMiddleware, Y as raw, Z as createAuthMiddleware, _ as createLinkedinProvider, _t as orderByEntriesToTuples, a as createSpotifyProvider, at as requireAuth, b as pkceTokenParams, bt as parseQueryOptions, c as createGitLabProvider, ct as createStorageApiKeyGuard, d as createFacebookProvider, dt as extractBearerToken, et as fileTokenAuth, f as createAppleProvider, ft as safeCompare, g as createGitHubProvider, gt as isOperationAllowed, h as verifyOidcIdToken, ht as httpMethodToOperation, i as createApiKeyStore, it as requireAdmin, j as MemoryRateLimitStore, k as DEFAULT_FUNCTIONS_ANONYMOUS_LIMIT, l as createDiscordProvider, lt as isApiKeyToken, m as tryVerifyOidcIdToken, mt as scopeDataDriver, n as createCustomAuthAdapter, nt as publicObjectAuth, o as createSlackProvider, ot as createApiKeyPreAuth, p as createMicrosoftProvider, pt as SERVICE_IDENTITY, q as escapeHtml, r as createApiKeyRoutes, rt as queryTokenAuth, s as createBitbucketProvider, st as createFunctionApiKeyGuard, tt as optionalAuth, u as createTwitterProvider, ut as validateApiKey, v as createGoogleProvider, vt as parseAggregateSelect, w as ZodNumber, x as providerVerifiedEmail, xt as resolveListLimitParam, y as oauthCodeFlowSchema, yt as parseGroupBy, z as verifyPassword } from "./auth-BobZVd0j.js";
10
11
  import { t as logger } from "./logger-DfvF_8r-.js";
11
12
  import { n as errorHandler, t as ApiError } from "./errors-EBYiaJ2E.js";
12
- import { i as generateDownloadToken, n as configureJwt } from "./jwt-_IFqfTOg.js";
13
13
  import { t as logMiddleware } from "./logs-routes-BYA72C_C.js";
14
14
  import { n as DOCUMENT_MIME_TYPES, r as IMAGE_MIME_TYPES, t as DEFAULT_MAX_FILE_SIZE } from "./types-DSnOC4mF.js";
15
15
  import { t as S3StorageController } from "./S3StorageController-B2EDXXMH.js";
@@ -19,8 +19,10 @@ import { t as createFunctionRoutes } from "./function-routes-Btcez1T-.js";
19
19
  import { n as loadCronJobsFromDirectory, r as loadCronJobsWithDiagnostics } from "./cron-loader-YhhQeVBM.js";
20
20
  import { r as validateCronExpression, t as CronScheduler } from "./cron-scheduler-DIpYBmZP.js";
21
21
  import { t as createCronRoutes } from "./cron-routes-maM_RlUu.js";
22
- import { t as createCronStore } from "./cron-store-Dvr4Y1sZ.js";
22
+ import { t as createCronStore } from "./cron-store-CB1x-Ken.js";
23
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-DR4SjGrD.js";
25
+ import { t as FunctionSelectionError } from "./selection-_z6TM1DB.js";
24
26
  import { createHash, randomBytes, randomUUID } from "node:crypto";
25
27
  import * as fs$2 from "fs";
26
28
  import fs, { existsSync } from "fs";
@@ -49,153 +51,6 @@ import { secureHeaders } from "hono/secure-headers";
49
51
  import { getRequestListener } from "@hono/node-server";
50
52
  import { execFile } from "node:child_process";
51
53
  import { promisify as promisify$1 } from "node:util";
52
- //#region ../types/src/types/storage_source.ts
53
- /**
54
- * Describes a named storage backend — a place files live.
55
- *
56
- * Declared once and shared front + back: the frontend uses it to decide
57
- * transport (HTTP proxy vs direct SDK), the backend uses the same `key`
58
- * to resolve a StorageController, and collection properties reference
59
- * a definition by its `key` via `StorageConfig.storageSource`.
60
- *
61
- * This mirrors the {@link DataSourceDefinition} pattern used for databases.
62
- *
63
- * @group Models
64
- */
65
- /**
66
- * The default storage source key, used when a property does not specify
67
- * a `storageSource`. Shared by the frontend and backend registries so
68
- * both agree on "the default storage backend".
69
- * @group Models
70
- */
71
- var DEFAULT_STORAGE_SOURCE_KEY = "(default)";
72
- /**
73
- * The environment-variable suffix for a storage or data source key.
74
- *
75
- * `""` for the default source — so a single-bucket project keeps configuring
76
- * plain `S3_BUCKET` — and `__<KEY>` for every named one, uppercased with
77
- * non-alphanumerics collapsed to underscores: `media-cdn` → `S3_BUCKET__MEDIA_CDN`.
78
- *
79
- * The rule derives the variable name from the declared key rather than
80
- * discovering keys by scanning the environment. Scanning would have to guess how
81
- * `S3_BUCKET__MEDIA_CDN` splits into a key; deriving cannot be ambiguous, and a
82
- * typo surfaces as a missing source at boot instead of a silently ignored
83
- * variable.
84
- *
85
- * It lives in this package, with no dependencies, because four things must agree
86
- * on it exactly: the CLI (validating a build), the runtime (reading its own
87
- * environment), the control plane (writing a tenant's Secret), and the docs. A
88
- * second implementation of a naming convention is a second chance to disagree.
89
- *
90
- * @group Models
91
- */
92
- function storageEnvSuffix(key, defaultKey = DEFAULT_STORAGE_SOURCE_KEY) {
93
- if (!key || key === defaultKey) return "";
94
- const normalized = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
95
- if (!normalized) throw new Error(`Source key "${key}" cannot be turned into an environment variable name. Use a key containing at least one letter or digit.`);
96
- return `__${normalized}`;
97
- }
98
- /**
99
- * Two distinct keys that collapse onto the same variable name, or `null`.
100
- *
101
- * `media-cdn` and `media_cdn` are different source keys but the same suffix, so
102
- * without this one of them silently reads the other's configuration. Returns the
103
- * offending pair rather than throwing, so each caller can raise it in its own
104
- * idiom — a `BundleError` at boot, a build failure in the CLI, a rejected deploy
105
- * in a control plane.
106
- *
107
- * @group Models
108
- */
109
- function findStorageSuffixCollision(keys, defaultKey = DEFAULT_STORAGE_SOURCE_KEY) {
110
- const seen = /* @__PURE__ */ new Map();
111
- for (const key of keys) {
112
- const suffix = storageEnvSuffix(key, defaultKey);
113
- const existing = seen.get(suffix);
114
- if (existing !== void 0 && existing !== key) return {
115
- a: existing,
116
- b: key,
117
- suffix
118
- };
119
- seen.set(suffix, key);
120
- }
121
- return null;
122
- }
123
- /**
124
- * Merge the two places a project may declare storage sources into one list.
125
- *
126
- * `rebase.json` is authoritative for every field it states. Config code may add
127
- * sources it does not mention and fill in fields it left out, but may not
128
- * contradict it: the manifest is what a host reads to decide which buckets need
129
- * configuring, and a runtime that quietly disagreed with it would put the
130
- * console back to describing a topology the tenant does not have — the exact
131
- * failure this whole mechanism exists to end.
132
- *
133
- * Note what is *not* here: no default source is invented when both inputs are
134
- * empty. That decision belongs to the resolver, which knows whether declaring
135
- * nothing means "one plain bucket" (it does) or "no storage at all".
136
- *
137
- * @group Models
138
- */
139
- function normalizeStorageSources(declared, exported) {
140
- const merged = /* @__PURE__ */ new Map();
141
- const declaredEntries = Array.isArray(declared) ? declared.filter((d) => d?.key).map((d) => [d.key, d]) : Object.entries(declared ?? {});
142
- for (const [key, config] of declaredEntries) merged.set(key, {
143
- key,
144
- engine: config.engine,
145
- transport: config.transport ?? "server",
146
- ...config.label !== void 0 ? { label: config.label } : {}
147
- });
148
- for (const definition of exported ?? []) {
149
- if (!definition?.key) continue;
150
- const existing = merged.get(definition.key);
151
- if (!existing) {
152
- merged.set(definition.key, {
153
- key: definition.key,
154
- engine: definition.engine,
155
- transport: definition.transport ?? "server",
156
- ...definition.label !== void 0 ? { label: definition.label } : {}
157
- });
158
- continue;
159
- }
160
- if (existing.label === void 0 && definition.label !== void 0) existing.label = definition.label;
161
- }
162
- return Array.from(merged.values());
163
- }
164
- //#endregion
165
- //#region ../common/src/data/sort-dialect.ts
166
- /**
167
- * Sort-order wire codec.
168
- *
169
- * This is the ONLY module that knows about the colon-delimited wire format
170
- * (`"field:direction"`) used in HTTP query parameters.
171
- * Everything else speaks {@link OrderByTuple} exclusively.
172
- *
173
- * Mirrors the filter architecture in `filter-dialect.ts`.
174
- *
175
- * @module
176
- */
177
- /**
178
- * Serialize an {@link OrderByTuple} to the wire format `"field:direction"`.
179
- *
180
- * **Runtime tolerance:** if the input is already a well-formed wire string
181
- * (from an untyped JS caller), it is returned unchanged.
182
- * This is undocumented tolerance, not public API — don't rely on it.
183
- *
184
- * @param orderBy - A canonical `[field, direction]` tuple, or at runtime
185
- * possibly a pre-serialized string (undocumented tolerance).
186
- * @returns The wire-format string, or `undefined` if the input is falsy.
187
- *
188
- * @remarks
189
- * Field names containing `:` are representable in the tuple form but
190
- * **not** on the wire — this is an inherent limitation of the colon-delimited
191
- * encoding and is not resolved here.
192
- */
193
- function serializeOrderBy(orderBy) {
194
- if (!orderBy) return void 0;
195
- if (typeof orderBy === "string") return orderBy;
196
- return `${orderBy[0]}:${orderBy[1]}`;
197
- }
198
- //#endregion
199
54
  //#region src/collections/BackendCollectionRegistry.ts
200
55
  /**
201
56
  * Backend-agnostic collection registry.
@@ -673,6 +528,344 @@ async function loadCollectionsFromDirectory(source, options = {}) {
673
528
  return validate(applyCollectionDefaults(collections, await readDefaults(resolved)));
674
529
  }
675
530
  //#endregion
531
+ //#region src/boot/version-skew.ts
532
+ /**
533
+ * Telling a *stale* driver apart from a driver that never had the feature.
534
+ *
535
+ * The managed runtime is two halves that version independently. The image
536
+ * supplies `@rebasepro/server` — `docker/entrypoint.mjs` symlinks it over
537
+ * whatever the bundle installed, so the boot harness is always the image's. The
538
+ * **driver** is not redirected: `@rebasepro/server-postgres` comes from the
539
+ * bundle's `deps.declared`, pinned to whatever the project's `package.json`
540
+ * asked npm for. Shipping a new image therefore does not ship a new driver, and
541
+ * a fix that spans both packages reaches a tenant only after an npm publish AND
542
+ * a rebuild.
543
+ *
544
+ * That split has a nasty failure mode, and it has already burned a day. Boot
545
+ * looks for an optional capability, does not find it, and reports the honest
546
+ * local fact — "this driver does not implement collection-table creation" —
547
+ * which is exactly what a schemaless driver looks like. But a driver that is
548
+ * merely *older than the runtime asking* looks identical, and the two want
549
+ * opposite responses: one is "this database is not managed by Rebase, carry on",
550
+ * the other is "your tables were never created and every data route is about to
551
+ * 500". Naming the versions is what separates them, so these helpers exist to
552
+ * put both numbers in front of whoever reads the log.
553
+ */
554
+ /** The package whose version defines "the runtime" for skew purposes. */
555
+ var RUNTIME_PACKAGE = "@rebasepro/server";
556
+ /**
557
+ * Locate a package by walking `node_modules` up from a directory.
558
+ *
559
+ * Lives here rather than in `driver.ts` because both the driver loader and the
560
+ * version check need it, and `driver.ts` already imports this module.
561
+ */
562
+ function findPackageDir(fromDir, packageName) {
563
+ let dir = path.resolve(fromDir);
564
+ for (;;) {
565
+ const candidate = path.join(dir, "node_modules", ...packageName.split("/"));
566
+ if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
567
+ const parent = path.dirname(dir);
568
+ if (parent === dir) return void 0;
569
+ dir = parent;
570
+ }
571
+ }
572
+ /**
573
+ * Parse the subset of semver the workspace actually publishes.
574
+ *
575
+ * Deliberately lenient: an unparseable version yields `undefined` and every
576
+ * comparison then declines to judge, because a wrong "your driver is old"
577
+ * warning on a fork's custom version string is worse than no warning at all.
578
+ */
579
+ function parseVersion(version) {
580
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(version.trim());
581
+ if (!match) return void 0;
582
+ return {
583
+ parts: [
584
+ Number(match[1]),
585
+ Number(match[2]),
586
+ Number(match[3])
587
+ ],
588
+ prerelease: match[4]
589
+ };
590
+ }
591
+ /**
592
+ * Compare two versions, or `undefined` when either cannot be parsed.
593
+ *
594
+ * Follows semver on the one rule that matters here: a prerelease sorts *below*
595
+ * the release it leads to, so `0.12.1-canary.g7f1150f` < `0.12.1`. Without that
596
+ * rule every canary would read as newer than the stable it precedes, and the
597
+ * canaries are precisely where fixes land first.
598
+ */
599
+ function compareVersions(a, b) {
600
+ const left = parseVersion(a);
601
+ const right = parseVersion(b);
602
+ if (!left || !right) return void 0;
603
+ for (let i = 0; i < 3; i++) if (left.parts[i] !== right.parts[i]) return left.parts[i] < right.parts[i] ? -1 : 1;
604
+ if (left.prerelease === right.prerelease) return 0;
605
+ if (left.prerelease === void 0) return 1;
606
+ if (right.prerelease === void 0) return -1;
607
+ return left.prerelease < right.prerelease ? -1 : 1;
608
+ }
609
+ /**
610
+ * Describe how a driver's version relates to the runtime asking it for work.
611
+ *
612
+ * Returns `stale: false` whenever it cannot tell — an unknown version, an
613
+ * unparseable one, a driver that is newer. Silence beats a confident wrong
614
+ * diagnosis, and the caller still prints its own local fact either way.
615
+ */
616
+ function describeDriverSkew(driverVersion, runtimeVersion) {
617
+ if (!driverVersion || !runtimeVersion) return { stale: false };
618
+ const order = compareVersions(driverVersion, runtimeVersion);
619
+ if (order === void 0) return { stale: false };
620
+ if (order < 0) return {
621
+ stale: true,
622
+ detail: `The driver is at ${driverVersion} while this runtime is ${runtimeVersion} — the driver is OLDER, so this is very likely a stale pin rather than a driver that never had the capability.`
623
+ };
624
+ return {
625
+ stale: false,
626
+ detail: `Driver ${driverVersion}, runtime ${runtimeVersion}.`
627
+ };
628
+ }
629
+ /**
630
+ * How to get a project's schema applied, written once and owned by the runtime.
631
+ *
632
+ * This text lives in `@rebasepro/server` on purpose. The equivalent guidance in
633
+ * the Postgres driver's schema-drift warning can only ever be as current as the
634
+ * driver a tenant pinned — so the tenants who most need to be told "your driver
635
+ * is too old" are exactly the ones whose driver still prints the old advice.
636
+ * The image's copy of this package is never stale, so guidance printed from
637
+ * here always reflects the platform as it is today.
638
+ *
639
+ * Both audiences get a line because the runtime cannot reliably tell which one
640
+ * it is serving, and naming only the pnpm scripts — as this once did everywhere
641
+ * — tells a managed operator to run a command that structurally cannot reach
642
+ * their in-cluster database.
643
+ */
644
+ function schemaRecoveryGuidance(options = {}) {
645
+ const lines = [
646
+ " To apply this project's schema:",
647
+ " • Managed cloud: the runtime applies tables and RLS at boot (unless",
648
+ " REBASE_MIGRATE_ON_BOOT=none). `rebase db push` cannot reach a tenant's",
649
+ " in-cluster database — redeploy instead."
650
+ ];
651
+ if (options.staleDriver) lines.push(" Because the driver is pinned by your bundle, bump the", " `@rebasepro/server-postgres` version in your project's package.json", " and redeploy — a newer platform image alone will NOT update it.");
652
+ lines.push(" • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod).");
653
+ return lines.join("\n");
654
+ }
655
+ /**
656
+ * The installed version of a package, read from its `package.json`.
657
+ *
658
+ * Returns `undefined` rather than throwing for every failure — a missing or
659
+ * malformed manifest degrades the diagnosis, and must never take down a boot
660
+ * that would otherwise have served.
661
+ */
662
+ function readPackageVersion(packageDir) {
663
+ try {
664
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
665
+ return typeof pkg.version === "string" ? pkg.version : void 0;
666
+ } catch {
667
+ return;
668
+ }
669
+ }
670
+ /**
671
+ * The version of `@rebasepro/server` this deployment is actually running.
672
+ *
673
+ * Resolved from the same roots the drivers are, and that is the point rather
674
+ * than a convenience. In a managed container `docker/entrypoint.mjs` replaces
675
+ * `/bundle/node_modules/@rebasepro/server` with a link to the image's copy, so
676
+ * reading the manifest *through the bundle* reports the version that will
677
+ * really execute — which is exactly the number the driver has to be compared
678
+ * against. Reading this package's own manifest by module path would report the
679
+ * same thing in the happy case and quietly lie in the case that matters: a
680
+ * bundle whose link did not happen.
681
+ *
682
+ * Returns `undefined` when the package cannot be found; every caller treats
683
+ * that as "cannot judge" and stays quiet.
684
+ */
685
+ function readRuntimeVersion(roots) {
686
+ for (const root of roots) {
687
+ const dir = findPackageDir(root, RUNTIME_PACKAGE);
688
+ const version = dir ? readPackageVersion(dir) : void 0;
689
+ if (version) return version;
690
+ }
691
+ }
692
+ //#endregion
693
+ //#region src/boot/provision.ts
694
+ /**
695
+ * Name the object that is missing a method, in a way that points somewhere.
696
+ *
697
+ * What is missing is the method on the ADAPTER — the only object this module
698
+ * ever sees — which is not the same as the driver package lacking the code. So
699
+ * a resolved package name is quoted as the adapter's *origin*, and an adapter
700
+ * the application built itself is described as exactly that, with no package
701
+ * name attached for the reader to go and bump.
702
+ */
703
+ function describeAdapter(target) {
704
+ return target.driverPackage ? `the adapter from "${target.driverPackage}" (engine "${target.engine}")` : `this project's "${target.engine}" adapter`;
705
+ }
706
+ function provisioningDisabled(env = process.env) {
707
+ return (env.REBASE_MIGRATE_ON_BOOT || "ensure") === "none";
708
+ }
709
+ /**
710
+ * Create any collection tables the database is missing, before serving.
711
+ *
712
+ * Additive only: the driver may create missing tables, columns and enum types,
713
+ * and may never drop or rewrite. Destructive changes stay a deliberate
714
+ * migration, because this runs unattended with nobody reading a diff.
715
+ *
716
+ * Every path out of here says why, at info or louder. Guaranteeing the tables
717
+ * exist is this function's entire job, so "it declined, and said nothing" is the
718
+ * one outcome it must never produce: a deployment that skips comes up answering
719
+ * sign-in and 500ing every `/api/data/*` route, and the operator's only evidence
720
+ * is what these lines print.
721
+ *
722
+ * Note that silence is still possible one level up — if nothing CALLS this, no
723
+ * line is printed and the absence is the only signal. That is precisely the bug
724
+ * this module was extracted to make structurally impossible, and it is why the
725
+ * outcome is reported back to the driver rather than only logged.
726
+ *
727
+ * Failure is fatal on purpose. Booting anyway would produce exactly the state
728
+ * this exists to prevent, and a crash-looping pod with the DDL error in its logs
729
+ * is a far better signal than a running one that silently cannot serve.
730
+ */
731
+ async function provisionCollectionTables(collections, target, options = {}) {
732
+ const skip = (reason, level = "info") => {
733
+ logger[level](`Collection schema: skipped — ${reason}`);
734
+ return {
735
+ status: "skipped",
736
+ reason
737
+ };
738
+ };
739
+ if (options.provision === false) return skip("this process does not provision the schema — another process in this deployment owns it.");
740
+ if (provisioningDisabled(options.env ?? process.env)) return skip("REBASE_MIGRATE_ON_BOOT=none, leaving the database schema untouched.");
741
+ if (options.introspecting) return skip("this project declares no collections, so they are read from the database rather than from code.");
742
+ if (collections.length === 0) return skip("no collections resolved for this data source.");
743
+ if (!target.bootstrapper.ensureCollectionSchema) return skip(`${describeAdapter(target)} does not expose collection-table creation. The driver package may well implement it on a class the adapter does not forward, so check the adapter's shape before blaming its version. Collection tables will NOT be created, so every /api/data route will fail on a missing relation.
744
+ ` + schemaRecoveryGuidance(), "warn");
745
+ const { applied } = await target.bootstrapper.ensureCollectionSchema(collections, target.driverResult, (message) => logger.debug(`schema: ${message}`));
746
+ if (applied > 0) logger.info(`Applied ${applied} additive schema change(s) before boot.`);
747
+ else logger.debug("Collection schema is up to date.");
748
+ return {
749
+ status: "applied",
750
+ applied
751
+ };
752
+ }
753
+ /**
754
+ * Apply the collections' RLS policies — the companion to
755
+ * {@link provisionCollectionTables}, which creates the tables this makes
756
+ * servable.
757
+ *
758
+ * Runs after auth is initialized, not alongside table creation: the generated
759
+ * policies call the `auth.*` helper functions and `CREATE POLICY` validates
760
+ * those exist. Tables without policies are not servable either — authenticated
761
+ * requests run as a restricted role, so a read with no policy returns nothing.
762
+ *
763
+ * The benign gates return quietly here rather than logging the same reason
764
+ * twice: table provisioning already ran on this same boot and explained itself.
765
+ * The one thing this does say out loud is a driver that created tables but
766
+ * cannot apply policies — that is the difference between a served collection and
767
+ * a 401, and it must not pass in silence.
768
+ */
769
+ async function provisionCollectionPolicies(collections, target, options = {}) {
770
+ if (options.provision === false) {
771
+ logger.info("Collection RLS policies: not applied by this process — another process in this deployment owns them.");
772
+ return {
773
+ status: "skipped",
774
+ reason: "another process provisions"
775
+ };
776
+ }
777
+ if (provisioningDisabled(options.env ?? process.env)) return {
778
+ status: "skipped",
779
+ reason: "REBASE_MIGRATE_ON_BOOT=none"
780
+ };
781
+ if (options.introspecting) return {
782
+ status: "skipped",
783
+ reason: "collections are introspected"
784
+ };
785
+ if (collections.length === 0) return {
786
+ status: "skipped",
787
+ reason: "no collections"
788
+ };
789
+ if (!target.bootstrapper.ensureCollectionPolicies) {
790
+ const reason = `${describeAdapter(target)} does not apply RLS policies at boot`;
791
+ logger.warn(`Collection policies: skipped — ${reason}. Collections are not row-secured until policies are applied — depending on the driver's grants that means reads are denied or that they are unfiltered. Do not serve traffic until this is resolved.
792
+ ` + schemaRecoveryGuidance());
793
+ return {
794
+ status: "skipped",
795
+ reason
796
+ };
797
+ }
798
+ const { applied } = await target.bootstrapper.ensureCollectionPolicies(collections, target.driverResult, (message) => logger.debug(`policies: ${message}`));
799
+ if (applied > 0) logger.info(`Applied ${applied} RLS policy statement(s) before serving.`);
800
+ else logger.debug("RLS policies are up to date.");
801
+ return {
802
+ status: "applied",
803
+ applied
804
+ };
805
+ }
806
+ /**
807
+ * The collections a data source's engine is the store for.
808
+ *
809
+ * A project's collections directory holds *every* collection it declares,
810
+ * whatever engine serves it — that is the point of `dataSource` routing. What it
811
+ * is not is a list of tables to create: handing the whole directory to the
812
+ * primary source's bootstrapper made a Firestore collection declared alongside
813
+ * the Postgres ones arrive as an empty Postgres table, with RLS policies, while
814
+ * the app went on reading its documents from Firestore.
815
+ *
816
+ * Excluding is deliberately conservative. A collection that names neither an
817
+ * `engine` nor a `dataSource` belongs to whichever source is primary — the
818
+ * "postgres" that `resolveDataSource` falls back to there is a default, not a
819
+ * declaration, and must not exclude anything on its own. Only a collection that
820
+ * explicitly routes to a *different* engine is dropped, so a project running two
821
+ * sources on the same engine is unaffected.
822
+ *
823
+ * Takes the shapes it actually reads rather than an `InitializedDataSource`, so
824
+ * the path that has data sources and the path that has only an adapter can share
825
+ * one implementation instead of growing a second one that drifts.
826
+ */
827
+ function collectionsStoredBy(collections, primary, dataSources) {
828
+ const registry = createDataSourceRegistry(dataSources.map((source) => ({
829
+ key: source.key,
830
+ engine: source.engine
831
+ })));
832
+ return collections.filter((collection) => {
833
+ const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
834
+ if (!declared) return true;
835
+ return declared === primary.engine;
836
+ });
837
+ }
838
+ /** Say what was routed elsewhere, so a missing table is never a silent one. */
839
+ function logForeignCollections(all, stored, primary) {
840
+ if (stored.length === all.length) return;
841
+ const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
842
+ logger.info(`Skipping ${foreign.length} collection(s) served by another engine, not "${primary.engine}": ${foreign.join(", ")}. Their storage is not managed by this data source.`);
843
+ }
844
+ /**
845
+ * Which bootstrapper provisions, and what to call it in a message.
846
+ *
847
+ * The default source is the one that stores this project's collections, so it
848
+ * is the one asked to create them; `dataSources[0]` is the bundle path's
849
+ * equivalent of the same choice.
850
+ *
851
+ * `driverResult` is deliberately left undefined here. The hooks run BEFORE
852
+ * `initializeDriver`, so there is no driver result to pass, and unlike the
853
+ * bundle path — where the coordinator opens the connection itself — an app that
854
+ * built its own adapter never handed the framework a connection handle. The
855
+ * adapter has one: it was constructed with it. So the contract is that an
856
+ * adapter falls back to its own connection when no result is supplied, which is
857
+ * the only shape that works for both paths.
858
+ */
859
+ function provisionTargetFor(bootstrappers, adapter, driverResult) {
860
+ const primary = bootstrappers.find((b) => b.isDefault) ?? bootstrappers[0];
861
+ return {
862
+ driverPackage: void 0,
863
+ engine: primary?.type ?? adapter?.type ?? "unknown",
864
+ bootstrapper: primary ?? {},
865
+ driverResult
866
+ };
867
+ }
868
+ //#endregion
676
869
  //#region src/services/driver-registry.ts
677
870
  /**
678
871
  * The default driver identifier used when:
@@ -1083,6 +1276,35 @@ function projectResponseFields(rows, fields, collection, options) {
1083
1276
  return projected;
1084
1277
  });
1085
1278
  }
1279
+ /**
1280
+ * Both write checks, as one call, for a transport that is not the REST router.
1281
+ *
1282
+ * The REST routes run `assertKnownWriteFields` and `assertWriteValuesValid`
1283
+ * back to back on the caller's body, seven times over — and they were the only
1284
+ * place either ran. The WebSocket `SAVE` handler took a client payload straight
1285
+ * to `driver.save`, so the same write arrived validated through one door and
1286
+ * unvalidated through the other: `PATCH /api/data/users/1 { age: 999 }` was a
1287
+ * 400, and the socket wrote it.
1288
+ *
1289
+ * Exported for the sockets in `@rebasepro/server-postgres` and
1290
+ * `@rebasepro/server-mongo`, which are the other request boundaries. Not the
1291
+ * route builder — `index.ts` keeps that internal, and this is a rule rather
1292
+ * than wiring.
1293
+ *
1294
+ * It stays at the boundary rather than moving into the driver deliberately: it
1295
+ * validates what a *caller sent*, before `beforeSave` gets a chance to fill in
1296
+ * or rewrite anything. In-process writes through `rebase.data` are trusted
1297
+ * server code and are not run through it, which is the placement the REST layer
1298
+ * already chose.
1299
+ *
1300
+ * @param values the caller's payload, exactly as it arrived
1301
+ * @param collection resolved from the registry by path — never the copy the
1302
+ * client sent, or the rules would be the caller's to pick
1303
+ */
1304
+ function assertWriteRequestValid(values, collection) {
1305
+ assertKnownWriteFields(values, collection);
1306
+ assertWriteValuesValid(values, collection);
1307
+ }
1086
1308
  //#endregion
1087
1309
  //#region src/api/rest/idempotency.ts
1088
1310
  /**
@@ -1482,6 +1704,25 @@ var RestApiGenerator = class {
1482
1704
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
1483
1705
  return c.json({ count: total });
1484
1706
  });
1707
+ this.router.get(`${basePath}/aggregate`, async (c) => {
1708
+ this.enforceApiKeyPermission(c, collection.slug);
1709
+ const queryDict = c.req.queries();
1710
+ const queryOptions = this.parseQuery(queryDict);
1711
+ const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
1712
+ const aggregates = parseAggregateSelect(queryDict.select);
1713
+ if (!aggregates) throw ApiError.badRequest("`select` is required, e.g. `?select=count()` or `?select=sum(total)&groupBy=status`.", "MISSING_AGGREGATE_SELECT");
1714
+ const fetchService = this.getScopedDriver(c).restFetchService;
1715
+ if (!fetchService?.aggregate) throw new ApiError(501, "AGGREGATE_NOT_SUPPORTED", "Aggregates are not implemented for this backend's data driver.");
1716
+ const data = await fetchService.aggregate(collection.slug, {
1717
+ aggregates,
1718
+ groupBy: parseGroupBy(queryDict.groupBy),
1719
+ filter: queryOptions.where,
1720
+ logical: queryOptions.logical,
1721
+ searchString,
1722
+ limit: queryOptions.limit
1723
+ });
1724
+ return c.json({ data });
1725
+ });
1485
1726
  this.router.get(basePath, async (c) => {
1486
1727
  this.enforceApiKeyPermission(c, collection.slug);
1487
1728
  const queryDict = c.req.queries();
@@ -1495,12 +1736,11 @@ var RestApiGenerator = class {
1495
1736
  logical: queryOptions.logical,
1496
1737
  limit: queryOptions.limit,
1497
1738
  offset: queryOptions.offset,
1498
- orderBy: queryOptions.orderBy?.[0]?.field,
1499
- order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
1739
+ orderBy: orderByEntriesToTuples(queryOptions.orderBy),
1500
1740
  searchString,
1501
1741
  searchExplain,
1502
1742
  vectorSearch: queryOptions.vectorSearch
1503
- }, queryOptions.include) : await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString);
1743
+ }, queryOptions.include) : await this.fetchRawCollection(driver, resolvedCollection, queryOptions, searchString, searchExplain);
1504
1744
  const total = await this.countRawEntities(driver, resolvedCollection, queryOptions, searchString);
1505
1745
  return c.json({
1506
1746
  data: projectResponseFields(entities, queryOptions.fields, resolvedCollection, { include: queryOptions.include }),
@@ -1829,7 +2069,8 @@ var RestApiGenerator = class {
1829
2069
  path: parsed.collectionPath,
1830
2070
  filter: queryOptions.where,
1831
2071
  logical: queryOptions.logical,
1832
- searchString
2072
+ searchString,
2073
+ vectorSearch: queryOptions.vectorSearch
1833
2074
  }) : 0;
1834
2075
  return c.json({ count: total });
1835
2076
  } else if (parsed.id) {
@@ -1853,9 +2094,9 @@ var RestApiGenerator = class {
1853
2094
  logical: queryOptions.logical,
1854
2095
  limit: queryOptions.limit,
1855
2096
  offset: queryOptions.offset,
1856
- orderBy: queryOptions.orderBy?.[0]?.field,
1857
- order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
1858
- searchString
2097
+ orderBy: orderByEntriesToTuples(queryOptions.orderBy),
2098
+ searchString,
2099
+ vectorSearch: queryOptions.vectorSearch
1859
2100
  };
1860
2101
  const entities = fetchService ? await fetchService.fetchCollectionForRest(parsed.collectionPath, listOptions, queryOptions.include) : await driver.fetchCollection({
1861
2102
  path: parsed.collectionPath,
@@ -1865,7 +2106,8 @@ var RestApiGenerator = class {
1865
2106
  path: parsed.collectionPath,
1866
2107
  filter: queryOptions.where,
1867
2108
  logical: queryOptions.logical,
1868
- searchString
2109
+ searchString,
2110
+ vectorSearch: queryOptions.vectorSearch
1869
2111
  }) : entities.length;
1870
2112
  const listCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
1871
2113
  return c.json({
@@ -1960,17 +2202,17 @@ var RestApiGenerator = class {
1960
2202
  /**
1961
2203
  * Fetch raw collection data without Entity wrapper (fallback for non-Postgres)
1962
2204
  */
1963
- async fetchRawCollection(driver, collection, queryOptions, searchString) {
2205
+ async fetchRawCollection(driver, collection, queryOptions, searchString, searchExplain) {
1964
2206
  return await driver.fetchCollection({
1965
2207
  path: getCollectionDataPath(collection),
1966
2208
  collection,
1967
2209
  filter: queryOptions.where,
1968
2210
  logical: queryOptions.logical,
1969
2211
  limit: queryOptions.limit,
1970
- orderBy: queryOptions.orderBy?.[0]?.field,
1971
- order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
1972
- startAfter: queryOptions.offset ? String(queryOptions.offset) : void 0,
2212
+ orderBy: orderByEntriesToTuples(queryOptions.orderBy),
2213
+ offset: queryOptions.offset,
1973
2214
  searchString,
2215
+ searchExplain,
1974
2216
  vectorSearch: queryOptions.vectorSearch
1975
2217
  });
1976
2218
  }
@@ -1983,7 +2225,8 @@ var RestApiGenerator = class {
1983
2225
  collection,
1984
2226
  filter: queryOptions.where,
1985
2227
  logical: queryOptions.logical,
1986
- searchString
2228
+ searchString,
2229
+ vectorSearch: queryOptions.vectorSearch
1987
2230
  }) : 0;
1988
2231
  }
1989
2232
  /**
@@ -2410,34 +2653,157 @@ var LocalStorageController = class {
2410
2653
  else items.push(ref);
2411
2654
  count++;
2412
2655
  }
2413
- return {
2414
- items,
2415
- prefixes,
2416
- nextPageToken: scanned < entries.length ? String(scanned) : void 0
2417
- };
2418
- } catch (error) {
2419
- const code = error?.code;
2420
- if (code === "ENOENT" || code === "ENOTDIR") return {
2421
- items: [],
2422
- prefixes: []
2423
- };
2424
- throw error;
2656
+ return {
2657
+ items,
2658
+ prefixes,
2659
+ nextPageToken: scanned < entries.length ? String(scanned) : void 0
2660
+ };
2661
+ } catch (error) {
2662
+ const code = error?.code;
2663
+ if (code === "ENOENT" || code === "ENOTDIR") return {
2664
+ items: [],
2665
+ prefixes: []
2666
+ };
2667
+ throw error;
2668
+ }
2669
+ }
2670
+ /**
2671
+ * Get the absolute filesystem path for serving files
2672
+ * Used by the storage routes to serve files directly
2673
+ */
2674
+ getAbsolutePath(key, bucket) {
2675
+ return this.getFullPath(key, bucket);
2676
+ }
2677
+ /**
2678
+ * Get the base path for the storage
2679
+ */
2680
+ getBasePath() {
2681
+ return this.basePath;
2682
+ }
2683
+ };
2684
+ //#endregion
2685
+ //#region src/storage/storage-registry.ts
2686
+ /**
2687
+ * Storage Registry
2688
+ *
2689
+ * Manages multiple storage controllers for Rebase backend.
2690
+ * Allows different storage backends for different use cases.
2691
+ *
2692
+ * Usage:
2693
+ * - Single storage: Pass a single StorageController → maps to "(default)"
2694
+ * - Multiple storages: Pass a map of { storageId: StorageController }
2695
+ * - String properties use `storageId` in their config to specify which storage to use
2696
+ * - Properties without `storageId` fallback to "(default)"
2697
+ */
2698
+ /**
2699
+ * The default storage identifier used when:
2700
+ * - A single storage controller is provided (not a map)
2701
+ * - A property doesn't specify a storageId
2702
+ *
2703
+ * An alias rather than a second `"(default)"` literal: the same string is the
2704
+ * registry key here, the source key a project declares in `rebase.json`, and
2705
+ * the scope baked into a download token. Two literals that must stay equal are
2706
+ * a divergence waiting to happen — and this one would silently unscope tokens.
2707
+ */
2708
+ var DEFAULT_STORAGE_ID = DEFAULT_STORAGE_SOURCE_KEY;
2709
+ /**
2710
+ * A request named a storage source that is not registered.
2711
+ *
2712
+ * This used to be a `logger.warn` and a fallback to the default source, which
2713
+ * is the wrong answer in a way that is hard to notice: the caller asks for
2714
+ * `media`, the `storageAuthorize` hook is asked about `media`, and the bytes
2715
+ * are read from — or written to — `(default)`. A hook that widens access for
2716
+ * one named source therefore widens it for the default one, and the object the
2717
+ * hook approved is not the object the request touched. That is the same shape
2718
+ * as the non-canonical-key bypass: the check and the read disagree about which
2719
+ * object is in play.
2720
+ *
2721
+ * The failure is silent by construction. Both sources hold the *same key*,
2722
+ * because that is what makes a caller reach for a second source in the first
2723
+ * place, so the fallback returns plausible bytes rather than an error.
2724
+ *
2725
+ * It is also reachable without an attacker: a source declared in `rebase.json`
2726
+ * but not configured by the environment is skipped at boot and never
2727
+ * registered, while `GET /sources` still advertises it. The client asks for a
2728
+ * source it was told exists and silently gets a different bucket's contents.
2729
+ *
2730
+ * Carries `knownKeys` so the route layer can tell a typo from a source that was
2731
+ * declared but never configured — two different answers to the caller. The
2732
+ * registry itself does not know what was *declared*, only what was registered,
2733
+ * so it does not make that decision.
2734
+ */
2735
+ var UnknownStorageSourceError = class extends Error {
2736
+ storageId;
2737
+ knownKeys;
2738
+ constructor(storageId, knownKeys) {
2739
+ super(`[StorageRegistry] Storage source "${storageId}" is not registered. Registered sources: ${knownKeys.length > 0 ? knownKeys.map((k) => `"${k}"`).join(", ") : "(none)"}.`);
2740
+ this.storageId = storageId;
2741
+ this.knownKeys = knownKeys;
2742
+ this.name = "UnknownStorageSourceError";
2743
+ }
2744
+ };
2745
+ /**
2746
+ * Default implementation of StorageRegistry
2747
+ */
2748
+ var DefaultStorageRegistry = class DefaultStorageRegistry {
2749
+ controllers = /* @__PURE__ */ new Map();
2750
+ /**
2751
+ * Create a StorageRegistry from either a single controller or a map
2752
+ * @param input - Single StorageController (maps to "(default)") or Record<string, StorageController>
2753
+ */
2754
+ static create(input) {
2755
+ const registry = new DefaultStorageRegistry();
2756
+ if (isStorageController(input)) registry.register(DEFAULT_STORAGE_ID, input);
2757
+ else {
2758
+ for (const [id, controller] of Object.entries(input)) if (isStorageController(controller)) registry.register(id, controller);
2759
+ if (!registry.has(DEFAULT_STORAGE_ID) && registry.size() > 0) {
2760
+ const firstId = Object.keys(input).find((k) => isStorageController(input[k]));
2761
+ if (firstId) {
2762
+ logger.warn(`[StorageRegistry] No "${DEFAULT_STORAGE_ID}" storage provided. Using "${firstId}" as the default.`);
2763
+ registry.register(DEFAULT_STORAGE_ID, input[firstId]);
2764
+ }
2765
+ }
2425
2766
  }
2767
+ return registry;
2426
2768
  }
2427
- /**
2428
- * Get the absolute filesystem path for serving files
2429
- * Used by the storage routes to serve files directly
2430
- */
2431
- getAbsolutePath(key, bucket) {
2432
- return this.getFullPath(key, bucket);
2769
+ register(id, controller) {
2770
+ if (this.controllers.has(id)) logger.warn(`[StorageRegistry] Overwriting storage with id "${id}"`);
2771
+ this.controllers.set(id, controller);
2433
2772
  }
2434
- /**
2435
- * Get the base path for the storage
2436
- */
2437
- getBasePath() {
2438
- return this.basePath;
2773
+ getDefault() {
2774
+ const controller = this.controllers.get(DEFAULT_STORAGE_ID);
2775
+ if (!controller) throw new Error(`[StorageRegistry] No default storage registered. Register one with id "${DEFAULT_STORAGE_ID}" or pass a single StorageController.`);
2776
+ return controller;
2777
+ }
2778
+ get(id) {
2779
+ return this.controllers.get(canonicalStorageId(id));
2780
+ }
2781
+ getOrDefault(id) {
2782
+ const key = canonicalStorageId(id);
2783
+ if (key === DEFAULT_STORAGE_ID) return this.getDefault();
2784
+ const controller = this.controllers.get(key);
2785
+ if (controller) return controller;
2786
+ throw new UnknownStorageSourceError(key, this.list());
2787
+ }
2788
+ has(id) {
2789
+ return this.controllers.has(canonicalStorageId(id));
2790
+ }
2791
+ list() {
2792
+ return Array.from(this.controllers.keys());
2793
+ }
2794
+ size() {
2795
+ return this.controllers.size;
2439
2796
  }
2440
2797
  };
2798
+ /**
2799
+ * Type guard to check if an object is a StorageController
2800
+ * vs a Record<string, StorageController> (multiple storages)
2801
+ */
2802
+ function isStorageController(obj) {
2803
+ if (typeof obj !== "object" || obj === null) return false;
2804
+ const controller = obj;
2805
+ return typeof controller.putObject === "function" && typeof controller.getSignedUrl === "function" && typeof controller.deleteObject === "function" && typeof controller.listObjects === "function" && typeof controller.getType === "function";
2806
+ }
2441
2807
  //#endregion
2442
2808
  //#region src/storage/image-transform.ts
2443
2809
  /**
@@ -2810,6 +3176,7 @@ var TusHandler = class {
2810
3176
  throw new ApiError(400, "INVALID_STORAGE_BUCKET", err instanceof InvalidStorageBucketError ? err.message : "Invalid storage bucket");
2811
3177
  }
2812
3178
  const storageId = metadata.storageId || c.req.query("storageId") || void 0;
3179
+ if (storageId !== void 0 && this.storageRegistry && !this.storageRegistry.has(canonicalStorageId(storageId))) throw new ApiError(400, "UNKNOWN_STORAGE_SOURCE", `Unknown storage source "${storageId}". Available: ${this.storageRegistry.list().map((k) => `"${k}"`).join(", ") || "(none)"}.`, void 0, true);
2813
3180
  if (this.authorizeUpload) await this.authorizeUpload(c, key, bucket || "default", storageId);
2814
3181
  const filePath = join(this.tusDir, id);
2815
3182
  await writeFile(filePath, Buffer.alloc(0));
@@ -2912,7 +3279,12 @@ var TusHandler = class {
2912
3279
  async finalize(upload) {
2913
3280
  const storageId = upload.storageId;
2914
3281
  let targetController = this.storageController;
2915
- if (this.storageRegistry) targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
3282
+ if (this.storageRegistry) try {
3283
+ targetController = storageId ? this.storageRegistry.getOrDefault(storageId) : this.storageRegistry.getDefault();
3284
+ } catch (err) {
3285
+ if (!(err instanceof UnknownStorageSourceError)) throw err;
3286
+ targetController = void 0;
3287
+ }
2916
3288
  if (!targetController) {
2917
3289
  logger.error("[TUS] Upload received but no StorageController is configured — nothing can be written", {
2918
3290
  uploadId: upload.id,
@@ -3074,8 +3446,8 @@ function canonicalKeyOrBadRequest(key) {
3074
3446
  * The bucket's counterpart to {@link canonicalKeyOrBadRequest}, and it exists
3075
3447
  * for the same reason: the value routes a write, so it has to be checked where
3076
3448
  * it enters rather than where it is used. Applied at every entry point a bucket
3077
- * has — this route's multipart body, the `?bucket=` query, and the TUS
3078
- * `Upload-Metadata` header.
3449
+ * has — this route's multipart body, the folder route's JSON body, the
3450
+ * `?bucket=` query, and the TUS `Upload-Metadata` header.
3079
3451
  */
3080
3452
  function canonicalBucketOrBadRequest(bucket) {
3081
3453
  try {
@@ -3175,13 +3547,46 @@ function createStorageRoutes(config) {
3175
3547
  if (!allowed) throw ApiError.forbidden("Not authorized for this object");
3176
3548
  };
3177
3549
  /**
3178
- * Resolve the storage controller for a request.
3179
- * Looks up by `storageId` in the registry, falls back to the single
3180
- * controller, and finally to the registry default.
3550
+ * Resolve the storage controller for a request, or refuse the request.
3551
+ *
3552
+ * A `storageId` that names no registered source used to fall back to the
3553
+ * default one. That is silently wrong: the `storageAuthorize` hook is asked
3554
+ * about the source the caller named while the bytes come from `(default)`,
3555
+ * so the object the hook approved is not the object the request touched —
3556
+ * and because the two sources hold the same key, the fallback returns
3557
+ * plausible bytes instead of an error.
3558
+ *
3559
+ * The caller gets one of two answers, because they are two different
3560
+ * problems with two different fixes:
3561
+ *
3562
+ * - **501** if the source *is* declared in `rebase.json` but has no
3563
+ * credentials in this environment. It was skipped at boot rather than
3564
+ * crash-looping the backend, but `GET /sources` still advertises it, so a
3565
+ * client asking for it is not confused — the deployment is incomplete.
3566
+ * 501 (not 503) for the same reason the whole-storage stub uses it: this
3567
+ * is permanent until someone configures it, and the client's offline
3568
+ * queue retries 503 forever.
3569
+ * - **400** otherwise. The id names nothing this deployment has ever heard
3570
+ * of, which is a caller mistake. `expected`, so one client holding a
3571
+ * stale source name does not write a warning per request forever.
3181
3572
  */
3573
+ const declaredKeys = new Set((declaredSources ?? []).map((s) => s.key));
3574
+ const refuseUnknownSource = (storageId, knownKeys) => {
3575
+ if (declaredKeys.has(storageId)) throw new ApiError(501, "STORAGE_SOURCE_NOT_CONFIGURED", `Storage source "${storageId}" is declared but not configured on this deployment, so it cannot be read from or written to. Set its credentials (the ${storageId} suffixed environment variables) and redeploy.`);
3576
+ throw new ApiError(400, "UNKNOWN_STORAGE_SOURCE", `Unknown storage source "${storageId}". Available: ${knownKeys.length > 0 ? knownKeys.map((k) => `"${k}"`).join(", ") : "(none)"}.`, void 0, true);
3577
+ };
3182
3578
  const resolveController = (storageId) => {
3183
- if (registry) return registry.getOrDefault(storageId);
3184
- if (controller) return controller;
3579
+ if (registry) try {
3580
+ return registry.getOrDefault(storageId);
3581
+ } catch (err) {
3582
+ if (err instanceof UnknownStorageSourceError) return refuseUnknownSource(err.storageId, err.knownKeys);
3583
+ throw err;
3584
+ }
3585
+ if (controller) {
3586
+ const requested = canonicalStorageId(storageId);
3587
+ if (requested !== "(default)") return refuseUnknownSource(requested, [DEFAULT_STORAGE_SOURCE_KEY]);
3588
+ return controller;
3589
+ }
3185
3590
  throw new Error("No storage controller or registry available");
3186
3591
  };
3187
3592
  /** Get the default controller (used for TUS and base-path derivation). */
@@ -3263,7 +3668,7 @@ function createStorageRoutes(config) {
3263
3668
  const transformKeyPrefix = `${storageId || "(default)"}/${bucket}/${resolvedPath}`;
3264
3669
  const transformOpts = transformOptionsOrBadRequest(c.req.query());
3265
3670
  if (resolved.getType() === "local") {
3266
- const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
3671
+ const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket ?? "default");
3267
3672
  try {
3268
3673
  await fsp.access(absolutePath);
3269
3674
  } catch {
@@ -3324,7 +3729,7 @@ function createStorageRoutes(config) {
3324
3729
  const scopedPath = `${bucket}/${resolvedPath}`;
3325
3730
  if (isPublicStoragePath(scopedPath)) downloadConfig.metadata.public = true;
3326
3731
  else {
3327
- downloadConfig.metadata.token = generateDownloadToken(scopedPath, 300);
3732
+ downloadConfig.metadata.token = generateDownloadToken(scopedPath, 300, storageId);
3328
3733
  downloadConfig.metadata.tokenExpiresIn = 300;
3329
3734
  }
3330
3735
  }
@@ -3384,9 +3789,10 @@ function createStorageRoutes(config) {
3384
3789
  const storageId = typeof body.storageId === "string" ? body.storageId : c.req.query("storageId");
3385
3790
  if (!folderPath || typeof folderPath !== "string") throw ApiError.badRequest("Folder path is required");
3386
3791
  const resolved = resolveController(storageId);
3387
- const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
3792
+ const { resolvedPath } = parseBucketAndPath(folderPath);
3793
+ const bucket = canonicalBucketOrBadRequest(typeof body.bucket === "string" ? body.bucket : void 0);
3388
3794
  if (!resolvedPath || resolvedPath.trim() === "") throw ApiError.badRequest("Invalid folder path");
3389
- await checkAuthorized(c, "write", resolvedPath, bucket, storageId);
3795
+ await checkAuthorized(c, "write", resolvedPath, bucket ?? "default", storageId);
3390
3796
  if (resolved.getType() === "local") {
3391
3797
  const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
3392
3798
  fs$1.mkdirSync(absolutePath, { recursive: true });
@@ -3395,7 +3801,8 @@ function createStorageRoutes(config) {
3395
3801
  const emptyFile = new File([], key, { type: "application/x-directory" });
3396
3802
  await resolved.putObject({
3397
3803
  file: emptyFile,
3398
- key
3804
+ key,
3805
+ bucket
3399
3806
  });
3400
3807
  }
3401
3808
  return c.json({
@@ -3446,77 +3853,6 @@ function createStorageRoutes(config) {
3446
3853
  return router;
3447
3854
  }
3448
3855
  //#endregion
3449
- //#region src/storage/storage-registry.ts
3450
- /**
3451
- * The default storage identifier used when:
3452
- * - A single storage controller is provided (not a map)
3453
- * - A property doesn't specify a storageId
3454
- */
3455
- var DEFAULT_STORAGE_ID = "(default)";
3456
- /**
3457
- * Default implementation of StorageRegistry
3458
- */
3459
- var DefaultStorageRegistry = class DefaultStorageRegistry {
3460
- controllers = /* @__PURE__ */ new Map();
3461
- /**
3462
- * Create a StorageRegistry from either a single controller or a map
3463
- * @param input - Single StorageController (maps to "(default)") or Record<string, StorageController>
3464
- */
3465
- static create(input) {
3466
- const registry = new DefaultStorageRegistry();
3467
- if (isStorageController(input)) registry.register(DEFAULT_STORAGE_ID, input);
3468
- else {
3469
- for (const [id, controller] of Object.entries(input)) if (isStorageController(controller)) registry.register(id, controller);
3470
- if (!registry.has("(default)") && registry.size() > 0) {
3471
- const firstId = Object.keys(input).find((k) => isStorageController(input[k]));
3472
- if (firstId) {
3473
- logger.warn(`[StorageRegistry] No "${DEFAULT_STORAGE_ID}" storage provided. Using "${firstId}" as the default.`);
3474
- registry.register(DEFAULT_STORAGE_ID, input[firstId]);
3475
- }
3476
- }
3477
- }
3478
- return registry;
3479
- }
3480
- register(id, controller) {
3481
- if (this.controllers.has(id)) logger.warn(`[StorageRegistry] Overwriting storage with id "${id}"`);
3482
- this.controllers.set(id, controller);
3483
- }
3484
- getDefault() {
3485
- const controller = this.controllers.get(DEFAULT_STORAGE_ID);
3486
- if (!controller) throw new Error(`[StorageRegistry] No default storage registered. Register one with id "${DEFAULT_STORAGE_ID}" or pass a single StorageController.`);
3487
- return controller;
3488
- }
3489
- get(id) {
3490
- if (id === void 0 || id === null) return this.controllers.get(DEFAULT_STORAGE_ID);
3491
- return this.controllers.get(id);
3492
- }
3493
- getOrDefault(id) {
3494
- if (id === void 0 || id === null) return this.getDefault();
3495
- const controller = this.controllers.get(id);
3496
- if (controller) return controller;
3497
- logger.warn(`[StorageRegistry] Storage "${id}" not found, falling back to "${DEFAULT_STORAGE_ID}"`);
3498
- return this.getDefault();
3499
- }
3500
- has(id) {
3501
- return this.controllers.has(id);
3502
- }
3503
- list() {
3504
- return Array.from(this.controllers.keys());
3505
- }
3506
- size() {
3507
- return this.controllers.size;
3508
- }
3509
- };
3510
- /**
3511
- * Type guard to check if an object is a StorageController
3512
- * vs a Record<string, StorageController> (multiple storages)
3513
- */
3514
- function isStorageController(obj) {
3515
- if (typeof obj !== "object" || obj === null) return false;
3516
- const controller = obj;
3517
- return typeof controller.putObject === "function" && typeof controller.getSignedUrl === "function" && typeof controller.deleteObject === "function" && typeof controller.listObjects === "function" && typeof controller.getType === "function";
3518
- }
3519
- //#endregion
3520
3856
  //#region src/storage/index.ts
3521
3857
  /**
3522
3858
  * Create a storage controller from a config object.
@@ -3610,7 +3946,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
3610
3946
  */
3611
3947
  async function mountOpenApiDocs(app, basePath, enableSwagger, serverCollections, requireAuth) {
3612
3948
  if (enableSwagger === false || serverCollections.length === 0) return;
3613
- const { generateOpenApiSpec } = await import("./openapi-generator-DPKtUC9X.js");
3949
+ const { generateOpenApiSpec } = await import("./openapi-generator-DQeQ_q2f.js");
3614
3950
  app.get(`${basePath}/docs`, (c) => {
3615
3951
  const spec = generateOpenApiSpec(serverCollections, {
3616
3952
  basePath,
@@ -3763,6 +4099,10 @@ function createShutdown(config) {
3763
4099
  config.cronScheduler.stop();
3764
4100
  logger.info("Cron scheduler stopped");
3765
4101
  }
4102
+ if (config.jobQueue) {
4103
+ await config.jobQueue.stop();
4104
+ logger.info("Job queue stopped");
4105
+ }
3766
4106
  for (const [key, rt] of Object.entries(config.realtimeServices)) try {
3767
4107
  if (typeof rt.destroy === "function") {
3768
4108
  await rt.destroy();
@@ -3787,6 +4127,42 @@ function createShutdown(config) {
3787
4127
  };
3788
4128
  }
3789
4129
  //#endregion
4130
+ //#region src/init/surfaces.ts
4131
+ /** Every surface, in a stable order. */
4132
+ var ALL_RUNTIME_SURFACES = [
4133
+ "auth",
4134
+ "data",
4135
+ "storage",
4136
+ "admin",
4137
+ "functions",
4138
+ "cron",
4139
+ "meta"
4140
+ ];
4141
+ /**
4142
+ * Resolve a partial surface set against the default, which is everything on.
4143
+ *
4144
+ * Defaulting to on rather than off is the whole compatibility story: a caller
4145
+ * that says nothing gets the process this server has always booted, and a
4146
+ * surface added later is mounted by every existing deployment without anyone
4147
+ * editing a list.
4148
+ */
4149
+ function resolveSurfaces(options) {
4150
+ const resolved = {};
4151
+ for (const surface of ALL_RUNTIME_SURFACES) resolved[surface] = options?.[surface] ?? true;
4152
+ return resolved;
4153
+ }
4154
+ /** Resolve partial ownership against the default, which is owning everything. */
4155
+ function resolveOwnership(options) {
4156
+ return {
4157
+ cronScheduler: options?.cronScheduler ?? true,
4158
+ jobWorkers: options?.jobWorkers ?? true
4159
+ };
4160
+ }
4161
+ /** The surfaces a process is not serving, for a boot log line. */
4162
+ function disabledSurfaces(resolved) {
4163
+ return ALL_RUNTIME_SURFACES.filter((surface) => !resolved[surface]);
4164
+ }
4165
+ //#endregion
3790
4166
  //#region src/init/process-safety.ts
3791
4167
  /**
3792
4168
  * Installed once per process. The singleton is process-global for the same
@@ -5092,9 +5468,15 @@ var SDKQueryBuilder = class {
5092
5468
  }
5093
5469
  /**
5094
5470
  * Order the results by a specific column.
5471
+ *
5472
+ * Call it again to add a tie-breaker rather than replace the sort: keys
5473
+ * apply in the order they were added, so
5474
+ * `.orderBy("roles").orderBy("created_at", "desc")` sorts by role and
5475
+ * shows the newest first within each one.
5095
5476
  */
5096
5477
  orderBy(column, direction = "asc") {
5097
- this.params.orderBy = [column, direction];
5478
+ const existing = normalizeOrderBy(this.params.orderBy) ?? [];
5479
+ this.params.orderBy = [...existing, [column, direction]];
5098
5480
  return this;
5099
5481
  }
5100
5482
  /**
@@ -5409,8 +5791,7 @@ function createCollectionClient(transport, slug, ws) {
5409
5791
  logical: params?.logical,
5410
5792
  limit: params?.limit,
5411
5793
  offset: window.driverOffset,
5412
- orderBy: params?.orderBy?.[0],
5413
- order: params?.orderBy?.[1],
5794
+ orderBy: normalizeOrderBy(params?.orderBy),
5414
5795
  searchString: params?.searchString,
5415
5796
  searchExplain: params?.searchExplain
5416
5797
  }, (incomingRows) => {
@@ -7894,27 +8275,43 @@ function matchesParams(row, params) {
7894
8275
  * Sort in place, Postgres-style: nulls last ascending, first descending, with
7895
8276
  * the row id as a tiebreak so paging through an unsorted-but-equal run does
7896
8277
  * not shuffle rows between pages.
8278
+ *
8279
+ * The tiebreak runs *descending*, which is not a taste: every server-side sort
8280
+ * ends on `id DESC` — `FetchService.buildOrderExpressions` appends it to make
8281
+ * the ordering total, and the keyset cursor is built to match. This ran
8282
+ * ascending, so two rows sharing a sort value came back from the local overlay
8283
+ * in the opposite order to the server's, and {@link isLocallySortable} called
8284
+ * that page exactly reproducible while it was not.
7897
8285
  */
7898
8286
  function sortRows(rows, orderBy) {
7899
- if (!orderBy) return rows;
7900
- const [field, direction = "asc"] = orderBy;
7901
- const sign = direction === "desc" ? -1 : 1;
8287
+ const keys = normalizeOrderBy(orderBy);
8288
+ if (!keys) return rows;
7902
8289
  return rows.sort((a, b) => {
7903
- const av = a[field];
7904
- const bv = b[field];
7905
- const aNull = isNullish(toComparable(av));
7906
- const bNull = isNullish(toComparable(bv));
7907
- if (aNull || bNull) {
7908
- if (aNull && bNull) return tiebreak(a, b);
7909
- return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
7910
- }
7911
- const cmp = compareValues(av, bv);
7912
- if (cmp === void 0 || cmp === 0) return tiebreak(a, b);
7913
- return cmp * sign;
8290
+ for (const [field, direction = "asc"] of keys) {
8291
+ const cmp = compareOnKey(a, b, field, direction);
8292
+ if (cmp !== 0) return cmp;
8293
+ }
8294
+ return tiebreak(a, b);
7914
8295
  });
7915
8296
  }
8297
+ /** One key's verdict: negative, positive, or 0 for "these two are equal here". */
8298
+ function compareOnKey(a, b, field, direction) {
8299
+ const av = a[field];
8300
+ const bv = b[field];
8301
+ const aNull = isNullish(toComparable(av));
8302
+ const bNull = isNullish(toComparable(bv));
8303
+ if (aNull || bNull) {
8304
+ if (aNull && bNull) return 0;
8305
+ return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
8306
+ }
8307
+ const cmp = compareValues(av, bv);
8308
+ if (cmp === void 0 || cmp === 0) return 0;
8309
+ return cmp * (direction === "desc" ? -1 : 1);
8310
+ }
8311
+ /** The last word, and the server's: `id DESC`. */
7916
8312
  function tiebreak(a, b) {
7917
- return compareValues(a.id, b.id) ?? 0;
8313
+ const cmp = compareValues(a.id, b.id);
8314
+ return cmp === void 0 ? 0 : -cmp;
7918
8315
  }
7919
8316
  /**
7920
8317
  * Resolve `page`/`offset`/`limit` the way the server does.
@@ -8009,17 +8406,16 @@ function isExactlyEvaluable(params) {
8009
8406
  * comparator.
8010
8407
  */
8011
8408
  function isLocallySortable(rows, orderBy) {
8012
- if (!orderBy) return true;
8013
- const [field] = orderBy;
8014
- for (const row of rows) {
8409
+ const keys = normalizeOrderBy(orderBy);
8410
+ if (!keys) return true;
8411
+ return keys.every(([field]) => rows.every((row) => {
8015
8412
  const value = toComparable(row[field]);
8016
- if (isNullish(value)) continue;
8017
- if (typeof value === "number" || typeof value === "boolean") continue;
8018
- if (typeof value === "bigint") continue;
8019
- if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) continue;
8413
+ if (isNullish(value)) return true;
8414
+ if (typeof value === "number" || typeof value === "boolean") return true;
8415
+ if (typeof value === "bigint") return true;
8416
+ if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) return true;
8020
8417
  return false;
8021
- }
8022
- return true;
8418
+ }));
8023
8419
  }
8024
8420
  /** Run a full query — filter, sort, paginate — over a set of rows. */
8025
8421
  function runLocalQuery(rows, params) {
@@ -9946,6 +10342,13 @@ async function _initializeRebaseBackend(config) {
9946
10342
  installUnhandledRejectionHandler();
9947
10343
  const basePath = config.basePath || "/api";
9948
10344
  const isProduction = process.env.NODE_ENV === "production";
10345
+ const surfaces = resolveSurfaces(config.surfaces);
10346
+ const ownership = resolveOwnership(config.ownership);
10347
+ const offSurfaces = disabledSurfaces(surfaces);
10348
+ if (offSurfaces.length > 0) logger.info("Partial runtime surface — some routes are not served by this process", {
10349
+ serving: ALL_RUNTIME_SURFACES.filter((surface) => surfaces[surface]),
10350
+ notServing: offSurfaces
10351
+ });
9949
10352
  configureMiddlewares(config.app, basePath, isProduction, config);
9950
10353
  const collectionRegistry = new BackendCollectionRegistry();
9951
10354
  const dataSourceRegistry = createDataSourceRegistry(config.dataSources);
@@ -9974,6 +10377,16 @@ async function _initializeRebaseBackend(config) {
9974
10377
  if (bootstrappers.length === 0) throw new Error("No bootstrappers or database adapter provided. Cannot initialize database drivers.");
9975
10378
  let defaultDriverId = DEFAULT_DRIVER_ID;
9976
10379
  let defaultDriverResult = void 0;
10380
+ const provisionTarget = provisionTargetFor(bootstrappers, config.database, config.provisioningDriverResult);
10381
+ const provisionable = collectionsStoredBy(activeCollections, { engine: provisionTarget.engine }, (config.dataSources ?? []).map((source) => ({
10382
+ key: source.key,
10383
+ engine: source.engine ?? provisionTarget.engine
10384
+ })));
10385
+ logForeignCollections(activeCollections, provisionable, { engine: provisionTarget.engine });
10386
+ const schemaOutcome = await provisionCollectionTables(provisionable, provisionTarget, {
10387
+ introspecting: introspectCollections,
10388
+ provision: config.provisionSchema ?? true
10389
+ });
9977
10390
  for (const bootstrapper of bootstrappers) {
9978
10391
  const b = bootstrapper;
9979
10392
  logger.debug("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
@@ -9982,7 +10395,11 @@ async function _initializeRebaseBackend(config) {
9982
10395
  collections: activeCollections,
9983
10396
  collectionRegistry,
9984
10397
  introspectCollections,
9985
- baas: config.baas
10398
+ baas: config.baas,
10399
+ schemaProvisioning: {
10400
+ attempted: schemaOutcome.status === "applied",
10401
+ reason: schemaOutcome.status === "skipped" ? schemaOutcome.reason : void 0
10402
+ }
9986
10403
  });
9987
10404
  delegates[b.id || bootstrapper.type] = driverResult.driver;
9988
10405
  if (introspectCollections) {
@@ -10057,7 +10474,9 @@ async function _initializeRebaseBackend(config) {
10057
10474
  if (safeAuthConfig.jwtSecret) configureJwt({
10058
10475
  secret: safeAuthConfig.jwtSecret,
10059
10476
  accessExpiresIn: safeAuthConfig.accessExpiresIn || "1h",
10060
- refreshExpiresIn: safeAuthConfig.refreshExpiresIn || "30d"
10477
+ refreshExpiresIn: safeAuthConfig.refreshExpiresIn || "30d",
10478
+ signingKeys: safeAuthConfig.signingKeys,
10479
+ activeKid: safeAuthConfig.activeKid
10061
10480
  });
10062
10481
  if (safeAuthConfig.serviceKey) {
10063
10482
  if (safeAuthConfig.serviceKey.length < 32) throw new Error("REBASE_SERVICE_KEY is too short. Must be at least 32 characters. Generate one with: node -e \"logger.info(require('crypto').randomBytes(48).toString('base64'))\"");
@@ -10070,6 +10489,10 @@ async function _initializeRebaseBackend(config) {
10070
10489
  logger.info("Authentication initialized");
10071
10490
  } else logger.warn("Auth requested but default bootstrapper does not support initializeAuth");
10072
10491
  }
10492
+ await provisionCollectionPolicies(provisionable, provisionTarget, {
10493
+ introspecting: introspectCollections,
10494
+ provision: config.provisionSchema ?? true
10495
+ });
10073
10496
  let historyConfigResult = void 0;
10074
10497
  if (config.history) if (defaultBootstrapper.initializeHistory) {
10075
10498
  logger.debug("Bootstrapping entity history via driver protocol");
@@ -10094,8 +10517,8 @@ async function _initializeRebaseBackend(config) {
10094
10517
  store: apiKeyStore,
10095
10518
  driver: defaultDriver
10096
10519
  }) : void 0;
10097
- if (apiKeyPreAuth) config.app.use(`${basePath}/admin/*`, apiKeyPreAuth);
10098
- if (apiKeyStore) {
10520
+ if (apiKeyPreAuth && surfaces.admin) config.app.use(`${basePath}/admin/*`, apiKeyPreAuth);
10521
+ if (apiKeyStore && surfaces.admin) {
10099
10522
  const apiKeyRoutes = createApiKeyRoutes({
10100
10523
  store: apiKeyStore,
10101
10524
  serviceKey: internalServiceKey
@@ -10185,7 +10608,7 @@ async function _initializeRebaseBackend(config) {
10185
10608
  ]) {
10186
10609
  const providerConfig = safeAuthConfig[key];
10187
10610
  if (providerConfig && requiredFields.every((f) => Boolean(providerConfig[f]))) {
10188
- const createFn = (await import("./auth-CYoPVf-E.js").then((n) => n.t))[factory];
10611
+ const createFn = (await import("./auth-BobZVd0j.js").then((n) => n.t))[factory];
10189
10612
  oauthProviders.push(createFn(providerConfig));
10190
10613
  }
10191
10614
  }
@@ -10212,14 +10635,14 @@ async function _initializeRebaseBackend(config) {
10212
10635
  if (!isProduction && !process.env.CORS_ORIGINS && !process.env.FRONTEND_URL) logger.warn("[Auth] Cookie authentication (cookieAuth) is enabled, but no CORS restrictions are detected. Browser-based clients will require credentials: 'include' and the server MUST NOT use Access-Control-Allow-Origin: '*'. Ensure CORS_ORIGINS is set to your frontend URL.");
10213
10636
  }
10214
10637
  }
10215
- if (authAdapter && authAdapter.createAuthRoutes) {
10638
+ if (authAdapter && authAdapter.createAuthRoutes && surfaces.auth) {
10216
10639
  const authRoutes = authAdapter.createAuthRoutes();
10217
10640
  if (authRoutes) {
10218
10641
  config.app.route(`${basePath}/auth`, authRoutes);
10219
10642
  logger.debug("Auth routes mounted via adapter", { adapter: authAdapter.id });
10220
10643
  }
10221
10644
  }
10222
- if (authAdapter && authAdapter.createAdminRoutes) {
10645
+ if (authAdapter && authAdapter.createAdminRoutes && surfaces.admin) {
10223
10646
  const adminRoutes = authAdapter.createAdminRoutes();
10224
10647
  if (adminRoutes) {
10225
10648
  config.app.route(`${basePath}/admin`, adminRoutes);
@@ -10227,6 +10650,10 @@ async function _initializeRebaseBackend(config) {
10227
10650
  }
10228
10651
  }
10229
10652
  }
10653
+ if (isJwtConfigured() && surfaces.auth) {
10654
+ config.app.route("/.well-known", createJwksRoutes());
10655
+ if (hasAsymmetricSigningKey()) logger.info("Access tokens are signed asymmetrically; public keys at /.well-known/jwks.json");
10656
+ }
10230
10657
  const adminSurfacesGated = !!authAdapter && (isAuthAdapter(config.auth) || !!config.auth.jwtSecret);
10231
10658
  const applyAdminGate = (router, surface) => {
10232
10659
  if (!adminSurfacesGated) {
@@ -10291,7 +10718,7 @@ async function _initializeRebaseBackend(config) {
10291
10718
  logger.warn(`Schema Editor disabled: ${schemaEditorOff.message}`);
10292
10719
  } else throw err;
10293
10720
  }
10294
- {
10721
+ if (surfaces.admin) {
10295
10722
  const schemaEditorRouter = new Hono();
10296
10723
  applyAdminGate(schemaEditorRouter, "Schema editor");
10297
10724
  schemaEditorRouter.get("/status", (c) => c.json(schemaEditorOff ? {
@@ -10342,15 +10769,17 @@ async function _initializeRebaseBackend(config) {
10342
10769
  }
10343
10770
  }));
10344
10771
  storageRouter.route("/", storageRoutes);
10345
- config.app.route(`${basePath}/storage`, storageRouter);
10772
+ if (surfaces.storage) config.app.route(`${basePath}/storage`, storageRouter);
10346
10773
  } else {
10347
10774
  const storageStub = new Hono();
10348
10775
  storageStub.all("/*", (c) => c.json({ error: {
10349
10776
  message: "File storage is not configured on this deployment, so uploads and downloads are disabled. Configure a storage backend (STORAGE_TYPE=s3 or STORAGE_TYPE=gcs plus its bucket and credentials) and redeploy.",
10350
10777
  code: "STORAGE_NOT_CONFIGURED"
10351
10778
  } }, 501));
10352
- config.app.route(`${basePath}/storage`, storageStub);
10353
- logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
10779
+ if (surfaces.storage) {
10780
+ config.app.route(`${basePath}/storage`, storageStub);
10781
+ logger.info("Storage not configured — /storage returns 501 STORAGE_NOT_CONFIGURED");
10782
+ }
10354
10783
  }
10355
10784
  const serverCollections = activeCollections.filter((collection) => resolveDataSource(collection, dataSourceRegistry).transport === "server");
10356
10785
  if (activeCollections.length > 0) {
@@ -10399,9 +10828,9 @@ async function _initializeRebaseBackend(config) {
10399
10828
  }
10400
10829
  const restGenerator = new RestApiGenerator(serverCollections, defaultDriver, authAdapter);
10401
10830
  dataRouter.route("/", restGenerator.generateRoutes());
10402
- config.app.route(`${basePath}/data`, dataRouter);
10831
+ if (surfaces.data) config.app.route(`${basePath}/data`, dataRouter);
10403
10832
  }
10404
- await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
10833
+ if (surfaces.data) await mountOpenApiDocs(config.app, basePath, config.enableSwagger, serverCollections, resolveRequireAuth(config.auth));
10405
10834
  const serverClient = createRebaseClient({
10406
10835
  baseUrl: "http://localhost",
10407
10836
  apiPath: basePath,
@@ -10460,11 +10889,17 @@ async function _initializeRebaseBackend(config) {
10460
10889
  const driver = defaultDriverResult.internals.driver;
10461
10890
  if (driver && "client" in driver) driver.client = serverClient;
10462
10891
  }
10463
- if (config.functionsDir) {
10892
+ if (config.functionsDir && surfaces.functions) {
10464
10893
  const { loadFunctionsWithDiagnostics } = await import("./function-loader-DDS1v7YX.js").then((n) => n.t);
10465
10894
  const { createFunctionRoutes } = await import("./function-routes-Btcez1T-.js").then((n) => n.n);
10466
- const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-RivJsME0.js");
10467
- const { functions: loadedFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
10895
+ const { createFunctionsRequestTimeout, resolveFunctionsTimeoutMs } = await import("./request-timeout-BuFoEKwT.js");
10896
+ const { functions: allFunctions, problems } = await loadFunctionsWithDiagnostics(config.functionsDir);
10897
+ const { selectFunctions } = await import("./selection-_z6TM1DB.js").then((n) => n.n);
10898
+ const loadedFunctions = selectFunctions(allFunctions, config.functionsSelection);
10899
+ if (loadedFunctions.length !== allFunctions.length) logger.info("Serving a subset of this bundle's functions", {
10900
+ serving: loadedFunctions.map((fn) => fn.name),
10901
+ notServing: allFunctions.filter((fn) => !loadedFunctions.includes(fn)).map((fn) => fn.name)
10902
+ });
10468
10903
  const functionsRouter = new Hono();
10469
10904
  functionsRouter.onError(errorHandler);
10470
10905
  const functionsTimeoutMs = resolveFunctionsTimeoutMs(config.functionsTimeoutMs);
@@ -10497,12 +10932,23 @@ async function _initializeRebaseBackend(config) {
10497
10932
  });
10498
10933
  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."));
10499
10934
  }
10935
+ if (config.functionsUpstream && !surfaces.functions) {
10936
+ const { createFunctionsProxy } = await import("./proxy-Bj5DVllb.js");
10937
+ config.app.route(`${basePath}/functions`, createFunctionsProxy({
10938
+ upstream: config.functionsUpstream,
10939
+ basePath: `${basePath}/functions`
10940
+ }));
10941
+ logger.info("Forwarding custom functions to another process", {
10942
+ path: `${basePath}/functions`,
10943
+ upstream: config.functionsUpstream
10944
+ });
10945
+ }
10500
10946
  let cronScheduler;
10501
- if (config.cronsDir) {
10947
+ if (config.cronsDir && (surfaces.cron || ownership.cronScheduler)) {
10502
10948
  const { loadCronJobsWithDiagnostics } = await import("./cron-loader-YhhQeVBM.js").then((n) => n.t);
10503
10949
  const { CronScheduler } = await import("./cron-scheduler-DIpYBmZP.js").then((n) => n.n);
10504
10950
  const { createCronRoutes } = await import("./cron-routes-maM_RlUu.js").then((n) => n.n);
10505
- const { createCronStore } = await import("./cron-store-Dvr4Y1sZ.js").then((n) => n.n);
10951
+ const { createCronStore } = await import("./cron-store-CB1x-Ken.js").then((n) => n.n);
10506
10952
  const { jobs: loadedCronJobs, problems: cronProblems } = await loadCronJobsWithDiagnostics(config.cronsDir);
10507
10953
  cronScheduler = new CronScheduler();
10508
10954
  cronScheduler.setClient(serverSingleton);
@@ -10517,8 +10963,12 @@ async function _initializeRebaseBackend(config) {
10517
10963
  const cronRouter = new Hono();
10518
10964
  applyAdminGate(cronRouter, "Cron");
10519
10965
  cronRouter.route("/", createCronRoutes(cronScheduler, cronProblems.length));
10520
- config.app.route(`${basePath}/cron`, cronRouter);
10521
- if (loadedCronJobs.length > 0) {
10966
+ if (surfaces.cron) config.app.route(`${basePath}/cron`, cronRouter);
10967
+ if (loadedCronJobs.length > 0 && !ownership.cronScheduler) logger.info("Cron jobs registered but NOT scheduled in this process", {
10968
+ count: loadedCronJobs.length,
10969
+ reason: "this process does not own the cron scheduler"
10970
+ });
10971
+ else if (loadedCronJobs.length > 0) {
10522
10972
  cronScheduler.start();
10523
10973
  logger.info("Mounted cron jobs", {
10524
10974
  count: loadedCronJobs.length,
@@ -10526,7 +10976,20 @@ async function _initializeRebaseBackend(config) {
10526
10976
  });
10527
10977
  } 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."));
10528
10978
  }
10529
- {
10979
+ let jobQueue;
10980
+ if (config.jobs?.enabled) {
10981
+ const { createJobStore, createJobQueue } = await import("./jobs-DR4SjGrD.js").then((n) => n.t);
10982
+ const store = createJobStore(defaultDriver);
10983
+ if (store) {
10984
+ await store.ensureTable();
10985
+ jobQueue = createJobQueue(store, config.jobs);
10986
+ if (ownership.jobWorkers) {
10987
+ jobQueue.start();
10988
+ logger.info("Job queue started", { tasks: Object.keys(config.jobs.tasks ?? {}).length });
10989
+ } else logger.info("Job queue available for enqueue, workers NOT started in this process", { tasks: Object.keys(config.jobs.tasks ?? {}).length });
10990
+ } 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.");
10991
+ }
10992
+ if (surfaces.admin) {
10530
10993
  const { createBackupRoutes, parseBackupDestination } = await import("./backup-C6ljYVTp.js").then((n) => n.t);
10531
10994
  const backupRouter = new Hono();
10532
10995
  applyAdminGate(backupRouter, "Backup");
@@ -10540,7 +11003,7 @@ async function _initializeRebaseBackend(config) {
10540
11003
  config.app.route(`${basePath}/admin/backups`, backupRouter);
10541
11004
  logger.debug("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
10542
11005
  }
10543
- {
11006
+ if (surfaces.admin) {
10544
11007
  const { default: logsRoutes } = await import("./logs-routes-BYA72C_C.js").then((n) => n.n);
10545
11008
  const logsRouter = new Hono();
10546
11009
  applyAdminGate(logsRouter, "Logs");
@@ -10548,7 +11011,7 @@ async function _initializeRebaseBackend(config) {
10548
11011
  config.app.route(`${basePath}/logs`, logsRouter);
10549
11012
  logger.debug("Logs routes mounted", { path: `${basePath}/logs` });
10550
11013
  }
10551
- {
11014
+ if (surfaces.meta) {
10552
11015
  const { createContractRoutes } = await import("./contract-routes-Bet-eCNJ.js").then((n) => n.t);
10553
11016
  const contractRouter = new Hono();
10554
11017
  if (adminSurfacesGated) {
@@ -10581,6 +11044,7 @@ async function _initializeRebaseBackend(config) {
10581
11044
  const shutdown = createShutdown({
10582
11045
  server: config.server,
10583
11046
  cronScheduler,
11047
+ jobQueue,
10584
11048
  realtimeServices
10585
11049
  });
10586
11050
  /**
@@ -10619,6 +11083,7 @@ async function _initializeRebaseBackend(config) {
10619
11083
  storageController,
10620
11084
  collectionRegistry,
10621
11085
  cronScheduler,
11086
+ jobQueue,
10622
11087
  healthCheck,
10623
11088
  shutdown
10624
11089
  };
@@ -10811,6 +11276,22 @@ var rebaseEnvSchema = object({
10811
11276
  DATABASE_URL: string().url("DATABASE_URL must be a valid URL"),
10812
11277
  ADMIN_CONNECTION_STRING: string().url().optional(),
10813
11278
  JWT_SECRET: string().min(32, "JWT_SECRET must be at least 32 characters long"),
11279
+ /**
11280
+ * PEM private key for signing access tokens asymmetrically, so anything
11281
+ * holding the JWKS can verify a session without being able to mint one.
11282
+ * Optional — without it, tokens stay HS256 and nothing changes.
11283
+ *
11284
+ * Accepts a PEM with real newlines, a PEM with `\n` escapes (how most
11285
+ * secret managers and `.env` files carry a multi-line value), or base64 of
11286
+ * the whole PEM.
11287
+ */
11288
+ JWT_PRIVATE_KEY: string().optional(),
11289
+ /**
11290
+ * Names {@link rebaseEnvSchema.JWT_PRIVATE_KEY} in the token header and in
11291
+ * the JWKS. Rotation depends on the old and new keys being
11292
+ * distinguishable, so change this whenever the key changes.
11293
+ */
11294
+ JWT_KEY_ID: string().default("default"),
10814
11295
  JWT_ACCESS_EXPIRES_IN: string().default("1h"),
10815
11296
  JWT_REFRESH_EXPIRES_IN: string().default("400d"),
10816
11297
  GOOGLE_CLIENT_ID: string().optional(),
@@ -11032,6 +11513,14 @@ async function assertAllowedOutboundUrl(rawUrl, options = {}) {
11032
11513
  }
11033
11514
  //#endregion
11034
11515
  //#region src/services/webhook-service.ts
11516
+ /**
11517
+ * The task name a queued webhook delivery is stored under.
11518
+ *
11519
+ * Namespaced, and frozen: it is written into `rebase.jobs` rows that outlive
11520
+ * the deploy that wrote them, so renaming it would strand every delivery
11521
+ * already queued under the old name until they dead-lettered.
11522
+ */
11523
+ var WEBHOOK_DELIVERY_TASK = "rebase.webhook.deliver";
11035
11524
  /** Read at most this much of a response before cancelling the rest. */
11036
11525
  var MAX_RESPONSE_BYTES = 64 * 1024;
11037
11526
  var WebhookDispatcher = class {
@@ -11085,21 +11574,64 @@ var WebhookDispatcher = class {
11085
11574
  * for a third party. It also means a receiver that is down can no longer
11086
11575
  * fail the customer's write.
11087
11576
  *
11088
- * The cost, stated plainly: the queue is in-process and in-memory. A crash
11089
- * or a deploy between the enqueue and the delivery drops the event, and a
11090
- * receiver may see the notification a few milliseconds before the row it
11091
- * describes is committed. Use {@link flush} on shutdown, and
11092
- * `onDelivery` to record failures.
11577
+ * The cost, when no `jobQueue` is configured: the queue is in-process and
11578
+ * in-memory. A crash or a deploy between the enqueue and the delivery drops
11579
+ * the event, and a receiver may see the notification a few milliseconds
11580
+ * before the row it describes is committed. Use {@link flush} on shutdown,
11581
+ * and `onDelivery` to record failures.
11582
+ *
11583
+ * With `jobQueue` set, only the second half of that remains: the delivery
11584
+ * becomes a row in `rebase.jobs` and survives the crash, the deploy and the
11585
+ * pod being rescheduled.
11093
11586
  */
11094
11587
  enqueueEntityChange(table, event, id, entity, previousEntity) {
11095
11588
  const jobs = this.buildDeliveries(table, event, entity, previousEntity);
11096
11589
  if (jobs.length === 0) return;
11590
+ if (this.options.jobQueue) {
11591
+ for (const job of jobs) this.options.jobQueue.enqueue(WEBHOOK_DELIVERY_TASK, {
11592
+ webhookId: job.webhook.id,
11593
+ event: job.event,
11594
+ payload: job.payload
11595
+ }).catch((error) => {
11596
+ logger.error("[webhooks] Could not queue a delivery; falling back to in-process", { error });
11597
+ this.queue.push(job);
11598
+ this.startDraining();
11599
+ });
11600
+ return;
11601
+ }
11097
11602
  this.queue.push(...jobs);
11098
11603
  this.startDraining();
11099
11604
  }
11100
11605
  /**
11606
+ * Deliver one queued job, as the worker runs it.
11607
+ *
11608
+ * One attempt, and it throws on failure. The retries belong to the queue
11609
+ * now: keeping both would multiply — three in-process attempts inside each
11610
+ * of three job attempts is nine deliveries and about two minutes of holding
11611
+ * a worker slot — and only the queue's retries survive a restart, which is
11612
+ * the whole reason for moving.
11613
+ */
11614
+ async deliverQueuedJob(job) {
11615
+ const webhook = this.webhooks.find((w) => w.id === job.webhookId);
11616
+ if (!webhook) {
11617
+ logger.warn(`[webhooks] Dropping a queued delivery for unknown or disabled webhook "${job.webhookId}"`);
11618
+ return;
11619
+ }
11620
+ const { result, terminal } = await this.deliver(webhook, job.event, job.payload, 1);
11621
+ this.options.onDelivery?.(result);
11622
+ if (result.success) return;
11623
+ if (terminal) {
11624
+ logger.error(`[webhooks] "${webhook.id}" failed permanently: ${result.responseBody.slice(0, 200)}`);
11625
+ return;
11626
+ }
11627
+ throw new Error(`Webhook "${webhook.id}" responded ${result.statusCode}: ${result.responseBody.slice(0, 200)}`);
11628
+ }
11629
+ /**
11101
11630
  * Wait for every queued delivery to finish. For graceful shutdown, and for
11102
11631
  * tests that need to observe what {@link enqueueEntityChange} sent.
11632
+ *
11633
+ * In-process deliveries only — with a `jobQueue` configured there is
11634
+ * nothing here to wait for, because the deliveries are rows.
11103
11635
  */
11104
11636
  async flush() {
11105
11637
  while (this.draining) await this.draining;
@@ -11299,7 +11831,7 @@ function listenWithPortRetry(server, startPort, options) {
11299
11831
  server.once("error", onError);
11300
11832
  server.listen(startPort, host, () => {
11301
11833
  server.removeListener("error", onError);
11302
- resolve(startPort);
11834
+ resolve(boundPort(server) ?? startPort);
11303
11835
  });
11304
11836
  });
11305
11837
  let affinityPort = null;
@@ -11329,14 +11861,15 @@ function listenWithPortRetry(server, startPort, options) {
11329
11861
  attempt++;
11330
11862
  const onListening = () => {
11331
11863
  cleanup();
11864
+ const bound = boundPort(server) ?? port;
11332
11865
  if (portFileDir) {
11333
11866
  try {
11334
11867
  const portFile = path.join(portFileDir, DEV_PORT_FILENAME);
11335
- fs.writeFileSync(portFile, `${port} ${startPort}`, "utf-8");
11868
+ fs.writeFileSync(portFile, `${bound} ${startPort}`, "utf-8");
11336
11869
  } catch {}
11337
- writeStateFile(portFileDir, port, options?.serviceKey);
11870
+ writeStateFile(portFileDir, bound, options?.serviceKey);
11338
11871
  }
11339
- resolve(port);
11872
+ resolve(bound);
11340
11873
  };
11341
11874
  const onError = (err) => {
11342
11875
  cleanup();
@@ -11404,6 +11937,18 @@ function writeStateFile(projectRoot, port, serviceKey) {
11404
11937
  fs.chmodSync(stateFile, 384);
11405
11938
  } catch {}
11406
11939
  }
11940
+ /**
11941
+ * The port a server is actually listening on, or `undefined` if it cannot say.
11942
+ *
11943
+ * `Server.address()` returns a string for a UNIX socket and `null` before the
11944
+ * socket is bound, so neither is a port and both fall back to the caller's own
11945
+ * answer rather than being coerced into a number.
11946
+ */
11947
+ function boundPort(server) {
11948
+ const address = server.address();
11949
+ if (!address || typeof address === "string") return void 0;
11950
+ return address.port;
11951
+ }
11407
11952
  //#endregion
11408
11953
  //#region src/serve-spa.ts
11409
11954
  /**
@@ -11895,7 +12440,64 @@ var bootEnvExtension = object({
11895
12440
  ""
11896
12441
  ]).default("true").transform((v) => v !== "false"),
11897
12442
  /** Comma-separated origins allowed to make credentialed cross-origin calls. */
11898
- CORS_ORIGINS: string().optional()
12443
+ CORS_ORIGINS: string().optional(),
12444
+ /**
12445
+ * Which part of the project this process serves.
12446
+ *
12447
+ * `all` — everything, in one process. The default, and what every existing
12448
+ * deployment gets by setting nothing.
12449
+ *
12450
+ * The other three exist so one image and one bundle can be booted several
12451
+ * times over, each serving a different part: `api` (data, auth, admin,
12452
+ * storage, meta), `functions` (custom functions only), `worker` (no HTTP
12453
+ * surface at all — cron and the job queue).
12454
+ *
12455
+ * See `resolveRole` in `boot/role.ts` for exactly what each one mounts and
12456
+ * owns, and for the combinations that refuse to boot.
12457
+ */
12458
+ REBASE_ROLE: _enum([
12459
+ "all",
12460
+ "api",
12461
+ "functions",
12462
+ "worker",
12463
+ ""
12464
+ ]).default("all").transform((v) => v === "" ? "all" : v),
12465
+ /**
12466
+ * Override whether this process runs the cron scheduler's timers.
12467
+ *
12468
+ * Separate from the role because "which URLs answer" and "which timers fire"
12469
+ * are independent questions: a three-way split wants cron off the `api`
12470
+ * process, a two-way split wants it on. Unset follows the role.
12471
+ */
12472
+ REBASE_CRON_SCHEDULER: _enum([
12473
+ "true",
12474
+ "false",
12475
+ ""
12476
+ ]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
12477
+ /** Override whether this process runs job-queue workers. Unset follows the role. */
12478
+ REBASE_JOB_WORKERS: _enum([
12479
+ "true",
12480
+ "false",
12481
+ ""
12482
+ ]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
12483
+ /**
12484
+ * Comma-separated function names this process serves. Unset means all.
12485
+ *
12486
+ * `functions` role only. A name that is not in the bundle is a boot failure,
12487
+ * not a warning — the deployment exists to serve that function, and a typo
12488
+ * that silently serves nothing is the failure this must not have.
12489
+ */
12490
+ REBASE_FUNCTIONS_ONLY: string().optional(),
12491
+ /** Comma-separated function names to skip. Applied after `_ONLY`. */
12492
+ REBASE_FUNCTIONS_EXCLUDE: string().optional(),
12493
+ /**
12494
+ * Base URL of the process serving `/api/functions/*`.
12495
+ *
12496
+ * `api` role only. Set it and this process forwards those requests there
12497
+ * instead of mounting them, so a split deployment presents the identical URL
12498
+ * surface and nobody has to run a reverse proxy to try one.
12499
+ */
12500
+ REBASE_FUNCTIONS_UPSTREAM: string().optional()
11899
12501
  });
11900
12502
  /**
11901
12503
  * Load and validate the environment for a bundle boot.
@@ -11952,24 +12554,152 @@ function resolveEnableSwagger(env) {
11952
12554
  return env.NODE_ENV === "production" ? false : void 0;
11953
12555
  }
11954
12556
  /**
11955
- * Build the CORS origin policy.
12557
+ * Build the CORS origin policy.
12558
+ *
12559
+ * Production serves an explicit allow-list and nothing else. `loadEnv` already
12560
+ * refuses to start a production process with neither `CORS_ORIGINS` nor
12561
+ * `FRONTEND_URL`, so an empty list here can only mean the values were blank
12562
+ * strings — still worth failing on, because the alternative is an API that
12563
+ * quietly rejects its own frontend.
12564
+ */
12565
+ function resolveCorsOrigin(env) {
12566
+ if (!(env.NODE_ENV === "production")) return (origin) => {
12567
+ if (!origin) return "*";
12568
+ return isLocalhostOrigin(origin) ? origin : null;
12569
+ };
12570
+ const allowed = (env.CORS_ORIGINS || env.FRONTEND_URL || "").split(",").map((s) => s.trim()).filter(Boolean);
12571
+ if (allowed.length === 0) throw new Error("CORS_ORIGINS or FRONTEND_URL must be set in production. Example: CORS_ORIGINS=https://yourdomain.com");
12572
+ if (allowed.includes("*")) throw new Error("CORS_ORIGINS cannot be \"*\" — the API sends credentials, and browsers refuse a wildcard origin on credentialed requests. List the exact origins.");
12573
+ return (origin) => allowed.includes(origin) ? origin : null;
12574
+ }
12575
+ //#endregion
12576
+ //#region src/boot/role.ts
12577
+ /**
12578
+ * A refusal to boot, phrased as the variable to change.
12579
+ *
12580
+ * Its own class so `bootFromBundle` can present it as a configuration problem
12581
+ * rather than a crash. Every message names the variable and the fix, because the
12582
+ * reader is looking at a container that will not start and has one line of log
12583
+ * to work from.
12584
+ */
12585
+ var RoleConfigurationError = class extends Error {
12586
+ hint;
12587
+ constructor(message, hint) {
12588
+ super(message);
12589
+ this.hint = hint;
12590
+ this.name = "RoleConfigurationError";
12591
+ }
12592
+ };
12593
+ /**
12594
+ * What each role serves and owns.
12595
+ *
12596
+ * Read as a table, deliberately. `api` keeps cron and the job workers so that
12597
+ * the common two-service split (`api` + `functions`) is complete without a third
12598
+ * container; a deployment that wants them off the request path adds a `worker`
12599
+ * and sets `REBASE_CRON_SCHEDULER=false` on the api. `functions` runs no timers
12600
+ * at all — a function process is scaled by request load and replaced at will, so
12601
+ * giving it scheduled work would make its replica count mean something.
12602
+ */
12603
+ var ROLES = {
12604
+ all: {
12605
+ surfaces: {},
12606
+ ownership: {},
12607
+ provisionSchema: true
12608
+ },
12609
+ api: {
12610
+ surfaces: { functions: false },
12611
+ ownership: {},
12612
+ provisionSchema: true
12613
+ },
12614
+ functions: {
12615
+ surfaces: {
12616
+ auth: false,
12617
+ data: false,
12618
+ storage: false,
12619
+ admin: false,
12620
+ cron: false,
12621
+ meta: false
12622
+ },
12623
+ ownership: {
12624
+ cronScheduler: false,
12625
+ jobWorkers: false
12626
+ },
12627
+ provisionSchema: false
12628
+ },
12629
+ worker: {
12630
+ surfaces: {
12631
+ auth: false,
12632
+ data: false,
12633
+ storage: false,
12634
+ admin: false,
12635
+ functions: false,
12636
+ cron: false,
12637
+ meta: false
12638
+ },
12639
+ ownership: {},
12640
+ provisionSchema: false
12641
+ }
12642
+ };
12643
+ /** Split a comma-separated list, dropping blanks. */
12644
+ function parseNameList(raw) {
12645
+ if (!raw) return [];
12646
+ return raw.split(",").map((name) => name.trim()).filter(Boolean);
12647
+ }
12648
+ /**
12649
+ * Resolve the process shape, or refuse.
12650
+ *
12651
+ * @throws RoleConfigurationError for a combination that would boot into a state
12652
+ * nobody meant — see the refusals inline. Each one is a refusal rather than a
12653
+ * warning because its failure mode is silent: a second process racing schema
12654
+ * DDL, or presence quietly not crossing instances, is not something an
12655
+ * operator finds by reading logs.
12656
+ */
12657
+ function resolveRole(env) {
12658
+ const role = env.REBASE_ROLE ?? "all";
12659
+ const base = ROLES[role];
12660
+ if (!base) throw new RoleConfigurationError(`REBASE_ROLE="${role}" is not a role.`, `Use one of: ${Object.keys(ROLES).join(", ")}.`);
12661
+ const migrate = env.REBASE_MIGRATE_ON_BOOT || "ensure";
12662
+ if (!base.provisionSchema && migrate !== "none") throw new RoleConfigurationError(`REBASE_ROLE="${role}" cannot also provision the database schema (REBASE_MIGRATE_ON_BOOT="${migrate}").`, "Set REBASE_MIGRATE_ON_BOOT=none on this process. Exactly one process in a split deployment provisions the schema, and that is the one running REBASE_ROLE=api (or all).");
12663
+ if (env.REBASE_FUNCTIONS_UPSTREAM && role !== "api") throw new RoleConfigurationError(`REBASE_FUNCTIONS_UPSTREAM is only read by REBASE_ROLE=api, and this process is "${role}".`, role === "functions" ? "A functions process serves those routes; it does not forward them. Set the upstream on the api process instead." : "Remove it, or set REBASE_ROLE=api on this process.");
12664
+ if (Boolean(env.REBASE_FUNCTIONS_ONLY || env.REBASE_FUNCTIONS_EXCLUDE) && role !== "functions") throw new RoleConfigurationError(`REBASE_FUNCTIONS_ONLY / REBASE_FUNCTIONS_EXCLUDE are only read by REBASE_ROLE=functions, and this process is "${role}".`, "Set them on the functions process, or set REBASE_ROLE=functions here.");
12665
+ const ownership = { ...base.ownership };
12666
+ if (env.REBASE_CRON_SCHEDULER !== void 0) ownership.cronScheduler = env.REBASE_CRON_SCHEDULER;
12667
+ if (env.REBASE_JOB_WORKERS !== void 0) ownership.jobWorkers = env.REBASE_JOB_WORKERS;
12668
+ return {
12669
+ role,
12670
+ surfaces: { ...base.surfaces },
12671
+ ownership,
12672
+ provisionSchema: base.provisionSchema,
12673
+ functionsSelection: {
12674
+ only: parseNameList(env.REBASE_FUNCTIONS_ONLY),
12675
+ exclude: parseNameList(env.REBASE_FUNCTIONS_EXCLUDE)
12676
+ },
12677
+ functionsUpstream: env.REBASE_FUNCTIONS_UPSTREAM?.trim() || void 0
12678
+ };
12679
+ }
12680
+ /**
12681
+ * ## Why there is no channel-bus refusal here
11956
12682
  *
11957
- * Production serves an explicit allow-list and nothing else. `loadEnv` already
11958
- * refuses to start a production process with neither `CORS_ORIGINS` nor
11959
- * `FRONTEND_URL`, so an empty list here can only mean the values were blank
11960
- * strings — still worth failing on, because the alternative is an API that
11961
- * quietly rejects its own frontend.
12683
+ * Splitting `api` from `functions` was expected to need one: the in-memory
12684
+ * channel bus is the default, and broadcast and presence do not cross processes
12685
+ * on it. It turns out to be the wrong place to check, for two reasons.
12686
+ *
12687
+ * The first is that a role split does not create the problem. Only the roles
12688
+ * that mount the API serve websockets at all — a `functions` process has no
12689
+ * realtime clients to keep in sync, and a `worker` has no HTTP surface. What
12690
+ * makes the bus matter is the *replica count* of the websocket-serving process,
12691
+ * which is exactly as true for a single `all` deployment scaled to three as it
12692
+ * is for a split one, and which no process can read from its own environment.
12693
+ *
12694
+ * The second is that the runtime already answers it better than a guess from
12695
+ * configuration could: `RealtimeService.warnIfMemoryBusOnMultiplePods` fires on
12696
+ * *evidence* — the first notification seen from another instance while the bus
12697
+ * is still in-memory. That is a fact rather than an inference, and it catches
12698
+ * the scaled-`all` case this would have missed.
12699
+ *
12700
+ * So: no refusal, and the operator guidance belongs in the deployment docs
12701
+ * beside the replica count, not here.
11962
12702
  */
11963
- function resolveCorsOrigin(env) {
11964
- if (!(env.NODE_ENV === "production")) return (origin) => {
11965
- if (!origin) return "*";
11966
- return isLocalhostOrigin(origin) ? origin : null;
11967
- };
11968
- const allowed = (env.CORS_ORIGINS || env.FRONTEND_URL || "").split(",").map((s) => s.trim()).filter(Boolean);
11969
- if (allowed.length === 0) throw new Error("CORS_ORIGINS or FRONTEND_URL must be set in production. Example: CORS_ORIGINS=https://yourdomain.com");
11970
- if (allowed.includes("*")) throw new Error("CORS_ORIGINS cannot be \"*\" — the API sends credentials, and browsers refuse a wildcard origin on credentialed requests. List the exact origins.");
11971
- return (origin) => allowed.includes(origin) ? origin : null;
11972
- }
11973
12703
  //#endregion
11974
12704
  //#region src/boot/sources.ts
11975
12705
  /**
@@ -12190,168 +12920,6 @@ function loadDeclaredStorageSources(startDir, levels = 5) {
12190
12920
  return [];
12191
12921
  }
12192
12922
  //#endregion
12193
- //#region src/boot/version-skew.ts
12194
- /**
12195
- * Telling a *stale* driver apart from a driver that never had the feature.
12196
- *
12197
- * The managed runtime is two halves that version independently. The image
12198
- * supplies `@rebasepro/server` — `docker/entrypoint.mjs` symlinks it over
12199
- * whatever the bundle installed, so the boot harness is always the image's. The
12200
- * **driver** is not redirected: `@rebasepro/server-postgres` comes from the
12201
- * bundle's `deps.declared`, pinned to whatever the project's `package.json`
12202
- * asked npm for. Shipping a new image therefore does not ship a new driver, and
12203
- * a fix that spans both packages reaches a tenant only after an npm publish AND
12204
- * a rebuild.
12205
- *
12206
- * That split has a nasty failure mode, and it has already burned a day. Boot
12207
- * looks for an optional capability, does not find it, and reports the honest
12208
- * local fact — "this driver does not implement collection-table creation" —
12209
- * which is exactly what a schemaless driver looks like. But a driver that is
12210
- * merely *older than the runtime asking* looks identical, and the two want
12211
- * opposite responses: one is "this database is not managed by Rebase, carry on",
12212
- * the other is "your tables were never created and every data route is about to
12213
- * 500". Naming the versions is what separates them, so these helpers exist to
12214
- * put both numbers in front of whoever reads the log.
12215
- */
12216
- /** The package whose version defines "the runtime" for skew purposes. */
12217
- var RUNTIME_PACKAGE = "@rebasepro/server";
12218
- /**
12219
- * Locate a package by walking `node_modules` up from a directory.
12220
- *
12221
- * Lives here rather than in `driver.ts` because both the driver loader and the
12222
- * version check need it, and `driver.ts` already imports this module.
12223
- */
12224
- function findPackageDir(fromDir, packageName) {
12225
- let dir = path.resolve(fromDir);
12226
- for (;;) {
12227
- const candidate = path.join(dir, "node_modules", ...packageName.split("/"));
12228
- if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
12229
- const parent = path.dirname(dir);
12230
- if (parent === dir) return void 0;
12231
- dir = parent;
12232
- }
12233
- }
12234
- /**
12235
- * Parse the subset of semver the workspace actually publishes.
12236
- *
12237
- * Deliberately lenient: an unparseable version yields `undefined` and every
12238
- * comparison then declines to judge, because a wrong "your driver is old"
12239
- * warning on a fork's custom version string is worse than no warning at all.
12240
- */
12241
- function parseVersion(version) {
12242
- const match = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(version.trim());
12243
- if (!match) return void 0;
12244
- return {
12245
- parts: [
12246
- Number(match[1]),
12247
- Number(match[2]),
12248
- Number(match[3])
12249
- ],
12250
- prerelease: match[4]
12251
- };
12252
- }
12253
- /**
12254
- * Compare two versions, or `undefined` when either cannot be parsed.
12255
- *
12256
- * Follows semver on the one rule that matters here: a prerelease sorts *below*
12257
- * the release it leads to, so `0.12.1-canary.g7f1150f` < `0.12.1`. Without that
12258
- * rule every canary would read as newer than the stable it precedes, and the
12259
- * canaries are precisely where fixes land first.
12260
- */
12261
- function compareVersions(a, b) {
12262
- const left = parseVersion(a);
12263
- const right = parseVersion(b);
12264
- if (!left || !right) return void 0;
12265
- for (let i = 0; i < 3; i++) if (left.parts[i] !== right.parts[i]) return left.parts[i] < right.parts[i] ? -1 : 1;
12266
- if (left.prerelease === right.prerelease) return 0;
12267
- if (left.prerelease === void 0) return 1;
12268
- if (right.prerelease === void 0) return -1;
12269
- return left.prerelease < right.prerelease ? -1 : 1;
12270
- }
12271
- /**
12272
- * Describe how a driver's version relates to the runtime asking it for work.
12273
- *
12274
- * Returns `stale: false` whenever it cannot tell — an unknown version, an
12275
- * unparseable one, a driver that is newer. Silence beats a confident wrong
12276
- * diagnosis, and the caller still prints its own local fact either way.
12277
- */
12278
- function describeDriverSkew(driverVersion, runtimeVersion) {
12279
- if (!driverVersion || !runtimeVersion) return { stale: false };
12280
- const order = compareVersions(driverVersion, runtimeVersion);
12281
- if (order === void 0) return { stale: false };
12282
- if (order < 0) return {
12283
- stale: true,
12284
- detail: `The driver is at ${driverVersion} while this runtime is ${runtimeVersion} — the driver is OLDER, so this is very likely a stale pin rather than a driver that never had the capability.`
12285
- };
12286
- return {
12287
- stale: false,
12288
- detail: `Driver ${driverVersion}, runtime ${runtimeVersion}.`
12289
- };
12290
- }
12291
- /**
12292
- * How to get a project's schema applied, written once and owned by the runtime.
12293
- *
12294
- * This text lives in `@rebasepro/server` on purpose. The equivalent guidance in
12295
- * the Postgres driver's schema-drift warning can only ever be as current as the
12296
- * driver a tenant pinned — so the tenants who most need to be told "your driver
12297
- * is too old" are exactly the ones whose driver still prints the old advice.
12298
- * The image's copy of this package is never stale, so guidance printed from
12299
- * here always reflects the platform as it is today.
12300
- *
12301
- * Both audiences get a line because the runtime cannot reliably tell which one
12302
- * it is serving, and naming only the pnpm scripts — as this once did everywhere
12303
- * — tells a managed operator to run a command that structurally cannot reach
12304
- * their in-cluster database.
12305
- */
12306
- function schemaRecoveryGuidance(options = {}) {
12307
- const lines = [
12308
- " To apply this project's schema:",
12309
- " • Managed cloud: the runtime applies tables and RLS at boot (unless",
12310
- " REBASE_MIGRATE_ON_BOOT=none). `rebase db push` cannot reach a tenant's",
12311
- " in-cluster database — redeploy instead."
12312
- ];
12313
- if (options.staleDriver) lines.push(" Because the driver is pinned by your bundle, bump the", " `@rebasepro/server-postgres` version in your project's package.json", " and redeploy — a newer platform image alone will NOT update it.");
12314
- lines.push(" • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod).");
12315
- return lines.join("\n");
12316
- }
12317
- /**
12318
- * The installed version of a package, read from its `package.json`.
12319
- *
12320
- * Returns `undefined` rather than throwing for every failure — a missing or
12321
- * malformed manifest degrades the diagnosis, and must never take down a boot
12322
- * that would otherwise have served.
12323
- */
12324
- function readPackageVersion(packageDir) {
12325
- try {
12326
- const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
12327
- return typeof pkg.version === "string" ? pkg.version : void 0;
12328
- } catch {
12329
- return;
12330
- }
12331
- }
12332
- /**
12333
- * The version of `@rebasepro/server` this deployment is actually running.
12334
- *
12335
- * Resolved from the same roots the drivers are, and that is the point rather
12336
- * than a convenience. In a managed container `docker/entrypoint.mjs` replaces
12337
- * `/bundle/node_modules/@rebasepro/server` with a link to the image's copy, so
12338
- * reading the manifest *through the bundle* reports the version that will
12339
- * really execute — which is exactly the number the driver has to be compared
12340
- * against. Reading this package's own manifest by module path would report the
12341
- * same thing in the happy case and quietly lie in the case that matters: a
12342
- * bundle whose link did not happen.
12343
- *
12344
- * Returns `undefined` when the package cannot be found; every caller treats
12345
- * that as "cannot judge" and stays quiet.
12346
- */
12347
- function readRuntimeVersion(roots) {
12348
- for (const root of roots) {
12349
- const dir = findPackageDir(root, RUNTIME_PACKAGE);
12350
- const version = dir ? readPackageVersion(dir) : void 0;
12351
- if (version) return version;
12352
- }
12353
- }
12354
- //#endregion
12355
12923
  //#region src/boot/driver.ts
12356
12924
  /**
12357
12925
  * Run a trivial query against a source, to see whether it answers.
@@ -12567,6 +13135,10 @@ function resolveAuthOptions(env, usersCollection) {
12567
13135
  const auth = {
12568
13136
  collection: usersCollection,
12569
13137
  jwtSecret: env.JWT_SECRET,
13138
+ signingKeys: env.JWT_PRIVATE_KEY ? [{
13139
+ kid: env.JWT_KEY_ID,
13140
+ privateKey: normalizePemFromEnv(env.JWT_PRIVATE_KEY)
13141
+ }] : void 0,
12570
13142
  accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
12571
13143
  refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
12572
13144
  serviceKey: env.REBASE_SERVICE_KEY,
@@ -12990,6 +13562,11 @@ async function bootFromBundle(options = {}) {
12990
13562
  if (bundle.manifest.kind === "static") return bootStaticApp(bundle, devRoot, options);
12991
13563
  const env = loadBootEnv();
12992
13564
  const isProduction = env.NODE_ENV === "production";
13565
+ const runtimeRole = resolveRole(env);
13566
+ if (runtimeRole.role !== "all") logger.info(`Runtime role: ${runtimeRole.role}`, {
13567
+ provisionsSchema: runtimeRole.provisionSchema,
13568
+ ...runtimeRole.ownership
13569
+ });
12993
13570
  const configExports = await loadBundleConfigExports(bundle);
12994
13571
  const dataSourceDefs = configExports.dataSources;
12995
13572
  const declaredStorage = normalizeStorageSources(bundle.manifest.storage?.sources, configExports.storageSources);
@@ -13013,14 +13590,20 @@ async function bootFromBundle(options = {}) {
13013
13590
  const server = createServer(getRequestListener(app.fetch));
13014
13591
  const usersCollection = await loadUsersCollection(bundle);
13015
13592
  const storage = resolveStorageSources(process.env, storageSourceDefs, path.join(bundle.dir, "uploads"));
13016
- await ensureCollectionSchema(bundle, dataSources, env);
13593
+ warnOnUnusableBundleShape(bundle);
13017
13594
  const backend = await initializeRebaseBackend({
13018
13595
  server,
13019
13596
  app,
13020
13597
  basePath: env.REBASE_BASE_PATH,
13598
+ provisioningDriverResult: dataSources[0] ? { internals: dataSources[0].connection } : void 0,
13021
13599
  collectionsDir: bundle.collectionsDir,
13022
13600
  functionsDir: bundle.functionsDir,
13023
13601
  cronsDir: bundle.cronsDir,
13602
+ provisionSchema: options.provisionSchema ?? runtimeRole.provisionSchema,
13603
+ surfaces: runtimeRole.surfaces,
13604
+ ownership: runtimeRole.ownership,
13605
+ functionsSelection: runtimeRole.functionsSelection,
13606
+ functionsUpstream: runtimeRole.functionsUpstream,
13024
13607
  bootstrappers: dataSources.map((s) => s.bootstrapper),
13025
13608
  dataSources: dataSourceDefs,
13026
13609
  storage,
@@ -13040,7 +13623,6 @@ async function bootFromBundle(options = {}) {
13040
13623
  runtimeVersion: bundle.manifest.runtime?.builtAgainst,
13041
13624
  schemaEditor: false
13042
13625
  });
13043
- await ensureCollectionPolicies(bundle, dataSources, env);
13044
13626
  metrics?.setKnownCollections(backend.collectionRegistry.getCollections().map((collection) => collection.slug).filter((slug) => Boolean(slug)));
13045
13627
  const healthPaths = ["/health", `${(env.REBASE_BASE_PATH || "/api").replace(/\/$/, "")}/health`];
13046
13628
  for (const healthPath of [...new Set(healthPaths)]) app.get(healthPath, async (c) => {
@@ -13079,6 +13661,7 @@ async function bootFromBundle(options = {}) {
13079
13661
  "/health",
13080
13662
  "/livez",
13081
13663
  "/metrics",
13664
+ "/.well-known",
13082
13665
  ...siblings
13083
13666
  ],
13084
13667
  spa: staticApp.spa
@@ -13090,10 +13673,12 @@ async function bootFromBundle(options = {}) {
13090
13673
  server.once("error", reject);
13091
13674
  server.listen(env.PORT, () => {
13092
13675
  server.removeListener("error", reject);
13676
+ const address = server.address();
13677
+ if (address && typeof address === "object") port = address.port;
13093
13678
  resolve();
13094
13679
  });
13095
13680
  });
13096
- logger.info(`Rebase runtime listening on port ${env.PORT}`);
13681
+ logger.info(`Rebase runtime listening on port ${port}`);
13097
13682
  } else {
13098
13683
  port = await listenWithPortRetry(server, env.PORT, {
13099
13684
  portFileDir: devRoot,
@@ -13227,7 +13812,7 @@ async function runFromBundle(options = {}) {
13227
13812
  try {
13228
13813
  return await bootFromBundle(options);
13229
13814
  } catch (err) {
13230
- if (err instanceof BundleError) {
13815
+ if (err instanceof BundleError || err instanceof RoleConfigurationError || err instanceof FunctionSelectionError) {
13231
13816
  logger.error(err.message);
13232
13817
  if (err.hint) logger.error(err.hint);
13233
13818
  } else logger.error("Failed to start the Rebase runtime", { error: err instanceof Error ? err : new Error(String(err)) });
@@ -13259,158 +13844,25 @@ function warnOnDriverSkew(dataSources, runtimeVersion) {
13259
13844
  }
13260
13845
  }
13261
13846
  /**
13262
- * The collections a data source's engine is the store for.
13263
- *
13264
- * A bundle's collections directory holds *every* collection the project
13265
- * declares, whatever engine serves it — that is the point of `dataSource`
13266
- * routing. What it is not is a list of tables to create: handing the whole
13267
- * directory to the primary source's bootstrapper made a Firestore collection
13268
- * declared alongside the Postgres ones arrive as an empty Postgres table, with
13269
- * RLS policies, while the app went on reading its documents from Firestore.
13270
- *
13271
- * Excluding is deliberately conservative. A collection that names neither an
13272
- * `engine` nor a `dataSource` belongs to whichever source is primary — the
13273
- * "postgres" that `resolveDataSource` falls back to there is a default, not a
13274
- * declaration, and must not exclude anything on its own. Only a collection that
13275
- * explicitly routes to a *different* engine is dropped, so a project running two
13276
- * sources on the same engine is unaffected.
13277
- */
13278
- function collectionsStoredBy(collections, primary, dataSources) {
13279
- const registry = createDataSourceRegistry(dataSources.map((source) => ({
13280
- key: source.key,
13281
- engine: source.engine
13282
- })));
13283
- return collections.filter((collection) => {
13284
- const declared = collection.engine ?? (collection.dataSource ? resolveDataSource(collection, registry).engine : void 0);
13285
- if (!declared) return true;
13286
- return declared === primary.engine;
13287
- });
13288
- }
13289
- /** Say what was routed elsewhere, so a missing table is never a silent one. */
13290
- function logForeignCollections(all, stored, primary) {
13291
- if (stored.length === all.length) return;
13292
- const foreign = all.filter((c) => !stored.includes(c)).map((c) => c.slug);
13293
- logger.info(`Skipping ${foreign.length} collection(s) served by another engine, not "${primary.engine}": ${foreign.join(", ")}. Their storage is not managed by this data source.`);
13294
- }
13295
- /**
13296
- * Bring the database's collection tables up to date before serving.
13297
- *
13298
- * Delegates to whichever driver bootstrapped the default data source; a driver
13299
- * without `ensureCollectionSchema` (a schemaless one, or an older build) skips
13300
- * rather than failing, which is why this cannot break an existing deployment.
13301
- *
13302
- * Every path out of here says why, at info or louder. Guaranteeing the tables
13303
- * exist is this function's entire job, so "it declined, and said nothing" is the
13304
- * one outcome it must never produce: a deployment that skips comes up answering
13305
- * sign-in and 500ing every `/api/data/*` route, and the operator's only evidence
13306
- * is what these lines print. Silence here has already sent one investigation
13307
- * chasing a stale runtime image that was not stale.
13308
- *
13309
- * Failure is fatal on purpose. Booting anyway would produce exactly the state
13310
- * this exists to prevent — an app that answers sign-in and 500s every data
13311
- * request — and a crash-looping pod with the DDL error in its logs is a far
13312
- * better signal than a running one that silently cannot serve.
13313
- */
13314
- async function ensureCollectionSchema(bundle, dataSources, env) {
13315
- const skip = (reason, level = "info") => {
13316
- logger[level](`Collection schema: skipped — ${reason}`);
13317
- };
13318
- if ((env.REBASE_MIGRATE_ON_BOOT || "ensure") === "none") {
13319
- skip("REBASE_MIGRATE_ON_BOOT=none, leaving the database schema untouched.");
13320
- return;
13321
- }
13322
- if (bundle.manifest.kind !== "backend") {
13323
- skip(`this bundle's kind is "${bundle.manifest.kind}", which serves no database.`);
13324
- return;
13325
- }
13326
- if (!bundle.manifest.entry?.config) {
13327
- skip("this bundle declares no config package, so its collections are read from the database rather than from code.");
13328
- return;
13329
- }
13330
- if (!bundle.collectionsDir) {
13331
- skip(`this bundle declares a config package at "${bundle.manifest.entry.config}", but no collections directory resolved inside it. Rebuild with \`rebase build\` and check the manifest's \`entry.collections\`.`, "warn");
13332
- return;
13333
- }
13334
- const primary = dataSources[0];
13335
- if (!primary) {
13336
- skip("no data source was initialized for this runtime.", "warn");
13337
- return;
13338
- }
13339
- if (!primary.bootstrapper.ensureCollectionSchema) {
13340
- const skew = describeDriverSkew(primary.driverVersion, readRuntimeVersion(bundleResolutionRoots(bundle.dir)));
13341
- skip(`the adapter from "${primary.driverPackage}" (engine "${primary.engine}") does not expose collection-table creation. ` + (skew.detail ? `${skew.detail} ` : "") + "The driver package may well implement it on a class the adapter does not forward, so check the adapter's shape before blaming its version. Collection tables will NOT be created, so every /api/data route will fail on a missing relation.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }), "warn");
13342
- return;
13343
- }
13344
- const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
13345
- if (loaded.length === 0) {
13346
- skip(`no collections were loaded from "${bundle.collectionsDir}".`, "warn");
13347
- return;
13348
- }
13349
- const collections = collectionsStoredBy(loaded, primary, dataSources);
13350
- logForeignCollections(loaded, collections, primary);
13351
- if (collections.length === 0) {
13352
- skip(`none of the ${loaded.length} declared collection(s) are stored by the "${primary.engine}" data source.`);
13353
- return;
13354
- }
13355
- const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, preInitDriverResult(primary), (message) => logger.debug(`schema: ${message}`));
13356
- if (applied > 0) logger.info(`Applied ${applied} additive schema change(s) before boot.`);
13357
- else logger.debug("Collection schema is up to date.");
13358
- }
13359
- /**
13360
- * The `InitializedDriver` to hand a bootstrapper before any driver exists.
13847
+ * Warn about a bundle whose shape makes provisioning impossible.
13361
13848
  *
13362
- * Both schema hooks are declared to take the result of `initializeDriver`, but
13363
- * they deliberately run *before* it: the tables have to exist before the driver
13364
- * introspects them and registers collections. So there is no real result to
13365
- * pass, and the field the hooks actually read is `internals` the driver's own
13366
- * opaque handle, which at this point is exactly the connection the coordinator
13367
- * just opened (`{ db, pool }`, where `db` is the drizzle instance).
13849
+ * `initializeRebaseBackend` provisions the collection schema for every boot
13850
+ * path, and it decides from what actually resolved: a project with no
13851
+ * collections reads them from the database and creates nothing. That rule is
13852
+ * right, but from inside the runtime it cannot tell "this project declares no
13853
+ * collections" from "this build lost them" and the second is a broken build
13854
+ * that silently serves an empty API.
13368
13855
  *
13369
- * Wrapping it matters: the connection passed *bare* type-checks through any cast
13370
- * and then reads `undefined.db` inside the driver, which surfaces as a boot
13371
- * crash — `TypeError: Cannot read properties of undefined (reading 'db')` — on
13372
- * every project whose driver implements these hooks. The cast is narrowed to the
13373
- * one field a pre-init result cannot honestly supply, rather than `as never`
13374
- * blanketing the whole argument.
13856
+ * Only the bundle knows the difference, so this is the one piece of the old
13857
+ * bundle-side provisioning worth keeping here. It warns; it never provisions.
13375
13858
  */
13376
- function preInitDriverResult(source) {
13377
- return { internals: source.connection };
13378
- }
13379
- /**
13380
- * Apply the project's RLS policies before serving — the companion to
13381
- * {@link ensureCollectionSchema}, which creates the tables this makes servable.
13382
- *
13383
- * Runs after `initializeRebaseBackend`, not alongside table creation: the
13384
- * generated policies call the `auth.*` helper functions, and `CREATE POLICY`
13385
- * validates those exist, so this cannot run before auth is initialized. The
13386
- * gate conditions mirror `ensureCollectionSchema` (mode, bundle shape, driver
13387
- * support) — and because that function already ran and explained any skip on
13388
- * this same boot, the benign gates here return quietly rather than logging the
13389
- * same reason twice. The one thing it does say out loud is a driver that
13390
- * created tables but cannot apply policies: that is the difference between a
13391
- * served collection and a 401, and it must not pass in silence.
13392
- */
13393
- async function ensureCollectionPolicies(bundle, dataSources, env) {
13394
- if ((env.REBASE_MIGRATE_ON_BOOT || "ensure") === "none") return;
13859
+ function warnOnUnusableBundleShape(bundle) {
13395
13860
  if (bundle.manifest.kind !== "backend") return;
13396
13861
  if (!bundle.manifest.entry?.config) return;
13397
- if (!bundle.collectionsDir) return;
13398
- const primary = dataSources[0];
13399
- if (!primary) return;
13400
- if (!primary.bootstrapper.ensureCollectionPolicies) {
13401
- const skew = describeDriverSkew(primary.driverVersion, readRuntimeVersion(bundleResolutionRoots(bundle.dir)));
13402
- logger.warn(`Collection policies: skipped — the "${primary.driverPackage}" driver (engine "${primary.engine}") does not apply RLS policies at boot. ` + (skew.detail ? `${skew.detail} ` : "") + "Collections are not row-secured until policies are applied — depending on the driver's grants that means reads are denied or that they are unfiltered. Do not serve traffic until this is resolved.\n" + schemaRecoveryGuidance({ staleDriver: skew.stale }));
13403
- return;
13404
- }
13405
- const loaded = await loadCollectionsFromDirectory(bundle.collectionsDir);
13406
- if (loaded.length === 0) return;
13407
- const collections = collectionsStoredBy(loaded, primary, dataSources);
13408
- if (collections.length === 0) return;
13409
- const { applied } = await primary.bootstrapper.ensureCollectionPolicies(collections, preInitDriverResult(primary), (message) => logger.debug(`policies: ${message}`));
13410
- if (applied > 0) logger.info(`Applied ${applied} RLS policy statement(s) before serving.`);
13411
- else logger.debug("RLS policies are up to date.");
13862
+ if (bundle.collectionsDir) return;
13863
+ 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\`.`);
13412
13864
  }
13413
13865
  //#endregion
13414
- export { ApiError, BlockedUrlError, BundleError, 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, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertAllowedOutboundUrl, assertCollectionConfigs, assertDistinctSuffixes, assertEmailLinkBases, authJwt, authRoles, authUid, blockedAddressReason, bootFromBundle, classifySurface, cleanupDevPortFile, createAppleProvider, createBackupRoutes, createBitbucketProvider, createBuiltinAuthAdapter, createContractRoutes, createCronRoutes, createCronStore, createCustomAuthAdapter, createDiscordProvider, createEmailService, createFacebookProvider, createFunctionRoutes, createGitHubProvider, createGitLabProvider, createGoogleProvider, createHistoryRoutes, createLinkedinProvider, createMetricsMiddleware, createMetricsRoutes, createMicrosoftProvider, createSlackProvider, createSourceBundle, createSpotifyProvider, createStorageController, createStorageRoutes, createTwitterProvider, defineCron, defineFunction, envSuffixForKey, errorHandler, escapeHtml, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, html, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, 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, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword };
13866
+ 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, resolveEmailLinkBase, resolveEmailOptions, resolveRequireAuth, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, tryVerifyOidcIdToken, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyOidcIdToken, verifyPassword };
13415
13867
 
13416
13868
  //# sourceMappingURL=index.es.js.map