@rebasepro/server 0.10.1-canary.b1e3dbf → 0.10.1-canary.ed8caed

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.
@@ -1,3 +1,13 @@
1
+ /**
2
+ * Move presentation keys into the `admin` block.
3
+ *
4
+ * `ADMIN_COLLECTION_KEYS` comes from `@rebasepro/types` rather than being spelled
5
+ * out here, so adding a field to `AdminCollectionOptions` cannot leave this writer
6
+ * behind. Any such key already inside `admin` wins over a top-level copy: the
7
+ * nested one is what the file said, and a flat duplicate is the view model's
8
+ * flattening leaking back.
9
+ */
10
+ export declare function nestAdminKeys(collectionData: Record<string, unknown>): Record<string, unknown>;
1
11
  export declare class AstSchemaEditor {
2
12
  private project;
3
13
  private collectionsDir;
@@ -41,6 +41,20 @@ export declare class RestApiGenerator {
41
41
  * slug, never an id.
42
42
  */
43
43
  private enforceSubcollectionApiKeyPermission;
44
+ /**
45
+ * The collection a nested path writes into — the target of the relation its
46
+ * last segment names.
47
+ *
48
+ * Needed so a nested write can be checked against a schema at all. Without
49
+ * it these routes skipped `assertKnownWriteFields` entirely, which is why a
50
+ * typo `POST /posts` rejected with a 400 while the same typo on
51
+ * `POST /authors/1/posts` reached the database.
52
+ *
53
+ * Returns `undefined` rather than throwing when the path cannot be walked:
54
+ * the driver raises the authoritative error a moment later, and duplicating
55
+ * it here would report a resolution failure as a validation failure.
56
+ */
57
+ private resolveNestedWriteCollection;
44
58
  /**
45
59
  * Get the request-scoped driver. Throws if none is set — never falls
46
60
  * back to the unscoped `this.driver` to avoid bypassing RLS/auth.
package/dist/index.es.js CHANGED
@@ -2,7 +2,7 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { _ as __toESM, a as generateRefreshToken, c as getRefreshTokenTtlMs, d as verifyAccessToken, f as verifyDownloadToken, g as __require, h as __exportAll, i as generateDownloadToken, l as hashRefreshToken, m as __commonJSMin, n as configureJwt, o as getAccessTokenExpiry, p as require_jsonwebtoken, r as generateAccessToken, s as getRefreshTokenExpiry, t as MAX_COOKIE_AGE_MS } from "./jwt-Dj7r7QX7.js";
5
- import { _ as EntityRelation, a as DEFAULT_DATA_SOURCE_KEY, b as RebaseApiError, c as policy, d as isPostgresCollectionConfig, f as CANONICAL_TO_REST, g as EntityReference, h as toCanonicalOp, i as DEFAULT_STORAGE_SOURCE_KEY, l as getCollectionDataPath, m as REST_TO_CANONICAL, n as serializeCollections, o as getDataSourceCapabilities, p as NULL_OPS, r as SCHEMA_VERSION_HEADER, s as isSQLAdmin, t as computeSchemaVersion, u as getDeclaredSubcollections, v as GeoPoint, x as RebaseClientError, y as Vector } from "./src-BITicbgD.js";
5
+ import { S as RebaseClientError, _ as EntityReference, a as DEFAULT_DATA_SOURCE_KEY, b as Vector, c as policy, d as isPostgresCollectionConfig, g as toCanonicalOp, h as REST_TO_CANONICAL, i as DEFAULT_STORAGE_SOURCE_KEY, l as getCollectionDataPath, m as NULL_OPS, n as serializeCollections, o as getDataSourceCapabilities, p as CANONICAL_TO_REST, r as SCHEMA_VERSION_HEADER, s as isSQLAdmin, t as computeSchemaVersion, u as getDeclaredSubcollections, v as EntityRelation, x as RebaseApiError, y as GeoPoint } from "./src-DRUswG_w.js";
6
6
  import { t as logger } from "./logger-BYU66ENZ.js";
7
7
  import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
8
8
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
@@ -1309,6 +1309,19 @@ function sanitizeRelation(relation, sourceCollection, resolveCollection) {
1309
1309
  if (newRelation.cardinality === "many" && newRelation.direction === "inverse" && !newRelation.foreignKeyOnTarget && !newRelation.joinPath && !newRelation.inverseRelationName) throw new Error(`Configuration Error in relation from '${sourceCollection.name}': An 'inverse' one-to-many relation requires a 'foreignKeyOnTarget'. Check the relation config for '${newRelation.relationName}'`);
1310
1310
  return newRelation;
1311
1311
  }
1312
+ /**
1313
+ * Whether the target rows of this relation are reached through a junction — a
1314
+ * many-to-many, or a multi-hop `joinPath`.
1315
+ *
1316
+ * The distinction decides what a write "through" the relation may touch: a
1317
+ * junction-backed target is shared with other parents, so the parent owns the
1318
+ * *link* and not the row. The backend enforces that (an unlink rather than a
1319
+ * delete) and the admin renders it (remove-from-parent rather than delete), so
1320
+ * the predicate lives here rather than once per side.
1321
+ */
1322
+ function isJunctionBackedRelation(relation) {
1323
+ return Boolean(relation.through) || Boolean(relation.joinPath && relation.joinPath.length > 1);
1324
+ }
1312
1325
  /** WeakMap cache — same collection instance always yields the same relation map. */
1313
1326
  var _resolvedRelationsCache = /* @__PURE__ */ new WeakMap();
1314
1327
  function resolveCollectionRelations(collection) {
@@ -1385,34 +1398,115 @@ function findRelation(resolvedRelations, key) {
1385
1398
  }
1386
1399
  //#endregion
1387
1400
  //#region ../common/src/util/resolutions.ts
1388
- function getSubcollections(collection) {
1389
- if (collection.childCollections) return collection.childCollections() ?? [];
1390
- const declaredSubcollections = getDeclaredSubcollections(collection);
1391
- if (getDataSourceCapabilities(collection.engine).supportsSubcollections && declaredSubcollections) return declaredSubcollections() ?? [];
1392
- if (getDataSourceCapabilities(collection.engine).supportsRelations) {
1393
- const resolvedRelations = resolveCollectionRelations(collection);
1394
- return Object.values(resolvedRelations).filter((r) => r.cardinality === "many").map((r) => {
1395
- const target = r.target();
1396
- if (!target) return void 0;
1397
- const relationKey = r.relationName || target.slug;
1398
- let customName;
1399
- if (collection.properties) {
1400
- const prop = Object.entries(collection.properties).find(([_, p]) => p.type === "relation" && p.relationName === relationKey);
1401
- if (prop && prop[1].name) customName = prop[1].name;
1401
+ /**
1402
+ * Relation identities that only ever appear *below* the top level of
1403
+ * `properties` — inside a `map`, an `array`, or a `oneOf` branch.
1404
+ *
1405
+ * A relation declared at the top level is a list hanging off the record and
1406
+ * earns a tab. One declared inside a map is a field of that map. Both end up in
1407
+ * the collection's flat `relations` array after normalization, so the shape of
1408
+ * `properties` is the only remaining evidence of which is which.
1409
+ *
1410
+ * An identity declared in both places is *not* nested-only, and keeps its tab.
1411
+ */
1412
+ function getNestedRelationIdentities(properties) {
1413
+ const topLevel = /* @__PURE__ */ new Set();
1414
+ const nested = /* @__PURE__ */ new Set();
1415
+ const walk = (props, depth) => {
1416
+ if (!props) return;
1417
+ for (const [key, property] of Object.entries(props)) {
1418
+ if (!property) continue;
1419
+ if (property.type === "relation") {
1420
+ const relProp = property;
1421
+ (depth === 0 ? topLevel : nested).add(relProp.relationName ?? key);
1422
+ } else if (property.type === "map") walk(property.properties, depth + 1);
1423
+ else if (property.type === "array") {
1424
+ const arrayProp = property;
1425
+ if (Array.isArray(arrayProp.of)) arrayProp.of.forEach((p) => walk({ entry: p }, depth + 1));
1426
+ else if (arrayProp.of) walk({ entry: arrayProp.of }, depth + 1);
1427
+ if (arrayProp.oneOf?.properties) walk(arrayProp.oneOf.properties, depth + 1);
1402
1428
  }
1403
- const baseOverrides = { slug: relationKey };
1404
- if (customName) {
1405
- baseOverrides.name = customName;
1406
- baseOverrides.singularName = customName;
1429
+ }
1430
+ };
1431
+ walk(properties, 0);
1432
+ for (const identity of topLevel) nested.delete(identity);
1433
+ return nested;
1434
+ }
1435
+ /**
1436
+ * The lists rendered inside an entity view of `collection` — its tabs.
1437
+ *
1438
+ * The single derivation. There used to be two that disagreed: this one, and a
1439
+ * copy in `CollectionRegistry.normalizeCollection` that stamped each child with
1440
+ * the *target collection's* slug instead of the relation key. Since the
1441
+ * registry ran first and cached its answer onto `childCollections`, its version
1442
+ * was the one that won, and the frontend addressed child listings by a segment
1443
+ * the backend could not resolve.
1444
+ *
1445
+ * Order of precedence:
1446
+ * 1. `childCollections` — the explicit escape hatch for custom drivers.
1447
+ * 2. `subcollections` on an engine that has real containment (Firestore).
1448
+ * 3. many-relations on an engine that has relations (SQL).
1449
+ */
1450
+ function getEntityChildViews(collection) {
1451
+ const asSubcollections = (collections) => collections.filter(Boolean).map((child) => ({
1452
+ key: child.slug,
1453
+ collection: child,
1454
+ source: { kind: "subcollection" }
1455
+ }));
1456
+ if (collection.childCollections) return asSubcollections(collection.childCollections() ?? []);
1457
+ const capabilities = getDataSourceCapabilities(collection.engine);
1458
+ const declaredSubcollections = getDeclaredSubcollections(collection);
1459
+ if (capabilities.supportsSubcollections && declaredSubcollections) return asSubcollections(declaredSubcollections() ?? []);
1460
+ if (!capabilities.supportsRelations) return [];
1461
+ const resolvedRelations = resolveCollectionRelations(collection);
1462
+ const nestedOnly = getNestedRelationIdentities(collection.properties);
1463
+ const views = [];
1464
+ const seen = /* @__PURE__ */ new Set();
1465
+ for (const [relationKey, relation] of Object.entries(resolvedRelations)) {
1466
+ if (relation.cardinality !== "many") continue;
1467
+ const identity = relation.relationName ?? relationKey;
1468
+ if (seen.has(identity)) continue;
1469
+ if (nestedOnly.has(identity)) continue;
1470
+ let target;
1471
+ try {
1472
+ target = relation.target();
1473
+ } catch {
1474
+ continue;
1475
+ }
1476
+ if (!target) continue;
1477
+ seen.add(identity);
1478
+ const customName = Object.entries(collection.properties ?? {}).find(([propKey, p]) => p.type === "relation" && (p.relationName ?? propKey) === identity)?.[1]?.name;
1479
+ const base = {
1480
+ ...target,
1481
+ slug: relationKey,
1482
+ ...customName ? {
1483
+ name: customName,
1484
+ singularName: customName
1485
+ } : {}
1486
+ };
1487
+ views.push({
1488
+ key: relationKey,
1489
+ collection: relation.overrides ? mergeDeep(base, relation.overrides) : base,
1490
+ source: {
1491
+ kind: "relation",
1492
+ relationKey,
1493
+ mode: isJunctionBackedRelation(relation) ? "linked" : "owned",
1494
+ targetSlug: target.slug
1407
1495
  }
1408
- const targetWithOverrides = {
1409
- ...target,
1410
- ...baseOverrides
1411
- };
1412
- return r.overrides ? mergeDeep(targetWithOverrides, r.overrides) : targetWithOverrides;
1413
- }).filter((c) => Boolean(c));
1496
+ });
1414
1497
  }
1415
- return [];
1498
+ return views;
1499
+ }
1500
+ /**
1501
+ * The child views of `collection` as bare collections.
1502
+ *
1503
+ * The flattened view of {@link getEntityChildViews}, for navigation code that
1504
+ * only needs to match a path segment against a slug. Anything that cares *what
1505
+ * kind* of list it is showing — chiefly the admin, which must not offer a
1506
+ * global delete on a shared row — should read the views instead.
1507
+ */
1508
+ function getSubcollections(collection) {
1509
+ return getEntityChildViews(collection).map((view) => view.collection);
1416
1510
  }
1417
1511
  policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1418
1512
  policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
@@ -1689,39 +1783,9 @@ policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1689
1783
  return jsonLogic;
1690
1784
  });
1691
1785
  })))();
1692
- //#endregion
1693
- //#region ../common/src/util/filter-operator-resolution.ts
1694
- /**
1695
- * Default operators offered per property type, before engine capabilities and
1696
- * per-property narrowing are applied. These mirror what the built-in filter
1697
- * fields can render.
1698
- */
1699
- var COMPARISON_OPS = [
1700
- "==",
1701
- "!=",
1702
- ">",
1703
- ">=",
1704
- "<",
1705
- "<="
1706
- ];
1707
- var NULL_CHECK_OPS = ["is-null", "is-not-null"];
1708
- var MEMBERSHIP_OPS = ["in", "not-in"];
1709
- var PATTERN_OPS = [
1710
- "like",
1711
- "ilike",
1712
- "not-like",
1713
- "not-ilike"
1714
- ];
1715
- [
1716
- ...COMPARISON_OPS,
1717
- ...MEMBERSHIP_OPS,
1718
- ...PATTERN_OPS,
1719
- ...NULL_CHECK_OPS
1720
- ], [
1721
- ...COMPARISON_OPS,
1722
- ...MEMBERSHIP_OPS,
1723
- ...NULL_CHECK_OPS
1724
- ], [...COMPARISON_OPS, ...NULL_CHECK_OPS], [...NULL_CHECK_OPS], [...MEMBERSHIP_OPS, ...NULL_CHECK_OPS], [...MEMBERSHIP_OPS, ...NULL_CHECK_OPS];
1786
+ /**
1787
+ * Apply PropertyConditions to a resolved property, evaluating all JSON Logic rules.
1788
+ */
1725
1789
  //#endregion
1726
1790
  //#region ../../node_modules/.pnpm/fast-equals@6.0.0/node_modules/fast-equals/dist/es/index.mjs
1727
1791
  var { getOwnPropertyNames, getOwnPropertySymbols } = Object;
@@ -2338,18 +2402,6 @@ var CollectionRegistry = class {
2338
2402
  relResult.relations = mergedRelations;
2339
2403
  }
2340
2404
  result.properties = this.normalizeProperties(result.properties, mergedRelations);
2341
- if (!result.childCollections) {
2342
- const capabilities = getDataSourceCapabilities(result.engine);
2343
- const declaredSubcollections = getDeclaredSubcollections(result);
2344
- if (capabilities.supportsSubcollections && declaredSubcollections) result.childCollections = declaredSubcollections;
2345
- else if (capabilities.supportsRelations && relResult.relations) {
2346
- const manyRelations = relResult.relations.filter((r) => r.cardinality === "many");
2347
- if (manyRelations.length > 0) result.childCollections = () => manyRelations.map((r) => {
2348
- const target = r.target();
2349
- return r.overrides ? mergeDeep(target, r.overrides) : target;
2350
- });
2351
- }
2352
- }
2353
2405
  return result;
2354
2406
  }
2355
2407
  /**
@@ -2448,9 +2500,7 @@ var CollectionRegistry = class {
2448
2500
  const relation = findRelation(resolveCollectionRelations(currentCollection), relationKey);
2449
2501
  if (!relation) throw new Error(`Relation '${relationKey}' not found in collection '${currentCollection.slug}'`);
2450
2502
  const target = relation.target();
2451
- const targetRelationKey = relation.relationName || target.slug;
2452
- const targetSlug = relation.overrides?.slug ?? targetRelationKey;
2453
- currentCollection = this.get(targetSlug) || this.normalizeCollection(target);
2503
+ currentCollection = this.collectionsByTableName.get(getTableName(target)) ?? this.normalizeCollection(target);
2454
2504
  if (i + 1 < pathSegments.length) {}
2455
2505
  }
2456
2506
  return currentCollection;
@@ -2485,7 +2535,7 @@ var CollectionRegistry = class {
2485
2535
  if (!subcollections || subcollections.length === 0) throw new Error(`No subcollections found for ${currentCollection.slug} in path: ${path}`);
2486
2536
  const subcollection = subcollections.find((c) => c.slug === subcollectionSlug);
2487
2537
  if (!subcollection) throw new Error(`Subcollection '${subcollectionSlug}' not found in ${currentCollection.slug}`);
2488
- currentCollection = this.get(subcollection.slug) || this.normalizeCollection(subcollection);
2538
+ currentCollection = this.normalizeCollection(subcollection);
2489
2539
  collections.push(currentCollection);
2490
2540
  }
2491
2541
  }
@@ -4054,6 +4104,34 @@ var RestApiGenerator = class {
4054
4104
  this.enforceApiKeyPermission(c, collectionPath.split("/").pop());
4055
4105
  }
4056
4106
  /**
4107
+ * The collection a nested path writes into — the target of the relation its
4108
+ * last segment names.
4109
+ *
4110
+ * Needed so a nested write can be checked against a schema at all. Without
4111
+ * it these routes skipped `assertKnownWriteFields` entirely, which is why a
4112
+ * typo `POST /posts` rejected with a 400 while the same typo on
4113
+ * `POST /authors/1/posts` reached the database.
4114
+ *
4115
+ * Returns `undefined` rather than throwing when the path cannot be walked:
4116
+ * the driver raises the authoritative error a moment later, and duplicating
4117
+ * it here would report a resolution failure as a validation failure.
4118
+ */
4119
+ resolveNestedWriteCollection(collectionPath) {
4120
+ const segments = collectionPath.split("/").filter((s) => s && s !== "undefined");
4121
+ let current = this.collections.find((c) => c.slug === segments[0]);
4122
+ for (let i = 2; i < segments.length && current; i += 2) {
4123
+ const relation = findRelation(resolveCollectionRelations(current), segments[i]);
4124
+ if (!relation) return void 0;
4125
+ try {
4126
+ const target = relation.target();
4127
+ current = this.collections.find((c) => c.slug === target?.slug) ?? target;
4128
+ } catch {
4129
+ return;
4130
+ }
4131
+ }
4132
+ return current;
4133
+ }
4134
+ /**
4057
4135
  * Get the request-scoped driver. Throws if none is set — never falls
4058
4136
  * back to the unscoped `this.driver` to avoid bypassing RLS/auth.
4059
4137
  */
@@ -4294,7 +4372,9 @@ var RestApiGenerator = class {
4294
4372
  }) : 0;
4295
4373
  return c.json({ count: total });
4296
4374
  } else if (parsed.id) {
4297
- const entity = await driver.fetchOne({
4375
+ const queryOptions = this.parseQuery(c.req.queries());
4376
+ const fetchService = driver.restFetchService;
4377
+ const entity = fetchService ? await fetchService.fetchOneForRest(parsed.collectionPath, parsed.id, queryOptions.include) : await driver.fetchOne({
4298
4378
  path: parsed.collectionPath,
4299
4379
  id: parsed.id
4300
4380
  });
@@ -4304,13 +4384,18 @@ var RestApiGenerator = class {
4304
4384
  const queryDict = c.req.queries();
4305
4385
  const queryOptions = this.parseQuery(queryDict);
4306
4386
  const searchString = Array.isArray(queryDict.searchString) ? queryDict.searchString[queryDict.searchString.length - 1] : void 0;
4307
- const entities = await driver.fetchCollection({
4308
- path: parsed.collectionPath,
4387
+ const fetchService = driver.restFetchService;
4388
+ const listOptions = {
4309
4389
  filter: queryOptions.where,
4310
4390
  limit: queryOptions.limit,
4391
+ offset: queryOptions.offset,
4311
4392
  orderBy: queryOptions.orderBy?.[0]?.field,
4312
4393
  order: queryOptions.orderBy?.[0]?.direction === "desc" ? "desc" : "asc",
4313
4394
  searchString
4395
+ };
4396
+ const entities = fetchService ? await fetchService.fetchCollectionForRest(parsed.collectionPath, listOptions, queryOptions.include) : await driver.fetchCollection({
4397
+ path: parsed.collectionPath,
4398
+ ...listOptions
4314
4399
  });
4315
4400
  const total = driver.count ? await driver.count({
4316
4401
  path: parsed.collectionPath,
@@ -4336,6 +4421,8 @@ var RestApiGenerator = class {
4336
4421
  const driver = this.getScopedDriver(c);
4337
4422
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4338
4423
  const body = await parseJsonBody(c);
4424
+ const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
4425
+ if (targetCollection) assertKnownWriteFields(body, targetCollection);
4339
4426
  const entity = await driver.save({
4340
4427
  path: parsed.collectionPath,
4341
4428
  values: body,
@@ -4352,6 +4439,8 @@ var RestApiGenerator = class {
4352
4439
  const driver = this.getScopedDriver(c);
4353
4440
  this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4354
4441
  const body = await parseJsonBody(c);
4442
+ const targetCollection = this.resolveNestedWriteCollection(parsed.collectionPath);
4443
+ if (targetCollection) assertKnownWriteFields(body, targetCollection);
4355
4444
  const entity = await driver.save({
4356
4445
  path: parsed.collectionPath,
4357
4446
  id: parsed.id,
@@ -12888,6 +12977,7 @@ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
12888
12977
  function createGoogleProvider(config) {
12889
12978
  const clientId = typeof config === "string" ? config : config.clientId;
12890
12979
  const clientSecret = typeof config === "string" ? void 0 : config.clientSecret;
12980
+ if (!clientSecret) console.warn("[Rebase] Google provider configured without a client secret. ID-token and access-token sign-in still work, but the authorization-code flow used by the admin login button will fail. Set GOOGLE_CLIENT_SECRET to enable it.");
12891
12981
  let googleClient;
12892
12982
  async function getClient() {
12893
12983
  if (!googleClient) {
@@ -14580,7 +14670,7 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
14580
14670
  function createStorageRoutes(config) {
14581
14671
  const router = new Hono();
14582
14672
  router.onError(errorHandler);
14583
- const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize } = config;
14673
+ const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize, authorizeData } = config;
14584
14674
  /**
14585
14675
  * Run the per-object authorization hook, if one is configured.
14586
14676
  *
@@ -14601,7 +14691,8 @@ function createStorageRoutes(config) {
14601
14691
  bucket,
14602
14692
  operation,
14603
14693
  user,
14604
- storageId: storageId ?? void 0
14694
+ storageId: storageId ?? void 0,
14695
+ data: authorizeData?.()
14605
14696
  });
14606
14697
  } catch {
14607
14698
  allowed = false;
@@ -15043,7 +15134,7 @@ function assertStorageAccessControlConfigured(state, isProduction) {
15043
15134
  //#region src/init/docs.ts
15044
15135
  async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
15045
15136
  if (enableSwagger === false || activeCollections.length === 0) return;
15046
- const { generateOpenApiSpec } = await import("./openapi-generator-DqSIwNLV.js");
15137
+ const { generateOpenApiSpec } = await import("./openapi-generator-C3_pTcsb.js");
15047
15138
  app.get(`${basePath}/docs`, (c) => {
15048
15139
  const spec = generateOpenApiSpec(activeCollections, {
15049
15140
  basePath,
@@ -15545,6 +15636,34 @@ function createAuth(transport, options) {
15545
15636
  transport.setToken(null);
15546
15637
  emit("SIGNED_OUT", null);
15547
15638
  }
15639
+ /**
15640
+ * Recover from a 401 on an ordinary API request.
15641
+ *
15642
+ * Returns `true` when the caller should retry — we minted a fresh access
15643
+ * token. When the refresh is rejected *fatally* (the refresh token itself
15644
+ * is invalid, expired or revoked) this client can no longer act as the
15645
+ * user at all, so we drop the session and emit `SIGNED_OUT`. UIs gate on
15646
+ * that event, so they show their login screen instead of leaving the user
15647
+ * staring at "Invalid or expired token" on every view.
15648
+ *
15649
+ * Transient failures (offline, 5xx, backend restarting) keep the session:
15650
+ * the scheduled refresh backs off and retries, and the token is very
15651
+ * likely still good once the backend answers again.
15652
+ */
15653
+ async function handleUnauthorized() {
15654
+ if (!currentSession) return false;
15655
+ if (authFlowMode !== "cookie" && !currentSession.refreshToken) {
15656
+ abandonSessionLocally();
15657
+ return false;
15658
+ }
15659
+ try {
15660
+ await refreshSession();
15661
+ return true;
15662
+ } catch (err) {
15663
+ if (isFatalRefreshError(err)) abandonSessionLocally();
15664
+ return false;
15665
+ }
15666
+ }
15548
15667
  async function attemptScheduledRefresh(attempt) {
15549
15668
  try {
15550
15669
  await refreshSession();
@@ -16046,6 +16165,7 @@ function createAuth(transport, options) {
16046
16165
  signInWithSpotify,
16047
16166
  signOut,
16048
16167
  refreshSession,
16168
+ handleUnauthorized,
16049
16169
  getUser,
16050
16170
  findUserByEmail,
16051
16171
  updateUser,
@@ -20189,14 +20309,7 @@ function createRebaseClient(options) {
20189
20309
  } catch (e) {}
20190
20310
  return session?.accessToken || options.token || "";
20191
20311
  },
20192
- onUnauthorized: options.onUnauthorized || (async () => {
20193
- try {
20194
- await auth.refreshSession();
20195
- return true;
20196
- } catch (e) {
20197
- return false;
20198
- }
20199
- })
20312
+ onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
20200
20313
  });
20201
20314
  auth.onAuthStateChange((event, session) => {
20202
20315
  if (!ws) return;
@@ -20206,14 +20319,7 @@ function createRebaseClient(options) {
20206
20319
  }
20207
20320
  });
20208
20321
  }
20209
- if (!options.onUnauthorized) transport.setOnUnauthorized(async () => {
20210
- try {
20211
- await auth.refreshSession();
20212
- return true;
20213
- } catch (e) {
20214
- return false;
20215
- }
20216
- });
20322
+ if (!options.onUnauthorized) transport.setOnUnauthorized(() => auth.handleUnauthorized());
20217
20323
  /**
20218
20324
  * Suggest the closest known collection key for a mistyped accessor.
20219
20325
  * Uses edit-distance-1 and prefix matching — no external dependency.
@@ -20951,7 +21057,7 @@ async function _initializeRebaseBackend(config) {
20951
21057
  if (schemaEditorEnabled && config.collectionsDir) {
20952
21058
  let editorModule;
20953
21059
  try {
20954
- editorModule = await import("./schema-editor-routes-D3ef8zu1.js");
21060
+ editorModule = await import("./schema-editor-routes-CF-g0gVN.js");
20955
21061
  } catch (err) {
20956
21062
  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.");
20957
21063
  else throw err;
@@ -20963,6 +21069,7 @@ async function _initializeRebaseBackend(config) {
20963
21069
  logger.info("Schema Editor mounted", { path: `${basePath}/schema-editor` });
20964
21070
  }
20965
21071
  }
21072
+ const storageAuthorizeData = {};
20966
21073
  if (storageController) {
20967
21074
  const storageMaxSize = (config.storage && typeof config.storage === "object" && "type" in config.storage ? config.storage.maxFileSize : void 0) ?? 50 * 1024 * 1024;
20968
21075
  assertStorageAccessControlConfigured({
@@ -20977,7 +21084,8 @@ async function _initializeRebaseBackend(config) {
20977
21084
  requireAuth: resolveRequireAuth(config.auth),
20978
21085
  publicRead: config.storagePublicRead === true,
20979
21086
  authAdapter,
20980
- authorize: config.storageAuthorize
21087
+ authorize: config.storageAuthorize,
21088
+ authorizeData: () => storageAuthorizeData.current
20981
21089
  });
20982
21090
  const storageRouter = new Hono();
20983
21091
  if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
@@ -21056,6 +21164,7 @@ async function _initializeRebaseBackend(config) {
21056
21164
  roles: ["admin"]
21057
21165
  };
21058
21166
  const defaultData = buildSdkData(await scopeDataDriver(defaultDriver, serviceIdentity));
21167
+ storageAuthorizeData.current = defaultData;
21059
21168
  const dataSourcesByKey = {};
21060
21169
  for (const driverKey of driverRegistry.list()) {
21061
21170
  if (driverKey === "(default)") continue;
@@ -22457,8 +22566,11 @@ var DEV_PORT_FILENAME = ".rebase-dev-port";
22457
22566
  * Try to `listen` on `startPort`. If the port is busy (`EADDRINUSE`), increment
22458
22567
  * and retry up to `maxAttempts` times.
22459
22568
  *
22460
- * When a port file already exists (written by a previous run), the saved port
22461
- * is tried first to maintain port affinity across tsx watch restarts.
22569
+ * When a port file written by a previous run exists *and that run asked for the
22570
+ * same `startPort`*, the port it landed on is tried first, so tsx watch restarts
22571
+ * keep the address the frontend was configured with. A different `startPort` means
22572
+ * the configuration changed and the file is ignored — an explicitly requested port
22573
+ * is never overridden by a stale one.
22462
22574
  *
22463
22575
  * Resolves with the port that was actually bound.
22464
22576
  *
@@ -22486,8 +22598,10 @@ function listenWithPortRetry(server, startPort, options) {
22486
22598
  if (portFileDir) try {
22487
22599
  const portFile = path.join(portFileDir, DEV_PORT_FILENAME);
22488
22600
  if (fs.existsSync(portFile)) {
22489
- const saved = parseInt(fs.readFileSync(portFile, "utf-8").trim(), 10);
22490
- if (saved > 0 && saved < 65536 && saved !== startPort) affinityPort = saved;
22601
+ const [savedRaw, requestedRaw] = fs.readFileSync(portFile, "utf-8").trim().split(/\s+/);
22602
+ const saved = parseInt(savedRaw, 10);
22603
+ const requestedThen = requestedRaw === void 0 ? NaN : parseInt(requestedRaw, 10);
22604
+ if (saved > 0 && saved < 65536 && saved !== startPort && (Number.isNaN(requestedThen) || requestedThen === startPort)) affinityPort = saved;
22491
22605
  }
22492
22606
  } catch {}
22493
22607
  return new Promise((resolve, reject) => {
@@ -22505,24 +22619,29 @@ function listenWithPortRetry(server, startPort, options) {
22505
22619
  }
22506
22620
  const port = portsToTry[index];
22507
22621
  attempt++;
22508
- const onError = (err) => {
22509
- if (err.code === "EADDRINUSE") {
22510
- server.removeListener("error", onError);
22511
- tryNext(index + 1);
22512
- } else reject(err);
22513
- };
22514
- server.once("error", onError);
22515
- server.listen(port, host, () => {
22516
- server.removeListener("error", onError);
22622
+ const onListening = () => {
22623
+ cleanup();
22517
22624
  if (portFileDir) {
22518
22625
  try {
22519
22626
  const portFile = path.join(portFileDir, DEV_PORT_FILENAME);
22520
- fs.writeFileSync(portFile, String(port), "utf-8");
22627
+ fs.writeFileSync(portFile, `${port} ${startPort}`, "utf-8");
22521
22628
  } catch {}
22522
22629
  writeStateFile(portFileDir, port, options?.serviceKey);
22523
22630
  }
22524
22631
  resolve(port);
22525
- });
22632
+ };
22633
+ const onError = (err) => {
22634
+ cleanup();
22635
+ if (err.code === "EADDRINUSE") tryNext(index + 1);
22636
+ else reject(err);
22637
+ };
22638
+ function cleanup() {
22639
+ server.removeListener("listening", onListening);
22640
+ server.removeListener("error", onError);
22641
+ }
22642
+ server.once("error", onError);
22643
+ server.once("listening", onListening);
22644
+ server.listen(port, host);
22526
22645
  }
22527
22646
  tryNext(0);
22528
22647
  });
@@ -23744,6 +23863,7 @@ async function bootFromBundle(options = {}) {
23744
23863
  const server = createServer(getRequestListener(app.fetch));
23745
23864
  const usersCollection = await loadUsersCollection(bundle);
23746
23865
  const storage = resolveStorageSources(process.env, storageSourceDefs, path.join(bundle.dir, "uploads"));
23866
+ await ensureCollectionSchema(bundle, dataSources, env);
23747
23867
  const backend = await initializeRebaseBackend({
23748
23868
  server,
23749
23869
  app,
@@ -23953,6 +24073,32 @@ async function runFromBundle(options = {}) {
23953
24073
  process.exit(1);
23954
24074
  }
23955
24075
  }
24076
+ /**
24077
+ * Bring the database's collection tables up to date before serving.
24078
+ *
24079
+ * Delegates to whichever driver bootstrapped the default data source; a driver
24080
+ * without `ensureCollectionSchema` (a schemaless one, or an older build) simply
24081
+ * skips, which is why this cannot break an existing deployment.
24082
+ *
24083
+ * Failure is fatal on purpose. Booting anyway would produce exactly the state
24084
+ * this exists to prevent — an app that answers sign-in and 500s every data
24085
+ * request — and a crash-looping pod with the DDL error in its logs is a far
24086
+ * better signal than a running one that silently cannot serve.
24087
+ */
24088
+ async function ensureCollectionSchema(bundle, dataSources, env) {
24089
+ if ((env.REBASE_MIGRATE_ON_BOOT || "ensure") === "none") {
24090
+ logger.info("REBASE_MIGRATE_ON_BOOT=none — leaving the database schema untouched.");
24091
+ return;
24092
+ }
24093
+ if ((bundle.manifest.mode ?? "cms") !== "cms") return;
24094
+ if (!bundle.collectionsDir) return;
24095
+ const primary = dataSources[0];
24096
+ if (!primary?.bootstrapper.ensureCollectionSchema) return;
24097
+ const collections = await loadCollectionsFromDirectory(bundle.collectionsDir);
24098
+ if (collections.length === 0) return;
24099
+ const { applied } = await primary.bootstrapper.ensureCollectionSchema(collections, primary.connection, (message) => logger.info(`schema: ${message}`));
24100
+ logger.info(applied > 0 ? `Applied ${applied} additive schema change(s) before boot.` : "Collection schema is up to date.");
24101
+ }
23956
24102
  //#endregion
23957
24103
  //#region src/api/contract-routes.ts
23958
24104
  var contract_routes_exports = /* @__PURE__ */ __exportAll({ createContractRoutes: () => createContractRoutes });