@rebasepro/server 0.11.1-canary.gfd39654 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/contract-routes.d.ts +0 -1
- package/dist/auth/api-keys/api-key-types.d.ts +6 -70
- package/dist/auth/builtin-auth-adapter.d.ts +11 -0
- package/dist/auth/registration-policy.d.ts +57 -0
- package/dist/boot/bundle.d.ts +17 -5
- package/dist/boot/driver.d.ts +17 -3
- package/dist/boot/env.d.ts +16 -2
- package/dist/boot/sources.d.ts +31 -0
- package/dist/collections/loader.d.ts +11 -1
- package/dist/collections/validate-config.d.ts +70 -0
- package/dist/cron/cron-scheduler.d.ts +9 -0
- package/dist/cron/index.d.ts +1 -0
- package/dist/cron/scale-to-zero.d.ts +58 -0
- package/dist/env.d.ts +5 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.es.js +1024 -125
- package/dist/index.es.js.map +1 -1
- package/dist/init.d.ts +14 -17
- package/dist/{openapi-generator-CeOnlAJ3.js → openapi-generator-Bjzmb5cn.js} +5 -4
- package/dist/openapi-generator-Bjzmb5cn.js.map +1 -0
- package/dist/{schema-editor-routes-BFpXKkPD.js → schema-editor-routes-DDxfOIid.js} +2 -2
- package/dist/{schema-editor-routes-BFpXKkPD.js.map → schema-editor-routes-DDxfOIid.js.map} +1 -1
- package/dist/serve-spa.d.ts +31 -1
- package/dist/{src-q6_elgGZ.js → src-CoOAMnBh.js} +193 -14
- package/dist/src-CoOAMnBh.js.map +1 -0
- package/dist/{src-BYbxB4PR.js → src-Ivjud8jD.js} +272 -90
- package/dist/src-Ivjud8jD.js.map +1 -0
- package/package.json +5 -5
- package/dist/openapi-generator-CeOnlAJ3.js.map +0 -1
- package/dist/src-BYbxB4PR.js.map +0 -1
- package/dist/src-q6_elgGZ.js.map +0 -1
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
|
|
6
|
-
import { a as deserializeFilter, c as serializeLogicalCondition, d as
|
|
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
|
-
|
|
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
|
|
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
|
|
998
|
+
let decoded;
|
|
603
999
|
try {
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
}
|
|
@@ -7310,6 +7714,26 @@ function redactRefreshToken(response, c, refreshToken, config) {
|
|
|
7310
7714
|
};
|
|
7311
7715
|
}
|
|
7312
7716
|
//#endregion
|
|
7717
|
+
//#region src/auth/registration-policy.ts
|
|
7718
|
+
/** The full predicate, for callers that already know whether setup is needed. */
|
|
7719
|
+
function isRegistrationOpen(policy) {
|
|
7720
|
+
if (policy.disableSelfRegistration) return false;
|
|
7721
|
+
return policy.needsSetup || !!policy.allowRegistration;
|
|
7722
|
+
}
|
|
7723
|
+
/**
|
|
7724
|
+
* The same predicate with the bootstrap window assumed closed.
|
|
7725
|
+
*
|
|
7726
|
+
* Exists so `POST /auth/register` can reject the common case without counting
|
|
7727
|
+
* rows. A `false` here does **not** mean "refuse" — it means "the answer depends
|
|
7728
|
+
* on whether the table is empty, so now go and look".
|
|
7729
|
+
*/
|
|
7730
|
+
function isSteadyStateRegistrationOpen(policy) {
|
|
7731
|
+
return isRegistrationOpen({
|
|
7732
|
+
...policy,
|
|
7733
|
+
needsSetup: false
|
|
7734
|
+
});
|
|
7735
|
+
}
|
|
7736
|
+
//#endregion
|
|
7313
7737
|
//#region src/auth/session-routes.ts
|
|
7314
7738
|
function mountSessionRoutes(opts) {
|
|
7315
7739
|
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
|
|
@@ -7471,13 +7895,34 @@ function mountSessionRoutes(opts) {
|
|
|
7471
7895
|
});
|
|
7472
7896
|
/**
|
|
7473
7897
|
* GET /auth/config
|
|
7474
|
-
* Get public auth configuration
|
|
7898
|
+
* Get public auth configuration.
|
|
7899
|
+
*
|
|
7900
|
+
* ⚠️ SHADOWED on a backend booted through `initializeRebaseBackend`.
|
|
7901
|
+
* `init.ts` registers `${basePath}/auth/config` directly and only mounts
|
|
7902
|
+
* this router afterwards, so Hono resolves that registration first and this
|
|
7903
|
+
* handler never runs. The live implementation is `getCapabilities()` in
|
|
7904
|
+
* `builtin-auth-adapter.ts`.
|
|
7905
|
+
*
|
|
7906
|
+
* Both return `needsSetup` and `registrationEnabled`, so the response shape
|
|
7907
|
+
* cannot tell them apart — which is how a fix for the empty-database dead
|
|
7908
|
+
* end was once applied here, to no effect, while the live copy kept
|
|
7909
|
+
* advertising the wrong answer. `bootstrap-e2e.test.ts` pins which handler
|
|
7910
|
+
* actually answers.
|
|
7911
|
+
*
|
|
7912
|
+
* It is kept because this router is also mounted standalone (tests, and any
|
|
7913
|
+
* embedder that wires `createAuthRoutes` without init.ts). If you change the
|
|
7914
|
+
* registration rule, change it in `registration-policy.ts` — both callers
|
|
7915
|
+
* read it from there, so neither can drift again.
|
|
7475
7916
|
*/
|
|
7476
7917
|
router.get("/config", defaultAuthLimiter, async (c) => {
|
|
7477
7918
|
let needsSetup;
|
|
7478
7919
|
if (config.isBootstrapCompleted) needsSetup = !await config.isBootstrapCompleted();
|
|
7479
7920
|
else needsSetup = (await authRepo.listUsers()).length === 0;
|
|
7480
|
-
const registrationAllowed =
|
|
7921
|
+
const registrationAllowed = isRegistrationOpen({
|
|
7922
|
+
disableSelfRegistration: config.disableSelfRegistration,
|
|
7923
|
+
allowRegistration: config.allowRegistration,
|
|
7924
|
+
needsSetup
|
|
7925
|
+
});
|
|
7481
7926
|
const enabledProviders = (config.oauthProviders || []).map((p) => p.id);
|
|
7482
7927
|
return c.json({
|
|
7483
7928
|
needsSetup,
|
|
@@ -7717,13 +8162,22 @@ function createAuthRoutes(config) {
|
|
|
7717
8162
|
return !!(emailService && emailService.isConfigured());
|
|
7718
8163
|
}
|
|
7719
8164
|
/**
|
|
7720
|
-
*
|
|
7721
|
-
*
|
|
7722
|
-
*
|
|
8165
|
+
* Whether registration is open without consulting the user table.
|
|
8166
|
+
*
|
|
8167
|
+
* The rule lives in `registration-policy.ts` and is shared with both config
|
|
8168
|
+
* endpoints, so what this route enforces and what they advertise cannot
|
|
8169
|
+
* drift apart — which is exactly how the empty-database dead end happened.
|
|
8170
|
+
*
|
|
8171
|
+
* `false` here does not mean "refuse": it means the answer depends on
|
|
8172
|
+
* whether the table is empty, which `POST /auth/register` checks only at
|
|
8173
|
+
* that point, because it serves anonymous callers and a count per rejected
|
|
8174
|
+
* attempt is a free hit on the database.
|
|
7723
8175
|
*/
|
|
7724
8176
|
function isRegistrationAllowed() {
|
|
7725
|
-
|
|
7726
|
-
|
|
8177
|
+
return isSteadyStateRegistrationOpen({
|
|
8178
|
+
disableSelfRegistration: config.disableSelfRegistration,
|
|
8179
|
+
allowRegistration
|
|
8180
|
+
});
|
|
7727
8181
|
}
|
|
7728
8182
|
/**
|
|
7729
8183
|
* Send welcome email to a newly registered user (fire-and-forget).
|
|
@@ -7779,7 +8233,12 @@ function createAuthRoutes(config) {
|
|
|
7779
8233
|
router.post("/register", defaultAuthLimiter, async (c) => {
|
|
7780
8234
|
const { email, password, displayName } = parseBody(registerSchema, await c.req.json());
|
|
7781
8235
|
if (config.disableSelfRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
7782
|
-
|
|
8236
|
+
let bootstrapRegistration = false;
|
|
8237
|
+
if (!isRegistrationAllowed()) {
|
|
8238
|
+
const { total } = await authRepo.listUsersPaginated({ limit: 1 });
|
|
8239
|
+
bootstrapRegistration = total === 0;
|
|
8240
|
+
if (!bootstrapRegistration) throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
8241
|
+
}
|
|
7783
8242
|
const passwordValidation = ops.validatePasswordStrength(password);
|
|
7784
8243
|
if (!passwordValidation.valid) throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
7785
8244
|
if (await authRepo.getUserByEmail(email)) throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
@@ -7792,7 +8251,12 @@ function createAuthRoutes(config) {
|
|
|
7792
8251
|
if (ops.beforeUserCreate) createData = await ops.beforeUserCreate(createData);
|
|
7793
8252
|
const user = await authRepo.createUser(createData);
|
|
7794
8253
|
const existingUsers = await authRepo.listUsers();
|
|
7795
|
-
|
|
8254
|
+
const isFirstUser = existingUsers.length === 1 && existingUsers[0].id === user.id;
|
|
8255
|
+
if (bootstrapRegistration && !isFirstUser) {
|
|
8256
|
+
await authRepo.deleteUser(user.id);
|
|
8257
|
+
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
8258
|
+
}
|
|
8259
|
+
if (isFirstUser) await authRepo.setUserRoles(user.id, ["admin"]);
|
|
7796
8260
|
else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
7797
8261
|
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
|
|
7798
8262
|
sendWelcomeEmail({
|
|
@@ -8532,7 +8996,7 @@ function createAdminUsersRoute(config) {
|
|
|
8532
8996
|
* when the user passes a plain `RebaseAuthConfig` object.
|
|
8533
8997
|
*/
|
|
8534
8998
|
function createBuiltinAuthAdapter(config) {
|
|
8535
|
-
const { authRepository, emailService, emailConfig, allowRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
|
|
8999
|
+
const { authRepository, emailService, emailConfig, allowRegistration = false, disableSelfRegistration = false, allowUserLookup = false, defaultRole, oauthProviders = [], serviceKey, authHooks, collectionAuthConfig, enableMagicLink = false, cookieAuth } = config;
|
|
8536
9000
|
const resolvedOps = resolveAuthHooks(authHooks);
|
|
8537
9001
|
return {
|
|
8538
9002
|
id: "rebase-builtin",
|
|
@@ -8605,6 +9069,7 @@ function createBuiltinAuthAdapter(config) {
|
|
|
8605
9069
|
emailService,
|
|
8606
9070
|
emailConfig,
|
|
8607
9071
|
allowRegistration,
|
|
9072
|
+
disableSelfRegistration,
|
|
8608
9073
|
allowUserLookup,
|
|
8609
9074
|
defaultRole,
|
|
8610
9075
|
oauthProviders,
|
|
@@ -8674,11 +9139,16 @@ function createBuiltinAuthAdapter(config) {
|
|
|
8674
9139
|
needsSetup = (await authRepository.listUsersPaginated({ limit: 1 })).total === 0;
|
|
8675
9140
|
} catch {}
|
|
8676
9141
|
const enabledProviders = oauthProviders.map((p) => p.id);
|
|
9142
|
+
const registrationAllowed = isRegistrationOpen({
|
|
9143
|
+
disableSelfRegistration,
|
|
9144
|
+
allowRegistration,
|
|
9145
|
+
needsSetup
|
|
9146
|
+
});
|
|
8677
9147
|
return {
|
|
8678
9148
|
hasBuiltInAuthRoutes: true,
|
|
8679
9149
|
emailPasswordLogin: true,
|
|
8680
|
-
registration:
|
|
8681
|
-
registrationEnabled:
|
|
9150
|
+
registration: registrationAllowed,
|
|
9151
|
+
registrationEnabled: registrationAllowed,
|
|
8682
9152
|
passwordReset: !!emailService?.isConfigured(),
|
|
8683
9153
|
adminPasswordReset: true,
|
|
8684
9154
|
sessionManagement: true,
|
|
@@ -11970,7 +12440,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
|
|
|
11970
12440
|
//#region src/init/docs.ts
|
|
11971
12441
|
async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
|
|
11972
12442
|
if (enableSwagger === false || activeCollections.length === 0) return;
|
|
11973
|
-
const { generateOpenApiSpec } = await import("./openapi-generator-
|
|
12443
|
+
const { generateOpenApiSpec } = await import("./openapi-generator-Bjzmb5cn.js");
|
|
11974
12444
|
app.get(`${basePath}/docs`, (c) => {
|
|
11975
12445
|
const spec = generateOpenApiSpec(activeCollections, {
|
|
11976
12446
|
basePath,
|
|
@@ -12206,6 +12676,25 @@ function rebaseReviver(_key, value) {
|
|
|
12206
12676
|
}
|
|
12207
12677
|
return value;
|
|
12208
12678
|
}
|
|
12679
|
+
/**
|
|
12680
|
+
* True when there is no browser to have signed a user in — a Node script, a
|
|
12681
|
+
* cron job, an edge worker.
|
|
12682
|
+
*
|
|
12683
|
+
* Anonymous is an ordinary, correct state in a browser: before sign-in, on a
|
|
12684
|
+
* marketing page, for public reads. Warning there would be noise that teaches
|
|
12685
|
+
* people to ignore warnings, so the guard is off entirely. This uses the same
|
|
12686
|
+
* `typeof window` test as {@link resolveBaseUrl}, and additionally treats a
|
|
12687
|
+
* defined `document` as a browser so an SSR shim or test harness that installs
|
|
12688
|
+
* only one of the two is still excluded.
|
|
12689
|
+
*/
|
|
12690
|
+
function isServerLikeEnvironment() {
|
|
12691
|
+
return typeof window === "undefined" && typeof document === "undefined";
|
|
12692
|
+
}
|
|
12693
|
+
/**
|
|
12694
|
+
* Emitted once per client. Kept as a constant so the wording is testable and
|
|
12695
|
+
* greppable — this is the string a user will paste into a search.
|
|
12696
|
+
*/
|
|
12697
|
+
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
12698
|
function buildQueryString(params) {
|
|
12210
12699
|
if (!params) return "";
|
|
12211
12700
|
const parts = [];
|
|
@@ -12252,12 +12741,31 @@ function resolveBaseUrl(configured) {
|
|
|
12252
12741
|
if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
|
|
12253
12742
|
return "";
|
|
12254
12743
|
}
|
|
12255
|
-
function createTransport(config) {
|
|
12744
|
+
function createTransport(config, environment) {
|
|
12256
12745
|
const fetchFn = config.fetch || globalThis.fetch;
|
|
12257
12746
|
const apiPath = config.apiPath || "/api";
|
|
12258
12747
|
let token = config.token;
|
|
12259
12748
|
let tokenGetter;
|
|
12260
12749
|
let onUnauthorizedHandler = config.onUnauthorized;
|
|
12750
|
+
/** Once per client, never per request — log spam is its own bug. */
|
|
12751
|
+
let anonymousWarningIssued = false;
|
|
12752
|
+
/**
|
|
12753
|
+
* Warn a server-side caller that it built a client that can only ever be
|
|
12754
|
+
* anonymous. Deliberately checked at the *first request* rather than at
|
|
12755
|
+
* construction: `setToken()` / `setAuthTokenGetter()` and a server-side
|
|
12756
|
+
* `auth.signIn…()` (which calls `transport.setToken`) all land after the
|
|
12757
|
+
* constructor, and warning at construction would fire on every one of them.
|
|
12758
|
+
*/
|
|
12759
|
+
function warnIfAnonymousServerClient(activeToken) {
|
|
12760
|
+
if (anonymousWarningIssued) return;
|
|
12761
|
+
if (activeToken) return;
|
|
12762
|
+
if (tokenGetter) return;
|
|
12763
|
+
if (config.anonymous) return;
|
|
12764
|
+
if (environment?.credentialOutOfBand) return;
|
|
12765
|
+
if (!isServerLikeEnvironment()) return;
|
|
12766
|
+
anonymousWarningIssued = true;
|
|
12767
|
+
console.warn(ANONYMOUS_SERVER_CLIENT_WARNING);
|
|
12768
|
+
}
|
|
12261
12769
|
function getHeaders(activeToken, init) {
|
|
12262
12770
|
return {
|
|
12263
12771
|
"Content-Type": "application/json",
|
|
@@ -12272,6 +12780,7 @@ function createTransport(config) {
|
|
|
12272
12780
|
const fetched = await tokenGetter();
|
|
12273
12781
|
if (fetched !== null && fetched !== void 0) activeToken = fetched;
|
|
12274
12782
|
} catch (e) {}
|
|
12783
|
+
warnIfAnonymousServerClient(activeToken);
|
|
12275
12784
|
const headers = getHeaders(activeToken, init);
|
|
12276
12785
|
if (init?.body instanceof FormData) delete headers["Content-Type"];
|
|
12277
12786
|
const res = await fetchFn(url, {
|
|
@@ -13309,6 +13818,12 @@ function createCollectionClient(transport, slug, ws) {
|
|
|
13309
13818
|
meta: raw.meta
|
|
13310
13819
|
};
|
|
13311
13820
|
},
|
|
13821
|
+
iterate(params) {
|
|
13822
|
+
return paginateFind((p) => client.find(p), params, slug);
|
|
13823
|
+
},
|
|
13824
|
+
findAll(params) {
|
|
13825
|
+
return collectAllPages((p) => client.find(p), params, slug);
|
|
13826
|
+
},
|
|
13312
13827
|
async findById(id) {
|
|
13313
13828
|
try {
|
|
13314
13829
|
const raw = await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, { method: "GET" });
|
|
@@ -16109,6 +16624,8 @@ var OfflineManager = class {
|
|
|
16109
16624
|
meta: answer.meta
|
|
16110
16625
|
};
|
|
16111
16626
|
},
|
|
16627
|
+
iterate: (params) => paginateFind((p) => wrapped.find(p), params, slug),
|
|
16628
|
+
findAll: (params) => collectAllPages((p) => wrapped.find(p), params, slug),
|
|
16112
16629
|
findById: async (id) => {
|
|
16113
16630
|
await this.ensureCollection(slug);
|
|
16114
16631
|
if (this.connectivity.shouldAttempt()) try {
|
|
@@ -17176,7 +17693,7 @@ function deriveWebSocketUrl(baseUrl) {
|
|
|
17176
17693
|
return baseUrl.replace(/^https?:\/\//i, (match) => match.toLowerCase() === "https://" ? "wss://" : "ws://").replace(/\/$/, "");
|
|
17177
17694
|
}
|
|
17178
17695
|
function createRebaseClient(options) {
|
|
17179
|
-
const transport = createTransport(options);
|
|
17696
|
+
const transport = createTransport(options, { credentialOutOfBand: options.auth?.authFlowMode === "cookie" });
|
|
17180
17697
|
const auth = createAuth(transport, options.auth);
|
|
17181
17698
|
const admin = createAdmin(transport, options.admin);
|
|
17182
17699
|
const cron = createCron(transport, options.cron);
|
|
@@ -17553,13 +18070,37 @@ function createEmailService(config) {
|
|
|
17553
18070
|
}
|
|
17554
18071
|
//#endregion
|
|
17555
18072
|
//#region src/singleton.ts
|
|
17556
|
-
|
|
18073
|
+
/**
|
|
18074
|
+
* The backing instance lives on a process-global slot, NOT in a module-local
|
|
18075
|
+
* variable — because more than one copy of this module can be loaded into one
|
|
18076
|
+
* process, and a module-local would leave every copy but the booting one dead.
|
|
18077
|
+
*
|
|
18078
|
+
* That is the normal layout under the managed runtime, not an edge case: the
|
|
18079
|
+
* image ships the framework at `/app/node_modules`, while a project's bundle
|
|
18080
|
+
* installs its own dependencies into `/bundle/node_modules` — and every custom
|
|
18081
|
+
* function imports `defineFunction` from `@rebasepro/server`, which resolves to
|
|
18082
|
+
* the bundle's transitively-installed copy. `initializeRebaseBackend()` then ran
|
|
18083
|
+
* against `/app`'s copy while every function held `/bundle`'s, so `rebase.data`,
|
|
18084
|
+
* `rebase.storage` and `rebase.dataAsAdmin` threw "server not initialized yet"
|
|
18085
|
+
* on EVERY request, forever, in an otherwise healthy process.
|
|
18086
|
+
*
|
|
18087
|
+
* `Symbol.for` is the fix because its registry is per-process rather than
|
|
18088
|
+
* per-module: whichever copy boots publishes here, and every other copy — same
|
|
18089
|
+
* version or not — reads the same live client.
|
|
18090
|
+
*/
|
|
18091
|
+
var INSTANCE_SLOT = Symbol.for("@rebasepro/server:singleton-instance");
|
|
18092
|
+
function getInstance() {
|
|
18093
|
+
return globalThis[INSTANCE_SLOT] ?? null;
|
|
18094
|
+
}
|
|
18095
|
+
function setInstance(client) {
|
|
18096
|
+
globalThis[INSTANCE_SLOT] = client;
|
|
18097
|
+
}
|
|
17557
18098
|
/**
|
|
17558
18099
|
* @internal Called once during server initialization to set the backing instance.
|
|
17559
18100
|
* This is invoked by `initializeRebaseBackend()` — never call it manually.
|
|
17560
18101
|
*/
|
|
17561
18102
|
function _initRebase(client) {
|
|
17562
|
-
|
|
18103
|
+
setInstance(client);
|
|
17563
18104
|
}
|
|
17564
18105
|
/**
|
|
17565
18106
|
* @internal Allows overriding the underlying instance for unit testing.
|
|
@@ -17567,17 +18108,17 @@ function _initRebase(client) {
|
|
|
17567
18108
|
*/
|
|
17568
18109
|
function _setRebaseMock(mockInstance) {
|
|
17569
18110
|
if (process.env.NODE_ENV !== "test") throw new Error("_setRebaseMock can only be called in a test environment (NODE_ENV=test).");
|
|
17570
|
-
|
|
17571
|
-
...
|
|
18111
|
+
setInstance({
|
|
18112
|
+
...getInstance() || {},
|
|
17572
18113
|
...mockInstance
|
|
17573
|
-
};
|
|
18114
|
+
});
|
|
17574
18115
|
}
|
|
17575
18116
|
/**
|
|
17576
18117
|
* @internal Resets the singleton instance, useful for afterEach() in test suites.
|
|
17577
18118
|
*/
|
|
17578
18119
|
function _resetRebaseMock() {
|
|
17579
18120
|
if (process.env.NODE_ENV !== "test") throw new Error("_resetRebaseMock can only be called in a test environment.");
|
|
17580
|
-
|
|
18121
|
+
setInstance(null);
|
|
17581
18122
|
}
|
|
17582
18123
|
/**
|
|
17583
18124
|
* The server-side Rebase singleton.
|
|
@@ -17616,8 +18157,9 @@ function _resetRebaseMock() {
|
|
|
17616
18157
|
*/
|
|
17617
18158
|
var rebase = new Proxy({}, {
|
|
17618
18159
|
get(_, prop) {
|
|
17619
|
-
|
|
17620
|
-
|
|
18160
|
+
const instance = getInstance();
|
|
18161
|
+
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.`);
|
|
18162
|
+
return instance[prop];
|
|
17621
18163
|
},
|
|
17622
18164
|
set(_, prop) {
|
|
17623
18165
|
throw new Error(`Cannot set rebase.${String(prop)} directly. The singleton is read-only. Use _initRebase() during server startup.`);
|
|
@@ -17664,21 +18206,17 @@ async function _initializeRebaseBackend(config) {
|
|
|
17664
18206
|
const dataSourceRegistry = createDataSourceRegistry(config.dataSources);
|
|
17665
18207
|
collectionRegistry.setDataSources(dataSourceRegistry);
|
|
17666
18208
|
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
18209
|
let activeCollections = config.collections || [];
|
|
17670
|
-
if (
|
|
17671
|
-
|
|
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) {
|
|
18210
|
+
if (activeCollections.length > 0) assertCollectionConfigs(activeCollections);
|
|
18211
|
+
if (config.collectionsDir && activeCollections.length === 0) {
|
|
17676
18212
|
activeCollections = await loadCollectionsFromDirectory(config.collectionsDir);
|
|
17677
18213
|
logger.info("Auto-discovered collections", {
|
|
17678
18214
|
count: activeCollections.length,
|
|
17679
18215
|
dir: config.collectionsDir
|
|
17680
18216
|
});
|
|
17681
18217
|
}
|
|
18218
|
+
const introspectCollections = activeCollections.length === 0;
|
|
18219
|
+
logger.info(introspectCollections ? "No collections declared — deriving them from the database schema" : "Serving declared collections");
|
|
17682
18220
|
const realtimeServices = {};
|
|
17683
18221
|
const delegates = {};
|
|
17684
18222
|
let bootstrappers = config.bootstrappers || [];
|
|
@@ -17706,16 +18244,16 @@ async function _initializeRebaseBackend(config) {
|
|
|
17706
18244
|
const driverResult = await bootstrapper.initializeDriver({
|
|
17707
18245
|
collections: activeCollections,
|
|
17708
18246
|
collectionRegistry,
|
|
17709
|
-
|
|
18247
|
+
introspectCollections,
|
|
17710
18248
|
baas: config.baas
|
|
17711
18249
|
});
|
|
17712
18250
|
delegates[b.id || bootstrapper.type] = driverResult.driver;
|
|
17713
|
-
if (
|
|
18251
|
+
if (introspectCollections) {
|
|
17714
18252
|
const driverName = b.id || bootstrapper.type;
|
|
17715
|
-
if (!driverResult.collections) throw new Error(`Driver "${driverName}"
|
|
18253
|
+
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
18254
|
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
18255
|
}
|
|
17718
|
-
if (driverResult.collections?.length) activeCollections = [...activeCollections, ...driverResult.collections];
|
|
18256
|
+
if (introspectCollections && driverResult.collections?.length) activeCollections = [...activeCollections, ...driverResult.collections];
|
|
17719
18257
|
if ((b.id || bootstrapper.type) === defaultDriverId || !defaultDriverResult) defaultDriverResult = driverResult;
|
|
17720
18258
|
if (bootstrapper.initializeRealtime) {
|
|
17721
18259
|
const realtime = await bootstrapper.initializeRealtime({}, driverResult);
|
|
@@ -17921,6 +18459,7 @@ async function _initializeRebaseBackend(config) {
|
|
|
17921
18459
|
emailService: authConfigResult.emailService,
|
|
17922
18460
|
emailConfig: safeAuthConfig.email,
|
|
17923
18461
|
allowRegistration: safeAuthConfig.allowRegistration ?? false,
|
|
18462
|
+
disableSelfRegistration: safeAuthConfig.disableSelfRegistration ?? false,
|
|
17924
18463
|
allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
|
|
17925
18464
|
defaultRole: safeAuthConfig.defaultRole,
|
|
17926
18465
|
oauthProviders,
|
|
@@ -17958,12 +18497,12 @@ async function _initializeRebaseBackend(config) {
|
|
|
17958
18497
|
if (apiKeyPreAuth) router.use("/*", apiKeyPreAuth);
|
|
17959
18498
|
router.use("/*", createRequireAuth({ serviceKey: internalServiceKey }), requireAdmin);
|
|
17960
18499
|
};
|
|
17961
|
-
const schemaEditorEnabled = config.schemaEditor ?? (!!config.collectionsDir && process.env.NODE_ENV !== "production"
|
|
18500
|
+
const schemaEditorEnabled = config.schemaEditor ?? (!!config.collectionsDir && !introspectCollections && process.env.NODE_ENV !== "production");
|
|
17962
18501
|
if (schemaEditorEnabled && !config.collectionsDir) logger.warn("schemaEditor is enabled but no collectionsDir is set — the schema editor has nowhere to write. Skipping.");
|
|
17963
18502
|
if (schemaEditorEnabled && config.collectionsDir) {
|
|
17964
18503
|
let editorModule;
|
|
17965
18504
|
try {
|
|
17966
|
-
editorModule = await import("./schema-editor-routes-
|
|
18505
|
+
editorModule = await import("./schema-editor-routes-DDxfOIid.js");
|
|
17967
18506
|
} catch (err) {
|
|
17968
18507
|
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
18508
|
else throw err;
|
|
@@ -18215,7 +18754,6 @@ async function _initializeRebaseBackend(config) {
|
|
|
18215
18754
|
contractRouter.route("/", createContractRoutes({
|
|
18216
18755
|
collectionRegistry,
|
|
18217
18756
|
schemaVersion: config.schemaVersion,
|
|
18218
|
-
mode,
|
|
18219
18757
|
runtimeVersion: config.runtimeVersion
|
|
18220
18758
|
}));
|
|
18221
18759
|
config.app.route(`${basePath}/meta`, contractRouter);
|
|
@@ -18488,6 +19026,97 @@ function defineCron(definition) {
|
|
|
18488
19026
|
return definition;
|
|
18489
19027
|
}
|
|
18490
19028
|
//#endregion
|
|
19029
|
+
//#region src/cron/scale-to-zero.ts
|
|
19030
|
+
/**
|
|
19031
|
+
* Scale-to-zero detection for the cron scheduler.
|
|
19032
|
+
*
|
|
19033
|
+
* The scheduler drives jobs with in-process `setTimeout`. That works on any
|
|
19034
|
+
* always-running instance, but on a platform that freezes or evicts the
|
|
19035
|
+
* container between requests (Cloud Run with `--min-instances=0`, AWS Lambda,
|
|
19036
|
+
* Vercel functions) the timers simply never fire — the process boots, logs the
|
|
19037
|
+
* jobs as registered, and silently runs nothing.
|
|
19038
|
+
*
|
|
19039
|
+
* None of these platforms expose their scaling floor to the container, so this
|
|
19040
|
+
* detection is a heuristic: it identifies the *platform*, not the setting. It
|
|
19041
|
+
* is a warning only — it must never influence boot.
|
|
19042
|
+
*
|
|
19043
|
+
* Environment variables used here were verified against vendor documentation:
|
|
19044
|
+
* - `K_SERVICE` / `K_REVISION` / `K_CONFIGURATION` — Cloud Run services
|
|
19045
|
+
* (Cloud Run container contract; no variable exposes min-instances).
|
|
19046
|
+
* - `CLOUD_RUN_JOB` — Cloud Run jobs (same contract).
|
|
19047
|
+
* - `AWS_LAMBDA_FUNCTION_NAME` — reserved AWS Lambda runtime variable.
|
|
19048
|
+
* - `VERCEL=1` — Vercel system environment variable, available at runtime.
|
|
19049
|
+
* - `KUBERNETES_SERVICE_HOST` — injected into every pod by the kubelet. Used
|
|
19050
|
+
* as an *exclusion*: a Deployment pod runs continuously, and Knative on
|
|
19051
|
+
* Kubernetes also sets `K_SERVICE`, so a pod is never warned about.
|
|
19052
|
+
*/
|
|
19053
|
+
/** Environment variable that permanently silences the scale-to-zero warning. */
|
|
19054
|
+
var CRON_ALWAYS_ON_ENV = "REBASE_CRON_ALWAYS_ON";
|
|
19055
|
+
/** Accepts the usual truthy spellings; anything else (including "") is false. */
|
|
19056
|
+
function isTruthy(value) {
|
|
19057
|
+
if (!value) return false;
|
|
19058
|
+
const normalised = value.trim().toLowerCase();
|
|
19059
|
+
return normalised === "1" || normalised === "true" || normalised === "yes" || normalised === "on";
|
|
19060
|
+
}
|
|
19061
|
+
/**
|
|
19062
|
+
* Identify a runtime whose instances can be frozen or torn down between
|
|
19063
|
+
* requests. Returns `undefined` when the platform is unknown or known to run
|
|
19064
|
+
* continuously.
|
|
19065
|
+
*/
|
|
19066
|
+
function detectFreezableRuntime(env = process.env) {
|
|
19067
|
+
if (env.KUBERNETES_SERVICE_HOST) return void 0;
|
|
19068
|
+
if (env.K_SERVICE) {
|
|
19069
|
+
const signals = ["K_SERVICE"];
|
|
19070
|
+
if (env.K_REVISION) signals.push("K_REVISION");
|
|
19071
|
+
if (env.K_CONFIGURATION) signals.push("K_CONFIGURATION");
|
|
19072
|
+
return {
|
|
19073
|
+
platform: "Cloud Run",
|
|
19074
|
+
signals
|
|
19075
|
+
};
|
|
19076
|
+
}
|
|
19077
|
+
if (env.CLOUD_RUN_JOB) return {
|
|
19078
|
+
platform: "Cloud Run Jobs",
|
|
19079
|
+
signals: ["CLOUD_RUN_JOB"]
|
|
19080
|
+
};
|
|
19081
|
+
if (env.AWS_LAMBDA_FUNCTION_NAME) return {
|
|
19082
|
+
platform: "AWS Lambda",
|
|
19083
|
+
signals: ["AWS_LAMBDA_FUNCTION_NAME"]
|
|
19084
|
+
};
|
|
19085
|
+
if (env.VERCEL === "1") return {
|
|
19086
|
+
platform: "Vercel",
|
|
19087
|
+
signals: ["VERCEL"]
|
|
19088
|
+
};
|
|
19089
|
+
}
|
|
19090
|
+
/** How many job ids to name before collapsing the rest into "+N more". */
|
|
19091
|
+
var MAX_NAMED_JOBS = 10;
|
|
19092
|
+
/**
|
|
19093
|
+
* Build the boot-time warning, or `undefined` when it does not apply.
|
|
19094
|
+
*
|
|
19095
|
+
* Fires only when all of the following hold:
|
|
19096
|
+
* 1. `NODE_ENV=production` — a laptop or CI run is not at risk.
|
|
19097
|
+
* 2. At least one *enabled* job is registered — nothing to lose otherwise.
|
|
19098
|
+
* 3. The environment looks like a freezable platform (see above).
|
|
19099
|
+
* 4. `REBASE_CRON_ALWAYS_ON` is not set to a truthy value.
|
|
19100
|
+
*/
|
|
19101
|
+
function buildScaleToZeroWarning(jobs, env = process.env) {
|
|
19102
|
+
if (env.NODE_ENV !== "production") return void 0;
|
|
19103
|
+
if (isTruthy(env["REBASE_CRON_ALWAYS_ON"])) return void 0;
|
|
19104
|
+
const enabled = jobs.filter((job) => job.enabled).map((job) => job.id);
|
|
19105
|
+
if (enabled.length === 0) return void 0;
|
|
19106
|
+
const runtime = detectFreezableRuntime(env);
|
|
19107
|
+
if (!runtime) return void 0;
|
|
19108
|
+
const named = enabled.slice(0, MAX_NAMED_JOBS);
|
|
19109
|
+
const list = enabled.length > named.length ? `${named.join(", ")} (+${enabled.length - named.length} more)` : named.join(", ");
|
|
19110
|
+
return {
|
|
19111
|
+
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.`,
|
|
19112
|
+
data: {
|
|
19113
|
+
platform: runtime.platform,
|
|
19114
|
+
signals: runtime.signals,
|
|
19115
|
+
jobs: enabled
|
|
19116
|
+
}
|
|
19117
|
+
};
|
|
19118
|
+
}
|
|
19119
|
+
//#endregion
|
|
18491
19120
|
//#region src/cron/cron-scheduler.ts
|
|
18492
19121
|
var cron_scheduler_exports = /* @__PURE__ */ __exportAll({
|
|
18493
19122
|
CronScheduler: () => CronScheduler,
|
|
@@ -18696,6 +19325,7 @@ var CronScheduler = class {
|
|
|
18696
19325
|
});
|
|
18697
19326
|
for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
|
|
18698
19327
|
if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
|
|
19328
|
+
this.warnIfScaleToZero();
|
|
18699
19329
|
logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
|
|
18700
19330
|
}
|
|
18701
19331
|
/**
|
|
@@ -18789,6 +19419,23 @@ var CronScheduler = class {
|
|
|
18789
19419
|
return this.executeJob(job, true);
|
|
18790
19420
|
}
|
|
18791
19421
|
/**
|
|
19422
|
+
* Warn once at start when the process looks like it is running on a
|
|
19423
|
+
* platform that freezes or evicts instances between requests, where the
|
|
19424
|
+
* in-process timers this scheduler relies on never fire.
|
|
19425
|
+
*
|
|
19426
|
+
* Advisory only: any failure here is swallowed so a detection bug can
|
|
19427
|
+
* never take a production boot down.
|
|
19428
|
+
*/
|
|
19429
|
+
warnIfScaleToZero() {
|
|
19430
|
+
try {
|
|
19431
|
+
const warning = buildScaleToZeroWarning([...this.jobs.values()].map((job) => ({
|
|
19432
|
+
id: job.id,
|
|
19433
|
+
enabled: job.enabled
|
|
19434
|
+
})));
|
|
19435
|
+
if (warning) logger.warn(warning.message, warning.data);
|
|
19436
|
+
} catch {}
|
|
19437
|
+
}
|
|
19438
|
+
/**
|
|
18792
19439
|
* Schedule the next execution for a job.
|
|
18793
19440
|
*
|
|
18794
19441
|
* Safety guarantees:
|
|
@@ -19402,6 +20049,7 @@ var rebaseEnvSchema = object({
|
|
|
19402
20049
|
GOOGLE_CLIENT_SECRET: string().optional(),
|
|
19403
20050
|
REBASE_SERVICE_KEY: string().optional(),
|
|
19404
20051
|
ALLOW_REGISTRATION: boolString,
|
|
20052
|
+
DISABLE_SELF_REGISTRATION: optionalBoolString,
|
|
19405
20053
|
ALLOW_LOCALHOST_IN_PRODUCTION: optionalBoolString,
|
|
19406
20054
|
CORS_ORIGINS: string().optional(),
|
|
19407
20055
|
FRONTEND_URL: string().optional(),
|
|
@@ -19465,6 +20113,102 @@ function loadEnv(options) {
|
|
|
19465
20113
|
return env;
|
|
19466
20114
|
}
|
|
19467
20115
|
//#endregion
|
|
20116
|
+
//#region src/services/webhook-service.ts
|
|
20117
|
+
var WebhookDispatcher = class {
|
|
20118
|
+
webhooks = [];
|
|
20119
|
+
maxRetries = 3;
|
|
20120
|
+
retryDelays = [
|
|
20121
|
+
1e3,
|
|
20122
|
+
5e3,
|
|
20123
|
+
15e3
|
|
20124
|
+
];
|
|
20125
|
+
/** Register webhooks to watch */
|
|
20126
|
+
setWebhooks(webhooks) {
|
|
20127
|
+
this.webhooks = webhooks.filter((w) => w.enabled);
|
|
20128
|
+
}
|
|
20129
|
+
/** Called when a entity changes — checks if any webhook matches */
|
|
20130
|
+
async onEntityChange(table, event, id, entity, previousEntity) {
|
|
20131
|
+
const matchingWebhooks = this.webhooks.filter((w) => w.table === table && w.events.includes(event));
|
|
20132
|
+
if (matchingWebhooks.length === 0) return [];
|
|
20133
|
+
const results = [];
|
|
20134
|
+
for (const webhook of matchingWebhooks) {
|
|
20135
|
+
const payload = {
|
|
20136
|
+
type: event,
|
|
20137
|
+
table,
|
|
20138
|
+
record: entity,
|
|
20139
|
+
old_record: event === "UPDATE" ? previousEntity : void 0,
|
|
20140
|
+
schema: "public",
|
|
20141
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
20142
|
+
};
|
|
20143
|
+
const result = await this.deliverWithRetry(webhook, event, payload);
|
|
20144
|
+
results.push(result);
|
|
20145
|
+
}
|
|
20146
|
+
return results;
|
|
20147
|
+
}
|
|
20148
|
+
async deliverWithRetry(webhook, event, payload) {
|
|
20149
|
+
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
20150
|
+
const result = await this.deliver(webhook, event, payload, attempt);
|
|
20151
|
+
if (result.success) return result;
|
|
20152
|
+
if (attempt < this.maxRetries) await new Promise((r) => setTimeout(r, this.retryDelays[attempt - 1]));
|
|
20153
|
+
else return result;
|
|
20154
|
+
}
|
|
20155
|
+
return {
|
|
20156
|
+
webhookId: webhook.id,
|
|
20157
|
+
event,
|
|
20158
|
+
payload,
|
|
20159
|
+
statusCode: 0,
|
|
20160
|
+
responseBody: "Max retries exceeded",
|
|
20161
|
+
success: false,
|
|
20162
|
+
attemptNumber: this.maxRetries
|
|
20163
|
+
};
|
|
20164
|
+
}
|
|
20165
|
+
async deliver(webhook, event, payload, attemptNumber) {
|
|
20166
|
+
const body = JSON.stringify(payload);
|
|
20167
|
+
const headers = {
|
|
20168
|
+
"Content-Type": "application/json",
|
|
20169
|
+
"X-Webhook-Id": webhook.id,
|
|
20170
|
+
"X-Webhook-Event": event,
|
|
20171
|
+
"X-Webhook-Delivery": randomUUID$1(),
|
|
20172
|
+
"X-Webhook-Attempt": String(attemptNumber),
|
|
20173
|
+
...webhook.headers || {}
|
|
20174
|
+
};
|
|
20175
|
+
if (webhook.secret) headers["X-Webhook-Signature"] = `sha256=${createHmac("sha256", webhook.secret).update(body).digest("hex")}`;
|
|
20176
|
+
try {
|
|
20177
|
+
const controller = new AbortController();
|
|
20178
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
20179
|
+
const response = await fetch(webhook.url, {
|
|
20180
|
+
method: "POST",
|
|
20181
|
+
headers,
|
|
20182
|
+
body,
|
|
20183
|
+
signal: controller.signal
|
|
20184
|
+
});
|
|
20185
|
+
clearTimeout(timeout);
|
|
20186
|
+
const responseBody = await response.text().catch(() => "");
|
|
20187
|
+
const success = response.status >= 200 && response.status < 300;
|
|
20188
|
+
return {
|
|
20189
|
+
webhookId: webhook.id,
|
|
20190
|
+
event,
|
|
20191
|
+
payload,
|
|
20192
|
+
statusCode: response.status,
|
|
20193
|
+
responseBody: responseBody.slice(0, 1e3),
|
|
20194
|
+
success,
|
|
20195
|
+
attemptNumber
|
|
20196
|
+
};
|
|
20197
|
+
} catch (error) {
|
|
20198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20199
|
+
return {
|
|
20200
|
+
webhookId: webhook.id,
|
|
20201
|
+
event,
|
|
20202
|
+
payload,
|
|
20203
|
+
statusCode: 0,
|
|
20204
|
+
responseBody: message.slice(0, 1e3),
|
|
20205
|
+
success: false,
|
|
20206
|
+
attemptNumber
|
|
20207
|
+
};
|
|
20208
|
+
}
|
|
20209
|
+
}
|
|
20210
|
+
};
|
|
20211
|
+
//#endregion
|
|
19468
20212
|
//#region src/utils/dev-port.ts
|
|
19469
20213
|
var MAX_PORT_ATTEMPTS = 20;
|
|
19470
20214
|
/** Filename written next to the project `.env` so the CLI can read it. */
|
|
@@ -19606,6 +20350,21 @@ function writeStateFile(projectRoot, port, serviceKey) {
|
|
|
19606
20350
|
//#endregion
|
|
19607
20351
|
//#region src/serve-spa.ts
|
|
19608
20352
|
/**
|
|
20353
|
+
* Is `requestPath` the excluded path `prefix`, or something beneath it?
|
|
20354
|
+
*
|
|
20355
|
+
* Segment-aware on purpose. A plain `startsWith` reads "/api" as excluding
|
|
20356
|
+
* "/apidocs", and "/admin" as excluding "/administrators" — both ordinary
|
|
20357
|
+
* client-side routes of the app rooted at "/", both then answered with a 404
|
|
20358
|
+
* because the SPA fallback declined them and nothing else claims the path.
|
|
20359
|
+
* `apiBasePath` is always in the exclusion list, so this reached single-app
|
|
20360
|
+
* setups too, not just the multi-app ones the list was added for.
|
|
20361
|
+
*/
|
|
20362
|
+
function isUnderPath(requestPath, prefix) {
|
|
20363
|
+
const trimmed = prefix.replace(/\/+$/, "");
|
|
20364
|
+
if (trimmed === "") return true;
|
|
20365
|
+
return requestPath === trimmed || requestPath.startsWith(`${trimmed}/`);
|
|
20366
|
+
}
|
|
20367
|
+
/**
|
|
19609
20368
|
* Serve a Single Page Application from an Hono app.
|
|
19610
20369
|
*
|
|
19611
20370
|
* @internal Not part of the stable public API. Exported only because the
|
|
@@ -19615,21 +20374,30 @@ function writeStateFile(projectRoot, port, serviceKey) {
|
|
|
19615
20374
|
* may change without a major version bump.
|
|
19616
20375
|
*/
|
|
19617
20376
|
function serveSPA(app, config) {
|
|
19618
|
-
const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html" } = config;
|
|
20377
|
+
const { frontendPath, apiBasePath = "/api", excludePaths = [], indexFile = "index.html", spa = true } = config;
|
|
20378
|
+
const rawBase = config.basePath ?? "/";
|
|
20379
|
+
const basePath = rawBase !== "/" ? rawBase.replace(/\/+$/, "") : "/";
|
|
20380
|
+
const isRoot = basePath === "/";
|
|
19619
20381
|
if (!fs$2.existsSync(frontendPath)) {
|
|
19620
20382
|
logger.warn(`⚠️ Frontend build path does not exist: ${frontendPath}`);
|
|
19621
20383
|
logger.warn(" SPA serving is disabled. Build your frontend first.");
|
|
19622
20384
|
return;
|
|
19623
20385
|
}
|
|
19624
|
-
|
|
19625
|
-
app.use(
|
|
20386
|
+
const scope = isRoot ? "/*" : `${basePath}/*`;
|
|
20387
|
+
app.use(scope, responseCompression());
|
|
20388
|
+
app.use(scope, serveStatic({
|
|
19626
20389
|
root: path$1.relative(process.cwd(), frontendPath),
|
|
19627
|
-
precompressed: true
|
|
20390
|
+
precompressed: true,
|
|
20391
|
+
...isRoot ? {} : { rewriteRequestPath: (p) => p.slice(basePath.length) || "/" }
|
|
19628
20392
|
}));
|
|
20393
|
+
if (!spa) {
|
|
20394
|
+
logger.info(`✅ Static serving enabled at ${basePath} from: ${frontendPath}`);
|
|
20395
|
+
return;
|
|
20396
|
+
}
|
|
19629
20397
|
const allExcludePaths = [apiBasePath, ...excludePaths];
|
|
19630
20398
|
let cachedHtml = null;
|
|
19631
|
-
app.get(
|
|
19632
|
-
if (allExcludePaths.some((p) => c.req.path
|
|
20399
|
+
app.get(scope, async (c, next) => {
|
|
20400
|
+
if (allExcludePaths.some((p) => isUnderPath(c.req.path, p))) return next();
|
|
19633
20401
|
const indexPath = path$1.join(frontendPath, indexFile);
|
|
19634
20402
|
if (!cachedHtml) try {
|
|
19635
20403
|
cachedHtml = await fsp.readFile(indexPath, "utf-8");
|
|
@@ -19639,7 +20407,7 @@ function serveSPA(app, config) {
|
|
|
19639
20407
|
}
|
|
19640
20408
|
return c.html(cachedHtml);
|
|
19641
20409
|
});
|
|
19642
|
-
logger.info(`✅ SPA serving enabled from: ${frontendPath}`);
|
|
20410
|
+
logger.info(`✅ SPA serving enabled at ${basePath} from: ${frontendPath}`);
|
|
19643
20411
|
}
|
|
19644
20412
|
//#endregion
|
|
19645
20413
|
//#region src/boot/bundle.ts
|
|
@@ -19654,6 +20422,36 @@ var BundleError = class extends Error {
|
|
|
19654
20422
|
};
|
|
19655
20423
|
var MANIFEST_FILENAME = "manifest.json";
|
|
19656
20424
|
/**
|
|
20425
|
+
* Bring a format-1 manifest up to the shape the rest of this runtime expects.
|
|
20426
|
+
*
|
|
20427
|
+
* Old bundles booting on a new runtime is the case the format version exists to
|
|
20428
|
+
* protect, so this is not a courtesy — it is the contract. A project built
|
|
20429
|
+
* before the rename ships `mode` and a single `entry.static` directory string,
|
|
20430
|
+
* and without this it would boot with no `kind` (so every gate keyed on
|
|
20431
|
+
* `kind === "backend"` would skip) and an `entry.static` the loader would try to
|
|
20432
|
+
* iterate as a list.
|
|
20433
|
+
*
|
|
20434
|
+
* In place, and only ever filling in what is absent, so a format-2 manifest
|
|
20435
|
+
* passes through untouched.
|
|
20436
|
+
*/
|
|
20437
|
+
function upgradeLegacyManifest(manifest) {
|
|
20438
|
+
const legacy = manifest;
|
|
20439
|
+
if (!legacy.kind) legacy.kind = legacy.mode === "static" ? "static" : "backend";
|
|
20440
|
+
const entry = legacy.entry;
|
|
20441
|
+
if (!entry) return;
|
|
20442
|
+
if (typeof entry.static === "string") entry.static = [{
|
|
20443
|
+
path: "/",
|
|
20444
|
+
dir: entry.static,
|
|
20445
|
+
spa: true
|
|
20446
|
+
}];
|
|
20447
|
+
else if (!entry.static && typeof entry.admin === "string") entry.static = [{
|
|
20448
|
+
path: "/",
|
|
20449
|
+
dir: entry.admin,
|
|
20450
|
+
spa: true
|
|
20451
|
+
}];
|
|
20452
|
+
delete entry.admin;
|
|
20453
|
+
}
|
|
20454
|
+
/**
|
|
19657
20455
|
* Read and validate a bundle's manifest.
|
|
19658
20456
|
*
|
|
19659
20457
|
* The checks here are the runtime half of the compatibility contract, and they
|
|
@@ -19671,7 +20469,8 @@ function readBundleManifest(bundleDir) {
|
|
|
19671
20469
|
throw new BundleError(`${manifestPath} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
19672
20470
|
}
|
|
19673
20471
|
if (typeof manifest.bundleFormat !== "number") throw new BundleError(`${manifestPath} is missing "bundleFormat".`);
|
|
19674
|
-
if (manifest.bundleFormat >
|
|
20472
|
+
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.");
|
|
20473
|
+
upgradeLegacyManifest(manifest);
|
|
19675
20474
|
const contract = manifest.runtime?.contract;
|
|
19676
20475
|
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
20476
|
return manifest;
|
|
@@ -19720,8 +20519,14 @@ function loadBundle(bundleDir) {
|
|
|
19720
20519
|
collectionsDir: entry.collections ? resolveEntry(entry.collections, "collections") : entry.config ? resolveEntry(path.join(entry.config, "collections"), "collections") : void 0,
|
|
19721
20520
|
functionsDir: resolveEntry(entry.functions, "functions"),
|
|
19722
20521
|
cronsDir: resolveEntry(entry.crons, "crons"),
|
|
19723
|
-
|
|
19724
|
-
|
|
20522
|
+
staticApps: (entry.static ?? []).map((item) => {
|
|
20523
|
+
const resolved = resolveEntry(item.dir, `static app "${item.path}"`);
|
|
20524
|
+
return resolved ? {
|
|
20525
|
+
path: item.path,
|
|
20526
|
+
dir: resolved,
|
|
20527
|
+
spa: item.spa !== false
|
|
20528
|
+
} : void 0;
|
|
20529
|
+
}).filter((item) => item !== void 0).sort((a, b) => b.path.length - a.path.length)
|
|
19725
20530
|
};
|
|
19726
20531
|
}
|
|
19727
20532
|
/**
|
|
@@ -19770,7 +20575,7 @@ function createSourceBundle(options) {
|
|
|
19770
20575
|
return {
|
|
19771
20576
|
dir,
|
|
19772
20577
|
manifest: {
|
|
19773
|
-
bundleFormat:
|
|
20578
|
+
bundleFormat: 2,
|
|
19774
20579
|
runtime: {
|
|
19775
20580
|
range: `^1`,
|
|
19776
20581
|
builtAgainst: "source",
|
|
@@ -19778,7 +20583,7 @@ function createSourceBundle(options) {
|
|
|
19778
20583
|
},
|
|
19779
20584
|
schemaVersion: "",
|
|
19780
20585
|
app: options.app ?? "backend",
|
|
19781
|
-
|
|
20586
|
+
kind: "backend",
|
|
19782
20587
|
entry: {
|
|
19783
20588
|
config: options.config ?? configDir,
|
|
19784
20589
|
collections: collectionsDir,
|
|
@@ -19797,8 +20602,7 @@ function createSourceBundle(options) {
|
|
|
19797
20602
|
collectionsDir: resolve(collectionsDir),
|
|
19798
20603
|
functionsDir: resolve(options.functions),
|
|
19799
20604
|
cronsDir: resolve(options.crons),
|
|
19800
|
-
|
|
19801
|
-
staticDir: void 0
|
|
20605
|
+
staticApps: []
|
|
19802
20606
|
};
|
|
19803
20607
|
}
|
|
19804
20608
|
/**
|
|
@@ -19991,11 +20795,27 @@ var bootEnvExtension = object({
|
|
|
19991
20795
|
MICROSOFT_CLIENT_ID: string().optional(),
|
|
19992
20796
|
MICROSOFT_CLIENT_SECRET: string().optional(),
|
|
19993
20797
|
REBASE_BASE_PATH: string().default("/api"),
|
|
20798
|
+
/**
|
|
20799
|
+
* The OpenAPI surface: `/api/docs` (the spec) and `/api/swagger` (the UI).
|
|
20800
|
+
*
|
|
20801
|
+
* Deliberately tri-state, and resolved against NODE_ENV by
|
|
20802
|
+
* {@link resolveEnableSwagger} rather than defaulted here. Unset means "on
|
|
20803
|
+
* in development, off in production" — an explicit `true` or `false` always
|
|
20804
|
+
* wins in both.
|
|
20805
|
+
*
|
|
20806
|
+
* It used to default to `"false"` outright, which reads as a safe default
|
|
20807
|
+
* and was not one: the runtime is how every scaffolded project boots, so
|
|
20808
|
+
* the docs disappeared from projects that never asked for that. `rebase
|
|
20809
|
+
* init` prints "docs are at /api/swagger" on completion, the headless
|
|
20810
|
+
* README repeats it, and the console's API Explorer fetches `/api/docs` —
|
|
20811
|
+
* all three 404'd against a project running the runtime, and the baas e2e
|
|
20812
|
+
* failed on exactly that.
|
|
20813
|
+
*/
|
|
19994
20814
|
REBASE_ENABLE_SWAGGER: _enum([
|
|
19995
20815
|
"true",
|
|
19996
20816
|
"false",
|
|
19997
20817
|
""
|
|
19998
|
-
]).
|
|
20818
|
+
]).optional().transform((v) => v === void 0 || v === "" ? void 0 : v === "true"),
|
|
19999
20819
|
/**
|
|
20000
20820
|
* Maximum request body size, in **bytes**.
|
|
20001
20821
|
*
|
|
@@ -20055,6 +20875,24 @@ function isLocalhostOrigin(origin) {
|
|
|
20055
20875
|
return false;
|
|
20056
20876
|
}
|
|
20057
20877
|
}
|
|
20878
|
+
/** A CORS origin resolver of the shape Hono's `cors()` middleware expects. */
|
|
20879
|
+
/**
|
|
20880
|
+
* Whether this process serves the OpenAPI docs.
|
|
20881
|
+
*
|
|
20882
|
+
* An explicit `REBASE_ENABLE_SWAGGER` wins in either direction. Left unset, the
|
|
20883
|
+
* docs follow the environment: on in development, where they are part of how a
|
|
20884
|
+
* scaffolded project is meant to be explored, and off in production, where the
|
|
20885
|
+
* spec enumerates every collection and field to anyone who asks for it.
|
|
20886
|
+
*
|
|
20887
|
+
* Returning `undefined` for development is the point rather than an oversight —
|
|
20888
|
+
* it hands the decision to the server's own policy in `init/docs.ts`, which also
|
|
20889
|
+
* knows to withhold the Swagger UI while still serving the spec. Two defaults
|
|
20890
|
+
* that can disagree about the same route is the bug this replaces.
|
|
20891
|
+
*/
|
|
20892
|
+
function resolveEnableSwagger(env) {
|
|
20893
|
+
if (env.REBASE_ENABLE_SWAGGER !== void 0) return env.REBASE_ENABLE_SWAGGER;
|
|
20894
|
+
return env.NODE_ENV === "production" ? false : void 0;
|
|
20895
|
+
}
|
|
20058
20896
|
/**
|
|
20059
20897
|
* Build the CORS origin policy.
|
|
20060
20898
|
*
|
|
@@ -20081,12 +20919,17 @@ function resolveCorsOrigin(env) {
|
|
|
20081
20919
|
*
|
|
20082
20920
|
* The default key maps to no suffix at all, which is what keeps every existing
|
|
20083
20921
|
* single-database deployment working untouched.
|
|
20922
|
+
*
|
|
20923
|
+
* The rule itself lives in `@rebasepro/types` so the CLI and any control plane
|
|
20924
|
+
* derive identical names from identical keys; this wrapper exists only to raise
|
|
20925
|
+
* it as a `BundleError`, which is what the rest of boot reports failures as.
|
|
20084
20926
|
*/
|
|
20085
20927
|
function envSuffixForKey(key, defaultKey) {
|
|
20086
|
-
|
|
20087
|
-
|
|
20088
|
-
|
|
20089
|
-
|
|
20928
|
+
try {
|
|
20929
|
+
return storageEnvSuffix(key, defaultKey);
|
|
20930
|
+
} catch (err) {
|
|
20931
|
+
throw new BundleError(`Source key "${key}" cannot be turned into an environment variable name.`, "Use a key containing at least one letter or digit.");
|
|
20932
|
+
}
|
|
20090
20933
|
}
|
|
20091
20934
|
/** Read `<base>` for the default source, `<base>__<KEY>` for a named one. */
|
|
20092
20935
|
function readVar(env, base, suffix) {
|
|
@@ -20105,13 +20948,8 @@ function readBool(env, base, suffix) {
|
|
|
20105
20948
|
* without this check one of them would silently read the other's configuration.
|
|
20106
20949
|
*/
|
|
20107
20950
|
function assertDistinctSuffixes(definitions, defaultKey, what) {
|
|
20108
|
-
const
|
|
20109
|
-
|
|
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
|
-
}
|
|
20951
|
+
const collision = findStorageSuffixCollision(definitions.map((d) => d.key), defaultKey);
|
|
20952
|
+
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
20953
|
}
|
|
20116
20954
|
/** Which driver package backs a given engine, before env overrides. */
|
|
20117
20955
|
var ENGINE_DRIVERS = {
|
|
@@ -20188,23 +21026,37 @@ function resolvePoolConfig(env, suffix) {
|
|
|
20188
21026
|
*/
|
|
20189
21027
|
function resolveStorageBackend(env, key, engineHint, defaultBasePath) {
|
|
20190
21028
|
const suffix = envSuffixForKey(key, DEFAULT_STORAGE_SOURCE_KEY);
|
|
20191
|
-
const
|
|
21029
|
+
const declaredType = readVar(env, "STORAGE_TYPE", suffix);
|
|
21030
|
+
const type = (declaredType || engineHint || "").toLowerCase();
|
|
21031
|
+
const explicit = Boolean(declaredType);
|
|
20192
21032
|
if (type === "s3") {
|
|
20193
21033
|
const bucket = readVar(env, "S3_BUCKET", suffix);
|
|
20194
|
-
if (!bucket)
|
|
21034
|
+
if (!bucket) {
|
|
21035
|
+
if (!explicit) return void 0;
|
|
21036
|
+
throw new BundleError(`Storage source "${key}" is set to s3 but has no bucket — set ${`S3_BUCKET${suffix}`}.`);
|
|
21037
|
+
}
|
|
21038
|
+
const accessKeyId = readVar(env, "S3_ACCESS_KEY_ID", suffix);
|
|
21039
|
+
const secretAccessKey = readVar(env, "S3_SECRET_ACCESS_KEY", suffix);
|
|
21040
|
+
if (!accessKeyId || !secretAccessKey) {
|
|
21041
|
+
if (!explicit) return void 0;
|
|
21042
|
+
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.");
|
|
21043
|
+
}
|
|
20195
21044
|
return {
|
|
20196
21045
|
type: "s3",
|
|
20197
21046
|
bucket,
|
|
20198
21047
|
region: readVar(env, "S3_REGION", suffix) || "auto",
|
|
20199
|
-
accessKeyId
|
|
20200
|
-
secretAccessKey
|
|
21048
|
+
accessKeyId,
|
|
21049
|
+
secretAccessKey,
|
|
20201
21050
|
endpoint: readVar(env, "S3_ENDPOINT", suffix),
|
|
20202
21051
|
forcePathStyle: readBool(env, "S3_FORCE_PATH_STYLE", suffix)
|
|
20203
21052
|
};
|
|
20204
21053
|
}
|
|
20205
21054
|
if (type === "gcs") {
|
|
20206
21055
|
const bucket = readVar(env, "GCS_BUCKET", suffix);
|
|
20207
|
-
if (!bucket)
|
|
21056
|
+
if (!bucket) {
|
|
21057
|
+
if (!explicit) return void 0;
|
|
21058
|
+
throw new BundleError(`Storage source "${key}" is set to gcs but has no bucket — set ${`GCS_BUCKET${suffix}`}.`);
|
|
21059
|
+
}
|
|
20208
21060
|
return {
|
|
20209
21061
|
type: "gcs",
|
|
20210
21062
|
bucket,
|
|
@@ -20240,6 +21092,45 @@ function resolveStorageSources(env, definitions, defaultBasePath) {
|
|
|
20240
21092
|
}
|
|
20241
21093
|
return Object.keys(result).length > 0 ? result : void 0;
|
|
20242
21094
|
}
|
|
21095
|
+
/**
|
|
21096
|
+
* Read a project's declared storage sources from its `rebase.json`.
|
|
21097
|
+
*
|
|
21098
|
+
* A managed bundle carries its topology in `manifest.json`, resolved at build
|
|
21099
|
+
* time. A **custom** runtime has no manifest — it builds its own image and its
|
|
21100
|
+
* own entrypoint — so without this it would have to re-declare in code what
|
|
21101
|
+
* `rebase.json` already says, and the two would drift. Since a custom image
|
|
21102
|
+
* contains the repository anyway, reading the file it already ships is what
|
|
21103
|
+
* keeps one declaration authoritative for both runtimes.
|
|
21104
|
+
*
|
|
21105
|
+
* Walks up from `startDir` because an entrypoint lives at `backend/src` in the
|
|
21106
|
+
* scaffolded layout and somewhere else in a hand-rolled one. A missing,
|
|
21107
|
+
* unreadable or malformed file means "declared nothing" — one default source —
|
|
21108
|
+
* which is the correct reading of every project that predates this and must
|
|
21109
|
+
* never be an error: a storage declaration is optional, and failing to boot a
|
|
21110
|
+
* whole backend over an absent optional file would be the worse bug.
|
|
21111
|
+
*/
|
|
21112
|
+
function loadDeclaredStorageSources(startDir, levels = 5) {
|
|
21113
|
+
let dir = startDir;
|
|
21114
|
+
for (let i = 0; i <= levels; i++) {
|
|
21115
|
+
const candidate = path.join(dir, "rebase.json");
|
|
21116
|
+
if (fs.existsSync(candidate)) {
|
|
21117
|
+
let declared;
|
|
21118
|
+
try {
|
|
21119
|
+
declared = JSON.parse(fs.readFileSync(candidate, "utf8"))?.storage;
|
|
21120
|
+
} catch (err) {
|
|
21121
|
+
logger.warn(`Could not read storage sources from ${candidate}: ${err instanceof Error ? err.message : String(err)}. Continuing with a single default storage source.`);
|
|
21122
|
+
return [];
|
|
21123
|
+
}
|
|
21124
|
+
const sources = normalizeStorageSources(declared, void 0);
|
|
21125
|
+
assertDistinctSuffixes(sources, DEFAULT_STORAGE_SOURCE_KEY, "Storage source");
|
|
21126
|
+
return sources;
|
|
21127
|
+
}
|
|
21128
|
+
const parent = path.dirname(dir);
|
|
21129
|
+
if (parent === dir) break;
|
|
21130
|
+
dir = parent;
|
|
21131
|
+
}
|
|
21132
|
+
return [];
|
|
21133
|
+
}
|
|
20243
21134
|
//#endregion
|
|
20244
21135
|
//#region src/boot/driver.ts
|
|
20245
21136
|
/**
|
|
@@ -20452,6 +21343,7 @@ function resolveAuthOptions(env, usersCollection) {
|
|
|
20452
21343
|
serviceKey: env.REBASE_SERVICE_KEY,
|
|
20453
21344
|
requireAuth: env.AUTH_REQUIRE,
|
|
20454
21345
|
allowRegistration: env.ALLOW_REGISTRATION,
|
|
21346
|
+
disableSelfRegistration: env.DISABLE_SELF_REGISTRATION,
|
|
20455
21347
|
allowUserLookup: env.AUTH_ALLOW_USER_LOOKUP,
|
|
20456
21348
|
email: resolveEmailOptions(env),
|
|
20457
21349
|
cookieAuth: { sameSite: env.AUTH_COOKIE_SAME_SITE || "Lax" }
|
|
@@ -20741,16 +21633,17 @@ async function bootFromBundle(options = {}) {
|
|
|
20741
21633
|
const devRoot = process.env.REBASE_DEV_PROJECT_ROOT || process.cwd();
|
|
20742
21634
|
logger.info("Loaded bundle", {
|
|
20743
21635
|
app: bundle.manifest.app,
|
|
20744
|
-
|
|
21636
|
+
kind: bundle.manifest.kind,
|
|
20745
21637
|
schemaVersion: bundle.manifest.schemaVersion,
|
|
20746
21638
|
builtAgainst: bundle.manifest.runtime?.builtAgainst
|
|
20747
21639
|
});
|
|
20748
|
-
if (bundle.manifest.
|
|
21640
|
+
if (bundle.manifest.kind === "static") return bootStaticApp(bundle, devRoot, options);
|
|
20749
21641
|
const env = loadBootEnv();
|
|
20750
21642
|
const isProduction = env.NODE_ENV === "production";
|
|
20751
21643
|
const configExports = await loadBundleConfigExports(bundle);
|
|
20752
21644
|
const dataSourceDefs = configExports.dataSources;
|
|
20753
|
-
const
|
|
21645
|
+
const declaredStorage = normalizeStorageSources(bundle.manifest.storage?.sources, configExports.storageSources);
|
|
21646
|
+
const storageSourceDefs = declaredStorage.length > 0 ? declaredStorage : void 0;
|
|
20754
21647
|
const dataSources = await initializeDataSources(resolveDataSources(process.env, dataSourceDefs), await loadBundleSchema(bundle), [
|
|
20755
21648
|
bundle.dir,
|
|
20756
21649
|
path.join(bundle.dir, "backend"),
|
|
@@ -20775,7 +21668,6 @@ async function bootFromBundle(options = {}) {
|
|
|
20775
21668
|
server,
|
|
20776
21669
|
app,
|
|
20777
21670
|
basePath: env.REBASE_BASE_PATH,
|
|
20778
|
-
mode: bundle.manifest.mode ?? "cms",
|
|
20779
21671
|
collectionsDir: bundle.collectionsDir,
|
|
20780
21672
|
functionsDir: bundle.functionsDir,
|
|
20781
21673
|
cronsDir: bundle.cronsDir,
|
|
@@ -20789,7 +21681,7 @@ async function bootFromBundle(options = {}) {
|
|
|
20789
21681
|
callbacks: configExports.callbacks,
|
|
20790
21682
|
auth: resolveAuthOptions(env, usersCollection),
|
|
20791
21683
|
history: env.REBASE_HISTORY,
|
|
20792
|
-
enableSwagger: env
|
|
21684
|
+
enableSwagger: resolveEnableSwagger(env),
|
|
20793
21685
|
compression: env.REBASE_COMPRESSION,
|
|
20794
21686
|
maxBodySize: env.REBASE_MAX_BODY_SIZE,
|
|
20795
21687
|
logging: env.LOG_LEVEL ? { level: env.LOG_LEVEL } : void 0,
|
|
@@ -20821,20 +21713,24 @@ async function bootFromBundle(options = {}) {
|
|
|
20821
21713
|
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
21714
|
app.route("/metrics", createMetricsRoutes(metrics.registry, env.REBASE_METRICS_TOKEN));
|
|
20823
21715
|
}
|
|
20824
|
-
if (env.REBASE_SERVE_STATIC) {
|
|
20825
|
-
const
|
|
20826
|
-
|
|
20827
|
-
|
|
20828
|
-
|
|
20829
|
-
|
|
20830
|
-
|
|
20831
|
-
|
|
20832
|
-
|
|
20833
|
-
|
|
20834
|
-
|
|
20835
|
-
|
|
20836
|
-
|
|
20837
|
-
|
|
21716
|
+
if (env.REBASE_SERVE_STATIC) for (const staticApp of bundle.staticApps) {
|
|
21717
|
+
const siblings = bundle.staticApps.filter((other) => other !== staticApp).map((other) => other.path).filter((other) => other !== "/");
|
|
21718
|
+
logger.info("Serving static assets", {
|
|
21719
|
+
path: staticApp.dir,
|
|
21720
|
+
at: staticApp.path
|
|
21721
|
+
});
|
|
21722
|
+
serveSPA(app, {
|
|
21723
|
+
frontendPath: staticApp.dir,
|
|
21724
|
+
basePath: staticApp.path,
|
|
21725
|
+
apiBasePath: env.REBASE_BASE_PATH,
|
|
21726
|
+
excludePaths: [
|
|
21727
|
+
"/health",
|
|
21728
|
+
"/livez",
|
|
21729
|
+
"/metrics",
|
|
21730
|
+
...siblings
|
|
21731
|
+
],
|
|
21732
|
+
spa: staticApp.spa
|
|
21733
|
+
});
|
|
20838
21734
|
}
|
|
20839
21735
|
let port = env.PORT;
|
|
20840
21736
|
if (options.listen !== false) if (isProduction) {
|
|
@@ -20886,8 +21782,7 @@ async function bootFromBundle(options = {}) {
|
|
|
20886
21782
|
* backend — the only difference is what the bundle contains.
|
|
20887
21783
|
*/
|
|
20888
21784
|
async function bootStaticApp(bundle, devRoot, options) {
|
|
20889
|
-
|
|
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`.");
|
|
21785
|
+
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
21786
|
const isProduction = process.env.NODE_ENV === "production";
|
|
20892
21787
|
const requestedPort = Number(process.env.PORT ?? "3001") || 3001;
|
|
20893
21788
|
const basePath = process.env.REBASE_BASE_PATH || "/api";
|
|
@@ -20907,19 +21802,26 @@ async function bootStaticApp(bundle, devRoot, options) {
|
|
|
20907
21802
|
latencyMs: 0
|
|
20908
21803
|
}));
|
|
20909
21804
|
if (metrics) app.route("/metrics", createMetricsRoutes(metrics.registry, metricsToken));
|
|
20910
|
-
|
|
20911
|
-
|
|
20912
|
-
|
|
20913
|
-
|
|
20914
|
-
|
|
20915
|
-
|
|
20916
|
-
|
|
20917
|
-
|
|
20918
|
-
|
|
20919
|
-
|
|
20920
|
-
|
|
20921
|
-
|
|
20922
|
-
|
|
21805
|
+
for (const staticApp of bundle.staticApps) {
|
|
21806
|
+
const siblings = bundle.staticApps.filter((other) => other !== staticApp).map((other) => other.path).filter((other) => other !== "/");
|
|
21807
|
+
logger.info("Serving static app", {
|
|
21808
|
+
app: bundle.manifest.app,
|
|
21809
|
+
path: staticApp.dir,
|
|
21810
|
+
at: staticApp.path
|
|
21811
|
+
});
|
|
21812
|
+
serveSPA(app, {
|
|
21813
|
+
frontendPath: staticApp.dir,
|
|
21814
|
+
basePath: staticApp.path,
|
|
21815
|
+
apiBasePath: basePath,
|
|
21816
|
+
excludePaths: [
|
|
21817
|
+
"/health",
|
|
21818
|
+
"/livez",
|
|
21819
|
+
"/metrics",
|
|
21820
|
+
...siblings
|
|
21821
|
+
],
|
|
21822
|
+
spa: staticApp.spa
|
|
21823
|
+
});
|
|
21824
|
+
}
|
|
20923
21825
|
let port = requestedPort;
|
|
20924
21826
|
if (options.listen !== false) if (isProduction) {
|
|
20925
21827
|
await new Promise((resolve, reject) => {
|
|
@@ -20997,7 +21899,8 @@ async function ensureCollectionSchema(bundle, dataSources, env) {
|
|
|
20997
21899
|
logger.info("REBASE_MIGRATE_ON_BOOT=none — leaving the database schema untouched.");
|
|
20998
21900
|
return;
|
|
20999
21901
|
}
|
|
21000
|
-
if (
|
|
21902
|
+
if (bundle.manifest.kind !== "backend") return;
|
|
21903
|
+
if (!bundle.manifest.entry?.config) return;
|
|
21001
21904
|
if (!bundle.collectionsDir) return;
|
|
21002
21905
|
const primary = dataSources[0];
|
|
21003
21906
|
if (!primary?.bootstrapper.ensureCollectionSchema) return;
|
|
@@ -21043,7 +21946,6 @@ function createContractRoutes(config) {
|
|
|
21043
21946
|
version: config.runtimeVersion ?? "unknown",
|
|
21044
21947
|
contract: 1
|
|
21045
21948
|
},
|
|
21046
|
-
mode: config.mode,
|
|
21047
21949
|
collections: serialized,
|
|
21048
21950
|
collectionSlugs: collections.map((collection) => collection.slug).filter((slug) => Boolean(slug)).sort(),
|
|
21049
21951
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -21062,15 +21964,12 @@ function createContractRoutes(config) {
|
|
|
21062
21964
|
router.get("/schema-version", (c) => {
|
|
21063
21965
|
const schemaVersion = schemaVersionOf(config.collectionRegistry.getRawCollections());
|
|
21064
21966
|
c.header(SCHEMA_VERSION_HEADER, schemaVersion);
|
|
21065
|
-
return c.json({
|
|
21066
|
-
schemaVersion,
|
|
21067
|
-
mode: config.mode
|
|
21068
|
-
});
|
|
21967
|
+
return c.json({ schemaVersion });
|
|
21069
21968
|
});
|
|
21070
21969
|
logger.debug("Contract routes mounted");
|
|
21071
21970
|
return router;
|
|
21072
21971
|
}
|
|
21073
21972
|
//#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 };
|
|
21973
|
+
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
21974
|
|
|
21076
21975
|
//# sourceMappingURL=index.es.js.map
|