@rebasepro/server 0.11.0 → 0.11.1-canary.g039ee17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -2,8 +2,8 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { i as __toESM, n as __exportAll } from "./chunk-DSJWtz9O.js";
5
- import { C as RebaseClientError, S as RebaseApiError, _ as toCanonicalOp, a as DEFAULT_DATA_SOURCE_KEY, b as GeoPoint, f as isPostgresCollectionConfig, i as DEFAULT_STORAGE_SOURCE_KEY, n as serializeCollections, r as SCHEMA_VERSION_HEADER, s as isSQLAdmin, t as computeSchemaVersion, u as getCollectionDataPath, v as EntityReference, x as Vector, y as EntityRelation } from "./src-BYbxB4PR.js";
6
- import { a as deserializeFilter, c as serializeLogicalCondition, d as resolveDataSource, f as findRelation, h as toSnakeCase, i as buildSdkData, l as CollectionRegistry, m as buildCompositeId, n as serializeOrderBy, o as deserializeLogicalCondition, p as resolveCollectionRelations, r as buildRoutedRebaseData, s as serializeFilter, t as deserializeOrderBy, u as createDataSourceRegistry } from "./src-q6_elgGZ.js";
5
+ import { C as EntityReference, D as RebaseApiError, E as Vector, O as RebaseClientError, S as toCanonicalOp, T as GeoPoint, _ as ADMIN_COLLECTION_KEYS, a as findStorageSuffixCollision, c as isSQLAdmin, d as getCollectionDataPath, h as DEFAULT_DATA_SOURCE_KEY, i as DEFAULT_STORAGE_SOURCE_KEY, n as serializeCollections, o as normalizeStorageSources, p as isPostgresCollectionConfig, r as SCHEMA_VERSION_HEADER, s as storageEnvSuffix, t as computeSchemaVersion, v as ADMIN_PROPERTY_KEYS, w as EntityRelation } from "./src-Ivjud8jD.js";
6
+ import { _ as toSnakeCase, a as deserializeFilter, c as serializeLogicalCondition, d as CollectionRegistry, f as createDataSourceRegistry, g as buildCompositeId, h as resolveCollectionRelations, i as buildSdkData, l as collectAllPages, m as findRelation, n as serializeOrderBy, o as deserializeLogicalCondition, p as resolveDataSource, r as buildRoutedRebaseData, s as serializeFilter, t as deserializeOrderBy, u as paginateFind } from "./src-CoOAMnBh.js";
7
7
  import { t as logger } from "./logger-BYU66ENZ.js";
8
8
  import { a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, i as generateDownloadToken, l as hashRefreshToken, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-D-eI6TTu.js";
9
9
  import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
@@ -93,6 +93,359 @@ var BackendCollectionRegistry = class extends CollectionRegistry {
93
93
  }
94
94
  };
95
95
  //#endregion
96
+ //#region src/collections/validate-config.ts
97
+ /**
98
+ * Read the unknown-key policy from the environment.
99
+ *
100
+ * `REBASE_STRICT_COLLECTION_CONFIG` accepts `error`/`strict`/`1`/`true` to
101
+ * escalate, `off`/`0`/`false` to silence, and anything else warns.
102
+ */
103
+ function unknownKeyPolicyFromEnv(env = process.env) {
104
+ const raw = env.REBASE_STRICT_COLLECTION_CONFIG?.trim().toLowerCase();
105
+ if (!raw) return "warn";
106
+ if ([
107
+ "error",
108
+ "strict",
109
+ "1",
110
+ "true",
111
+ "yes"
112
+ ].includes(raw)) return "error";
113
+ if ([
114
+ "off",
115
+ "0",
116
+ "false",
117
+ "no",
118
+ "none"
119
+ ].includes(raw)) return "off";
120
+ return "warn";
121
+ }
122
+ /** `BaseCollectionConfig`, plus every engine-specific field, plus the `admin` block. */
123
+ var COLLECTION_KEYS = new Set([
124
+ "slug",
125
+ "name",
126
+ "singularName",
127
+ "description",
128
+ "childCollections",
129
+ "dataSource",
130
+ "engine",
131
+ "databaseId",
132
+ "properties",
133
+ "auth",
134
+ "disableDefaultPolicies",
135
+ "callbacks",
136
+ "ownerId",
137
+ "metadata",
138
+ "history",
139
+ "strictWrites",
140
+ "table",
141
+ "relations",
142
+ "securityRules",
143
+ "schema",
144
+ "path",
145
+ "subcollections",
146
+ "admin"
147
+ ]);
148
+ /** `BaseProperty` — legal on a property of any type. */
149
+ var BASE_PROPERTY_KEYS = [
150
+ "type",
151
+ "name",
152
+ "description",
153
+ "propertyConfig",
154
+ "columnName",
155
+ "defaultValue",
156
+ "validation",
157
+ "excludeFromApi",
158
+ "dynamicProps",
159
+ "conditions",
160
+ "callbacks",
161
+ "metadata",
162
+ "admin"
163
+ ];
164
+ /** The keys each property `type` adds on top of {@link BASE_PROPERTY_KEYS}. */
165
+ var PROPERTY_KEYS_BY_TYPE = {
166
+ string: [
167
+ "columnType",
168
+ "isId",
169
+ "enum",
170
+ "storage",
171
+ "userSelect",
172
+ "email",
173
+ "url"
174
+ ],
175
+ number: [
176
+ "columnType",
177
+ "isId",
178
+ "enum"
179
+ ],
180
+ boolean: [],
181
+ date: [
182
+ "columnType",
183
+ "mode",
184
+ "timezone",
185
+ "autoValue"
186
+ ],
187
+ geopoint: [],
188
+ binary: [],
189
+ vector: ["dimensions"],
190
+ reference: [
191
+ "isId",
192
+ "path",
193
+ "fixedFilter",
194
+ "includeId",
195
+ "includeEntityLink"
196
+ ],
197
+ relation: [
198
+ "isId",
199
+ "relation",
200
+ "resolvedRelation",
201
+ "fixedFilter",
202
+ "includeId",
203
+ "includeEntityLink",
204
+ "widget"
205
+ ],
206
+ array: [
207
+ "columnType",
208
+ "of",
209
+ "oneOf",
210
+ "sortable",
211
+ "canAddElements"
212
+ ],
213
+ map: [
214
+ "columnType",
215
+ "properties",
216
+ "propertiesOrder",
217
+ "previewProperties",
218
+ "keyValue"
219
+ ]
220
+ };
221
+ var PROPERTY_TYPES = Object.keys(PROPERTY_KEYS_BY_TYPE);
222
+ /** `RelationBase` plus the fields of every `kind` in the tagged union. */
223
+ var RELATION_KEYS = new Set([
224
+ "kind",
225
+ "relationName",
226
+ "target",
227
+ "onUpdate",
228
+ "onDelete",
229
+ "overrides",
230
+ "validation",
231
+ "localKey",
232
+ "foreignKeyOnTarget",
233
+ "through",
234
+ "joinPath",
235
+ "cardinality"
236
+ ]);
237
+ var RELATION_KINDS = [
238
+ "belongsTo",
239
+ "hasOne",
240
+ "hasMany",
241
+ "manyToMany",
242
+ "via"
243
+ ];
244
+ /** Which link field each `kind` admits. Anything else is a leftover shape. */
245
+ var RELATION_FIELDS_BY_KIND = {
246
+ belongsTo: ["localKey"],
247
+ hasOne: ["foreignKeyOnTarget"],
248
+ hasMany: ["foreignKeyOnTarget"],
249
+ manyToMany: ["through"],
250
+ via: ["joinPath", "cardinality"]
251
+ };
252
+ var RELATION_LINK_FIELDS = [
253
+ "localKey",
254
+ "foreignKeyOnTarget",
255
+ "through",
256
+ "joinPath",
257
+ "cardinality"
258
+ ];
259
+ /** Collection-level keys that no longer exist at the top level. */
260
+ var COLLECTION_MIGRATIONS = { editable: { fix: "`editable` was removed in 0.10 — collections are editable by default. Delete it, or use `admin.disableDefaultActions` to take actions away" } };
261
+ for (const key of ADMIN_COLLECTION_KEYS) COLLECTION_MIGRATIONS[key] = {
262
+ fix: `\`${key}\` moved into the collection's \`admin\` block in 0.11 — write \`admin: { ${key}: … }\``,
263
+ codemod: "node scripts/codemod/collections-admin-block.mjs"
264
+ };
265
+ /** Property-level keys that no longer exist at the top level of a property. */
266
+ var PROPERTY_MIGRATIONS = {
267
+ ui: { fix: "`ui` was renamed to `admin` in 0.11, to match the collection's block — rename the key" },
268
+ editable: { fix: "`editable` was removed in 0.10 — properties are editable by default. Use `admin.readOnly` or `admin.disabled` instead" }
269
+ };
270
+ for (const key of ADMIN_PROPERTY_KEYS) PROPERTY_MIGRATIONS[key] = { fix: `\`${key}\` belongs in the property's \`admin\` block — write \`admin: { ${key}: … }\`` };
271
+ /**
272
+ * The flat relation fields that `RelationProperty` used to carry.
273
+ *
274
+ * All of them moved into the nested `relation` object. Two of them do not
275
+ * survive the move at all: `direction` and `inverseRelationName` were how the
276
+ * old shape said which side owned the link, and the `kind` discriminant says it
277
+ * now.
278
+ */
279
+ var RELATION_PROPERTY_MIGRATIONS = {
280
+ target: { fix: "move `target` inside `relation` — `relation: { kind: …, target: … }`" },
281
+ cardinality: { fix: "`cardinality` is implied by the relation's `kind` (`belongsTo`/`hasOne` are one, `hasMany`/`manyToMany` are many); it survives only on `relation: { kind: \"via\" }`" },
282
+ direction: { fix: "`direction` was removed — the `kind` says which side owns the link. `owning` + one is `belongsTo`, `inverse` + one is `hasOne`, `inverse` + many is `hasMany`, `owning` + many is `manyToMany`" },
283
+ inverseRelationName: { fix: "`inverseRelationName` was removed — name the far side with `relation: { kind: \"hasMany\", foreignKeyOnTarget: … }` instead of pointing at it" },
284
+ localKey: { fix: "move `localKey` inside `relation` — `relation: { kind: \"belongsTo\", localKey: … }`" },
285
+ foreignKeyOnTarget: { fix: "move `foreignKeyOnTarget` inside `relation` — `relation: { kind: \"hasOne\" | \"hasMany\", foreignKeyOnTarget: … }`" },
286
+ through: { fix: "move `through` inside `relation` — `relation: { kind: \"manyToMany\", through: … }`" },
287
+ joinPath: { fix: "move `joinPath` inside `relation` — `relation: { kind: \"via\", joinPath: … }`" },
288
+ onUpdate: { fix: "move `onUpdate` inside `relation`" },
289
+ onDelete: { fix: "move `onDelete` inside `relation`" },
290
+ overrides: { fix: "move `overrides` inside `relation`" },
291
+ relationName: { fix: "move `relationName` inside `relation` — `relation: { kind: …, relationName: … }`" }
292
+ };
293
+ var RELATION_UNION_CODEMOD = "node scripts/codemod/relations-tagged-union.mjs";
294
+ /** Fields the old flat `Relation` carried that the tagged union does not. */
295
+ var RELATION_MIGRATIONS = {
296
+ direction: {
297
+ fix: RELATION_PROPERTY_MIGRATIONS.direction.fix,
298
+ codemod: RELATION_UNION_CODEMOD
299
+ },
300
+ inverseRelationName: {
301
+ fix: RELATION_PROPERTY_MIGRATIONS.inverseRelationName.fix,
302
+ codemod: RELATION_UNION_CODEMOD
303
+ }
304
+ };
305
+ function isPlainObject$2(value) {
306
+ return typeof value === "object" && value !== null && !Array.isArray(value);
307
+ }
308
+ var ProblemCollector = class {
309
+ unknownKeys;
310
+ problems = [];
311
+ constructor(unknownKeys) {
312
+ this.unknownKeys = unknownKeys;
313
+ }
314
+ error(path, message) {
315
+ this.problems.push({
316
+ severity: "error",
317
+ path,
318
+ message
319
+ });
320
+ }
321
+ /** A key we know moved or died. Always fatal — we know exactly what to do. */
322
+ migrated(path, key, migration) {
323
+ this.error(path, `\`${key}\` is no longer read here. ${migration.fix}.` + (migration.codemod ? ` Run \`${migration.codemod}\` to migrate the whole project.` : ""));
324
+ }
325
+ /** A key nobody recognises. Might be metadata, might be newer than us. */
326
+ unknown(path, key, context) {
327
+ if (this.unknownKeys === "off") return;
328
+ this.problems.push({
329
+ severity: this.unknownKeys === "error" ? "error" : "warning",
330
+ path,
331
+ message: `\`${key}\` is not a known ${context} key and is being ignored. If it is deliberate metadata this is safe; if it is a typo or a key from an older version, the feature it configures is silently absent.`
332
+ });
333
+ }
334
+ };
335
+ function checkRelation(relation, path, collect) {
336
+ if (!isPlainObject$2(relation)) return;
337
+ const kind = relation.kind;
338
+ if (typeof kind !== "string") collect.error(path, `a relation has no \`kind\`. Relations became a tagged union in 0.11 — pick one of ${RELATION_KINDS.join(", ")}. Run \`${RELATION_UNION_CODEMOD}\` to migrate the whole project.`);
339
+ else if (!RELATION_KINDS.includes(kind)) collect.error(path, `\`kind: "${kind}"\` is not a relation kind. Expected one of ${RELATION_KINDS.join(", ")}.`);
340
+ for (const key of Object.keys(relation)) {
341
+ const migration = RELATION_MIGRATIONS[key];
342
+ if (migration) {
343
+ collect.migrated(`${path}.${key}`, key, migration);
344
+ continue;
345
+ }
346
+ if (!RELATION_KEYS.has(key)) collect.unknown(`${path}.${key}`, key, "relation");
347
+ }
348
+ if (typeof kind === "string" && RELATION_FIELDS_BY_KIND[kind]) {
349
+ const allowed = RELATION_FIELDS_BY_KIND[kind];
350
+ for (const field of RELATION_LINK_FIELDS) if (relation[field] !== void 0 && !allowed.includes(field)) collect.error(`${path}.${field}`, `\`${field}\` is not valid on a "${kind}" relation. A "${kind}" takes ${allowed.length ? allowed.map((a) => `\`${a}\``).join(" and ") : "no link field"}.`);
351
+ }
352
+ }
353
+ function checkProperty(property, path, collect) {
354
+ if (typeof property === "function") return;
355
+ if (!isPlainObject$2(property)) {
356
+ collect.error(path, "a property must be an object.");
357
+ return;
358
+ }
359
+ const type = property.type;
360
+ if (typeof type !== "string") collect.error(path, "a property has no `type`.");
361
+ else if (!PROPERTY_TYPES.includes(type)) collect.error(path, `\`type: "${type}"\` is not a property type. Expected one of ${PROPERTY_TYPES.join(", ")}.`);
362
+ const allowed = new Set([...BASE_PROPERTY_KEYS, ...typeof type === "string" ? PROPERTY_KEYS_BY_TYPE[type] ?? [] : []]);
363
+ for (const key of Object.keys(property)) {
364
+ if (allowed.has(key)) continue;
365
+ if (type === "relation" && RELATION_PROPERTY_MIGRATIONS[key]) {
366
+ collect.migrated(`${path}.${key}`, key, {
367
+ ...RELATION_PROPERTY_MIGRATIONS[key],
368
+ codemod: RELATION_UNION_CODEMOD
369
+ });
370
+ continue;
371
+ }
372
+ const migration = PROPERTY_MIGRATIONS[key];
373
+ if (migration) {
374
+ collect.migrated(`${path}.${key}`, key, migration);
375
+ continue;
376
+ }
377
+ collect.unknown(`${path}.${key}`, key, `property (\`${String(type)}\`)`);
378
+ }
379
+ if (type === "relation" && property.relation !== void 0) checkRelation(property.relation, `${path}.relation`, collect);
380
+ if (type === "array") {
381
+ const of = property.of;
382
+ if (Array.isArray(of)) of.forEach((entry, index) => checkProperty(entry, `${path}.of[${index}]`, collect));
383
+ else if (of !== void 0) checkProperty(of, `${path}.of`, collect);
384
+ const oneOf = property.oneOf;
385
+ if (isPlainObject$2(oneOf) && isPlainObject$2(oneOf.properties)) checkProperties(oneOf.properties, `${path}.oneOf.properties`, collect);
386
+ }
387
+ if (type === "map" && isPlainObject$2(property.properties)) checkProperties(property.properties, `${path}.properties`, collect);
388
+ }
389
+ function checkProperties(properties, path, collect) {
390
+ for (const [key, property] of Object.entries(properties)) checkProperty(property, `${path}.${key}`, collect);
391
+ }
392
+ function checkCollection(collection, index, collect) {
393
+ if (!isPlainObject$2(collection)) {
394
+ collect.error(`collection[${index}]`, "a collection must be an object.");
395
+ return;
396
+ }
397
+ const slug = typeof collection.slug === "string" && collection.slug ? collection.slug : void 0;
398
+ const at = slug ?? `collection[${index}]`;
399
+ if (!slug) collect.error(at, "a collection has no `slug`. It is the collection's identity — the URL, the API path and the key every relation targets.");
400
+ for (const key of Object.keys(collection)) {
401
+ if (COLLECTION_KEYS.has(key)) continue;
402
+ const migration = COLLECTION_MIGRATIONS[key];
403
+ if (migration) {
404
+ collect.migrated(`${at}.${key}`, key, migration);
405
+ continue;
406
+ }
407
+ collect.unknown(`${at}.${key}`, key, "collection");
408
+ }
409
+ if (isPlainObject$2(collection.properties)) checkProperties(collection.properties, `${at}.properties`, collect);
410
+ else if (collection.properties !== void 0) collect.error(`${at}.properties`, "`properties` must be an object keyed by property name.");
411
+ if (Array.isArray(collection.relations)) collection.relations.forEach((relation, i) => {
412
+ checkRelation(relation, `${at}.relations[${isPlainObject$2(relation) && typeof relation.relationName === "string" ? relation.relationName : String(i)}]`, collect);
413
+ });
414
+ }
415
+ /**
416
+ * Every problem across every collection, in one pass.
417
+ *
418
+ * Pure: it logs nothing and throws nothing, so callers that want to render the
419
+ * list themselves (the doctor, a test) can.
420
+ */
421
+ function findCollectionConfigProblems(collections, options = {}) {
422
+ const collect = new ProblemCollector(options.unknownKeys ?? unknownKeyPolicyFromEnv());
423
+ collections.forEach((collection, index) => checkCollection(collection, index, collect));
424
+ return collect.problems;
425
+ }
426
+ function render(problems) {
427
+ return problems.map((p) => ` • ${p.path}\n ${p.message}`).join("\n\n");
428
+ }
429
+ /**
430
+ * Warn about everything questionable, then refuse to boot if anything is wrong.
431
+ *
432
+ * Warnings are logged even when there are errors: someone migrating wants the
433
+ * whole picture in one run, and the second-most annoying thing after a broken
434
+ * boot is a boot that breaks again on something it could have told you the
435
+ * first time.
436
+ */
437
+ function assertCollectionConfigs(collections, options = {}) {
438
+ const problems = findCollectionConfigProblems(collections, options);
439
+ if (problems.length === 0) return;
440
+ const warnings = problems.filter((p) => p.severity === "warning");
441
+ const errors = problems.filter((p) => p.severity === "error");
442
+ if (warnings.length > 0) logger.warn(`[collections] ${warnings.length} unrecognised key(s) in the collection config, ignored:\n\n` + render(warnings) + "\n\nSet REBASE_STRICT_COLLECTION_CONFIG=error to make these fail the boot.\n");
443
+ if (errors.length === 0) return;
444
+ throw new Error(`${errors.length} problem(s) in the collection config.\n\nThese keys are not read by this version. Nothing would have failed at runtime — whatever they configure would simply be absent — so they are fatal at boot instead.
445
+
446
+ ` + render(errors) + "\n");
447
+ }
448
+ //#endregion
96
449
  //#region src/collections/loader.ts
97
450
  function isCollectionFile(file) {
98
451
  return (file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js";
@@ -132,16 +485,27 @@ function applyCollectionDefaults(collections, defaults) {
132
485
  * configuration error, and continuing produces the worst outcome available: an
133
486
  * API missing a route, or a policy file missing a table, with a successful exit
134
487
  * code. Both read as "no data" rather than as a failure.
135
- */
136
- async function loadCollectionsFromDirectory(source) {
488
+ *
489
+ * Every collection is strict-parsed on the way out — see `validate-config` for
490
+ * why a key that moved is fatal and a key nobody recognises only warns. It
491
+ * happens here, at the one definition of "the collections", so the runtime, the
492
+ * schema generator, the policy generator and the doctor all see the same
493
+ * verdict rather than three of them silently accepting a config the fourth
494
+ * rejects.
495
+ */
496
+ async function loadCollectionsFromDirectory(source, options = {}) {
137
497
  const resolved = path$1.resolve(source);
498
+ const validate = (collections) => {
499
+ if (options.validate !== false) assertCollectionConfigs(collections, options.validate ?? {});
500
+ return collections;
501
+ };
138
502
  if (!fs$2.existsSync(resolved)) {
139
503
  logger.warn(`[collections] Not found: ${resolved}`);
140
504
  return [];
141
505
  }
142
506
  if (!fs$2.statSync(resolved).isDirectory()) {
143
507
  const mod = await importModule(resolved);
144
- return applyCollectionDefaults([...mod.backendCollections || mod.collections || []], { defaultSecurityRules: mod.defaultSecurityRules });
508
+ return validate(applyCollectionDefaults([...mod.backendCollections || mod.collections || []], { defaultSecurityRules: mod.defaultSecurityRules }));
145
509
  }
146
510
  const collections = [];
147
511
  const failures = [];
@@ -153,7 +517,7 @@ async function loadCollectionsFromDirectory(source) {
153
517
  failures.push(`${file}: ${err instanceof Error ? err.message : String(err)}`);
154
518
  }
155
519
  if (failures.length > 0) throw new Error(`Could not load ${failures.length} collection file(s) from ${resolved}:\n` + failures.map((f) => ` • ${f}`).join("\n") + "\n\nEvery collection file must import cleanly and default-export a collection.");
156
- return applyCollectionDefaults(collections, await readDefaults(resolved));
520
+ return validate(applyCollectionDefaults(collections, await readDefaults(resolved)));
157
521
  }
158
522
  //#endregion
159
523
  //#region src/services/driver-registry.ts
@@ -528,6 +892,33 @@ function parseLogicalGroup(type, raw) {
528
892
  return "type" in parsed ? parsed : void 0;
529
893
  }
530
894
  /**
895
+ * Parse the `?where=` JSON filter object.
896
+ *
897
+ * This is the dialect the OpenAPI document publishes on every
898
+ * `GET /api/data/{slug}` — `{"status":["==","active"]}`: field → canonical
899
+ * `[WhereFilterOp, value]` tuple. It is normalized through the same
900
+ * `deserializeFilter` as the `?field=op.value` params below, so a value that
901
+ * arrives as a PostgREST dot-string (`{"status":"eq.active"}`) or as a bare
902
+ * scalar (`{"status":"active"}`) compiles to the same condition. Unlike the
903
+ * querystring dialect, JSON carries types — a number stays a number.
904
+ *
905
+ * A malformed value is a 400 rather than a silent drop: dropping the filter
906
+ * would run the read unfiltered and return everything RLS happens to allow.
907
+ */
908
+ function parseWhereParam(raw) {
909
+ const str = String(raw).trim();
910
+ if (!str) return void 0;
911
+ let parsed;
912
+ try {
913
+ parsed = JSON.parse(str);
914
+ } catch {
915
+ throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
916
+ }
917
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw ApiError.badRequest("Invalid `where` parameter: expected a JSON object mapping fields to conditions, e.g. {\"status\":[\"==\",\"active\"]}", "INVALID_WHERE");
918
+ const filter = deserializeFilter(parsed);
919
+ return Object.keys(filter).length > 0 ? filter : void 0;
920
+ }
921
+ /**
531
922
  * Parse query parameters into QueryOptions
532
923
  */
533
924
  function parseQueryOptions(query, limits = {}) {
@@ -566,14 +957,19 @@ function parseQueryOptions(query, limits = {}) {
566
957
  "vector_distance",
567
958
  "vector_threshold",
568
959
  "or",
569
- "and"
960
+ "and",
961
+ "where"
570
962
  ];
571
963
  const filterDict = {};
572
964
  for (const [key, rawValue] of Object.entries(query)) {
573
965
  if (reservedQueryKeys.includes(key)) continue;
574
966
  filterDict[key] = rawValue;
575
967
  }
576
- const where = deserializeFilter(filterDict);
968
+ const whereVal = getLastValue(query.where);
969
+ const where = {
970
+ ...whereVal !== void 0 && whereVal !== null ? parseWhereParam(whereVal) : void 0,
971
+ ...deserializeFilter(filterDict)
972
+ };
577
973
  if (Object.keys(where).length > 0) options.where = where;
578
974
  const orderByVal = getLastValue(query.orderBy);
579
975
  if (orderByVal) try {
@@ -599,16 +995,17 @@ function parseQueryOptions(query, limits = {}) {
599
995
  const vectorVal = getLastValue(query.vector);
600
996
  if (vectorSearchVal && vectorVal) {
601
997
  const vectorStr = String(vectorVal);
602
- let queryVector;
998
+ let decoded;
603
999
  try {
604
- queryVector = JSON.parse(vectorStr);
605
- if (!Array.isArray(queryVector) || !queryVector.every((v) => typeof v === "number")) throw new Error("Expected array of numbers");
1000
+ decoded = JSON.parse(vectorStr);
606
1001
  } catch {
607
- throw new Error("Invalid vector format. Expected JSON array of numbers, e.g. [0.1,0.2,0.3]");
1002
+ decoded = void 0;
608
1003
  }
1004
+ if (!Array.isArray(decoded) || !decoded.every((v) => typeof v === "number")) throw ApiError.badRequest("Invalid `vector` format. Expected a JSON array of numbers, e.g. [0.1,0.2,0.3]", "INVALID_VECTOR");
1005
+ const queryVector = decoded;
609
1006
  const distanceParamVal = getLastValue(query.vector_distance);
610
1007
  const distanceParam = distanceParamVal ? String(distanceParamVal) : "cosine";
611
- if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw new Error(`Invalid vector_distance: ${distanceParam}. Expected: cosine, l2, or inner_product`);
1008
+ if (distanceParam !== "cosine" && distanceParam !== "l2" && distanceParam !== "inner_product") throw ApiError.badRequest(`Invalid \`vector_distance\`: ${distanceParam}. Expected: cosine, l2, or inner_product`, "INVALID_VECTOR_DISTANCE");
612
1009
  const vectorSearch = {
613
1010
  property: String(vectorSearchVal),
614
1011
  vector: queryVector,
@@ -617,7 +1014,7 @@ function parseQueryOptions(query, limits = {}) {
617
1014
  const thresholdVal = getLastValue(query.vector_threshold);
618
1015
  if (thresholdVal) {
619
1016
  const threshold = parseFloat(String(thresholdVal));
620
- if (isNaN(threshold)) throw new Error("Invalid vector_threshold. Expected a number.");
1017
+ if (isNaN(threshold)) throw ApiError.badRequest("Invalid `vector_threshold`. Expected a number.", "INVALID_VECTOR_THRESHOLD");
621
1018
  vectorSearch.threshold = threshold;
622
1019
  }
623
1020
  options.vectorSearch = vectorSearch;
@@ -765,7 +1162,14 @@ var TABLE$2 = "\"rebase\".\"idempotency_keys\"";
765
1162
  * by a scheduled job — there is no cron guaranteed to be running.
766
1163
  */
767
1164
  var TTL_HOURS = 24;
768
- /** The principal a key belongs to; anonymous and service writes share a sentinel. */
1165
+ /**
1166
+ * The principal a key belongs to; anonymous and service writes share a sentinel.
1167
+ *
1168
+ * The NUL is written as an escape, not as a raw byte in the source. The
1169
+ * sentinel itself is deliberate — a uid can never contain one — but written
1170
+ * literally it makes this file test as binary, and every repo-wide grep then
1171
+ * skips all 124 lines of it silently. Identical at runtime.
1172
+ */
769
1173
  function principal(uid) {
770
1174
  return uid && uid.length > 0 ? uid : "\0anon";
771
1175
  }
@@ -7477,7 +7881,7 @@ function mountSessionRoutes(opts) {
7477
7881
  let needsSetup;
7478
7882
  if (config.isBootstrapCompleted) needsSetup = !await config.isBootstrapCompleted();
7479
7883
  else needsSetup = (await authRepo.listUsers()).length === 0;
7480
- const registrationAllowed = needsSetup || !!config.allowRegistration;
7884
+ const registrationAllowed = !config.disableSelfRegistration && (needsSetup || !!config.allowRegistration);
7481
7885
  const enabledProviders = (config.oauthProviders || []).map((p) => p.id);
7482
7886
  return c.json({
7483
7887
  needsSetup,
@@ -7717,9 +8121,20 @@ function createAuthRoutes(config) {
7717
8121
  return !!(emailService && emailService.isConfigured());
7718
8122
  }
7719
8123
  /**
7720
- * Check if registration is allowed.
8124
+ * Check if registration is allowed in steady state.
7721
8125
  * Registration is only allowed when explicitly enabled via `allowRegistration`.
7722
- * First-user bootstrap must use POST /admin/bootstrap instead.
8126
+ *
8127
+ * The one exception lives in POST /auth/register itself: an empty user
8128
+ * table always admits the first registration (which auto-promotes to
8129
+ * admin). Without it a backend deployed with registration disabled is a
8130
+ * dead end — GET /auth/config reports `registrationEnabled` while
8131
+ * `needsSetup`, the login UI shows the first-admin form on the strength of
8132
+ * that, and POST /admin/bootstrap cannot help because it requires an
8133
+ * authenticated caller, which an empty database cannot produce.
8134
+ *
8135
+ * `disableSelfRegistration` is the hard kill switch and blocks even that
8136
+ * exception, for operators who provision users out of band and never want
8137
+ * a public first-come-first-admin window.
7723
8138
  */
7724
8139
  function isRegistrationAllowed() {
7725
8140
  if (config.disableSelfRegistration) return false;
@@ -7779,7 +8194,12 @@ function createAuthRoutes(config) {
7779
8194
  router.post("/register", defaultAuthLimiter, async (c) => {
7780
8195
  const { email, password, displayName } = parseBody(registerSchema, await c.req.json());
7781
8196
  if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
7782
- if (!isRegistrationAllowed()) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
8197
+ let bootstrapRegistration = false;
8198
+ if (!isRegistrationAllowed()) {
8199
+ const { total } = await authRepo.listUsersPaginated({ limit: 1 });
8200
+ bootstrapRegistration = total === 0;
8201
+ if (!bootstrapRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
8202
+ }
7783
8203
  const passwordValidation = ops.validatePasswordStrength(password);
7784
8204
  if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
7785
8205
  if (await authRepo.getUserByEmail(email)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
@@ -7792,7 +8212,12 @@ function createAuthRoutes(config) {
7792
8212
  if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
7793
8213
  const user = await authRepo.createUser(createData);
7794
8214
  const existingUsers = await authRepo.listUsers();
7795
- if (existingUsers.length === 1 && existingUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
8215
+ const isFirstUser = existingUsers.length === 1 && existingUsers[0].id === user.id;
8216
+ if (bootstrapRegistration && !isFirstUser) {
8217
+ await authRepo.deleteUser(user.id);
8218
+ throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
8219
+ }
8220
+ if (isFirstUser) await authRepo.setUserRoles(user.id, ["admin"]);
7796
8221
  else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
7797
8222
  const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
7798
8223
  sendWelcomeEmail({
@@ -11970,7 +12395,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
11970
12395
  //#region src/init/docs.ts
11971
12396
  async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
11972
12397
  if (enableSwagger === false || activeCollections.length === 0) return;
11973
- const { generateOpenApiSpec } = await import("./openapi-generator-CeOnlAJ3.js");
12398
+ const { generateOpenApiSpec } = await import("./openapi-generator-Bjzmb5cn.js");
11974
12399
  app.get(`${basePath}/docs`, (c) => {
11975
12400
  const spec = generateOpenApiSpec(activeCollections, {
11976
12401
  basePath,
@@ -12206,6 +12631,25 @@ function rebaseReviver(_key, value) {
12206
12631
  }
12207
12632
  return value;
12208
12633
  }
12634
+ /**
12635
+ * True when there is no browser to have signed a user in — a Node script, a
12636
+ * cron job, an edge worker.
12637
+ *
12638
+ * Anonymous is an ordinary, correct state in a browser: before sign-in, on a
12639
+ * marketing page, for public reads. Warning there would be noise that teaches
12640
+ * people to ignore warnings, so the guard is off entirely. This uses the same
12641
+ * `typeof window` test as {@link resolveBaseUrl}, and additionally treats a
12642
+ * defined `document` as a browser so an SSR shim or test harness that installs
12643
+ * only one of the two is still excluded.
12644
+ */
12645
+ function isServerLikeEnvironment() {
12646
+ return typeof window === "undefined" && typeof document === "undefined";
12647
+ }
12648
+ /**
12649
+ * Emitted once per client. Kept as a constant so the wording is testable and
12650
+ * greppable — this is the string a user will paste into a search.
12651
+ */
12652
+ var ANONYMOUS_SERVER_CLIENT_WARNING = "[rebase] This client was created outside a browser with no credential — no `token`, no auth token getter, and no cookie auth flow — so every request runs as an anonymous caller. Row-level security will return only publicly readable rows, which is usually nothing and occasionally the wrong thing. Inside a cron or function handler, use the `client` you were handed instead of building a new one: its data plane is already admin-scoped. In a standalone script or job, pass the service key as `token`. If you really do want anonymous access, pass `anonymous: true` to silence this.";
12209
12653
  function buildQueryString(params) {
12210
12654
  if (!params) return "";
12211
12655
  const parts = [];
@@ -12252,12 +12696,31 @@ function resolveBaseUrl(configured) {
12252
12696
  if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
12253
12697
  return "";
12254
12698
  }
12255
- function createTransport(config) {
12699
+ function createTransport(config, environment) {
12256
12700
  const fetchFn = config.fetch || globalThis.fetch;
12257
12701
  const apiPath = config.apiPath || "/api";
12258
12702
  let token = config.token;
12259
12703
  let tokenGetter;
12260
12704
  let onUnauthorizedHandler = config.onUnauthorized;
12705
+ /** Once per client, never per request — log spam is its own bug. */
12706
+ let anonymousWarningIssued = false;
12707
+ /**
12708
+ * Warn a server-side caller that it built a client that can only ever be
12709
+ * anonymous. Deliberately checked at the *first request* rather than at
12710
+ * construction: `setToken()` / `setAuthTokenGetter()` and a server-side
12711
+ * `auth.signIn…()` (which calls `transport.setToken`) all land after the
12712
+ * constructor, and warning at construction would fire on every one of them.
12713
+ */
12714
+ function warnIfAnonymousServerClient(activeToken) {
12715
+ if (anonymousWarningIssued) return;
12716
+ if (activeToken) return;
12717
+ if (tokenGetter) return;
12718
+ if (config.anonymous) return;
12719
+ if (environment?.credentialOutOfBand) return;
12720
+ if (!isServerLikeEnvironment()) return;
12721
+ anonymousWarningIssued = true;
12722
+ console.warn(ANONYMOUS_SERVER_CLIENT_WARNING);
12723
+ }
12261
12724
  function getHeaders(activeToken, init) {
12262
12725
  return {
12263
12726
  "Content-Type": "application/json",
@@ -12272,6 +12735,7 @@ function createTransport(config) {
12272
12735
  const fetched = await tokenGetter();
12273
12736
  if (fetched !== null && fetched !== void 0) activeToken = fetched;
12274
12737
  } catch (e) {}
12738
+ warnIfAnonymousServerClient(activeToken);
12275
12739
  const headers = getHeaders(activeToken, init);
12276
12740
  if (init?.body instanceof FormData) delete headers["Content-Type"];
12277
12741
  const res = await fetchFn(url, {
@@ -13309,6 +13773,12 @@ function createCollectionClient(transport, slug, ws) {
13309
13773
  meta: raw.meta
13310
13774
  };
13311
13775
  },
13776
+ iterate(params) {
13777
+ return paginateFind((p) => client.find(p), params, slug);
13778
+ },
13779
+ findAll(params) {
13780
+ return collectAllPages((p) => client.find(p), params, slug);
13781
+ },
13312
13782
  async findById(id) {
13313
13783
  try {
13314
13784
  const raw = await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
@@ -16109,6 +16579,8 @@ var OfflineManager = class {
16109
16579
  meta: answer.meta
16110
16580
  };
16111
16581
  },
16582
+ iterate: (params) => paginateFind((p) => wrapped.find(p), params, slug),
16583
+ findAll: (params) => collectAllPages((p) => wrapped.find(p), params, slug),
16112
16584
  findById: async (id) => {
16113
16585
  await this.ensureCollection(slug);
16114
16586
  if (this.connectivity.shouldAttempt()) try {
@@ -17176,7 +17648,7 @@ function deriveWebSocketUrl(baseUrl) {
17176
17648
  return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
17177
17649
  }
17178
17650
  function createRebaseClient(options) {
17179
- const transport = createTransport(options);
17651
+ const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
17180
17652
  const auth = createAuth(transport, options.auth);
17181
17653
  const admin = createAdmin(transport, options.admin);
17182
17654
  const cron = createCron(transport, options.cron);
@@ -17553,13 +18025,37 @@ function createEmailService(config) {
17553
18025
  }
17554
18026
  //#endregion
17555
18027
  //#region src/singleton.ts
17556
- var _instance = null;
18028
+ /**
18029
+ * The backing instance lives on a process-global slot, NOT in a module-local
18030
+ * variable — because more than one copy of this module can be loaded into one
18031
+ * process, and a module-local would leave every copy but the booting one dead.
18032
+ *
18033
+ * That is the normal layout under the managed runtime, not an edge case: the
18034
+ * image ships the framework at `/app/node_modules`, while a project's bundle
18035
+ * installs its own dependencies into `/bundle/node_modules` — and every custom
18036
+ * function imports `defineFunction` from `@rebasepro/server`, which resolves to
18037
+ * the bundle's transitively-installed copy. `initializeRebaseBackend()` then ran
18038
+ * against `/app`'s copy while every function held `/bundle`'s, so `rebase.data`,
18039
+ * `rebase.storage` and `rebase.dataAsAdmin` threw "server not initialized yet"
18040
+ * on EVERY request, forever, in an otherwise healthy process.
18041
+ *
18042
+ * `Symbol.for` is the fix because its registry is per-process rather than
18043
+ * per-module: whichever copy boots publishes here, and every other copy — same
18044
+ * version or not — reads the same live client.
18045
+ */
18046
+ var INSTANCE_SLOT = Symbol.for("@rebasepro/server:singleton-instance");
18047
+ function getInstance() {
18048
+ return globalThis[INSTANCE_SLOT] ?? null;
18049
+ }
18050
+ function setInstance(client) {
18051
+ globalThis[INSTANCE_SLOT] = client;
18052
+ }
17557
18053
  /**
17558
18054
  * @internal Called once during server initialization to set the backing instance.
17559
18055
  * This is invoked by `initializeRebaseBackend()` — never call it manually.
17560
18056
  */
17561
18057
  function _initRebase(client) {
17562
- _instance = client;
18058
+ setInstance(client);
17563
18059
  }
17564
18060
  /**
17565
18061
  * @internal Allows overriding the underlying instance for unit testing.
@@ -17567,17 +18063,17 @@ function _initRebase(client) {
17567
18063
  */
17568
18064
  function _setRebaseMock(mockInstance) {
17569
18065
  if (process.env.NODE_ENV !== "test") throw new Error("_setRebaseMock can only be called in a test environment (NODE_ENV=test).");
17570
- _instance = {
17571
- ..._instance || {},
18066
+ setInstance({
18067
+ ...getInstance() || {},
17572
18068
  ...mockInstance
17573
- };
18069
+ });
17574
18070
  }
17575
18071
  /**
17576
18072
  * @internal Resets the singleton instance, useful for afterEach() in test suites.
17577
18073
  */
17578
18074
  function _resetRebaseMock() {
17579
18075
  if (process.env.NODE_ENV !== "test") throw new Error("_resetRebaseMock can only be called in a test environment.");
17580
- _instance = null;
18076
+ setInstance(null);
17581
18077
  }
17582
18078
  /**
17583
18079
  * The server-side Rebase singleton.
@@ -17616,8 +18112,9 @@ function _resetRebaseMock() {
17616
18112
  */
17617
18113
  var rebase = new Proxy({}, {
17618
18114
  get(_, prop) {
17619
- if (!_instance) throw new Error(`rebase.${String(prop)}: server not initialized yet. The singleton is available after Rebase starts — don't call it at import time.`);
17620
- return _instance[prop];
18115
+ const instance = getInstance();
18116
+ if (!instance) throw new Error(`rebase.${String(prop)}: server not initialized yet. The singleton is available after Rebase starts — don't call it at import time.`);
18117
+ return instance[prop];
17621
18118
  },
17622
18119
  set(_, prop) {
17623
18120
  throw new Error(`Cannot set rebase.${String(prop)} directly. The singleton is read-only. Use _initRebase() during server startup.`);
@@ -17664,21 +18161,17 @@ async function _initializeRebaseBackend(config) {
17664
18161
  const dataSourceRegistry = createDataSourceRegistry(config.dataSources);
17665
18162
  collectionRegistry.setDataSources(dataSourceRegistry);
17666
18163
  if (config.callbacks) collectionRegistry.setGlobalCallbacks(config.callbacks);
17667
- const mode = config.mode ?? "cms";
17668
- logger.info(mode === "baas" ? "Starting in baas mode — collections derived from the database schema" : "Starting in cms mode — collections from config");
17669
18164
  let activeCollections = config.collections || [];
17670
- if (mode === "baas") {
17671
- if (activeCollections.length > 0 || config.collectionsDir) {
17672
- logger.warn("Ignoring configured collections: baas mode derives them from the database schema. Remove `collections`/`collectionsDir`, or use mode: \"cms\" to serve them.");
17673
- activeCollections = [];
17674
- }
17675
- } else if (config.collectionsDir && activeCollections.length === 0) {
18165
+ if (activeCollections.length > 0) assertCollectionConfigs(activeCollections);
18166
+ if (config.collectionsDir && activeCollections.length === 0) {
17676
18167
  activeCollections = await loadCollectionsFromDirectory(config.collectionsDir);
17677
18168
  logger.info("Auto-discovered collections", {
17678
18169
  count: activeCollections.length,
17679
18170
  dir: config.collectionsDir
17680
18171
  });
17681
18172
  }
18173
+ const introspectCollections = activeCollections.length === 0;
18174
+ logger.info(introspectCollections ? "No collections declared — deriving them from the database schema" : "Serving declared collections");
17682
18175
  const realtimeServices = {};
17683
18176
  const delegates = {};
17684
18177
  let bootstrappers = config.bootstrappers || [];
@@ -17706,16 +18199,16 @@ async function _initializeRebaseBackend(config) {
17706
18199
  const driverResult = await bootstrapper.initializeDriver({
17707
18200
  collections: activeCollections,
17708
18201
  collectionRegistry,
17709
- mode,
18202
+ introspectCollections,
17710
18203
  baas: config.baas
17711
18204
  });
17712
18205
  delegates[b.id || bootstrapper.type] = driverResult.driver;
17713
- if (mode === "baas") {
18206
+ if (introspectCollections) {
17714
18207
  const driverName = b.id || bootstrapper.type;
17715
- if (!driverResult.collections) throw new Error(`Driver "${driverName}" does not support baas mode: it cannot derive collections from the database schema. Use mode: "cms" and declare collections explicitly, or use a driver that implements introspection (e.g. @rebasepro/server-postgres).`);
18208
+ if (!driverResult.collections) throw new Error(`Driver "${driverName}" cannot derive collections from the database schema, and this project declared none. Declare collections, or use a driver that implements introspection (e.g. @rebasepro/server-postgres).`);
17716
18209
  if (driverResult.collections.length === 0) logger.warn(`Driver "${driverName}" found no tables to serve. The data API will not be mounted. Create tables (migrations, SQL, any tool) and restart.`);
17717
18210
  }
17718
- if (driverResult.collections?.length) activeCollections = [...activeCollections, ...driverResult.collections];
18211
+ if (introspectCollections && driverResult.collections?.length) activeCollections = [...activeCollections, ...driverResult.collections];
17719
18212
  if ((b.id || bootstrapper.type) === defaultDriverId || !defaultDriverResult) defaultDriverResult = driverResult;
17720
18213
  if (bootstrapper.initializeRealtime) {
17721
18214
  const realtime = await bootstrapper.initializeRealtime({}, driverResult);
@@ -17958,12 +18451,12 @@ async function _initializeRebaseBackend(config) {
17958
18451
  if (apiKeyPreAuth) router.use("/*", apiKeyPreAuth);
17959
18452
  router.use("/*", createRequireAuth({ serviceKey: internalServiceKey }), requireAdmin);
17960
18453
  };
17961
- const schemaEditorEnabled = config.schemaEditor ?? (!!config.collectionsDir && process.env.NODE_ENV !== "production" && mode === "cms");
18454
+ const schemaEditorEnabled = config.schemaEditor ?? (!!config.collectionsDir && !introspectCollections && process.env.NODE_ENV !== "production");
17962
18455
  if (schemaEditorEnabled && !config.collectionsDir) logger.warn("schemaEditor is enabled but no collectionsDir is set — the schema editor has nowhere to write. Skipping.");
17963
18456
  if (schemaEditorEnabled && config.collectionsDir) {
17964
18457
  let editorModule;
17965
18458
  try {
17966
- editorModule = await import("./schema-editor-routes-BFpXKkPD.js");
18459
+ editorModule = await import("./schema-editor-routes-DDxfOIid.js");
17967
18460
  } catch (err) {
17968
18461
  if (err?.code === "ERR_MODULE_NOT_FOUND") logger.warn("Schema Editor disabled: its dependency ts-morph is not installed. Run `npm install ts-morph@28.0.0` to enable it.");
17969
18462
  else throw err;
@@ -18215,7 +18708,6 @@ async function _initializeRebaseBackend(config) {
18215
18708
  contractRouter.route("/", createContractRoutes({
18216
18709
  collectionRegistry,
18217
18710
  schemaVersion: config.schemaVersion,
18218
- mode,
18219
18711
  runtimeVersion: config.runtimeVersion
18220
18712
  }));
18221
18713
  config.app.route(`${basePath}/meta`, contractRouter);
@@ -18488,6 +18980,97 @@ function defineCron(definition) {
18488
18980
  return definition;
18489
18981
  }
18490
18982
  //#endregion
18983
+ //#region src/cron/scale-to-zero.ts
18984
+ /**
18985
+ * Scale-to-zero detection for the cron scheduler.
18986
+ *
18987
+ * The scheduler drives jobs with in-process `setTimeout`. That works on any
18988
+ * always-running instance, but on a platform that freezes or evicts the
18989
+ * container between requests (Cloud Run with `--min-instances=0`, AWS Lambda,
18990
+ * Vercel functions) the timers simply never fire — the process boots, logs the
18991
+ * jobs as registered, and silently runs nothing.
18992
+ *
18993
+ * None of these platforms expose their scaling floor to the container, so this
18994
+ * detection is a heuristic: it identifies the *platform*, not the setting. It
18995
+ * is a warning only — it must never influence boot.
18996
+ *
18997
+ * Environment variables used here were verified against vendor documentation:
18998
+ * - `K_SERVICE` / `K_REVISION` / `K_CONFIGURATION` — Cloud Run services
18999
+ * (Cloud Run container contract; no variable exposes min-instances).
19000
+ * - `CLOUD_RUN_JOB` — Cloud Run jobs (same contract).
19001
+ * - `AWS_LAMBDA_FUNCTION_NAME` — reserved AWS Lambda runtime variable.
19002
+ * - `VERCEL=1` — Vercel system environment variable, available at runtime.
19003
+ * - `KUBERNETES_SERVICE_HOST` — injected into every pod by the kubelet. Used
19004
+ * as an *exclusion*: a Deployment pod runs continuously, and Knative on
19005
+ * Kubernetes also sets `K_SERVICE`, so a pod is never warned about.
19006
+ */
19007
+ /** Environment variable that permanently silences the scale-to-zero warning. */
19008
+ var CRON_ALWAYS_ON_ENV = "REBASE_CRON_ALWAYS_ON";
19009
+ /** Accepts the usual truthy spellings; anything else (including "") is false. */
19010
+ function isTruthy(value) {
19011
+ if (!value) return false;
19012
+ const normalised = value.trim().toLowerCase();
19013
+ return normalised === "1" || normalised === "true" || normalised === "yes" || normalised === "on";
19014
+ }
19015
+ /**
19016
+ * Identify a runtime whose instances can be frozen or torn down between
19017
+ * requests. Returns `undefined` when the platform is unknown or known to run
19018
+ * continuously.
19019
+ */
19020
+ function detectFreezableRuntime(env = process.env) {
19021
+ if (env.KUBERNETES_SERVICE_HOST) return void 0;
19022
+ if (env.K_SERVICE) {
19023
+ const signals = ["K_SERVICE"];
19024
+ if (env.K_REVISION) signals.push("K_REVISION");
19025
+ if (env.K_CONFIGURATION) signals.push("K_CONFIGURATION");
19026
+ return {
19027
+ platform: "Cloud Run",
19028
+ signals
19029
+ };
19030
+ }
19031
+ if (env.CLOUD_RUN_JOB) return {
19032
+ platform: "Cloud Run Jobs",
19033
+ signals: ["CLOUD_RUN_JOB"]
19034
+ };
19035
+ if (env.AWS_LAMBDA_FUNCTION_NAME) return {
19036
+ platform: "AWS Lambda",
19037
+ signals: ["AWS_LAMBDA_FUNCTION_NAME"]
19038
+ };
19039
+ if (env.VERCEL === "1") return {
19040
+ platform: "Vercel",
19041
+ signals: ["VERCEL"]
19042
+ };
19043
+ }
19044
+ /** How many job ids to name before collapsing the rest into "+N more". */
19045
+ var MAX_NAMED_JOBS = 10;
19046
+ /**
19047
+ * Build the boot-time warning, or `undefined` when it does not apply.
19048
+ *
19049
+ * Fires only when all of the following hold:
19050
+ * 1. `NODE_ENV=production` — a laptop or CI run is not at risk.
19051
+ * 2. At least one *enabled* job is registered — nothing to lose otherwise.
19052
+ * 3. The environment looks like a freezable platform (see above).
19053
+ * 4. `REBASE_CRON_ALWAYS_ON` is not set to a truthy value.
19054
+ */
19055
+ function buildScaleToZeroWarning(jobs, env = process.env) {
19056
+ if (env.NODE_ENV !== "production") return void 0;
19057
+ if (isTruthy(env["REBASE_CRON_ALWAYS_ON"])) return void 0;
19058
+ const enabled = jobs.filter((job) => job.enabled).map((job) => job.id);
19059
+ if (enabled.length === 0) return void 0;
19060
+ const runtime = detectFreezableRuntime(env);
19061
+ if (!runtime) return void 0;
19062
+ const named = enabled.slice(0, MAX_NAMED_JOBS);
19063
+ const list = enabled.length > named.length ? `${named.join(", ")} (+${enabled.length - named.length} more)` : named.join(", ");
19064
+ return {
19065
+ message: `[cron] ${runtime.platform} detected — in-process timers do not fire while an instance is frozen or scaled to zero, so ${enabled.length} enabled job(s) may never run: ${list}; drive them from an external scheduler instead (POST /api/cron/:id/trigger, e.g. Cloud Scheduler). ${runtime.platform} does not expose its scaling floor to the container, so an always-warm deployment cannot be confirmed from inside the process — set ${CRON_ALWAYS_ON_ENV}=1 to silence this if at least one instance is pinned warm.`,
19066
+ data: {
19067
+ platform: runtime.platform,
19068
+ signals: runtime.signals,
19069
+ jobs: enabled
19070
+ }
19071
+ };
19072
+ }
19073
+ //#endregion
18491
19074
  //#region src/cron/cron-scheduler.ts
18492
19075
  var cron_scheduler_exports = /* @__PURE__ */ __exportAll({
18493
19076
  CronScheduler: () => CronScheduler,
@@ -18696,6 +19279,7 @@ var CronScheduler = class {
18696
19279
  });
18697
19280
  for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
18698
19281
  if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
19282
+ this.warnIfScaleToZero();
18699
19283
  logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
18700
19284
  }
18701
19285
  /**
@@ -18789,6 +19373,23 @@ var CronScheduler = class {
18789
19373
  return this.executeJob(job, true);
18790
19374
  }
18791
19375
  /**
19376
+ * Warn once at start when the process looks like it is running on a
19377
+ * platform that freezes or evicts instances between requests, where the
19378
+ * in-process timers this scheduler relies on never fire.
19379
+ *
19380
+ * Advisory only: any failure here is swallowed so a detection bug can
19381
+ * never take a production boot down.
19382
+ */
19383
+ warnIfScaleToZero() {
19384
+ try {
19385
+ const warning = buildScaleToZeroWarning([...this.jobs.values()].map((job) => ({
19386
+ id: job.id,
19387
+ enabled: job.enabled
19388
+ })));
19389
+ if (warning) logger.warn(warning.message, warning.data);
19390
+ } catch {}
19391
+ }
19392
+ /**
18792
19393
  * Schedule the next execution for a job.
18793
19394
  *
18794
19395
  * Safety guarantees:
@@ -19465,6 +20066,102 @@ function loadEnv(options) {
19465
20066
  return env;
19466
20067
  }
19467
20068
  //#endregion
20069
+ //#region src/services/webhook-service.ts
20070
+ var WebhookDispatcher = class {
20071
+ webhooks = [];
20072
+ maxRetries = 3;
20073
+ retryDelays = [
20074
+ 1e3,
20075
+ 5e3,
20076
+ 15e3
20077
+ ];
20078
+ /** Register webhooks to watch */
20079
+ setWebhooks(webhooks) {
20080
+ this.webhooks = webhooks.filter((w) => w.enabled);
20081
+ }
20082
+ /** Called when a entity changes — checks if any webhook matches */
20083
+ async onEntityChange(table, event, id, entity, previousEntity) {
20084
+ const matchingWebhooks = this.webhooks.filter((w) => w.table === table && w.events.includes(event));
20085
+ if (matchingWebhooks.length === 0) return [];
20086
+ const results = [];
20087
+ for (const webhook of matchingWebhooks) {
20088
+ const payload = {
20089
+ type: event,
20090
+ table,
20091
+ record: entity,
20092
+ old_record: event === "UPDATE" ? previousEntity : void 0,
20093
+ schema: "public",
20094
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
20095
+ };
20096
+ const result = await this.deliverWithRetry(webhook, event, payload);
20097
+ results.push(result);
20098
+ }
20099
+ return results;
20100
+ }
20101
+ async deliverWithRetry(webhook, event, payload) {
20102
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
20103
+ const result = await this.deliver(webhook, event, payload, attempt);
20104
+ if (result.success) return result;
20105
+ if (attempt < this.maxRetries) await new Promise((r) => setTimeout(r, this.retryDelays[attempt - 1]));
20106
+ else return result;
20107
+ }
20108
+ return {
20109
+ webhookId: webhook.id,
20110
+ event,
20111
+ payload,
20112
+ statusCode: 0,
20113
+ responseBody: "Max retries exceeded",
20114
+ success: false,
20115
+ attemptNumber: this.maxRetries
20116
+ };
20117
+ }
20118
+ async deliver(webhook, event, payload, attemptNumber) {
20119
+ const body = JSON.stringify(payload);
20120
+ const headers = {
20121
+ "Content-Type": "application/json",
20122
+ "X-Webhook-Id": webhook.id,
20123
+ "X-Webhook-Event": event,
20124
+ "X-Webhook-Delivery": randomUUID$1(),
20125
+ "X-Webhook-Attempt": String(attemptNumber),
20126
+ ...webhook.headers || {}
20127
+ };
20128
+ if (webhook.secret) headers["X-Webhook-Signature"] = `sha256=${createHmac("sha256", webhook.secret).update(body).digest("hex")}`;
20129
+ try {
20130
+ const controller = new AbortController();
20131
+ const timeout = setTimeout(() => controller.abort(), 1e4);
20132
+ const response = await fetch(webhook.url, {
20133
+ method: "POST",
20134
+ headers,
20135
+ body,
20136
+ signal: controller.signal
20137
+ });
20138
+ clearTimeout(timeout);
20139
+ const responseBody = await response.text().catch(() => "");
20140
+ const success = response.status >= 200 && response.status < 300;
20141
+ return {
20142
+ webhookId: webhook.id,
20143
+ event,
20144
+ payload,
20145
+ statusCode: response.status,
20146
+ responseBody: responseBody.slice(0, 1e3),
20147
+ success,
20148
+ attemptNumber
20149
+ };
20150
+ } catch (error) {
20151
+ const message = error instanceof Error ? error.message : String(error);
20152
+ return {
20153
+ webhookId: webhook.id,
20154
+ event,
20155
+ payload,
20156
+ statusCode: 0,
20157
+ responseBody: message.slice(0, 1e3),
20158
+ success: false,
20159
+ attemptNumber
20160
+ };
20161
+ }
20162
+ }
20163
+ };
20164
+ //#endregion
19468
20165
  //#region src/utils/dev-port.ts
19469
20166
  var MAX_PORT_ATTEMPTS = 20;
19470
20167
  /** Filename written next to the project `.env` so the CLI can read it. */
@@ -19606,6 +20303,21 @@ function writeStateFile(projectRoot, port, serviceKey) {
19606
20303
  //#endregion
19607
20304
  //#region src/serve-spa.ts
19608
20305
  /**
20306
+ * Is `requestPath` the excluded path `prefix`, or something beneath it?
20307
+ *
20308
+ * Segment-aware on purpose. A plain `startsWith` reads "/api" as excluding
20309
+ * "/apidocs", and "/admin" as excluding "/administrators" — both ordinary
20310
+ * client-side routes of the app rooted at "/", both then answered with a 404
20311
+ * because the SPA fallback declined them and nothing else claims the path.
20312
+ * `apiBasePath` is always in the exclusion list, so this reached single-app
20313
+ * setups too, not just the multi-app ones the list was added for.
20314
+ */
20315
+ function isUnderPath(requestPath, prefix) {
20316
+ const trimmed = prefix.replace(/\/+$/, "");
20317
+ if (trimmed === "") return true;
20318
+ return requestPath === trimmed || requestPath.startsWith(`${trimmed}/`);
20319
+ }
20320
+ /**
19609
20321
  * Serve a Single Page Application from an Hono app.
19610
20322
  *
19611
20323
  * @internal Not part of the stable public API. Exported only because the
@@ -19615,21 +20327,30 @@ function writeStateFile(projectRoot, port, serviceKey) {
19615
20327
  * may change without a major version bump.
19616
20328
  */
19617
20329
  function serveSPA(app, config) {
19618
- const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html" } = config;
20330
+ const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
20331
+ const rawBase = config.basePath ?? "/";
20332
+ const basePath = rawBase !== "/" ? rawBase.replace(/\/+$/, "") : "/";
20333
+ const isRoot = basePath === "/";
19619
20334
  if (!fs$2.existsSync(frontendPath)) {
19620
20335
  logger.warn(`⚠️ Frontend build path does not exist: ${frontendPath}`);
19621
20336
  logger.warn(" SPA serving is disabled. Build your frontend first.");
19622
20337
  return;
19623
20338
  }
19624
- app.use("/*", responseCompression());
19625
- app.use("/*", serveStatic({
20339
+ const scope = isRoot ? "/*" : `${basePath}/*`;
20340
+ app.use(scope, responseCompression());
20341
+ app.use(scope, serveStatic({
19626
20342
  root: path$1.relative(process.cwd(), frontendPath),
19627
- precompressed: true
20343
+ precompressed: true,
20344
+ ...isRoot ? {} : { rewriteRequestPath: (p) => p.slice(basePath.length) || "/" }
19628
20345
  }));
20346
+ if (!spa) {
20347
+ logger.info(`✅ Static serving enabled at ${basePath} from: ${frontendPath}`);
20348
+ return;
20349
+ }
19629
20350
  const allExcludePaths = [apiBasePath, ...excludePaths];
19630
20351
  let cachedHtml = null;
19631
- app.get("*", async (c, next) => {
19632
- if (allExcludePaths.some((p) => c.req.path.startsWith(p))) return next();
20352
+ app.get(scope, async (c, next) => {
20353
+ if (allExcludePaths.some((p) => isUnderPath(c.req.path, p))) return next();
19633
20354
  const indexPath = path$1.join(frontendPath, indexFile);
19634
20355
  if (!cachedHtml) try {
19635
20356
  cachedHtml = await fsp.readFile(indexPath, "utf-8");
@@ -19639,7 +20360,7 @@ function serveSPA(app, config) {
19639
20360
  }
19640
20361
  return c.html(cachedHtml);
19641
20362
  });
19642
- logger.info(`✅ SPA serving enabled from: ${frontendPath}`);
20363
+ logger.info(`✅ SPA serving enabled at ${basePath} from: ${frontendPath}`);
19643
20364
  }
19644
20365
  //#endregion
19645
20366
  //#region src/boot/bundle.ts
@@ -19654,6 +20375,36 @@ var BundleError = class extends Error {
19654
20375
  };
19655
20376
  var MANIFEST_FILENAME = "manifest.json";
19656
20377
  /**
20378
+ * Bring a format-1 manifest up to the shape the rest of this runtime expects.
20379
+ *
20380
+ * Old bundles booting on a new runtime is the case the format version exists to
20381
+ * protect, so this is not a courtesy — it is the contract. A project built
20382
+ * before the rename ships `mode` and a single `entry.static` directory string,
20383
+ * and without this it would boot with no `kind` (so every gate keyed on
20384
+ * `kind === "backend"` would skip) and an `entry.static` the loader would try to
20385
+ * iterate as a list.
20386
+ *
20387
+ * In place, and only ever filling in what is absent, so a format-2 manifest
20388
+ * passes through untouched.
20389
+ */
20390
+ function upgradeLegacyManifest(manifest) {
20391
+ const legacy = manifest;
20392
+ if (!legacy.kind) legacy.kind = legacy.mode === "static" ? "static" : "backend";
20393
+ const entry = legacy.entry;
20394
+ if (!entry) return;
20395
+ if (typeof entry.static === "string") entry.static = [{
20396
+ path: "/",
20397
+ dir: entry.static,
20398
+ spa: true
20399
+ }];
20400
+ else if (!entry.static && typeof entry.admin === "string") entry.static = [{
20401
+ path: "/",
20402
+ dir: entry.admin,
20403
+ spa: true
20404
+ }];
20405
+ delete entry.admin;
20406
+ }
20407
+ /**
19657
20408
  * Read and validate a bundle's manifest.
19658
20409
  *
19659
20410
  * The checks here are the runtime half of the compatibility contract, and they
@@ -19671,7 +20422,8 @@ function readBundleManifest(bundleDir) {
19671
20422
  throw new BundleError(`${manifestPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
19672
20423
  }
19673
20424
  if (typeof manifest.bundleFormat !== "number") throw new BundleError(`${manifestPath} is missing "bundleFormat".`);
19674
- if (manifest.bundleFormat > 1) throw new BundleError(`This bundle uses format ${manifest.bundleFormat}, but this runtime understands up to 1.`, "Upgrade the runtime image, or rebuild the bundle with a matching CLI.");
20425
+ if (manifest.bundleFormat > 2) throw new BundleError(`This bundle uses format ${manifest.bundleFormat}, but this runtime understands up to 2.`, "Upgrade the runtime image, or rebuild the bundle with a matching CLI.");
20426
+ upgradeLegacyManifest(manifest);
19675
20427
  const contract = manifest.runtime?.contract;
19676
20428
  if (typeof contract === "number" && contract !== 1) throw new BundleError(`This bundle targets runtime contract v${contract}, but this runtime implements v1.`, contract > 1 ? "Upgrade the runtime image to a version that implements the newer contract." : "Rebuild the bundle against the current runtime (`rebase build`), or run a runtime image from the previous major.");
19677
20429
  return manifest;
@@ -19720,8 +20472,14 @@ function loadBundle(bundleDir) {
19720
20472
  collectionsDir: entry.collections ? resolveEntry(entry.collections, "collections") : entry.config ? resolveEntry(path.join(entry.config, "collections"), "collections") : void 0,
19721
20473
  functionsDir: resolveEntry(entry.functions, "functions"),
19722
20474
  cronsDir: resolveEntry(entry.crons, "crons"),
19723
- adminDir: resolveEntry(entry.admin, "admin"),
19724
- staticDir: resolveEntry(entry.static, "static")
20475
+ staticApps: (entry.static ?? []).map((item) => {
20476
+ const resolved = resolveEntry(item.dir, `static app "${item.path}"`);
20477
+ return resolved ? {
20478
+ path: item.path,
20479
+ dir: resolved,
20480
+ spa: item.spa !== false
20481
+ } : void 0;
20482
+ }).filter((item) => item !== void 0).sort((a, b) => b.path.length - a.path.length)
19725
20483
  };
19726
20484
  }
19727
20485
  /**
@@ -19770,7 +20528,7 @@ function createSourceBundle(options) {
19770
20528
  return {
19771
20529
  dir,
19772
20530
  manifest: {
19773
- bundleFormat: 1,
20531
+ bundleFormat: 2,
19774
20532
  runtime: {
19775
20533
  range: `^1`,
19776
20534
  builtAgainst: "source",
@@ -19778,7 +20536,7 @@ function createSourceBundle(options) {
19778
20536
  },
19779
20537
  schemaVersion: "",
19780
20538
  app: options.app ?? "backend",
19781
- mode: options.mode ?? "cms",
20539
+ kind: "backend",
19782
20540
  entry: {
19783
20541
  config: options.config ?? configDir,
19784
20542
  collections: collectionsDir,
@@ -19797,8 +20555,7 @@ function createSourceBundle(options) {
19797
20555
  collectionsDir: resolve(collectionsDir),
19798
20556
  functionsDir: resolve(options.functions),
19799
20557
  cronsDir: resolve(options.crons),
19800
- adminDir: void 0,
19801
- staticDir: void 0
20558
+ staticApps: []
19802
20559
  };
19803
20560
  }
19804
20561
  /**
@@ -20081,12 +20838,17 @@ function resolveCorsOrigin(env) {
20081
20838
  *
20082
20839
  * The default key maps to no suffix at all, which is what keeps every existing
20083
20840
  * single-database deployment working untouched.
20841
+ *
20842
+ * The rule itself lives in `@rebasepro/types` so the CLI and any control plane
20843
+ * derive identical names from identical keys; this wrapper exists only to raise
20844
+ * it as a `BundleError`, which is what the rest of boot reports failures as.
20084
20845
  */
20085
20846
  function envSuffixForKey(key, defaultKey) {
20086
- if (!key || key === defaultKey) return "";
20087
- const normalized = key.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase();
20088
- if (!normalized) throw new BundleError(`Source key "${key}" cannot be turned into an environment variable name.`, "Use a key containing at least one letter or digit.");
20089
- return `__${normalized}`;
20847
+ try {
20848
+ return storageEnvSuffix(key, defaultKey);
20849
+ } catch (err) {
20850
+ throw new BundleError(`Source key "${key}" cannot be turned into an environment variable name.`, "Use a key containing at least one letter or digit.");
20851
+ }
20090
20852
  }
20091
20853
  /** Read `<base>` for the default source, `<base>__<KEY>` for a named one. */
20092
20854
  function readVar(env, base, suffix) {
@@ -20105,13 +20867,8 @@ function readBool(env, base, suffix) {
20105
20867
  * without this check one of them would silently read the other's configuration.
20106
20868
  */
20107
20869
  function assertDistinctSuffixes(definitions, defaultKey, what) {
20108
- const seen = /* @__PURE__ */ new Map();
20109
- for (const def of definitions) {
20110
- const suffix = envSuffixForKey(def.key, defaultKey);
20111
- const existing = seen.get(suffix);
20112
- if (existing !== void 0 && existing !== def.key) throw new BundleError(`${what} keys "${existing}" and "${def.key}" both map to the same environment variable suffix "${suffix || "(none)"}".`, "Rename one of them so each source has its own configuration.");
20113
- seen.set(suffix, def.key);
20114
- }
20870
+ const collision = findStorageSuffixCollision(definitions.map((d) => d.key), defaultKey);
20871
+ if (collision) throw new BundleError(`${what} keys "${collision.a}" and "${collision.b}" both map to the same environment variable suffix "${collision.suffix || "(none)"}".`, "Rename one of them so each source has its own configuration.");
20115
20872
  }
20116
20873
  /** Which driver package backs a given engine, before env overrides. */
20117
20874
  var ENGINE_DRIVERS = {
@@ -20188,23 +20945,37 @@ function resolvePoolConfig(env, suffix) {
20188
20945
  */
20189
20946
  function resolveStorageBackend(env, key, engineHint, defaultBasePath) {
20190
20947
  const suffix = envSuffixForKey(key, DEFAULT_STORAGE_SOURCE_KEY);
20191
- const type = (readVar(env, "STORAGE_TYPE", suffix) || engineHint || "").toLowerCase();
20948
+ const declaredType = readVar(env, "STORAGE_TYPE", suffix);
20949
+ const type = (declaredType || engineHint || "").toLowerCase();
20950
+ const explicit = Boolean(declaredType);
20192
20951
  if (type === "s3") {
20193
20952
  const bucket = readVar(env, "S3_BUCKET", suffix);
20194
- if (!bucket) throw new BundleError(`Storage source "${key}" is set to s3 but has no bucket — set ${`S3_BUCKET${suffix}`}.`);
20953
+ if (!bucket) {
20954
+ if (!explicit) return void 0;
20955
+ throw new BundleError(`Storage source "${key}" is set to s3 but has no bucket — set ${`S3_BUCKET${suffix}`}.`);
20956
+ }
20957
+ const accessKeyId = readVar(env, "S3_ACCESS_KEY_ID", suffix);
20958
+ const secretAccessKey = readVar(env, "S3_SECRET_ACCESS_KEY", suffix);
20959
+ if (!accessKeyId || !secretAccessKey) {
20960
+ if (!explicit) return void 0;
20961
+ throw new BundleError(`Storage source "${key}" is set to s3 with a bucket but no credentials — set ${[!accessKeyId && `S3_ACCESS_KEY_ID${suffix}`, !secretAccessKey && `S3_SECRET_ACCESS_KEY${suffix}`].filter(Boolean).join(" and ")}.`, "A bucket without credentials cannot be reached: every upload fails when the request is signed.");
20962
+ }
20195
20963
  return {
20196
20964
  type: "s3",
20197
20965
  bucket,
20198
20966
  region: readVar(env, "S3_REGION", suffix) || "auto",
20199
- accessKeyId: readVar(env, "S3_ACCESS_KEY_ID", suffix) || "",
20200
- secretAccessKey: readVar(env, "S3_SECRET_ACCESS_KEY", suffix) || "",
20967
+ accessKeyId,
20968
+ secretAccessKey,
20201
20969
  endpoint: readVar(env, "S3_ENDPOINT", suffix),
20202
20970
  forcePathStyle: readBool(env, "S3_FORCE_PATH_STYLE", suffix)
20203
20971
  };
20204
20972
  }
20205
20973
  if (type === "gcs") {
20206
20974
  const bucket = readVar(env, "GCS_BUCKET", suffix);
20207
- if (!bucket) throw new BundleError(`Storage source "${key}" is set to gcs but has no bucket — set ${`GCS_BUCKET${suffix}`}.`);
20975
+ if (!bucket) {
20976
+ if (!explicit) return void 0;
20977
+ throw new BundleError(`Storage source "${key}" is set to gcs but has no bucket — set ${`GCS_BUCKET${suffix}`}.`);
20978
+ }
20208
20979
  return {
20209
20980
  type: "gcs",
20210
20981
  bucket,
@@ -20240,6 +21011,45 @@ function resolveStorageSources(env, definitions, defaultBasePath) {
20240
21011
  }
20241
21012
  return Object.keys(result).length > 0 ? result : void 0;
20242
21013
  }
21014
+ /**
21015
+ * Read a project's declared storage sources from its `rebase.json`.
21016
+ *
21017
+ * A managed bundle carries its topology in `manifest.json`, resolved at build
21018
+ * time. A **custom** runtime has no manifest — it builds its own image and its
21019
+ * own entrypoint — so without this it would have to re-declare in code what
21020
+ * `rebase.json` already says, and the two would drift. Since a custom image
21021
+ * contains the repository anyway, reading the file it already ships is what
21022
+ * keeps one declaration authoritative for both runtimes.
21023
+ *
21024
+ * Walks up from `startDir` because an entrypoint lives at `backend/src` in the
21025
+ * scaffolded layout and somewhere else in a hand-rolled one. A missing,
21026
+ * unreadable or malformed file means "declared nothing" — one default source —
21027
+ * which is the correct reading of every project that predates this and must
21028
+ * never be an error: a storage declaration is optional, and failing to boot a
21029
+ * whole backend over an absent optional file would be the worse bug.
21030
+ */
21031
+ function loadDeclaredStorageSources(startDir, levels = 5) {
21032
+ let dir = startDir;
21033
+ for (let i = 0; i <= levels; i++) {
21034
+ const candidate = path.join(dir, "rebase.json");
21035
+ if (fs.existsSync(candidate)) {
21036
+ let declared;
21037
+ try {
21038
+ declared = JSON.parse(fs.readFileSync(candidate, "utf8"))?.storage;
21039
+ } catch (err) {
21040
+ logger.warn(`Could not read storage sources from ${candidate}: ${err instanceof Error ? err.message : String(err)}. Continuing with a single default storage source.`);
21041
+ return [];
21042
+ }
21043
+ const sources = normalizeStorageSources(declared, void 0);
21044
+ assertDistinctSuffixes(sources, DEFAULT_STORAGE_SOURCE_KEY, "Storage source");
21045
+ return sources;
21046
+ }
21047
+ const parent = path.dirname(dir);
21048
+ if (parent === dir) break;
21049
+ dir = parent;
21050
+ }
21051
+ return [];
21052
+ }
20243
21053
  //#endregion
20244
21054
  //#region src/boot/driver.ts
20245
21055
  /**
@@ -20741,16 +21551,17 @@ async function bootFromBundle(options = {}) {
20741
21551
  const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();
20742
21552
  logger.info("Loaded bundle", {
20743
21553
  app: bundle.manifest.app,
20744
- mode: bundle.manifest.mode,
21554
+ kind: bundle.manifest.kind,
20745
21555
  schemaVersion: bundle.manifest.schemaVersion,
20746
21556
  builtAgainst: bundle.manifest.runtime?.builtAgainst
20747
21557
  });
20748
- if (bundle.manifest.mode === "static") return bootStaticApp(bundle, devRoot, options);
21558
+ if (bundle.manifest.kind === "static") return bootStaticApp(bundle, devRoot, options);
20749
21559
  const env = loadBootEnv();
20750
21560
  const isProduction = env.NODE_ENV === "production";
20751
21561
  const configExports = await loadBundleConfigExports(bundle);
20752
21562
  const dataSourceDefs = configExports.dataSources;
20753
- const storageSourceDefs = configExports.storageSources;
21563
+ const declaredStorage = normalizeStorageSources(bundle.manifest.storage?.sources, configExports.storageSources);
21564
+ const storageSourceDefs = declaredStorage.length > 0 ? declaredStorage : void 0;
20754
21565
  const dataSources = await initializeDataSources(resolveDataSources(process.env, dataSourceDefs), await loadBundleSchema(bundle), [
20755
21566
  bundle.dir,
20756
21567
  path.join(bundle.dir, "backend"),
@@ -20775,7 +21586,6 @@ async function bootFromBundle(options = {}) {
20775
21586
  server,
20776
21587
  app,
20777
21588
  basePath: env.REBASE_BASE_PATH,
20778
- mode: bundle.manifest.mode ?? "cms",
20779
21589
  collectionsDir: bundle.collectionsDir,
20780
21590
  functionsDir: bundle.functionsDir,
20781
21591
  cronsDir: bundle.cronsDir,
@@ -20821,20 +21631,24 @@ async function bootFromBundle(options = {}) {
20821
21631
  if (!env.REBASE_METRICS_TOKEN) logger.warn("Metrics are enabled without REBASE_METRICS_TOKEN — /metrics is readable by anyone who can reach this port. Set a token, or keep the port on a private network.");
20822
21632
  app.route("/metrics", createMetricsRoutes(metrics.registry, env.REBASE_METRICS_TOKEN));
20823
21633
  }
20824
- if (env.REBASE_SERVE_STATIC) {
20825
- const staticRoot = bundle.staticDir ?? bundle.adminDir;
20826
- if (staticRoot) {
20827
- logger.info("Serving static assets", { path: staticRoot });
20828
- serveSPA(app, {
20829
- frontendPath: staticRoot,
20830
- apiBasePath: env.REBASE_BASE_PATH,
20831
- excludePaths: [
20832
- "/health",
20833
- "/livez",
20834
- "/metrics"
20835
- ]
20836
- });
20837
- }
21634
+ if (env.REBASE_SERVE_STATIC) for (const staticApp of bundle.staticApps) {
21635
+ const siblings = bundle.staticApps.filter((other) => other !== staticApp).map((other) => other.path).filter((other) => other !== "/");
21636
+ logger.info("Serving static assets", {
21637
+ path: staticApp.dir,
21638
+ at: staticApp.path
21639
+ });
21640
+ serveSPA(app, {
21641
+ frontendPath: staticApp.dir,
21642
+ basePath: staticApp.path,
21643
+ apiBasePath: env.REBASE_BASE_PATH,
21644
+ excludePaths: [
21645
+ "/health",
21646
+ "/livez",
21647
+ "/metrics",
21648
+ ...siblings
21649
+ ],
21650
+ spa: staticApp.spa
21651
+ });
20838
21652
  }
20839
21653
  let port = env.PORT;
20840
21654
  if (options.listen !== false) if (isProduction) {
@@ -20886,8 +21700,7 @@ async function bootFromBundle(options = {}) {
20886
21700
  * backend — the only difference is what the bundle contains.
20887
21701
  */
20888
21702
  async function bootStaticApp(bundle, devRoot, options) {
20889
- const staticRoot = bundle.staticDir ?? bundle.adminDir;
20890
- if (!staticRoot) throw new BundleError("A static bundle declares no assets to serve.", "Its manifest has `mode: \"static\"` but no `entry.static` — rebuild the app with `rebase build`.");
21703
+ if (bundle.staticApps.length === 0) throw new BundleError("A static bundle declares no assets to serve.", "Its manifest has `kind: \"static\"` but no `entry.static` — rebuild the app with `rebase build`.");
20891
21704
  const isProduction = process.env.NODE_ENV === "production";
20892
21705
  const requestedPort = Number(process.env.PORT ?? "3001") || 3001;
20893
21706
  const basePath = process.env.REBASE_BASE_PATH || "/api";
@@ -20907,19 +21720,26 @@ async function bootStaticApp(bundle, devRoot, options) {
20907
21720
  latencyMs: 0
20908
21721
  }));
20909
21722
  if (metrics) app.route("/metrics", createMetricsRoutes(metrics.registry, metricsToken));
20910
- logger.info("Serving static app", {
20911
- app: bundle.manifest.app,
20912
- path: staticRoot
20913
- });
20914
- serveSPA(app, {
20915
- frontendPath: staticRoot,
20916
- apiBasePath: basePath,
20917
- excludePaths: [
20918
- "/health",
20919
- "/livez",
20920
- "/metrics"
20921
- ]
20922
- });
21723
+ for (const staticApp of bundle.staticApps) {
21724
+ const siblings = bundle.staticApps.filter((other) => other !== staticApp).map((other) => other.path).filter((other) => other !== "/");
21725
+ logger.info("Serving static app", {
21726
+ app: bundle.manifest.app,
21727
+ path: staticApp.dir,
21728
+ at: staticApp.path
21729
+ });
21730
+ serveSPA(app, {
21731
+ frontendPath: staticApp.dir,
21732
+ basePath: staticApp.path,
21733
+ apiBasePath: basePath,
21734
+ excludePaths: [
21735
+ "/health",
21736
+ "/livez",
21737
+ "/metrics",
21738
+ ...siblings
21739
+ ],
21740
+ spa: staticApp.spa
21741
+ });
21742
+ }
20923
21743
  let port = requestedPort;
20924
21744
  if (options.listen !== false) if (isProduction) {
20925
21745
  await new Promise((resolve, reject) => {
@@ -20997,7 +21817,8 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
20997
21817
  logger.info("REBASE_MIGRATE_ON_BOOT=none — leaving the database schema untouched.");
20998
21818
  return;
20999
21819
  }
21000
- if ((bundle.manifest.mode ?? "cms") !== "cms") return;
21820
+ if (bundle.manifest.kind !== "backend") return;
21821
+ if (!bundle.manifest.entry?.config) return;
21001
21822
  if (!bundle.collectionsDir) return;
21002
21823
  const primary = dataSources[0];
21003
21824
  if (!primary?.bootstrapper.ensureCollectionSchema) return;
@@ -21043,7 +21864,6 @@ function createContractRoutes(config) {
21043
21864
  version: config.runtimeVersion ?? "unknown",
21044
21865
  contract: 1
21045
21866
  },
21046
- mode: config.mode,
21047
21867
  collections: serialized,
21048
21868
  collectionSlugs: collections.map((collection) => collection.slug).filter((slug) => Boolean(slug)).sort(),
21049
21869
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -21062,15 +21882,12 @@ function createContractRoutes(config) {
21062
21882
  router.get("/schema-version", (c) => {
21063
21883
  const schemaVersion = schemaVersionOf(config.collectionRegistry.getRawCollections());
21064
21884
  c.header(SCHEMA_VERSION_HEADER, schemaVersion);
21065
- return c.json({
21066
- schemaVersion,
21067
- mode: config.mode
21068
- });
21885
+ return c.json({ schemaVersion });
21069
21886
  });
21070
21887
  logger.debug("Contract routes mounted");
21071
21888
  return router;
21072
21889
  }
21073
21890
  //#endregion
21074
- export { ApiError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, S3StorageController, SMTPEmailService, TransformCache, TusHandler, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, authJwt, authRoles, authUid, 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, extractUserFromToken, fileTokenAuth, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed, isRebaseApiError, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, queryTokenAuth, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailOptions, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, validateApiKey, validateCronExpression, validatePasswordStrength, verifyPassword };
21891
+ export { ApiError, BundleError, CronScheduler, DEFAULT_DRIVER_ID, DEFAULT_MAX_FILE_SIZE, DEFAULT_STORAGE_ID, DOCUMENT_MIME_TYPES, DefaultDriverRegistry, DefaultStorageRegistry, GCSStorageController, IMAGE_MIME_TYPES, LocalStorageController, MetricsRegistry, S3StorageController, SMTPEmailService, TransformCache, TusHandler, WebhookDispatcher, _resetRebaseMock, _setRebaseMock, applyCollectionDefaults, assertCollectionConfigs, assertDistinctSuffixes, authJwt, authRoles, authUid, 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, extractUserFromToken, fileTokenAuth, findCollectionConfigProblems, generateSecurePassword, getEmailVerificationTemplate, getMagicLinkTemplate, getPasswordResetTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, hashPassword, httpMethodToOperation, initializeDataSource, initializeDataSources, initializeRebaseBackend, installShutdownHandlers, isApiKeyToken, isAuthAdapter, isDatabaseAdapter, isLocalhostOrigin, isOperationAllowed, isRebaseApiError, isTransformableImage, listBackupObjects, listenWithPortRetry, loadBootEnv, loadBundle, loadBundleConfigExports, loadBundleSchema, loadCollectionsFromDirectory, loadCronJobsFromDirectory, loadDeclaredStorageSources, loadEnv, loadFunctionsFromDirectory, loadUsersCollection, logger, optionalAuth, parseBackupDestination, parseBackupTimestamp, parseTransformOptions, queryTokenAuth, readBackupBytes, readBundleManifest, rebase, requireAdmin, requireAuth, resolveAuthHooks, resolveAuthOptions, resolveCorsOrigin, resolveDataSources, resolveEmailOptions, resolveStorageBackend, resolveStorageSources, runFromBundle, safeCompare, serveSPA, transformImage, unknownKeyPolicyFromEnv, validateApiKey, validateCronExpression, validatePasswordStrength, verifyPassword };
21075
21892
 
21076
21893
  //# sourceMappingURL=index.es.js.map