@rebasepro/server 0.9.1-canary.fd3754b → 0.10.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.
Files changed (50) hide show
  1. package/dist/api/errors.d.ts +16 -1
  2. package/dist/api/rest/api-generator.d.ts +13 -1
  3. package/dist/api/rest/write-validation.d.ts +22 -0
  4. package/dist/api/types.d.ts +2 -2
  5. package/dist/auth/admin-users-route.d.ts +3 -3
  6. package/dist/auth/api-keys/api-key-middleware.d.ts +60 -1
  7. package/dist/auth/api-keys/api-key-permission-guard.d.ts +33 -0
  8. package/dist/auth/api-keys/api-key-types.d.ts +21 -8
  9. package/dist/auth/api-keys/index.d.ts +2 -2
  10. package/dist/auth/auth-hooks.d.ts +7 -7
  11. package/dist/auth/collection-callback-warning.d.ts +21 -0
  12. package/dist/auth/index.d.ts +4 -1
  13. package/dist/auth/interfaces.d.ts +28 -28
  14. package/dist/auth/jwt.d.ts +20 -4
  15. package/dist/auth/magic-link-routes.d.ts +2 -2
  16. package/dist/auth/mfa-routes.d.ts +1 -1
  17. package/dist/auth/middleware.d.ts +24 -3
  18. package/dist/auth/rate-limit-store.d.ts +49 -0
  19. package/dist/auth/rate-limiter.d.ts +68 -6
  20. package/dist/auth/reset-password-admin.d.ts +1 -1
  21. package/dist/auth/session-routes.d.ts +2 -2
  22. package/dist/cron/cron-store.d.ts +18 -0
  23. package/dist/env.d.ts +4 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.es.js +2124 -505
  26. package/dist/index.es.js.map +1 -1
  27. package/dist/init/middlewares.d.ts +1 -0
  28. package/dist/init.d.ts +62 -0
  29. package/dist/{jwt-BwIn8xmk.js → jwt-B3zjddCa.js} +177 -9
  30. package/dist/jwt-B3zjddCa.js.map +1 -0
  31. package/dist/{openapi-generator-Z9oYWLf_.js → openapi-generator-DqSIwNLV.js} +8 -2
  32. package/dist/openapi-generator-DqSIwNLV.js.map +1 -0
  33. package/dist/{src-B4OLmNVa.js → src-CsHhSKbi.js} +60 -2
  34. package/dist/src-CsHhSKbi.js.map +1 -0
  35. package/dist/storage/LocalStorageController.d.ts +19 -2
  36. package/dist/storage/routes.d.ts +8 -1
  37. package/dist/storage/tus-handler.d.ts +21 -1
  38. package/dist/storage/types.d.ts +33 -0
  39. package/dist/utils/compression.d.ts +16 -0
  40. package/dist/utils/sql.d.ts +2 -2
  41. package/package.json +12 -11
  42. package/dist/index.umd.js +0 -48745
  43. package/dist/index.umd.js.map +0 -1
  44. package/dist/jwt-BwIn8xmk.js.map +0 -1
  45. package/dist/ms-DnYXB-Wd.js +0 -162
  46. package/dist/ms-DnYXB-Wd.js.map +0 -1
  47. package/dist/openapi-generator-Z9oYWLf_.js.map +0 -1
  48. package/dist/src-B4OLmNVa.js.map +0 -1
  49. package/dist/src-B9tjYmqP.js +0 -24598
  50. package/dist/src-B9tjYmqP.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -1,10 +1,9 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
- import { a as __require, i as __exportAll, n as __commonJSMin, s as __toESM } from "./ms-DnYXB-Wd.js";
5
- import { _ as RebaseClientError, a as getCollectionDataPath, c as CANONICAL_TO_REST, d as toCanonicalOp, f as EntityReference, g as RebaseApiError, h as Vector, i as isSQLAdmin, l as NULL_OPS, m as GeoPoint, o as getDeclaredSubcollections, p as EntityRelation, r as getDataSourceCapabilities, s as isPostgresCollectionConfig, t as DEFAULT_STORAGE_SOURCE_KEY, u as REST_TO_CANONICAL } from "./src-B4OLmNVa.js";
4
+ import { a as getAccessTokenExpiry, d as require_jsonwebtoken, f as __commonJSMin, h as __toESM, i as generateRefreshToken, l as verifyAccessToken, m as __require, n as generateAccessToken, o as getRefreshTokenExpiry, p as __exportAll, r as generateDownloadToken, s as hashRefreshToken, t as configureJwt, u as verifyDownloadToken } from "./jwt-B3zjddCa.js";
5
+ import { _ as RebaseApiError, a as policy, c as isPostgresCollectionConfig, d as REST_TO_CANONICAL, f as toCanonicalOp, g as Vector, h as GeoPoint, i as isSQLAdmin, l as CANONICAL_TO_REST, m as EntityRelation, o as getCollectionDataPath, p as EntityReference, r as getDataSourceCapabilities, s as getDeclaredSubcollections, t as DEFAULT_STORAGE_SOURCE_KEY, u as NULL_OPS, v as RebaseClientError } from "./src-CsHhSKbi.js";
6
6
  import { t as logger } from "./logger-BYU66ENZ.js";
7
- import { a as getAccessTokenExpiry, d as require_jsonwebtoken, i as generateRefreshToken, l as verifyAccessToken, n as generateAccessToken, o as getRefreshTokenExpiry, r as generateDownloadToken, s as hashRefreshToken, t as configureJwt, u as verifyDownloadToken } from "./jwt-BwIn8xmk.js";
8
7
  import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
9
8
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
10
9
  import * as fs$2 from "fs";
@@ -18,6 +17,7 @@ import * as crypto from "crypto";
18
17
  import { createCipheriv, createDecipheriv, createHash as createHash$1, createHmac, randomBytes as randomBytes$1, randomUUID as randomUUID$1, scrypt, timingSafeEqual } from "crypto";
19
18
  import { bodyLimit } from "hono/body-limit";
20
19
  import { csrf } from "hono/csrf";
20
+ import { compress } from "hono/compress";
21
21
  import fs$1 from "node:fs";
22
22
  import fsp from "node:fs/promises";
23
23
  import { mkdir, open, unlink, writeFile } from "fs/promises";
@@ -1108,6 +1108,76 @@ function normalizeToEntityRelation(value, propertyType) {
1108
1108
  if (!(obj.__type === "relation" || obj.__type === "reference" || typeof obj.isEntityRelation === "function" && obj.isEntityRelation() || typeof obj.isEntityReference === "function" && obj.isEntityReference() || propertyType === "relation" && typeof obj.id !== "undefined" && typeof obj.path === "string")) return null;
1109
1109
  return new EntityRelation(obj.id, obj.path, obj.data);
1110
1110
  }
1111
+ /**
1112
+ * Derive a row's address from its key columns.
1113
+ *
1114
+ * Single key → the value as a string. Composite → each part joined by
1115
+ * {@link COMPOSITE_ID_SEPARATOR}, in primary-key order, which is what
1116
+ * {@link parseIdValues} expects to invert.
1117
+ */
1118
+ function buildCompositeId(values, primaryKeys) {
1119
+ if (primaryKeys.length === 0) return "";
1120
+ if (primaryKeys.length === 1) return String(values[primaryKeys[0].fieldName] ?? "");
1121
+ return primaryKeys.map((pk) => String(values[pk.fieldName] ?? "")).join(":::");
1122
+ }
1123
+ /**
1124
+ * The primary keys of a collection, as declared by its properties.
1125
+ *
1126
+ * This is the only tier both sides can read, because it is the only one written
1127
+ * in the config: the postgres driver can also infer keys from the Drizzle
1128
+ * schema, which the browser never sees and is never sent — the admin compiles
1129
+ * the collection files into its own bundle rather than being served them. A key
1130
+ * that lives only in the Drizzle schema is therefore invisible here, and the
1131
+ * server says so at boot (`warnOnKeysTheAdminCannotResolve`) naming the `isId`
1132
+ * to add.
1133
+ *
1134
+ * Returns an empty array when a collection declares none, which callers must
1135
+ * treat as "not addressable" rather than defaulting to `id`: guessing a key
1136
+ * that is not the real one produces confidently wrong addresses.
1137
+ */
1138
+ function getDeclaredPrimaryKeys(collection) {
1139
+ const properties = collection.properties;
1140
+ if (!properties) return [];
1141
+ const keys = [];
1142
+ for (const [fieldName, propRaw] of Object.entries(properties)) {
1143
+ const prop = propRaw;
1144
+ if (!prop || typeof prop !== "object") continue;
1145
+ if (!("isId" in prop) || !prop.isId) continue;
1146
+ keys.push({
1147
+ fieldName,
1148
+ type: prop.type === "number" ? "number" : "string",
1149
+ isUUID: prop.isId === "uuid"
1150
+ });
1151
+ }
1152
+ return keys;
1153
+ }
1154
+ /**
1155
+ * The keys to address a collection's rows with, resolved the way the driver
1156
+ * resolves them — minus the tier the browser cannot reach.
1157
+ *
1158
+ * The postgres driver tries, in order: properties marked `isId`; the primary
1159
+ * keys of the Drizzle schema; and finally a column literally named `id`. Only
1160
+ * the first and last are visible in a `CollectionConfig`, which is what both
1161
+ * sides share.
1162
+ *
1163
+ * So the two agree except on a collection that declares no `isId` and whose key
1164
+ * is known only to Drizzle. There, the driver reads the real key, and this
1165
+ * either resolves nothing (reported to the console by the caller) or — if the
1166
+ * table happens to have an unrelated `id` property — resolves `id`, which is
1167
+ * the wrong key and cannot be detected from here: the addresses look right and
1168
+ * route wrong. Only the config can settle it, so the server names both cases
1169
+ * at boot (`warnOnKeysTheAdminCannotResolve`) with the `isId` to add.
1170
+ */
1171
+ function resolvePrimaryKeys(collection) {
1172
+ const declared = getDeclaredPrimaryKeys(collection);
1173
+ if (declared.length > 0) return declared;
1174
+ const idProp = collection.properties?.id;
1175
+ if (idProp && typeof idProp === "object") return [{
1176
+ fieldName: "id",
1177
+ type: idProp.type === "number" ? "number" : "string"
1178
+ }];
1179
+ return [];
1180
+ }
1111
1181
  //#endregion
1112
1182
  //#region ../common/src/util/enums.ts
1113
1183
  function enumToObjectEntries(enumValues) {
@@ -1320,6 +1390,8 @@ function getSubcollections(collection) {
1320
1390
  }
1321
1391
  return [];
1322
1392
  }
1393
+ policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1394
+ policy.or(policy.serverContext(), policy.rolesOverlap(["admin"]));
1323
1395
  (/* @__PURE__ */ __commonJSMin(((exports, module) => {
1324
1396
  (function(root, factory) {
1325
1397
  if (typeof define === "function" && define.amd) define(factory);
@@ -2752,18 +2824,45 @@ function deserializeLogicalCondition(str) {
2752
2824
  }
2753
2825
  //#endregion
2754
2826
  //#region ../common/src/data/buildRebaseData.ts
2827
+ function createPrimaryKeyResolver(options) {
2828
+ const cache = /* @__PURE__ */ new Map();
2829
+ const warned = /* @__PURE__ */ new Set();
2830
+ return function primaryKeysFor(slug) {
2831
+ const cached = cache.get(slug);
2832
+ if (cached) return cached;
2833
+ const collection = options?.resolveCollection?.(slug);
2834
+ if (!collection) return [];
2835
+ const keys = resolvePrimaryKeys(collection);
2836
+ if (keys.length > 0) {
2837
+ cache.set(slug, keys);
2838
+ return keys;
2839
+ }
2840
+ if (!warned.has(slug)) {
2841
+ warned.add(slug);
2842
+ console.warn(`[rebase] Collection '${slug}' declares no primary key, so its rows have no address: detail links, caching and relations will not work for it. Mark the key property with \`isId\` in its collection config — the server logs which column to mark at boot, if its schema knows the key.`);
2843
+ }
2844
+ return keys;
2845
+ };
2846
+ }
2755
2847
  /**
2756
- * Convert a flat REST record (e.g. from RestFetchService) to Entity<M> format.
2757
- * Mirrors the client SDK's rowToEntity conversion.
2848
+ * Give a flat row the Entity view-model the admin renders.
2849
+ *
2850
+ * The address is *derived here* — it is not a column, and the row it came from
2851
+ * does not contain one. Rows carry exactly what the table has, with the types
2852
+ * Postgres returned; the id is this layer's invention, and this is the only
2853
+ * place it is minted.
2854
+ *
2855
+ * `primaryKeys` empty falls back to a literal `id` on the row: drivers other
2856
+ * than postgres still serve rows with one, and this keeps them working.
2758
2857
  */
2759
- function rowToEntity(row, slug) {
2858
+ function rowToEntity(row, slug, primaryKeys = []) {
2760
2859
  return {
2761
- id: row.id,
2860
+ id: primaryKeys.length > 0 ? buildCompositeId(row, primaryKeys) : row.id,
2762
2861
  path: slug,
2763
2862
  values: row
2764
2863
  };
2765
2864
  }
2766
- function createDriverAccessor(driver, slug) {
2865
+ function createDriverAccessor(driver, slug, getPks = () => []) {
2767
2866
  const accessor = {
2768
2867
  async find(params) {
2769
2868
  const filter = params?.where ? deserializeFilter(params.where) : void 0;
@@ -2796,7 +2895,7 @@ function createDriverAccessor(driver, slug) {
2796
2895
  hasMore = offset + rows.length < total;
2797
2896
  }
2798
2897
  return {
2799
- data: rows.map((row) => rowToEntity(row, slug)),
2898
+ data: rows.map((row) => rowToEntity(row, slug, getPks())),
2800
2899
  meta: {
2801
2900
  total,
2802
2901
  limit,
@@ -2810,7 +2909,7 @@ function createDriverAccessor(driver, slug) {
2810
2909
  path: slug,
2811
2910
  id
2812
2911
  });
2813
- return row ? rowToEntity(row, slug) : void 0;
2912
+ return row ? rowToEntity(row, slug, getPks()) : void 0;
2814
2913
  },
2815
2914
  async create(data, id) {
2816
2915
  return rowToEntity(await driver.save({
@@ -2818,15 +2917,22 @@ function createDriverAccessor(driver, slug) {
2818
2917
  values: data,
2819
2918
  id,
2820
2919
  status: "new"
2821
- }), slug);
2920
+ }), slug, getPks());
2822
2921
  },
2922
+ createMany: driver.saveMany ? async (data, options) => {
2923
+ return (await driver.saveMany({
2924
+ path: slug,
2925
+ rows: data,
2926
+ upsert: options?.upsert
2927
+ })).map((row) => rowToEntity(row, slug, getPks()));
2928
+ } : void 0,
2823
2929
  async update(id, data) {
2824
2930
  return rowToEntity(await driver.save({
2825
2931
  path: slug,
2826
2932
  values: data,
2827
2933
  id,
2828
2934
  status: "existing"
2829
- }), slug);
2935
+ }), slug, getPks());
2830
2936
  },
2831
2937
  async delete(id) {
2832
2938
  return driver.delete({ row: {
@@ -2855,7 +2961,7 @@ function createDriverAccessor(driver, slug) {
2855
2961
  searchString: params?.searchString,
2856
2962
  onUpdate: (entities) => {
2857
2963
  onUpdate({
2858
- data: entities.map((row) => rowToEntity(row, slug)),
2964
+ data: entities.map((row) => rowToEntity(row, slug, getPks())),
2859
2965
  meta: {
2860
2966
  total: entities.length,
2861
2967
  limit,
@@ -2871,7 +2977,7 @@ function createDriverAccessor(driver, slug) {
2871
2977
  return driver.listenOne({
2872
2978
  path: slug,
2873
2979
  id,
2874
- onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug) : void 0),
2980
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity(entity, slug, getPks()) : void 0),
2875
2981
  onError
2876
2982
  });
2877
2983
  } : void 0,
@@ -2910,12 +3016,13 @@ function createDriverAccessor(driver, slug) {
2910
3016
  * await data.products.create({ name: "Camera", price: 299 });
2911
3017
  * const { data: items } = await data.products.find({ where: { status: ["==", "published"] } });
2912
3018
  */
2913
- function buildRebaseData(driver) {
3019
+ function buildRebaseData(driver, options) {
2914
3020
  const cache = /* @__PURE__ */ new Map();
3021
+ const primaryKeysFor = createPrimaryKeyResolver(options);
2915
3022
  function getAccessor(slug) {
2916
3023
  let accessor = cache.get(slug);
2917
3024
  if (!accessor) {
2918
- accessor = createDriverAccessor(driver, slug);
3025
+ accessor = createDriverAccessor(driver, slug, () => primaryKeysFor(slug));
2919
3026
  cache.set(slug, accessor);
2920
3027
  }
2921
3028
  return accessor;
@@ -2928,8 +3035,9 @@ function buildRebaseData(driver) {
2928
3035
  } });
2929
3036
  }
2930
3037
  /**
2931
- * Unwrap a Entity into a flat row. `rowToEntity` stores the whole flat row
2932
- * (id included) under `.values`, so this is just that payload.
3038
+ * Unwrap a Entity back into the flat row it was built from. `rowToEntity` keeps
3039
+ * the row untouched under `.values` and derives `.id` alongside it, so dropping
3040
+ * the wrapper is the whole operation — the address was never part of the row.
2933
3041
  */
2934
3042
  function entityToRow(entity) {
2935
3043
  return entity.values;
@@ -3016,6 +3124,12 @@ function toSdkCollectionClient(snap) {
3016
3124
  async create(data, id) {
3017
3125
  return entityToRow(await snap.create(data, id));
3018
3126
  },
3127
+ async createMany(data, options) {
3128
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
3129
+ if (data.length === 0) return [];
3130
+ if (!snap.createMany) throw new Error("Bulk writes are not supported by this collection's data source. Fall back to create() per record.");
3131
+ return (await snap.createMany(data, options)).map(entityToRow);
3132
+ },
3019
3133
  async update(id, data) {
3020
3134
  return entityToRow(await snap.update(id, data));
3021
3135
  },
@@ -3191,7 +3305,7 @@ var BackendCollectionRegistry = class extends CollectionRegistry {
3191
3305
  //#endregion
3192
3306
  //#region src/collections/loader.ts
3193
3307
  function isCollectionFile(file) {
3194
- return (file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js";
3308
+ return (file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js";
3195
3309
  }
3196
3310
  async function importModule(filePath) {
3197
3311
  return await import(pathToFileURL(filePath).href);
@@ -3388,6 +3502,21 @@ function createRoutedRealtimeService(opts) {
3388
3502
  //#region src/api/errors.ts
3389
3503
  /** Tracks whether we've already shown the doctor hint (once per process). */
3390
3504
  var _schemaDriftHinted = false;
3505
+ /** 5-character SQLSTATE, e.g. `42501`, `23505`. */
3506
+ var SQLSTATE_RE = /^[0-9A-Z]{5}$/;
3507
+ /**
3508
+ * Walk the cause chain for the underlying database error, identified by a
3509
+ * 5-char SQLSTATE `code`. Drizzle wraps the pg error in `.cause`, and route
3510
+ * code sometimes wraps drizzle again, so the real error may sit several
3511
+ * levels down.
3512
+ */
3513
+ function extractDbError(error, depth = 0) {
3514
+ if (!error || typeof error !== "object" || depth > 8) return null;
3515
+ const e = error;
3516
+ if (typeof e.code === "string" && SQLSTATE_RE.test(e.code)) return e;
3517
+ if (e.cause && typeof e.cause === "object") return extractDbError(e.cause, depth + 1);
3518
+ return null;
3519
+ }
3391
3520
  /**
3392
3521
  * Extract the missing table or column name from a PG error.
3393
3522
  * PG 42P01 messages look like: 'relation "my_table" does not exist'
@@ -3410,12 +3539,23 @@ var ApiError = class ApiError extends Error {
3410
3539
  statusCode;
3411
3540
  code;
3412
3541
  details;
3413
- constructor(statusCode, code, message, details) {
3542
+ /**
3543
+ * Whether this outcome is a routine part of normal operation rather than
3544
+ * something an operator should look at. Expected errors log at debug; every
3545
+ * other operational error logs at warn.
3546
+ *
3547
+ * The motivating case is `POST /auth/refresh` with no session: clients
3548
+ * refresh on page load before they know whether one exists, so every
3549
+ * anonymous page view is a 401 — correct, and not worth a warning line.
3550
+ */
3551
+ expected;
3552
+ constructor(statusCode, code, message, details, expected = false) {
3414
3553
  super(message);
3415
3554
  this.name = "ApiError";
3416
3555
  this.statusCode = statusCode;
3417
3556
  this.code = code;
3418
3557
  this.details = details;
3558
+ this.expected = expected;
3419
3559
  }
3420
3560
  static badRequest(message, code = "BAD_REQUEST", details) {
3421
3561
  return new ApiError(400, code, message, details);
@@ -3423,6 +3563,13 @@ var ApiError = class ApiError extends Error {
3423
3563
  static unauthorized(message, code = "UNAUTHORIZED") {
3424
3564
  return new ApiError(401, code, message);
3425
3565
  }
3566
+ /**
3567
+ * A 401 that is a normal outcome, not an incident — logged at debug.
3568
+ * See {@link ApiError.expected}.
3569
+ */
3570
+ static unauthenticated(message, code = "UNAUTHORIZED") {
3571
+ return new ApiError(401, code, message, void 0, true);
3572
+ }
3426
3573
  static forbidden(message, code = "FORBIDDEN") {
3427
3574
  return new ApiError(403, code, message);
3428
3575
  }
@@ -3455,7 +3602,10 @@ var errorHandler = (err, c) => {
3455
3602
  const error = err;
3456
3603
  const reqId = typeof c.get === "function" ? c.get("requestId") : void 0;
3457
3604
  if (error instanceof ApiError || error.name === "ApiError") {
3458
- logger.warn(`⚠️ [API] ${c.req.method} ${c.req.path} ${error.statusCode} ${error.code}: ${error.message}` + (reqId ? ` [${reqId}]` : ""));
3605
+ const expected = error instanceof ApiError && error.expected;
3606
+ const line = `[API] ${c.req.method} ${c.req.path} → ${error.statusCode} ${error.code}: ${error.message}` + (reqId ? ` [${reqId}]` : "");
3607
+ if (expected) logger.debug(line);
3608
+ else logger.warn(`⚠️ ${line}`);
3459
3609
  return c.json({ error: {
3460
3610
  message: error.message,
3461
3611
  code: error.code || "INTERNAL_ERROR",
@@ -3472,26 +3622,31 @@ var errorHandler = (err, c) => {
3472
3622
  if (cause.code === "ECONNREFUSED" && !cause.address && Array.isArray(cause.errors)) resolvedCause = cause.errors.find((e) => e.address) || cause;
3473
3623
  else resolvedCause = cause;
3474
3624
  }
3475
- if (resolvedCause) {
3625
+ const dbError = extractDbError(error);
3626
+ if (resolvedCause && (resolvedCause.code === "ENETUNREACH" || resolvedCause.code === "ECONNREFUSED")) {
3476
3627
  const cause = resolvedCause;
3477
3628
  if (cause.code === "ENETUNREACH") logMessage = `Network unreachable. Cannot connect to database at ${cause.address}:${cause.port}.`;
3478
- else if (cause.code === "ECONNREFUSED") logMessage = `Connection refused to database at ${cause.address}:${cause.port}. Is PostgreSQL running?`;
3479
- else if (cause.code === "42703" || cause.code === "42P01") {
3480
- code = "SCHEMA_DRIFT";
3481
- logMessage = `Schema drift: ${cause.code === "42703" ? "column" : "table"} "${cause.table || cause.column || extractMissingIdentifier(cause.message) || "unknown"}" does not exist in the database. Run \`pnpm db:push\` to sync your schema, or \`pnpm db:migrate\` to apply pending migrations.`;
3482
- }
3629
+ else logMessage = `Connection refused to database at ${cause.address}:${cause.port}. Is PostgreSQL running?`;
3483
3630
  } else if ("code" in error && error.code === "ENETUNREACH") {
3484
3631
  const netErr = error;
3485
3632
  logMessage = `Network unreachable. Cannot connect to service at ${netErr.address}:${netErr.port}.`;
3486
- } else if ("code" in error && (error.code === "42703" || error.code === "42P01")) {
3633
+ } else if (dbError && (dbError.code === "42703" || dbError.code === "42P01")) {
3487
3634
  code = "SCHEMA_DRIFT";
3488
- const issue = error.code === "42703" ? "column" : "table";
3489
- const pgErr = error;
3490
- logMessage = `Schema drift: ${issue} "${pgErr.table || pgErr.column || extractMissingIdentifier(error.message) || "unknown"}" does not exist in the database. Run \`pnpm db:push\` to sync your schema, or \`pnpm db:migrate\` to apply pending migrations.`;
3491
- }
3492
- const causePg = error.cause && typeof error.cause === "object" ? error.cause : void 0;
3493
- const pgErrorCode = causePg?.code || error.code;
3494
- const isDbSchemaMismatch = code === "SCHEMA_DRIFT" || pgErrorCode === "42703" || pgErrorCode === "42P01";
3635
+ logMessage = `Schema drift: ${dbError.code === "42703" ? "column" : "table"} "${dbError.table || dbError.column || extractMissingIdentifier(dbError.message) || "unknown"}" does not exist in the database. Run \`pnpm db:push\` to sync your schema, or \`pnpm db:migrate\` to apply pending migrations.`;
3636
+ } else if (dbError) {
3637
+ const parts = [`[PG ${dbError.code}] ${dbError.message}`];
3638
+ if (dbError.detail) parts.push(`Detail: ${dbError.detail}`);
3639
+ if (dbError.hint) parts.push(`Hint: ${dbError.hint}`);
3640
+ if (dbError.table) parts.push(`Table: ${dbError.table}`);
3641
+ if (dbError.column) parts.push(`Column: ${dbError.column}`);
3642
+ if (dbError.constraint) parts.push(`Constraint: ${dbError.constraint}`);
3643
+ if (dbError.code === "42501") {
3644
+ code = "DB_PERMISSION_DENIED";
3645
+ parts.push(`The database rejected the statement for lack of privilege — usually a row-level security policy${dbError.table ? ` on "${dbError.table}"` : ""} denying this role, or a stale FORCE ROW LEVEL SECURITY flag binding the owner connection.`);
3646
+ }
3647
+ logMessage = parts.join(". ");
3648
+ }
3649
+ const isDbSchemaMismatch = code === "SCHEMA_DRIFT";
3495
3650
  if (isDbSchemaMismatch) {
3496
3651
  logger.warn(`⚠️ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` + (reqId ? ` [${reqId}]` : ""));
3497
3652
  if (!_schemaDriftHinted && process.env.NODE_ENV !== "production") {
@@ -3510,18 +3665,27 @@ var errorHandler = (err, c) => {
3510
3665
  ].join("\n"));
3511
3666
  }
3512
3667
  } else logger.error(`❌ [API] ${c.req.method} ${c.req.path} → ${statusCode} ${code}: ${logMessage}` + (reqId ? ` [${reqId}]` : ""));
3513
- if (!(isDbSchemaMismatch || statusCode < 500 && code === "BAD_REQUEST")) logger.error(String(error.stack || error));
3668
+ if (!(isDbSchemaMismatch || dbError !== null || statusCode < 500 && code === "BAD_REQUEST")) logger.error(String(error.stack || error));
3514
3669
  let clientMessage = "An unexpected error occurred";
3515
3670
  if (statusCode < 500 && error.message) clientMessage = error.message;
3516
3671
  else if (error instanceof ApiError || error.name === "ApiError") clientMessage = error.message;
3517
3672
  else if (code === "SCHEMA_DRIFT") {
3518
- const pgErr = causePg || error;
3519
- clientMessage = `Schema drift: ${pgErr.code === "42703" || pgErrorCode === "42703" ? "column" : "table"} "${pgErr.table || pgErr.column || extractMissingIdentifier(pgErr.message || error.message) || "unknown"}" does not exist. Run \`pnpm db:push\` to sync your schema.`;
3520
- } else if (code === "INTERNAL_ERROR") clientMessage = "Internal Server Error";
3673
+ const pgErr = dbError || error;
3674
+ clientMessage = `Schema drift: ${pgErr.code === "42703" ? "column" : "table"} "${pgErr.table || pgErr.column || extractMissingIdentifier(pgErr.message || error.message) || "unknown"}" does not exist. Run \`pnpm db:push\` to sync your schema.`;
3675
+ } else if (code === "DB_PERMISSION_DENIED") clientMessage = `Permission denied by the database${dbError?.table ? ` on "${dbError.table}"` : ""} (row-level security). Check the RLS policies for this table.`;
3676
+ else if (code === "INTERNAL_ERROR") clientMessage = "Internal Server Error";
3677
+ const dbDetails = dbError ? {
3678
+ dbCode: dbError.code,
3679
+ ...process.env.NODE_ENV !== "production" && {
3680
+ dbMessage: dbError.message,
3681
+ ...dbError.detail && { detail: dbError.detail },
3682
+ ...dbError.hint && { hint: dbError.hint }
3683
+ }
3684
+ } : void 0;
3521
3685
  return c.json({ error: {
3522
3686
  message: clientMessage,
3523
3687
  code,
3524
- ...error.details !== void 0 && { details: error.details },
3688
+ ...error.details !== void 0 ? { details: error.details } : dbDetails !== void 0 ? { details: dbDetails } : {},
3525
3689
  ...reqId && { requestId: reqId }
3526
3690
  } }, statusCode);
3527
3691
  };
@@ -3543,6 +3707,7 @@ function codeToStatus(code) {
3543
3707
  EMAIL_EXISTS: 409,
3544
3708
  ROLE_EXISTS: 409,
3545
3709
  SCHEMA_DRIFT: 500,
3710
+ DB_PERMISSION_DENIED: 500,
3546
3711
  INTERNAL_ERROR: 500,
3547
3712
  NOT_CONFIGURED: 503,
3548
3713
  SERVICE_UNAVAILABLE: 503
@@ -3668,6 +3833,41 @@ function parseQueryOptions(query) {
3668
3833
  return options;
3669
3834
  }
3670
3835
  //#endregion
3836
+ //#region src/api/rest/write-validation.ts
3837
+ /**
3838
+ * Reject a write naming a field the collection does not have.
3839
+ *
3840
+ * Unknown keys used to travel all the way into the INSERT, where Postgres
3841
+ * rejected them — so a typo came back as `column "titel" does not exist`,
3842
+ * phrased by the database, from a stack the caller cannot see, and only if the
3843
+ * column really was absent. It is a request problem and belongs in a 400.
3844
+ *
3845
+ * What counts as known:
3846
+ * - a declared property (for an introspected BaaS collection these *are* the
3847
+ * columns, so the set is exact);
3848
+ * - the foreign-key column behind an owning relation, which callers may write
3849
+ * directly instead of through the relation property;
3850
+ * - anything named in `options.extraKnownFields` — for an auth collection the
3851
+ * credential keys the auth adapter consumes before a row is ever built;
3852
+ * - nothing else. `id` in particular is not automatically known — see below.
3853
+ */
3854
+ function assertKnownWriteFields(values, collection, options) {
3855
+ if (collection.strictWrites === false) return;
3856
+ if (!collection.properties || Object.keys(collection.properties).length === 0) return;
3857
+ const known = new Set(Object.keys(collection.properties));
3858
+ for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.localKey) known.add(relation.localKey);
3859
+ for (const field of options?.extraKnownFields ?? []) known.add(field);
3860
+ const unknown = Object.keys(values).filter((key) => !known.has(key));
3861
+ if (unknown.length === 0) return;
3862
+ const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
3863
+ if (unknown.includes("id") && !known.has("id")) {
3864
+ const keys = Object.entries(collection.properties ?? {}).filter(([, prop]) => "isId" in prop && Boolean(prop.isId)).map(([name]) => `'${name}'`);
3865
+ const keyDesc = keys.length > 0 ? keys.join(" + ") : "its own key column";
3866
+ throw ApiError.badRequest(`${where}'${collection.slug}' has no 'id' column — it is keyed on ${keyDesc}. The \`id\` argument of \`create(data, id)\` is written as an \`id\` column, so for this collection put the key in \`data\` instead.`, "VALIDATION_UNKNOWN_FIELDS");
3867
+ }
3868
+ throw ApiError.badRequest(`${where}'${collection.slug}' has no field${unknown.length > 1 ? "s" : ""} ${unknown.map((f) => `'${f}'`).join(", ")}. Known fields: ${[...known].sort().map((f) => `'${f}'`).join(", ")}.`, "VALIDATION_UNKNOWN_FIELDS");
3869
+ }
3870
+ //#endregion
3671
3871
  //#region src/auth/api-keys/api-key-permission-guard.ts
3672
3872
  /**
3673
3873
  * Map an HTTP method string to an `ApiKeyOperation`.
@@ -3703,6 +3903,44 @@ function isOperationAllowed(permissions, collection, operation) {
3703
3903
  for (const perm of permissions) if ((perm.collection === "*" || perm.collection === collection) && perm.operations.includes(operation)) return true;
3704
3904
  return false;
3705
3905
  }
3906
+ /**
3907
+ * Check whether the given permissions array allows a storage operation.
3908
+ *
3909
+ * Storage, like functions, lives outside the collection namespace: an entry
3910
+ * with collection `"storage"` grants storage access for the listed
3911
+ * operations (GET routes → `read`, upload/folder/tus → `write`,
3912
+ * DELETE routes → `delete`), and the global `"*"` wildcard also matches.
3913
+ * A collection-scoped key gets no storage access at all.
3914
+ *
3915
+ * Matching is exactly {@link isOperationAllowed} with `"storage"` as the
3916
+ * resource name — delegated so the semantics can never drift.
3917
+ */
3918
+ function isStorageAllowed(permissions, operation) {
3919
+ return isOperationAllowed(permissions, "storage", operation);
3920
+ }
3921
+ /**
3922
+ * Check whether the given permissions array allows invoking a custom function.
3923
+ *
3924
+ * Functions live outside the collection namespace, so they have their own
3925
+ * resource names in the permission list:
3926
+ *
3927
+ * - `{ collection: "functions", ... }` grants every function
3928
+ * - `{ collection: "functions/<name>", ... }` grants a single function
3929
+ * - the global `"*"` wildcard (full-access keys) also matches
3930
+ *
3931
+ * The operation is derived from the HTTP method as usual (GET → read,
3932
+ * POST/PUT/PATCH → write, DELETE → delete). The functions index route is
3933
+ * checked as `functionName === ""`, which only the `"functions"` and `"*"`
3934
+ * entries can grant.
3935
+ *
3936
+ * A collection-scoped key (e.g. read-only on `events`) therefore can NOT
3937
+ * invoke functions — before this guard existed, any valid key could call
3938
+ * every function.
3939
+ */
3940
+ function isFunctionAllowed(permissions, functionName, operation) {
3941
+ for (const perm of permissions) if ((perm.collection === "*" || perm.collection === "functions" || functionName !== "" && perm.collection === `functions/${functionName}`) && perm.operations.includes(operation)) return true;
3942
+ return false;
3943
+ }
3706
3944
  //#endregion
3707
3945
  //#region src/api/rest/api-generator.ts
3708
3946
  /**
@@ -3724,15 +3962,19 @@ async function parseJsonBody(c) {
3724
3962
  * Lightweight REST API generator that leverages existing Rebase DataDriver.
3725
3963
  * Supports `include` query parameter for eager-loading relations via Drizzle.
3726
3964
  */
3965
+ /** Rows accepted by a single POST /<collection>/bulk. See `maxBulkRows`. */
3966
+ var DEFAULT_MAX_BULK_ROWS = 1e3;
3727
3967
  var RestApiGenerator = class {
3728
3968
  collections;
3729
3969
  router;
3730
3970
  driver;
3971
+ maxBulkRows;
3731
3972
  authAdapter;
3732
- constructor(collections, driver, authAdapter) {
3973
+ constructor(collections, driver, authAdapter, maxBulkRows = DEFAULT_MAX_BULK_ROWS) {
3733
3974
  this.collections = collections;
3734
3975
  this.driver = driver;
3735
3976
  this.authAdapter = authAdapter;
3977
+ this.maxBulkRows = maxBulkRows;
3736
3978
  this.router = new Hono();
3737
3979
  }
3738
3980
  /**
@@ -3757,6 +3999,17 @@ var RestApiGenerator = class {
3757
3999
  if (!isOperationAllowed(apiKey.permissions, collectionSlug, operation)) throw ApiError.forbidden(`API key does not have "${operation}" permission for collection "${collectionSlug}"`, "API_KEY_FORBIDDEN");
3758
4000
  }
3759
4001
  /**
4002
+ * API key permission check for nested paths. The operation targets the
4003
+ * LAST collection in the path (e.g. "posts" for /authors/1/posts), so
4004
+ * that is the slug the key must hold permission for — checking the
4005
+ * parent instead would let a key scoped to "authors" write "posts".
4006
+ * `parseSubPath` always yields a collectionPath ending in a collection
4007
+ * slug, never an id.
4008
+ */
4009
+ enforceSubcollectionApiKeyPermission(c, collectionPath) {
4010
+ this.enforceApiKeyPermission(c, collectionPath.split("/").pop());
4011
+ }
4012
+ /**
3760
4013
  * Get the request-scoped driver. Throws if none is set — never falls
3761
4014
  * back to the unscoped `this.driver` to avoid bypassing RLS/auth.
3762
4015
  */
@@ -3817,6 +4070,33 @@ var RestApiGenerator = class {
3817
4070
  if (!entity) throw ApiError.notFound("Entity not found");
3818
4071
  return c.json(entity);
3819
4072
  });
4073
+ this.router.post(`${basePath}/bulk`, async (c) => {
4074
+ this.enforceApiKeyPermission(c, collection.slug);
4075
+ const driver = this.getScopedDriver(c);
4076
+ const path = collection.slug;
4077
+ const body = await parseJsonBody(c);
4078
+ if (!Array.isArray(body?.rows)) throw ApiError.badRequest("Expected a JSON body of { rows: [...] }.", "INVALID_BULK_BODY");
4079
+ if (body.rows.length === 0) return c.json({
4080
+ data: [],
4081
+ meta: { written: 0 }
4082
+ });
4083
+ if (body.rows.some((row) => typeof row !== "object" || row === null || Array.isArray(row))) throw ApiError.badRequest("Every entry in `rows` must be an object.", "INVALID_BULK_BODY");
4084
+ if (body.upsert !== void 0 && typeof body.upsert !== "boolean") throw ApiError.badRequest("`upsert` must be a boolean.", "INVALID_BULK_BODY");
4085
+ const maxRows = this.maxBulkRows;
4086
+ if (body.rows.length > maxRows) throw ApiError.badRequest(`Too many rows: ${body.rows.length} exceeds the ${maxRows}-row limit for a single bulk write. Send it in chunks of ${maxRows} or fewer.`, "BULK_TOO_LARGE");
4087
+ if (!driver.saveMany) throw ApiError.badRequest("This collection's data source does not support bulk writes.", "BULK_UNSUPPORTED");
4088
+ body.rows.forEach((row, rowIndex) => assertKnownWriteFields(row, resolvedCollection, { rowIndex }));
4089
+ const rows = await driver.saveMany({
4090
+ path,
4091
+ rows: body.rows,
4092
+ collection: resolvedCollection,
4093
+ upsert: body.upsert === true
4094
+ });
4095
+ return c.json({
4096
+ data: rows.map((row) => this.formatResponse(row)),
4097
+ meta: { written: rows.length }
4098
+ });
4099
+ });
3820
4100
  this.router.post(basePath, async (c) => {
3821
4101
  try {
3822
4102
  this.enforceApiKeyPermission(c, collection.slug);
@@ -3824,8 +4104,14 @@ var RestApiGenerator = class {
3824
4104
  const path = collection.slug;
3825
4105
  const body = await parseJsonBody(c);
3826
4106
  const isAuth = collection.auth;
3827
- if ((isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true) && this.authAdapter?.prepareUserCreation) {
3828
- const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
4107
+ const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
4108
+ const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
4109
+ if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
4110
+ else {
4111
+ const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
4112
+ if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
4113
+ }
4114
+ if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
3829
4115
  const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
3830
4116
  const entity = await driver.save({
3831
4117
  path,
@@ -3838,7 +4124,7 @@ var RestApiGenerator = class {
3838
4124
  invitationSent: prepared.invitationSent
3839
4125
  } : this.authAdapter.finalizeUserCreation ? await this.authAdapter.finalizeUserCreation({
3840
4126
  id: entity.id,
3841
- values: entity.values
4127
+ values: entity
3842
4128
  }, prepared.clearPassword) : { invitationSent: false };
3843
4129
  const response = this.formatResponse(entity);
3844
4130
  return c.json({
@@ -3874,6 +4160,7 @@ var RestApiGenerator = class {
3874
4160
  collection: resolvedCollection
3875
4161
  })) throw ApiError.notFound("Entity not found");
3876
4162
  const body = await parseJsonBody(c);
4163
+ assertKnownWriteFields(body, resolvedCollection);
3877
4164
  const entity = await driver.save({
3878
4165
  path: getCollectionDataPath(collection),
3879
4166
  id: String(id),
@@ -3902,7 +4189,7 @@ var RestApiGenerator = class {
3902
4189
  if (!existingEntity) throw ApiError.notFound("Entity not found");
3903
4190
  await driver.delete({
3904
4191
  row: {
3905
- id: existingEntity.id,
4192
+ id: String(id),
3906
4193
  path: getCollectionDataPath(collection),
3907
4194
  values: existingEntity
3908
4195
  },
@@ -3950,7 +4237,7 @@ var RestApiGenerator = class {
3950
4237
  const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
3951
4238
  if (!parsed) return next();
3952
4239
  const driver = this.getScopedDriver(c);
3953
- this.enforceApiKeyPermission(c, c.req.param("parent"));
4240
+ this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
3954
4241
  if (parsed.id === "count") {
3955
4242
  const queryDict = c.req.queries();
3956
4243
  const queryOptions = parseQueryOptions(queryDict);
@@ -4002,7 +4289,7 @@ var RestApiGenerator = class {
4002
4289
  const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
4003
4290
  if (!parsed || parsed.id) return next();
4004
4291
  const driver = this.getScopedDriver(c);
4005
- this.enforceApiKeyPermission(c, c.req.param("parent"));
4292
+ this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4006
4293
  const body = await parseJsonBody(c);
4007
4294
  const entity = await driver.save({
4008
4295
  path: parsed.collectionPath,
@@ -4018,7 +4305,7 @@ var RestApiGenerator = class {
4018
4305
  const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
4019
4306
  if (!parsed || !parsed.id) return next();
4020
4307
  const driver = this.getScopedDriver(c);
4021
- this.enforceApiKeyPermission(c, c.req.param("parent"));
4308
+ this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4022
4309
  const body = await parseJsonBody(c);
4023
4310
  const entity = await driver.save({
4024
4311
  path: parsed.collectionPath,
@@ -4035,14 +4322,14 @@ var RestApiGenerator = class {
4035
4322
  const parsed = parseSubPath(`${c.req.param("parent")}/${c.req.param("parentId")}/${rest}`);
4036
4323
  if (!parsed || !parsed.id) return next();
4037
4324
  const driver = this.getScopedDriver(c);
4038
- this.enforceApiKeyPermission(c, c.req.param("parent"));
4325
+ this.enforceSubcollectionApiKeyPermission(c, parsed.collectionPath);
4039
4326
  const existingEntity = await driver.fetchOne({
4040
4327
  path: parsed.collectionPath,
4041
4328
  id: parsed.id
4042
4329
  });
4043
4330
  if (!existingEntity) throw ApiError.notFound("Entity not found");
4044
4331
  await driver.delete({ row: {
4045
- id: existingEntity.id,
4332
+ id: parsed.id,
4046
4333
  path: parsed.collectionPath,
4047
4334
  values: existingEntity
4048
4335
  } });
@@ -4165,6 +4452,15 @@ function safeCompare(a, b) {
4165
4452
  * 4. Sets `c.set("user", ...)` and `c.set("apiKey", ...)` for downstream use
4166
4453
  * 5. Scopes the DataDriver via `withAuth()` using the API key's service identity
4167
4454
  *
4455
+ * Authorization is double-gated for API keys: the key's own permission list
4456
+ * (checked by the REST generator) is one ceiling, and Postgres RLS is another,
4457
+ * independent one — `withAuth()` runs API-key requests as the restricted
4458
+ * `rebase_user` role like any other caller. An `admin` key passes RLS via the
4459
+ * injected `default_admin` policies; a non-admin key (roles `["service"]`,
4460
+ * uid `api-key:<id>`) only sees rows that a policy explicitly grants to the
4461
+ * `service` role or to the public. Owner-style policies
4462
+ * (`owner_id = auth.uid()`) never match an API key's synthetic uid.
4463
+ *
4168
4464
  * @module
4169
4465
  */
4170
4466
  /**
@@ -4204,10 +4500,10 @@ async function validateApiKey(c, token, options) {
4204
4500
  message: "API key has expired",
4205
4501
  code: "UNAUTHORIZED"
4206
4502
  } }, 401);
4207
- const userId = `api-key:${apiKey.id}`;
4503
+ const uid = `api-key:${apiKey.id}`;
4208
4504
  const roles = apiKey.admin ? ["admin", "service"] : ["service"];
4209
4505
  c.set("user", {
4210
- userId,
4506
+ uid,
4211
4507
  roles
4212
4508
  });
4213
4509
  const masked = {
@@ -4227,7 +4523,7 @@ async function validateApiKey(c, token, options) {
4227
4523
  c.set("apiKey", masked);
4228
4524
  try {
4229
4525
  const scopedDriver = await scopeDataDriver(driver, {
4230
- uid: userId,
4526
+ uid,
4231
4527
  roles
4232
4528
  });
4233
4529
  c.set("driver", scopedDriver);
@@ -4238,9 +4534,113 @@ async function validateApiKey(c, token, options) {
4238
4534
  code: "INTERNAL_ERROR"
4239
4535
  } }, 500);
4240
4536
  }
4241
- store.updateLastUsed(apiKey.id).catch(() => {});
4537
+ const lastTouch = lastUsedTouchedAt.get(apiKey.id);
4538
+ const now = Date.now();
4539
+ if (!lastTouch || now - lastTouch >= LAST_USED_DEBOUNCE_MS) {
4540
+ lastUsedTouchedAt.set(apiKey.id, now);
4541
+ store.updateLastUsed(apiKey.id).catch(() => {});
4542
+ }
4242
4543
  return true;
4243
4544
  }
4545
+ /** Per-process debounce state for last_used_at touches. */
4546
+ var lastUsedTouchedAt = /* @__PURE__ */ new Map();
4547
+ var LAST_USED_DEBOUNCE_MS = 6e4;
4548
+ /**
4549
+ * Shared 403 response for API-key permission denials outside the REST
4550
+ * generator (storage and functions guards). One envelope, one code — a
4551
+ * change to the error contract happens in one place.
4552
+ */
4553
+ function forbidApiKey(c, operation, resource, grantHint) {
4554
+ return c.json({ error: {
4555
+ message: `API key does not have "${operation}" permission for ${resource}. Grant it with a permission entry like { "collection": "${grantHint}", "operations": ["${operation}"] }.`,
4556
+ code: "API_KEY_FORBIDDEN"
4557
+ } }, 403);
4558
+ }
4559
+ /**
4560
+ * Permission guard for API-key requests to the storage router.
4561
+ *
4562
+ * Storage previously did not accept API keys at all (`rk_` tokens were
4563
+ * misparsed as JWTs and 401'd). Now that the pre-auth middleware
4564
+ * authenticates them, this guard decides what they may do: the key needs a
4565
+ * `"storage"` permission entry (or the global `"*"` wildcard) covering the
4566
+ * operation derived from the HTTP method. Requests not authenticated via an
4567
+ * API key pass through to the storage router's own auth gates.
4568
+ *
4569
+ * TUS resumable-upload routes (`/tus`, `/tus/:id`) are classified as `write`
4570
+ * for EVERY method: the protocol's offset check is a GET and its cancel is a
4571
+ * DELETE, but both are steps of an upload — a write-scoped key must be able
4572
+ * to complete (and abort) its own resumable upload without also holding
4573
+ * `read`/`delete` on stored objects.
4574
+ */
4575
+ function createStorageApiKeyGuard() {
4576
+ return async (c, next) => {
4577
+ const apiKey = c.get("apiKey");
4578
+ if (!apiKey) return next();
4579
+ const operation = /\/tus(\/|$)/.test(c.req.path) ? "write" : httpMethodToOperation(c.req.method);
4580
+ if (!isStorageAllowed(apiKey.permissions, operation)) return forbidApiKey(c, operation, "storage", "storage");
4581
+ return next();
4582
+ };
4583
+ }
4584
+ /**
4585
+ * Permission guard for API-key requests to the custom-functions router.
4586
+ *
4587
+ * The collection permission guard lives in the REST generator and never sees
4588
+ * function routes, so before this middleware existed ANY valid API key —
4589
+ * however narrowly scoped — could invoke every custom function. This guard
4590
+ * closes that: API-key requests must hold a `"functions"`/`"functions/<name>"`
4591
+ * permission entry (or the global `"*"` wildcard) for the derived operation.
4592
+ *
4593
+ * Non-API-key requests (JWT, service key, anonymous) pass through untouched —
4594
+ * functions decide their own auth for those, as before.
4595
+ *
4596
+ * @param mountPrefix - The path the functions router is mounted at
4597
+ * (e.g. `/api/functions`), used to extract the function
4598
+ * name from the request path.
4599
+ */
4600
+ function createFunctionApiKeyGuard(mountPrefix) {
4601
+ return async (c, next) => {
4602
+ const apiKey = c.get("apiKey");
4603
+ if (!apiKey) return next();
4604
+ const path = c.req.path;
4605
+ const idx = path.indexOf(mountPrefix);
4606
+ const rawName = (idx < 0 ? "" : path.slice(idx + mountPrefix.length)).split("/").filter(Boolean)[0] ?? "";
4607
+ let functionName;
4608
+ try {
4609
+ functionName = decodeURIComponent(rawName);
4610
+ } catch {
4611
+ functionName = rawName;
4612
+ }
4613
+ const operation = httpMethodToOperation(c.req.method);
4614
+ if (!isFunctionAllowed(apiKey.permissions, functionName, operation)) return forbidApiKey(c, operation, functionName ? `function "${functionName}"` : "the functions index", `functions${functionName ? `/${functionName}` : ""}`);
4615
+ return next();
4616
+ };
4617
+ }
4618
+ /**
4619
+ * Standalone pre-auth middleware for `rk_` bearer tokens.
4620
+ *
4621
+ * Routers whose auth gate is JWT-based (`requireAuth` / `createRequireAuth` —
4622
+ * the admin surfaces: admin users/roles, api-keys management, cron, backups,
4623
+ * logs, schema editor) don't know about API keys. Mounting this middleware in
4624
+ * front of them authenticates `rk_` tokens and populates the request context;
4625
+ * the downstream gates then see the already-resolved user and apply their
4626
+ * role checks (`requireAdmin`) as usual — so an `admin: true` key passes and
4627
+ * a non-admin key is rejected with 403.
4628
+ *
4629
+ * Requests without an `rk_` bearer token pass through untouched. An invalid,
4630
+ * revoked, or expired `rk_` token is rejected here (401) rather than falling
4631
+ * through to be misparsed as a JWT.
4632
+ */
4633
+ function createApiKeyPreAuth(options) {
4634
+ return async (c, next) => {
4635
+ if (c.get("apiKey")) return next();
4636
+ const authHeader = c.req.header("authorization") || "";
4637
+ const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
4638
+ if (!token || !isApiKeyToken(token)) return next();
4639
+ const result = await validateApiKey(c, token, options);
4640
+ if (result !== true) return result;
4641
+ return next();
4642
+ };
4643
+ }
4244
4644
  //#endregion
4245
4645
  //#region src/auth/middleware.ts
4246
4646
  /**
@@ -4254,14 +4654,12 @@ async function validateApiKey(c, token, options) {
4254
4654
  * need query-string tokens (e.g. storage file serving for `<img src>`).
4255
4655
  */
4256
4656
  var requireAuth = async (c, next) => {
4657
+ if (c.get("user")) return next();
4257
4658
  const authHeader = c.req.header("authorization");
4258
- if (!(authHeader && authHeader.startsWith("Bearer "))) {
4259
- if (c.get("user")) return next();
4260
- return c.json({ error: {
4261
- message: "Authorization header missing or invalid",
4262
- code: "UNAUTHORIZED"
4263
- } }, 401);
4264
- }
4659
+ if (!(authHeader && authHeader.startsWith("Bearer "))) return c.json({ error: {
4660
+ message: "Authorization header missing or invalid",
4661
+ code: "UNAUTHORIZED"
4662
+ } }, 401);
4265
4663
  const payload = verifyAccessToken(authHeader.substring(7));
4266
4664
  if (!payload) return c.json({ error: {
4267
4665
  message: "Invalid or expired token",
@@ -4284,18 +4682,16 @@ function createRequireAuth(options) {
4284
4682
  if (!options?.serviceKey) return requireAuth;
4285
4683
  const key = options.serviceKey;
4286
4684
  return async (c, next) => {
4685
+ if (c.get("user")) return next();
4287
4686
  const authHeader = c.req.header("authorization");
4288
- if (!(authHeader && authHeader.startsWith("Bearer "))) {
4289
- if (c.get("user")) return next();
4290
- return c.json({ error: {
4291
- message: "Authorization header missing or invalid",
4292
- code: "UNAUTHORIZED"
4293
- } }, 401);
4294
- }
4687
+ if (!(authHeader && authHeader.startsWith("Bearer "))) return c.json({ error: {
4688
+ message: "Authorization header missing or invalid",
4689
+ code: "UNAUTHORIZED"
4690
+ } }, 401);
4295
4691
  const token = authHeader.substring(7);
4296
4692
  if (safeCompare(token, key)) {
4297
4693
  c.set("user", {
4298
- userId: "service",
4694
+ uid: "service",
4299
4695
  roles: ["admin"]
4300
4696
  });
4301
4697
  return next();
@@ -4374,11 +4770,11 @@ function createAuthMiddleware(options) {
4374
4770
  if (validator) try {
4375
4771
  const authResult = await validator(c);
4376
4772
  if (authResult && typeof authResult === "object") {
4377
- const id = ("userId" in authResult ? authResult.userId : void 0) || ("uid" in authResult ? authResult.uid : void 0);
4773
+ const id = ("uid" in authResult ? authResult.uid : void 0) || ("uid" in authResult ? authResult.uid : void 0);
4378
4774
  if (id) {
4379
4775
  const roles = authResult.roles || [];
4380
4776
  c.set("user", {
4381
- userId: id,
4777
+ uid: id,
4382
4778
  roles
4383
4779
  });
4384
4780
  const user = {
@@ -4393,7 +4789,7 @@ function createAuthMiddleware(options) {
4393
4789
  }));
4394
4790
  } else if (authResult === true) {
4395
4791
  c.set("user", {
4396
- userId: "default",
4792
+ uid: "default",
4397
4793
  roles: []
4398
4794
  });
4399
4795
  c.set("driver", await scopeDataDriver(driver, {
@@ -4416,7 +4812,7 @@ function createAuthMiddleware(options) {
4416
4812
  const token = authHeader.substring(7);
4417
4813
  if (serviceKey && safeCompare(token, serviceKey)) {
4418
4814
  c.set("user", {
4419
- userId: "service",
4815
+ uid: "service",
4420
4816
  roles: ["admin"]
4421
4817
  });
4422
4818
  try {
@@ -4443,7 +4839,7 @@ function createAuthMiddleware(options) {
4443
4839
  c.set("user", payload);
4444
4840
  try {
4445
4841
  const user = {
4446
- uid: payload.userId,
4842
+ uid: payload.uid,
4447
4843
  roles: payload.roles
4448
4844
  };
4449
4845
  c.set("driver", await scopeDataDriver(driver, user));
@@ -4524,7 +4920,7 @@ var publicObjectAuth = async (c, next) => {
4524
4920
  const idx = fullPath.indexOf(prefix);
4525
4921
  const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
4526
4922
  if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
4527
- userId: "public",
4923
+ uid: "public",
4528
4924
  roles: ["public"]
4529
4925
  });
4530
4926
  return next();
@@ -4532,8 +4928,25 @@ var publicObjectAuth = async (c, next) => {
4532
4928
  /**
4533
4929
  * Helper to match paths for scoped file tokens.
4534
4930
  * Matches exact paths or prefixes (exact folder prefixes or ending with /).
4931
+ *
4932
+ * The traversal check is defence in depth, not the load-bearing defence.
4933
+ *
4934
+ * A prefix comparison is a lie for any path containing `..`: `user1/../user2/x`
4935
+ * starts with `user1/`, so a token scoped to `user1/` would authorize reading
4936
+ * user2's file — and the storage controller would not object, because its own
4937
+ * guard stops a path escaping the *bucket*, and that one resolves to `user2/x`,
4938
+ * comfortably inside it. The grant is what would be escaped, and this is the
4939
+ * only layer that knows what was granted.
4940
+ *
4941
+ * Nothing arrives here in that shape today: the URL parser resolves `..` (and
4942
+ * `%2e%2e`, which the WHATWG spec treats as a dot for normalization) before
4943
+ * Hono routes the request, so this comparison already runs on a resolved path.
4944
+ * But that is a guarantee of the runtime rather than of this code, and it is
4945
+ * one line to not depend on it. The same rule already guards the public-object
4946
+ * path — see `isPublicStoragePath`.
4535
4947
  */
4536
4948
  function isPathMatch(requested, allowed) {
4949
+ if (requested.split("/").some((seg) => seg === "..")) return false;
4537
4950
  if (requested === allowed) return true;
4538
4951
  if (allowed.endsWith("/")) return requested.startsWith(allowed);
4539
4952
  return requested.startsWith(allowed + "/");
@@ -4573,7 +4986,7 @@ var fileTokenAuth = async (c, next) => {
4573
4986
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
4574
4987
  if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
4575
4988
  c.set("user", {
4576
- userId: "download-token",
4989
+ uid: "download-token",
4577
4990
  roles: ["reader"]
4578
4991
  });
4579
4992
  return next();
@@ -4597,7 +5010,7 @@ var fileTokenAuth = async (c, next) => {
4597
5010
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
4598
5011
  if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
4599
5012
  c.set("user", {
4600
- userId: "download-token",
5013
+ uid: "download-token",
4601
5014
  roles: ["reader"]
4602
5015
  });
4603
5016
  return next();
@@ -4646,7 +5059,7 @@ function createAdapterAuthMiddleware(options) {
4646
5059
  }
4647
5060
  if (authenticatedUser) {
4648
5061
  c.set("user", {
4649
- userId: authenticatedUser.uid,
5062
+ uid: authenticatedUser.uid,
4650
5063
  email: authenticatedUser.email,
4651
5064
  roles: authenticatedUser.roles
4652
5065
  });
@@ -5357,60 +5770,118 @@ function resolveAuthHooks(hooks) {
5357
5770
  };
5358
5771
  }
5359
5772
  //#endregion
5773
+ //#region src/auth/rate-limit-store.ts
5774
+ /**
5775
+ * The default store: a sliding window in this process's memory.
5776
+ *
5777
+ * Sliding rather than fixed: a fixed window lets a caller spend its whole
5778
+ * allowance in the last second of one window and again in the first second of
5779
+ * the next, which is twice the limit over an instant. Timestamps outside the
5780
+ * window are dropped on read, and swept periodically so an idle key does not
5781
+ * pin its array forever.
5782
+ *
5783
+ * Per-process, so N replicas enforce N times the limit between them. That is
5784
+ * the honest reason {@link RateLimitStore} exists.
5785
+ */
5786
+ var MemoryRateLimitStore = class {
5787
+ store = /* @__PURE__ */ new Map();
5788
+ cleanupInterval;
5789
+ constructor(sweepMs = 900 * 1e3) {
5790
+ this.cleanupInterval = setInterval(() => this.sweep(sweepMs), sweepMs);
5791
+ this.cleanupInterval.unref?.();
5792
+ }
5793
+ async hit(key, windowMs, limit) {
5794
+ const now = Date.now();
5795
+ const timestamps = (this.store.get(key) ?? []).filter((t) => now - t < windowMs);
5796
+ if (timestamps.length >= limit) {
5797
+ this.store.set(key, timestamps);
5798
+ return {
5799
+ allowed: false,
5800
+ remaining: 0,
5801
+ retryAfterMs: Math.max(0, timestamps[0] + windowMs - now)
5802
+ };
5803
+ }
5804
+ timestamps.push(now);
5805
+ this.store.set(key, timestamps);
5806
+ return {
5807
+ allowed: true,
5808
+ remaining: limit - timestamps.length,
5809
+ retryAfterMs: 0
5810
+ };
5811
+ }
5812
+ sweep(windowMs) {
5813
+ const now = Date.now();
5814
+ for (const [key, timestamps] of this.store.entries()) {
5815
+ const live = timestamps.filter((t) => now - t < windowMs);
5816
+ if (live.length === 0) this.store.delete(key);
5817
+ else this.store.set(key, live);
5818
+ }
5819
+ }
5820
+ dispose() {
5821
+ if (this.cleanupInterval) clearInterval(this.cleanupInterval);
5822
+ this.cleanupInterval = void 0;
5823
+ this.store.clear();
5824
+ }
5825
+ };
5826
+ //#endregion
5360
5827
  //#region src/auth/rate-limiter.ts
5361
5828
  /**
5829
+ * Resolve the number of trusted proxy hops from an explicit option, the
5830
+ * `TRUSTED_PROXY_HOPS` env var, or the default of 1.
5831
+ */
5832
+ function resolveTrustedProxyHops(optionValue) {
5833
+ if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue >= 0) return Math.floor(optionValue);
5834
+ const fromEnv = Number(process.env.TRUSTED_PROXY_HOPS);
5835
+ if (Number.isFinite(fromEnv) && fromEnv >= 0) return Math.floor(fromEnv);
5836
+ return 1;
5837
+ }
5838
+ /**
5362
5839
  * Create a rate-limiting middleware.
5363
5840
  *
5364
- * Uses a sliding window algorithm: only timestamps within the last
5365
- * `windowMs` milliseconds are counted. Old entries are garbage-collected
5366
- * every `windowMs` to prevent unbounded memory growth.
5841
+ * Uses a sliding window: only hits within the last `windowMs` are counted.
5367
5842
  */
5368
5843
  function createRateLimiter(options = {}) {
5369
- const { windowMs = 900 * 1e3, limit = 100, keyGenerator = defaultKeyGenerator, message = "Too many requests, please try again later." } = options;
5370
- const store = /* @__PURE__ */ new Map();
5371
- const cleanupInterval = setInterval(() => {
5372
- const now = Date.now();
5373
- for (const [key, entry] of store.entries()) {
5374
- entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
5375
- if (entry.timestamps.length === 0) store.delete(key);
5376
- }
5377
- }, windowMs);
5378
- if (cleanupInterval.unref) cleanupInterval.unref();
5844
+ const trustedProxyHops = resolveTrustedProxyHops(options.trustedProxyHops);
5845
+ const { windowMs = 900 * 1e3, limit = 100, keyGenerator = (c) => defaultKeyGenerator(c, trustedProxyHops), message = "Too many requests, please try again later.", store = new MemoryRateLimitStore(windowMs), resolveLimit } = options;
5379
5846
  return async (c, next) => {
5380
- const key = keyGenerator(c);
5381
- const now = Date.now();
5382
- let entry = store.get(key);
5383
- if (!entry) {
5384
- entry = { timestamps: [] };
5385
- store.set(key, entry);
5386
- }
5387
- entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
5388
- if (entry.timestamps.length >= limit) {
5389
- const retryAfterMs = entry.timestamps[0] + windowMs - now;
5390
- const retryAfterSec = Math.ceil(retryAfterMs / 1e3);
5847
+ const effectiveLimit = resolveLimit ? resolveLimit(c) : limit;
5848
+ if (effectiveLimit === null) return next();
5849
+ const activeLimit = effectiveLimit ?? limit;
5850
+ const decision = await store.hit(keyGenerator(c), windowMs, activeLimit);
5851
+ c.header("X-RateLimit-Limit", String(activeLimit));
5852
+ c.header("X-RateLimit-Remaining", String(decision.remaining));
5853
+ if (!decision.allowed) {
5854
+ const retryAfterSec = Math.ceil(decision.retryAfterMs / 1e3);
5391
5855
  c.header("Retry-After", String(retryAfterSec));
5392
- c.header("X-RateLimit-Limit", String(limit));
5393
- c.header("X-RateLimit-Remaining", "0");
5394
- c.header("X-RateLimit-Reset", String(Math.ceil((now + retryAfterMs) / 1e3)));
5856
+ c.header("X-RateLimit-Reset", String(Math.ceil((Date.now() + decision.retryAfterMs) / 1e3)));
5395
5857
  return c.json({ error: {
5396
5858
  message,
5397
5859
  code: "RATE_LIMITED"
5398
5860
  } }, 429);
5399
5861
  }
5400
- entry.timestamps.push(now);
5401
- c.header("X-RateLimit-Limit", String(limit));
5402
- c.header("X-RateLimit-Remaining", String(limit - entry.timestamps.length));
5403
5862
  return next();
5404
5863
  };
5405
5864
  }
5406
5865
  /**
5407
5866
  * Default key generator: extract client IP from standard headers.
5867
+ *
5868
+ * `X-Forwarded-For` is a client-writable header; only the entries appended by
5869
+ * trusted reverse proxies can be believed. With `trustedProxyHops` proxies in
5870
+ * front, each appends the address it saw, so the real client IP is the
5871
+ * `trustedProxyHops`-th entry from the right — everything further left is
5872
+ * client-supplied and must be ignored. This is what prevents a caller from
5873
+ * spoofing `X-Forwarded-For` to spread its requests across many rate-limit keys.
5874
+ *
5875
+ * With `trustedProxyHops === 0` (server exposed directly, no proxy),
5876
+ * `X-Forwarded-For` is not trusted at all and `X-Real-IP` is used instead.
5408
5877
  */
5409
- function defaultKeyGenerator(c) {
5410
- const forwardedFor = c.req.header("x-forwarded-for");
5411
- if (forwardedFor) {
5412
- const ips = forwardedFor.split(",");
5413
- return ips[ips.length - 1].trim();
5878
+ function defaultKeyGenerator(c, trustedProxyHops = resolveTrustedProxyHops()) {
5879
+ if (trustedProxyHops > 0) {
5880
+ const forwardedFor = c.req.header("x-forwarded-for");
5881
+ if (forwardedFor) {
5882
+ const ips = forwardedFor.split(",").map((s) => s.trim()).filter(Boolean);
5883
+ if (ips.length > 0) return ips[Math.max(0, ips.length - trustedProxyHops)];
5884
+ }
5414
5885
  }
5415
5886
  return c.req.header("x-real-ip") || "unknown";
5416
5887
  }
@@ -5445,54 +5916,65 @@ function apiKeyKeyGenerator(c) {
5445
5916
  return defaultKeyGenerator(c);
5446
5917
  }
5447
5918
  /**
5919
+ * Rate limiting for the data API.
5920
+ *
5921
+ * Every request is in exactly one bucket, resolved most-specific first: an API
5922
+ * key by its id, a signed-in user by their uid, anyone else by IP. Previously
5923
+ * only the first of those was limited at all — the middleware returned early
5924
+ * for any request without an API key — so JWT and anonymous traffic to
5925
+ * `/api/data/*` was unbounded, which is most of the traffic a BaaS gets.
5926
+ *
5927
+ * Per bucket, not per route: the point is to bound what one caller costs, and
5928
+ * they can spend it wherever they like.
5929
+ *
5930
+ * The defaults are deliberately loose. This is a floor against a runaway client
5931
+ * or a naive scraper, not a quota — a deployment that wants real quotas should
5932
+ * set them, and one that already has a proxy doing this should pass
5933
+ * `enabled: false` rather than pay for it twice.
5934
+ */
5935
+ function createDataRateLimiter(config = {}) {
5936
+ const { windowMs = 900 * 1e3, apiKey: apiKeyLimit = 1e3, user: userLimit = 1e3, anonymous: anonLimit = 300, store = new MemoryRateLimitStore(windowMs) } = config;
5937
+ return createRateLimiter({
5938
+ windowMs,
5939
+ store,
5940
+ message: "Too many requests, please try again later.",
5941
+ keyGenerator: (c) => {
5942
+ const key = c.get("apiKey");
5943
+ if (key) return `api-key:${key.id}`;
5944
+ const user = c.get("user");
5945
+ if (user?.uid && user.uid !== "anon") return `user:${user.uid}`;
5946
+ return `ip:${defaultKeyGenerator(c)}`;
5947
+ },
5948
+ resolveLimit: (c) => {
5949
+ const key = c.get("apiKey");
5950
+ if (key) return key.rate_limit ?? apiKeyLimit;
5951
+ const user = c.get("user");
5952
+ if (user?.uid && user.uid !== "anon") return userLimit;
5953
+ return anonLimit;
5954
+ }
5955
+ });
5956
+ }
5957
+ /**
5448
5958
  * Create a rate limiter specifically for API key requests.
5449
5959
  *
5450
- * When a request is authenticated via an API key that has a `rate_limit`
5451
- * configured, this limiter enforces per-key limits using the key's ID
5452
- * as the rate limit bucket.
5960
+ * @deprecated Use {@link createDataRateLimiter}, which limits signed-in users
5961
+ * and anonymous callers too. This one skips every request that is not
5962
+ * API-key-authenticated, which was most of them.
5453
5963
  *
5454
5964
  * @param defaultLimit - Fallback limit when the key has no `rate_limit` set.
5455
5965
  * @param windowMs - Time window in milliseconds (default: 15 minutes).
5456
5966
  */
5457
5967
  function createApiKeyRateLimiter(defaultLimit = 1e3, windowMs = 900 * 1e3) {
5458
- const store = /* @__PURE__ */ new Map();
5459
- const cleanupInterval = setInterval(() => {
5460
- const now = Date.now();
5461
- for (const [key, entry] of store.entries()) {
5462
- entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
5463
- if (entry.timestamps.length === 0) store.delete(key);
5464
- }
5465
- }, windowMs);
5466
- if (cleanupInterval.unref) cleanupInterval.unref();
5467
- return async (c, next) => {
5468
- const apiKey = c.get("apiKey");
5469
- if (!apiKey) return next();
5470
- const limit = apiKey.rate_limit ?? defaultLimit;
5471
- const key = `api-key:${apiKey.id}`;
5472
- const now = Date.now();
5473
- let entry = store.get(key);
5474
- if (!entry) {
5475
- entry = { timestamps: [] };
5476
- store.set(key, entry);
5477
- }
5478
- entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
5479
- if (entry.timestamps.length >= limit) {
5480
- const retryAfterMs = entry.timestamps[0] + windowMs - now;
5481
- const retryAfterSec = Math.ceil(retryAfterMs / 1e3);
5482
- c.header("Retry-After", String(retryAfterSec));
5483
- c.header("X-RateLimit-Limit", String(limit));
5484
- c.header("X-RateLimit-Remaining", "0");
5485
- c.header("X-RateLimit-Reset", String(Math.ceil((now + retryAfterMs) / 1e3)));
5486
- return c.json({ error: {
5487
- message: "API key rate limit exceeded, please try again later.",
5488
- code: "RATE_LIMITED"
5489
- } }, 429);
5968
+ return createRateLimiter({
5969
+ windowMs,
5970
+ message: "API key rate limit exceeded, please try again later.",
5971
+ keyGenerator: apiKeyKeyGenerator,
5972
+ resolveLimit: (c) => {
5973
+ const apiKey = c.get("apiKey");
5974
+ if (!apiKey) return null;
5975
+ return apiKey.rate_limit ?? defaultLimit;
5490
5976
  }
5491
- entry.timestamps.push(now);
5492
- c.header("X-RateLimit-Limit", String(limit));
5493
- c.header("X-RateLimit-Remaining", String(limit - entry.timestamps.length));
5494
- return next();
5495
- };
5977
+ });
5496
5978
  }
5497
5979
  //#endregion
5498
5980
  //#region ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/core/core.js
@@ -9319,7 +9801,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9319
9801
  const body = await c.req.json().catch(() => ({}));
9320
9802
  const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
9321
9803
  const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
9322
- const user = await authRepo.getUserById(userCtx.userId);
9804
+ const user = await authRepo.getUserById(userCtx.uid);
9323
9805
  if (!user) throw ApiError.notFound("User not found");
9324
9806
  const { secret, uri } = generateTotpSecret(issuer, user.email);
9325
9807
  const encryptedSecret = encryptTotpSecret(secret);
@@ -9353,7 +9835,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9353
9835
  code: string().length(6, "Code must be 6 digits")
9354
9836
  }), await c.req.json());
9355
9837
  const factor = await authRepo.getMfaFactorById(factorId);
9356
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9838
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9357
9839
  if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
9358
9840
  if (!verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
9359
9841
  await authRepo.verifyMfaFactor(factorId);
@@ -9371,7 +9853,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9371
9853
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9372
9854
  const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
9373
9855
  const factor = await authRepo.getMfaFactorById(factorId);
9374
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9856
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9375
9857
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
9376
9858
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
9377
9859
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
@@ -9395,19 +9877,19 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9395
9877
  const challenge = await authRepo.getMfaChallengeById(challengeId);
9396
9878
  if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
9397
9879
  const factor = await authRepo.getMfaFactorById(challenge.factorId);
9398
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9880
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9399
9881
  let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
9400
9882
  if (!isValid) {
9401
9883
  const codeHash = hashRecoveryCode(code);
9402
- isValid = await authRepo.useRecoveryCode(userCtx.userId, codeHash);
9884
+ isValid = await authRepo.useRecoveryCode(userCtx.uid, codeHash);
9403
9885
  }
9404
9886
  if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
9405
9887
  await authRepo.verifyMfaChallenge(challengeId);
9406
- const roleIds = (await authRepo.getUserRoles(userCtx.userId)).map((r) => r.id);
9407
- const accessToken = generateAccessToken(userCtx.userId, roleIds, "aal2");
9888
+ const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
9889
+ const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
9408
9890
  const refreshToken = generateRefreshToken();
9409
- await authRepo.createRefreshToken(userCtx.userId, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
9410
- if (ops.onMfaVerified) ops.onMfaVerified(userCtx.userId, factor.id).catch((err) => {
9891
+ await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
9892
+ if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
9411
9893
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
9412
9894
  });
9413
9895
  let mfaResponse = { tokens: {
@@ -9415,7 +9897,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9415
9897
  refreshToken,
9416
9898
  accessTokenExpiresAt: getAccessTokenExpiry()
9417
9899
  } };
9418
- if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.userId);
9900
+ if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
9419
9901
  return c.json(mfaResponse);
9420
9902
  });
9421
9903
  /**
@@ -9425,7 +9907,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9425
9907
  router.get("/mfa/factors", requireAuth, async (c) => {
9426
9908
  const userCtx = c.get("user");
9427
9909
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9428
- const factors = await authRepo.getMfaFactors(userCtx.userId);
9910
+ const factors = await authRepo.getMfaFactors(userCtx.uid);
9429
9911
  return c.json({ factors: factors.map((f) => ({
9430
9912
  id: f.id,
9431
9913
  factorType: f.factorType,
@@ -9444,9 +9926,9 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9444
9926
  if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
9445
9927
  const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
9446
9928
  const factor = await authRepo.getMfaFactorById(factorId);
9447
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9448
- await authRepo.deleteMfaFactor(factorId, userCtx.userId);
9449
- if (!await authRepo.hasVerifiedMfaFactors(userCtx.userId)) await authRepo.deleteAllRecoveryCodes(userCtx.userId);
9929
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9930
+ await authRepo.deleteMfaFactor(factorId, userCtx.uid);
9931
+ if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) await authRepo.deleteAllRecoveryCodes(userCtx.uid);
9450
9932
  return c.json({
9451
9933
  success: true,
9452
9934
  message: "MFA factor removed"
@@ -9548,9 +10030,9 @@ function mountSessionRoutes(opts) {
9548
10030
  clearRefreshCookie(c, config.cookieAuth);
9549
10031
  const authHeader = c.req.header("authorization");
9550
10032
  if (ops.afterLogout && authHeader?.startsWith("Bearer ")) {
9551
- const { verifyAccessToken } = await import("./jwt-BwIn8xmk.js").then((n) => n.c);
10033
+ const { verifyAccessToken } = await import("./jwt-B3zjddCa.js").then((n) => n.c);
9552
10034
  const payload = verifyAccessToken(authHeader.substring(7));
9553
- if (payload) ops.afterLogout(payload.userId).catch((err) => {
10035
+ if (payload) ops.afterLogout(payload.uid).catch((err) => {
9554
10036
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
9555
10037
  });
9556
10038
  }
@@ -9565,7 +10047,7 @@ function mountSessionRoutes(opts) {
9565
10047
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9566
10048
  const currentRefreshToken = c.req.header("x-refresh-token");
9567
10049
  const currentTokenHash = currentRefreshToken ? hashRefreshToken(currentRefreshToken) : null;
9568
- const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.userId)).map((s) => ({
10050
+ const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.uid)).map((s) => ({
9569
10051
  id: s.id,
9570
10052
  userAgent: s.userAgent,
9571
10053
  ipAddress: s.ipAddress,
@@ -9581,7 +10063,7 @@ function mountSessionRoutes(opts) {
9581
10063
  router.delete("/sessions", requireAuth, async (c) => {
9582
10064
  const userCtx = c.get("user");
9583
10065
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9584
- await authRepo.deleteAllRefreshTokensForUser(userCtx.userId);
10066
+ await authRepo.deleteAllRefreshTokensForUser(userCtx.uid);
9585
10067
  return c.json({
9586
10068
  success: true,
9587
10069
  message: "All sessions revoked successfully"
@@ -9596,7 +10078,7 @@ function mountSessionRoutes(opts) {
9596
10078
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9597
10079
  const id = c.req.param("id");
9598
10080
  if (!id) throw ApiError.badRequest("Session ID is required", "INVALID_INPUT");
9599
- await authRepo.deleteRefreshTokenById(id, userCtx.userId);
10081
+ await authRepo.deleteRefreshTokenById(id, userCtx.uid);
9600
10082
  return c.json({
9601
10083
  success: true,
9602
10084
  message: "Session revoked successfully"
@@ -9609,7 +10091,7 @@ function mountSessionRoutes(opts) {
9609
10091
  router.get("/me", requireAuth, async (c) => {
9610
10092
  const userCtx = c.get("user");
9611
10093
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9612
- const result = await authRepo.getUserWithRoles(userCtx.userId);
10094
+ const result = await authRepo.getUserWithRoles(userCtx.uid);
9613
10095
  if (!result) throw ApiError.notFound("User not found");
9614
10096
  return c.json({ user: {
9615
10097
  uid: result.user.id,
@@ -9649,11 +10131,11 @@ function mountSessionRoutes(opts) {
9649
10131
  const userCtx = c.get("user");
9650
10132
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9651
10133
  const { displayName, photoURL } = parseBody(updateProfileSchema, await c.req.json());
9652
- if (!await authRepo.updateUser(userCtx.userId, {
10134
+ if (!await authRepo.updateUser(userCtx.uid, {
9653
10135
  displayName: displayName !== void 0 ? displayName : void 0,
9654
10136
  photoUrl: photoURL !== void 0 ? photoURL : void 0
9655
10137
  })) throw ApiError.notFound("User not found");
9656
- const result = await authRepo.getUserWithRoles(userCtx.userId);
10138
+ const result = await authRepo.getUserWithRoles(userCtx.uid);
9657
10139
  if (!result) throw ApiError.notFound("User not found");
9658
10140
  return c.json({ user: {
9659
10141
  uid: result.user.id,
@@ -9715,7 +10197,7 @@ function mountSessionRoutes(opts) {
9715
10197
  router.post("/anonymous/link", requireAuth, async (c) => {
9716
10198
  const userCtx = c.get("user");
9717
10199
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9718
- const user = await authRepo.getUserById(userCtx.userId);
10200
+ const user = await authRepo.getUserById(userCtx.uid);
9719
10201
  if (!user?.isAnonymous) throw ApiError.badRequest("User is not anonymous", "NOT_ANONYMOUS");
9720
10202
  const { email, password } = parseBody(linkSchema, await c.req.json());
9721
10203
  const passwordValidation = ops.validatePasswordStrength(password);
@@ -9804,7 +10286,7 @@ function mountMagicLinkRoutes(deps) {
9804
10286
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
9805
10287
  if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
9806
10288
  await authRepo.markMagicLinkTokenUsed(tokenHash);
9807
- const user = await authRepo.getUserById(storedToken.userId);
10289
+ const user = await authRepo.getUserById(storedToken.uid);
9808
10290
  if (!user) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
9809
10291
  if (!user.emailVerified) {
9810
10292
  await authRepo.setEmailVerified(user.id, true);
@@ -9862,11 +10344,11 @@ function createAuthRoutes(config) {
9862
10344
  * Errors are caught and logged — the untransformed response is returned
9863
10345
  * as a graceful fallback so auth never breaks due to a hook failure.
9864
10346
  */
9865
- async function applyTransformHook(response, method, request, userId) {
10347
+ async function applyTransformHook(response, method, request, uid) {
9866
10348
  if (!ops.transformAuthResponse) return response;
9867
10349
  try {
9868
10350
  return await ops.transformAuthResponse(response, {
9869
- userId,
10351
+ uid,
9870
10352
  method,
9871
10353
  request
9872
10354
  });
@@ -9893,7 +10375,7 @@ function createAuthRoutes(config) {
9893
10375
  oldPassword: string().min(1, "Old password is required").max(128),
9894
10376
  newPassword: string().min(1, "New password is required").max(128)
9895
10377
  });
9896
- const refreshSchema = object({ refreshToken: config.cookieAuth ? string().optional() : string().min(1, "Refresh token is required") });
10378
+ const refreshSchema = object({ refreshToken: string().min(1).optional() });
9897
10379
  object({ refreshToken: string().optional() });
9898
10380
  object({
9899
10381
  displayName: string().max(255).optional(),
@@ -9944,23 +10426,23 @@ function createAuthRoutes(config) {
9944
10426
  /**
9945
10427
  * Helper to generate and store session tokens
9946
10428
  */
9947
- async function createSessionAndTokens(userId, userAgent, ipAddress) {
9948
- const roleIds = (await authRepo.getUserRoles(userId)).map((r) => r.id);
10429
+ async function createSessionAndTokens(uid, userAgent, ipAddress) {
10430
+ const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
9949
10431
  let customClaims;
9950
10432
  if (ops.customizeAccessToken) {
9951
- const user = await authRepo.getUserById(userId);
10433
+ const user = await authRepo.getUserById(uid);
9952
10434
  if (user) {
9953
10435
  const defaultClaims = {
9954
- userId,
10436
+ uid,
9955
10437
  roles: roleIds,
9956
10438
  aal: "aal1"
9957
10439
  };
9958
10440
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
9959
10441
  }
9960
10442
  }
9961
- const accessToken = generateAccessToken(userId, roleIds, "aal1", customClaims);
10443
+ const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
9962
10444
  const refreshToken = generateRefreshToken();
9963
- await authRepo.createRefreshToken(userId, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
10445
+ await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
9964
10446
  return {
9965
10447
  roleIds,
9966
10448
  accessToken,
@@ -10024,7 +10506,7 @@ function createAuthRoutes(config) {
10024
10506
  logger.warn("[Security Audit] Auth login failure", {
10025
10507
  eventType: "auth.login.failure",
10026
10508
  email,
10027
- userId: user.id
10509
+ uid: user.id
10028
10510
  });
10029
10511
  throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
10030
10512
  }
@@ -10035,7 +10517,7 @@ function createAuthRoutes(config) {
10035
10517
  });
10036
10518
  logger.info("[Security Audit] Auth login success", {
10037
10519
  eventType: "auth.login.success",
10038
- userId: user.id,
10520
+ uid: user.id,
10039
10521
  email
10040
10522
  });
10041
10523
  const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, "password"), "login", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
@@ -10044,54 +10526,101 @@ function createAuthRoutes(config) {
10044
10526
  /**
10045
10527
  * Dynamically mount OAuth provider routes
10046
10528
  */
10047
- if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
10048
- const payload = parseBody(provider.schema, await c.req.json());
10049
- let externalUser;
10050
- try {
10051
- externalUser = await provider.verify(payload);
10052
- } catch (err) {
10053
- const msg = err instanceof Error ? err.message : String(err);
10054
- throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
10055
- }
10056
- if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10057
- let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10058
- if (!user) {
10059
- user = await authRepo.getUserByEmail(externalUser.email);
10060
- if (user) {
10061
- if (!externalUser.emailVerified) throw ApiError.forbidden("Cannot auto-link account: email not verified by the OAuth provider. Please log in with your password first and link the provider from your profile.", "EMAIL_NOT_VERIFIED");
10062
- await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10063
- await authRepo.updateUser(user.id, {
10064
- displayName: user.displayName || externalUser.displayName || void 0,
10065
- photoUrl: user.photoUrl || externalUser.photoUrl || void 0
10066
- });
10067
- } else {
10068
- user = await authRepo.createUser({
10069
- email: externalUser.email.toLowerCase(),
10070
- displayName: externalUser.displayName || void 0,
10071
- photoUrl: externalUser.photoUrl || void 0
10072
- });
10073
- await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10074
- if (ops.afterUserCreate) try {
10075
- await ops.afterUserCreate(user);
10076
- } catch (err) {
10077
- logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
10529
+ if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
10530
+ router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
10531
+ const payload = parseBody(provider.schema, await c.req.json());
10532
+ let externalUser;
10533
+ try {
10534
+ externalUser = await provider.verify(payload);
10535
+ } catch (err) {
10536
+ const msg = err instanceof Error ? err.message : String(err);
10537
+ throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
10538
+ }
10539
+ if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10540
+ let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10541
+ if (!user) {
10542
+ user = await authRepo.getUserByEmail(externalUser.email);
10543
+ if (user) {
10544
+ if (!externalUser.emailVerified) throw ApiError.forbidden(`An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, "EMAIL_NOT_VERIFIED");
10545
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10546
+ await authRepo.updateUser(user.id, {
10547
+ displayName: user.displayName || externalUser.displayName || void 0,
10548
+ photoUrl: user.photoUrl || externalUser.photoUrl || void 0
10549
+ });
10550
+ } else {
10551
+ user = await authRepo.createUser({
10552
+ email: externalUser.email.toLowerCase(),
10553
+ displayName: externalUser.displayName || void 0,
10554
+ photoUrl: externalUser.photoUrl || void 0
10555
+ });
10556
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10557
+ if (ops.afterUserCreate) try {
10558
+ await ops.afterUserCreate(user);
10559
+ } catch (err) {
10560
+ logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
10561
+ }
10562
+ const allUsers = await authRepo.listUsers();
10563
+ if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
10564
+ else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
10565
+ sendWelcomeEmail({
10566
+ email: user.email,
10567
+ displayName: user.displayName
10568
+ });
10078
10569
  }
10079
- const allUsers = await authRepo.listUsers();
10080
- if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
10081
- else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
10082
- sendWelcomeEmail({
10083
- email: user.email,
10084
- displayName: user.displayName
10085
- });
10570
+ } else await authRepo.updateUser(user.id, {
10571
+ displayName: externalUser.displayName || user.displayName || void 0,
10572
+ photoUrl: externalUser.photoUrl || user.photoUrl || void 0
10573
+ });
10574
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
10575
+ const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
10576
+ return c.json(finalResponse);
10577
+ });
10578
+ /**
10579
+ * POST /auth/link/:provider
10580
+ * Attach an OAuth identity to the *already authenticated* account.
10581
+ *
10582
+ * This is the escape hatch from the `EMAIL_NOT_VERIFIED` rejection
10583
+ * on the sign-in route above, and the way to attach a provider
10584
+ * whose email differs from the account's.
10585
+ *
10586
+ * Note the deliberate asymmetry with sign-in: linking here does
10587
+ * NOT require the provider to have verified the email, and does
10588
+ * not require the emails to match at all. On the sign-in route the
10589
+ * provider's email is the *only* evidence tying the incoming
10590
+ * identity to an existing account, so an unverified address would
10591
+ * let an attacker claim someone else's account. Here the caller
10592
+ * has already proven ownership by holding a valid session, and the
10593
+ * OAuth credential proves control of the provider identity — the
10594
+ * email plays no part in the decision, so its verification status
10595
+ * is irrelevant.
10596
+ */
10597
+ router.post(`/link/${provider.id}`, defaultAuthLimiter, requireAuth, async (c) => {
10598
+ const userCtx = c.get("user");
10599
+ if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10600
+ const payload = parseBody(provider.schema, await c.req.json());
10601
+ let externalUser;
10602
+ try {
10603
+ externalUser = await provider.verify(payload);
10604
+ } catch (err) {
10605
+ const msg = err instanceof Error ? err.message : String(err);
10606
+ throw ApiError.unauthorized(`${provider.id} link failed: ${msg}`, "OAUTH_ERROR");
10086
10607
  }
10087
- } else await authRepo.updateUser(user.id, {
10088
- displayName: externalUser.displayName || user.displayName || void 0,
10089
- photoUrl: externalUser.photoUrl || user.photoUrl || void 0
10608
+ if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10609
+ const identityOwner = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10610
+ if (identityOwner && identityOwner.id !== userCtx.uid) throw ApiError.conflict(`That ${provider.id} account is already linked to a different user.`, "IDENTITY_ALREADY_LINKED");
10611
+ if (identityOwner) return c.json({
10612
+ success: true,
10613
+ provider: provider.id,
10614
+ alreadyLinked: true
10615
+ });
10616
+ await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, { email: externalUser.email });
10617
+ return c.json({
10618
+ success: true,
10619
+ provider: provider.id,
10620
+ alreadyLinked: false
10621
+ });
10090
10622
  });
10091
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
10092
- const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
10093
- return c.json(finalResponse);
10094
- });
10623
+ }
10095
10624
  /**
10096
10625
  * POST /auth/forgot-password
10097
10626
  * Request password reset email
@@ -10143,10 +10672,10 @@ function createAuthRoutes(config) {
10143
10672
  const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
10144
10673
  if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
10145
10674
  const passwordHash = await ops.hashPassword(password);
10146
- await authRepo.updatePassword(storedToken.userId, passwordHash);
10675
+ await authRepo.updatePassword(storedToken.uid, passwordHash);
10147
10676
  await authRepo.markPasswordResetTokenUsed(tokenHash);
10148
- await authRepo.deleteAllRefreshTokensForUser(storedToken.userId);
10149
- if (ops.onPasswordReset) ops.onPasswordReset(storedToken.userId).catch((err) => {
10677
+ await authRepo.deleteAllRefreshTokensForUser(storedToken.uid);
10678
+ if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
10150
10679
  logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
10151
10680
  });
10152
10681
  return c.json({
@@ -10162,7 +10691,7 @@ function createAuthRoutes(config) {
10162
10691
  const userCtx = c.get("user");
10163
10692
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10164
10693
  const { oldPassword, newPassword } = parseBody(changePasswordSchema, await c.req.json());
10165
- const user = await authRepo.getUserById(userCtx.userId);
10694
+ const user = await authRepo.getUserById(userCtx.uid);
10166
10695
  if (!user || !user.passwordHash) throw ApiError.badRequest("Cannot change password for this account", "INVALID_ACCOUNT");
10167
10696
  if (!await ops.verifyPassword(oldPassword, user.passwordHash)) throw ApiError.unauthorized("Current password is incorrect", "INVALID_CREDENTIALS");
10168
10697
  const passwordValidation = ops.validatePasswordStrength(newPassword);
@@ -10183,7 +10712,7 @@ function createAuthRoutes(config) {
10183
10712
  const userCtx = c.get("user");
10184
10713
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10185
10714
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
10186
- const user = await authRepo.getUserById(userCtx.userId);
10715
+ const user = await authRepo.getUserById(userCtx.uid);
10187
10716
  if (!user) throw ApiError.notFound("User not found");
10188
10717
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
10189
10718
  const token = generateSecureToken();
@@ -10229,8 +10758,8 @@ function createAuthRoutes(config) {
10229
10758
  * Refresh access token using refresh token
10230
10759
  */
10231
10760
  router.post("/refresh", async (c) => {
10232
- const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json()), config.cookieAuth);
10233
- if (!refreshToken) throw ApiError.badRequest("Refresh token is required", "INVALID_INPUT");
10761
+ const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json().catch(() => ({}))), config.cookieAuth);
10762
+ if (!refreshToken) throw ApiError.unauthenticated("No refresh token presented", "NO_SESSION");
10234
10763
  const tokenHash = hashRefreshToken(refreshToken);
10235
10764
  const storedToken = await authRepo.findRefreshTokenByHash(tokenHash);
10236
10765
  if (!storedToken) {
@@ -10242,10 +10771,10 @@ function createAuthRoutes(config) {
10242
10771
  clearRefreshCookie(c, config.cookieAuth);
10243
10772
  throw ApiError.unauthorized("Refresh token expired", "TOKEN_EXPIRED");
10244
10773
  }
10245
- const roleIds = (await authRepo.getUserRoles(storedToken.userId)).map((r) => r.id);
10246
- const user = await authRepo.getUserById(storedToken.userId).catch((err) => {
10774
+ const roleIds = (await authRepo.getUserRoles(storedToken.uid)).map((r) => r.id);
10775
+ const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
10247
10776
  logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
10248
- userId: storedToken.userId,
10777
+ uid: storedToken.uid,
10249
10778
  error: err instanceof Error ? err.message : String(err)
10250
10779
  });
10251
10780
  return null;
@@ -10253,18 +10782,18 @@ function createAuthRoutes(config) {
10253
10782
  let customClaims;
10254
10783
  if (ops.customizeAccessToken && user) {
10255
10784
  const defaultClaims = {
10256
- userId: storedToken.userId,
10785
+ uid: storedToken.uid,
10257
10786
  roles: roleIds,
10258
10787
  aal: "aal1"
10259
10788
  };
10260
10789
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
10261
10790
  }
10262
- const newAccessToken = generateAccessToken(storedToken.userId, roleIds, "aal1", customClaims);
10791
+ const newAccessToken = generateAccessToken(storedToken.uid, roleIds, "aal1", customClaims);
10263
10792
  const newRefreshToken = generateRefreshToken();
10264
10793
  const userAgent = c.req.header("user-agent") || "unknown";
10265
10794
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
10266
10795
  await authRepo.deleteRefreshToken(tokenHash);
10267
- await authRepo.createRefreshToken(storedToken.userId, hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
10796
+ await authRepo.createRefreshToken(storedToken.uid, hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
10268
10797
  const tokensOnlyResponse = { tokens: {
10269
10798
  accessToken: newAccessToken,
10270
10799
  refreshToken: newRefreshToken,
@@ -10277,7 +10806,7 @@ function createAuthRoutes(config) {
10277
10806
  logger.warn("[Auth] Could not build enriched refresh response; returning tokens only", { error: err instanceof Error ? err.message : String(err) });
10278
10807
  refreshResponse = tokensOnlyResponse;
10279
10808
  }
10280
- const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.userId), c, newRefreshToken, config.cookieAuth);
10809
+ const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.uid), c, newRefreshToken, config.cookieAuth);
10281
10810
  return c.json(finalResponse);
10282
10811
  });
10283
10812
  mountSessionRoutes({
@@ -10314,7 +10843,7 @@ function createAuthRoutes(config) {
10314
10843
  /**
10315
10844
  * Create a standalone admin route for resetting user passwords.
10316
10845
  *
10317
- * Mounts: POST /users/:userId/reset-password
10846
+ * Mounts: POST /users/:uid/reset-password
10318
10847
  */
10319
10848
  function createResetPasswordRoute(config) {
10320
10849
  const router = new Hono();
@@ -10323,9 +10852,9 @@ function createResetPasswordRoute(config) {
10323
10852
  const ops = resolveAuthHooks(config.authHooks);
10324
10853
  router.onError(errorHandler);
10325
10854
  router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
10326
- router.post("/users/:userId/reset-password", requireAdmin, async (c) => {
10327
- const userId = c.req.param("userId");
10328
- const existing = await authRepo.getUserById(userId);
10855
+ router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
10856
+ const uid = c.req.param("uid");
10857
+ const existing = await authRepo.getUserById(uid);
10329
10858
  if (!existing) throw ApiError.notFound("User not found");
10330
10859
  let invitationSent = false;
10331
10860
  let temporaryPassword;
@@ -10439,10 +10968,10 @@ function createAdminRolesRoute(config) {
10439
10968
  *
10440
10969
  * Mounts:
10441
10970
  * GET /users
10442
- * GET /users/:userId
10971
+ * GET /users/:uid
10443
10972
  * POST /users
10444
- * PUT /users/:userId
10445
- * DELETE /users/:userId
10973
+ * PUT /users/:uid
10974
+ * DELETE /users/:uid
10446
10975
  * POST /bootstrap
10447
10976
  */
10448
10977
  function createAdminUsersRoute(config) {
@@ -10477,16 +11006,36 @@ function createAdminUsersRoute(config) {
10477
11006
  break;
10478
11007
  }
10479
11008
  if (hasAdmin) throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
10480
- const userId = "userId" in user ? user.userId : "uid" in user ? user.uid : void 0;
10481
- if (!userId) throw ApiError.unauthorized("User ID not found in auth context");
10482
- if (!await authRepo.getUserById(userId)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
10483
- await authRepo.setUserRoles(userId, ["admin"]);
11009
+ const uid = "uid" in user ? user.uid : "uid" in user ? user.uid : void 0;
11010
+ if (!uid) throw ApiError.unauthorized("User ID not found in auth context");
11011
+ if (!await authRepo.getUserById(uid)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
11012
+ if (users.length > 0) {
11013
+ const earliest = users.reduce((a, b) => {
11014
+ const at = new Date(a.createdAt).getTime();
11015
+ const bt = new Date(b.createdAt).getTime();
11016
+ if (at !== bt) return at < bt ? a : b;
11017
+ return a.id < b.id ? a : b;
11018
+ });
11019
+ if (earliest.id !== uid) {
11020
+ logger.warn("[Security Audit] Bootstrap denied: caller is not the earliest-registered user", {
11021
+ eventType: "auth.bootstrap.denied",
11022
+ callerId: uid,
11023
+ earliestUserId: earliest.id
11024
+ });
11025
+ throw ApiError.forbidden("Only the first registered user may claim the initial admin role. Ask that user to bootstrap, or assign the admin role using the service key.", "BOOTSTRAP_NOT_FIRST_USER");
11026
+ }
11027
+ }
11028
+ await authRepo.setUserRoles(uid, ["admin"]);
11029
+ logger.info("[Security Audit] Initial admin bootstrapped", {
11030
+ eventType: "auth.bootstrap.success",
11031
+ uid
11032
+ });
10484
11033
  if (config.setBootstrapCompleted) await config.setBootstrapCompleted();
10485
11034
  return c.json({
10486
11035
  success: true,
10487
11036
  message: "You are now an admin",
10488
11037
  user: {
10489
- uid: userId,
11038
+ uid,
10490
11039
  roles: ["admin"]
10491
11040
  }
10492
11041
  });
@@ -10517,9 +11066,9 @@ function createAdminUsersRoute(config) {
10517
11066
  offset: result.offset
10518
11067
  });
10519
11068
  });
10520
- router.get("/users/:userId", requireAdmin, async (c) => {
10521
- const userId = c.req.param("userId");
10522
- const result = await authRepo.getUserWithRoles(userId);
11069
+ router.get("/users/:uid", requireAdmin, async (c) => {
11070
+ const uid = c.req.param("uid");
11071
+ const result = await authRepo.getUserWithRoles(uid);
10523
11072
  if (!result) throw ApiError.notFound("User not found");
10524
11073
  const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
10525
11074
  return c.json({ user: adminUser });
@@ -10562,10 +11111,10 @@ function createAdminUsersRoute(config) {
10562
11111
  ...finalizeResult.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
10563
11112
  }, 201);
10564
11113
  });
10565
- router.put("/users/:userId", requireAdmin, async (c) => {
10566
- const userId = c.req.param("userId");
11114
+ router.put("/users/:uid", requireAdmin, async (c) => {
11115
+ const uid = c.req.param("uid");
10567
11116
  const { password, email, displayName, roles } = await c.req.json();
10568
- if (!await authRepo.getUserById(userId)) throw ApiError.notFound("User not found");
11117
+ if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
10569
11118
  const updates = {};
10570
11119
  if (email !== void 0) updates.email = email.toLowerCase();
10571
11120
  if (displayName !== void 0) updates.displayName = displayName;
@@ -10574,9 +11123,9 @@ function createAdminUsersRoute(config) {
10574
11123
  if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
10575
11124
  updates.passwordHash = await ops.hashPassword(password);
10576
11125
  }
10577
- if (Object.keys(updates).length > 0) await authRepo.updateUser(userId, updates);
11126
+ if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
10578
11127
  if (roles !== void 0 && Array.isArray(roles)) {
10579
- const wasAdmin = (await authRepo.getUserRoleIds(userId)).includes("admin");
11128
+ const wasAdmin = (await authRepo.getUserRoleIds(uid)).includes("admin");
10580
11129
  const willBeAdmin = roles.includes("admin");
10581
11130
  if (wasAdmin && !willBeAdmin) {
10582
11131
  if ((await authRepo.listUsersPaginated({
@@ -10584,24 +11133,23 @@ function createAdminUsersRoute(config) {
10584
11133
  limit: 1
10585
11134
  })).total <= 1) throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
10586
11135
  }
10587
- await authRepo.setUserRoles(userId, roles);
11136
+ await authRepo.setUserRoles(uid, roles);
10588
11137
  }
10589
- const result = await authRepo.getUserWithRoles(userId);
11138
+ const result = await authRepo.getUserWithRoles(uid);
10590
11139
  const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
10591
11140
  return c.json({ user: adminUser });
10592
11141
  });
10593
- router.delete("/users/:userId", requireAdmin, async (c) => {
10594
- const userId = c.req.param("userId");
10595
- const authUser = c.get("user");
10596
- if ((authUser?.uid || authUser?.userId) === userId) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
10597
- if (!await authRepo.getUserById(userId)) throw ApiError.notFound("User not found");
10598
- if ((await authRepo.getUserRoleIds(userId)).includes("admin")) {
11142
+ router.delete("/users/:uid", requireAdmin, async (c) => {
11143
+ const uid = c.req.param("uid");
11144
+ if (c.get("user")?.uid === uid) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
11145
+ if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
11146
+ if ((await authRepo.getUserRoleIds(uid)).includes("admin")) {
10599
11147
  if ((await authRepo.listUsersPaginated({
10600
11148
  roleId: "admin",
10601
11149
  limit: 1
10602
11150
  })).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
10603
11151
  }
10604
- await authRepo.deleteUser(userId);
11152
+ await authRepo.deleteUser(uid);
10605
11153
  return c.json({ success: true });
10606
11154
  });
10607
11155
  return router;
@@ -10623,10 +11171,8 @@ function createBuiltinAuthAdapter(config) {
10623
11171
  serviceKey,
10624
11172
  async verifyRequest(request) {
10625
11173
  const authHeader = request.headers.get("authorization");
10626
- const queryToken = new URL(request.url, "http://localhost").searchParams.get("token");
10627
- const hasBearer = authHeader?.startsWith("Bearer ");
10628
- if (!hasBearer && !queryToken) return null;
10629
- const token = hasBearer ? authHeader.substring(7) : queryToken;
11174
+ if (!authHeader?.startsWith("Bearer ")) return null;
11175
+ const token = authHeader.substring(7);
10630
11176
  if (serviceKey && safeCompare(token, serviceKey)) return {
10631
11177
  uid: "service",
10632
11178
  email: "service@rebase.internal",
@@ -10638,16 +11184,16 @@ function createBuiltinAuthAdapter(config) {
10638
11184
  if (!payload) return null;
10639
11185
  let roles = payload.roles || [];
10640
11186
  try {
10641
- roles = await authRepository.getUserRoleIds(payload.userId);
11187
+ roles = await authRepository.getUserRoleIds(payload.uid);
10642
11188
  } catch (err) {
10643
11189
  logger.warn("Role lookup from repository failed, using token roles as fallback", {
10644
- userId: payload.userId,
11190
+ uid: payload.uid,
10645
11191
  error: err
10646
11192
  });
10647
11193
  }
10648
11194
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
10649
11195
  return {
10650
- uid: payload.userId,
11196
+ uid: payload.uid,
10651
11197
  email: payload.email ?? "",
10652
11198
  displayName: payload.displayName ?? null,
10653
11199
  roles,
@@ -10667,16 +11213,16 @@ function createBuiltinAuthAdapter(config) {
10667
11213
  if (!payload) return null;
10668
11214
  let roles = payload.roles || [];
10669
11215
  try {
10670
- roles = await authRepository.getUserRoleIds(payload.userId);
11216
+ roles = await authRepository.getUserRoleIds(payload.uid);
10671
11217
  } catch (err) {
10672
11218
  logger.warn("Role lookup from repository failed, using token roles as fallback", {
10673
- userId: payload.userId,
11219
+ uid: payload.uid,
10674
11220
  error: err
10675
11221
  });
10676
11222
  }
10677
11223
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
10678
11224
  return {
10679
- uid: payload.userId,
11225
+ uid: payload.uid,
10680
11226
  email: payload.email ?? "",
10681
11227
  displayName: payload.displayName ?? null,
10682
11228
  roles,
@@ -10735,6 +11281,16 @@ function createBuiltinAuthAdapter(config) {
10735
11281
  collectionAuthConfig: collectionAuth ?? collectionAuthConfig
10736
11282
  });
10737
11283
  },
11284
+ describeUserCreationContract(collectionAuth) {
11285
+ if ((collectionAuth ?? collectionAuthConfig)?.onCreateUser || resolvedOps.onAdminCreateUser) return {
11286
+ validate: false,
11287
+ extraFields: []
11288
+ };
11289
+ return {
11290
+ validate: true,
11291
+ extraFields: ["password"]
11292
+ };
11293
+ },
10738
11294
  async finalizeUserCreation(entity, clearPassword) {
10739
11295
  return finalizeAdminUserCreation(entity, clearPassword, {
10740
11296
  authRepo: authRepository,
@@ -10824,11 +11380,11 @@ function createUserManagementFromRepo(repo, resolvedOps) {
10824
11380
  logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
10825
11381
  });
10826
11382
  },
10827
- async getUserRoles(userId) {
10828
- return repo.getUserRoleIds(userId);
11383
+ async getUserRoles(uid) {
11384
+ return repo.getUserRoleIds(uid);
10829
11385
  },
10830
- async setUserRoles(userId, roleIds) {
10831
- await repo.setUserRoles(userId, roleIds);
11386
+ async setUserRoles(uid, roleIds) {
11387
+ await repo.setUserRoles(uid, roleIds);
10832
11388
  }
10833
11389
  };
10834
11390
  }
@@ -10863,6 +11419,33 @@ function configureLogLevel(logLevel) {
10863
11419
  if (currentLevel < 0) console.error = () => {};
10864
11420
  }
10865
11421
  //#endregion
11422
+ //#region src/utils/compression.ts
11423
+ /**
11424
+ * Response compression (gzip/deflate), negotiated from `Accept-Encoding`.
11425
+ *
11426
+ * Wraps Hono's `compress` with two corrections it does not make itself:
11427
+ *
11428
+ * - **`Vary: Accept-Encoding`** on every response it guards. Without it a shared
11429
+ * cache may hand a gzipped body to a client that asked for identity.
11430
+ * - **Range responses are left alone.** `Content-Range` describes offsets into
11431
+ * the identity body, so compressing a 206 desyncs the framing from the bytes
11432
+ * actually sent.
11433
+ *
11434
+ * No brotli: `CompressionStream` has no "br", so a br-only client would fall
11435
+ * back to identity — every real client sends gzip too.
11436
+ */
11437
+ function responseCompression() {
11438
+ const gzip = compress();
11439
+ return async (c, next) => {
11440
+ await next();
11441
+ const vary = c.res.headers.get("Vary");
11442
+ if (!vary) c.res.headers.set("Vary", "Accept-Encoding");
11443
+ else if (!/\baccept-encoding\b/i.test(vary)) c.res.headers.append("Vary", "Accept-Encoding");
11444
+ if (c.res.status === 206 || c.res.headers.has("Content-Range")) return;
11445
+ await gzip(c, async () => {});
11446
+ };
11447
+ }
11448
+ //#endregion
10866
11449
  //#region src/utils/request-id.ts
10867
11450
  /**
10868
11451
  * X-Request-ID Middleware for Hono.
@@ -10917,36 +11500,133 @@ function requestLogger(options) {
10917
11500
  const reqId = c.get("requestId");
10918
11501
  if (reqId) data.requestId = reqId;
10919
11502
  if (contentLength) data.contentLength = parseInt(contentLength, 10);
10920
- const userId = c.get("userId");
10921
- if (userId) data.userId = userId;
11503
+ const uid = c.get("user")?.uid;
11504
+ if (uid) data.uid = uid;
10922
11505
  if (status >= 500) logger.error("request", data);
10923
11506
  else if (status >= 400) logger.warn("request", data);
10924
11507
  else logger.info("request", data);
10925
11508
  };
10926
11509
  }
10927
11510
  //#endregion
10928
- //#region src/init/middlewares.ts
10929
- function configureMiddlewares(app, basePath, isProduction, config) {
10930
- app.use(`${basePath}/*`, requestId());
10931
- const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
10932
- if (maxBodySize > 0) {
10933
- app.use(`${basePath}/*`, bodyLimit({
10934
- maxSize: maxBodySize,
10935
- onError: (c) => {
10936
- return c.json({ error: {
10937
- message: `Request body too large. Maximum size is ${Math.round(maxBodySize / 1024 / 1024)}MB.`,
10938
- code: "PAYLOAD_TOO_LARGE"
10939
- } }, 413);
10940
- }
10941
- }));
10942
- logger.info("Request body limit configured", { maxSizeMB: Math.round(maxBodySize / 1024 / 1024) });
10943
- }
10944
- if (config.csrf?.origin) {
10945
- app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
10946
- logger.info("CSRF protection enabled");
11511
+ //#region src/api/logs-routes.ts
11512
+ var logs_routes_exports = /* @__PURE__ */ __exportAll({
11513
+ addLog: () => addLog,
11514
+ default: () => app,
11515
+ logBuffer: () => logBuffer,
11516
+ logMiddleware: () => logMiddleware
11517
+ });
11518
+ var LogRingBuffer = class {
11519
+ buffer = [];
11520
+ maxSize;
11521
+ idCounter = 0;
11522
+ constructor(maxSize = 1e4) {
11523
+ this.maxSize = maxSize;
11524
+ }
11525
+ push(entry) {
11526
+ const id = `log_${++this.idCounter}`;
11527
+ this.buffer.push({
11528
+ ...entry,
11529
+ id
11530
+ });
11531
+ if (this.buffer.length > this.maxSize) this.buffer.shift();
11532
+ }
11533
+ query(options) {
11534
+ let filtered = this.buffer;
11535
+ if (options.level) filtered = filtered.filter((e) => e.level === options.level);
11536
+ if (options.source) filtered = filtered.filter((e) => e.source === options.source);
11537
+ if (options.search) {
11538
+ const searchLower = options.search.toLowerCase();
11539
+ filtered = filtered.filter((e) => e.message.toLowerCase().includes(searchLower));
11540
+ }
11541
+ if (options.since) {
11542
+ const sinceValue = options.since;
11543
+ filtered = filtered.filter((e) => e.timestamp >= sinceValue);
11544
+ }
11545
+ const sorted = [...filtered].reverse();
11546
+ const total = sorted.length;
11547
+ const limit = options.limit || 100;
11548
+ const offset = options.offset || 0;
11549
+ return {
11550
+ entries: sorted.slice(offset, offset + limit),
11551
+ total
11552
+ };
11553
+ }
11554
+ getLatest(count = 50) {
11555
+ return this.buffer.slice(-count).reverse();
10947
11556
  }
10948
- if (!isProduction && !process.env.CORS_ORIGINS && !process.env.FRONTEND_URL) logger.warn("No CORS configuration detected (CORS_ORIGINS / FRONTEND_URL not set). The API will accept requests from any origin. Set CORS_ORIGINS in your .env file to restrict access.");
11557
+ };
11558
+ var logBuffer = new LogRingBuffer();
11559
+ /** Add a log entry */
11560
+ function addLog(level, source, message, metadata) {
11561
+ logBuffer.push({
11562
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
11563
+ level,
11564
+ source,
11565
+ message,
11566
+ metadata
11567
+ });
11568
+ }
11569
+ /** Hono middleware to log API requests */
11570
+ function logMiddleware() {
11571
+ return async (c, next) => {
11572
+ const start = Date.now();
11573
+ await next();
11574
+ const duration = Date.now() - start;
11575
+ const reqId = c.get("requestId");
11576
+ addLog("info", "api", `${c.req.method} ${c.req.path} ${c.res.status} ${duration}ms`, {
11577
+ method: c.req.method,
11578
+ path: c.req.path,
11579
+ status: c.res.status,
11580
+ duration,
11581
+ ...reqId && { requestId: reqId }
11582
+ });
11583
+ };
11584
+ }
11585
+ var app = new Hono();
11586
+ app.get("/", (c) => {
11587
+ const query = c.req.query();
11588
+ const result = logBuffer.query({
11589
+ level: query.level,
11590
+ source: query.source,
11591
+ search: query.search,
11592
+ limit: query.limit ? parseInt(query.limit) : void 0,
11593
+ offset: query.offset ? parseInt(query.offset) : void 0,
11594
+ since: query.since
11595
+ });
11596
+ return c.json(result);
11597
+ });
11598
+ app.get("/latest", (c) => {
11599
+ const count = parseInt(c.req.query("count") || "50");
11600
+ return c.json({ entries: logBuffer.getLatest(count) });
11601
+ });
11602
+ //#endregion
11603
+ //#region src/init/middlewares.ts
11604
+ function configureMiddlewares(app, basePath, isProduction, config) {
11605
+ app.use(`${basePath}/*`, requestId());
11606
+ if (config.compression !== false) {
11607
+ app.use(`${basePath}/*`, responseCompression());
11608
+ logger.info("Response compression enabled");
11609
+ }
11610
+ const maxBodySize = config.maxBodySize ?? 10 * 1024 * 1024;
11611
+ if (maxBodySize > 0) {
11612
+ app.use(`${basePath}/*`, bodyLimit({
11613
+ maxSize: maxBodySize,
11614
+ onError: (c) => {
11615
+ return c.json({ error: {
11616
+ message: `Request body too large. Maximum size is ${Math.round(maxBodySize / 1024 / 1024)}MB.`,
11617
+ code: "PAYLOAD_TOO_LARGE"
11618
+ } }, 413);
11619
+ }
11620
+ }));
11621
+ logger.info("Request body limit configured", { maxSizeMB: Math.round(maxBodySize / 1024 / 1024) });
11622
+ }
11623
+ if (config.csrf?.origin) {
11624
+ app.use(`${basePath}/*`, csrf({ origin: config.csrf.origin }));
11625
+ logger.info("CSRF protection enabled");
11626
+ }
11627
+ if (!process.env.CORS_ORIGINS && !process.env.FRONTEND_URL) logger.warn((isProduction ? "[PRODUCTION] " : "") + "No CORS configuration detected (CORS_ORIGINS / FRONTEND_URL not set). If your app does not install its own CORS middleware, the API may accept requests from any origin. Set CORS_ORIGINS to restrict access.");
10949
11628
  app.use(`${basePath}/*`, requestLogger());
11629
+ app.use(`${basePath}/*`, logMiddleware());
10950
11630
  }
10951
11631
  //#endregion
10952
11632
  //#region src/storage/types.ts
@@ -11027,11 +11707,21 @@ var LocalStorageController = class {
11027
11707
  }
11028
11708
  }
11029
11709
  /**
11030
- * Get the full filesystem path for a storage path.
11031
- * Includes a path traversal guard to prevent escaping the base directory.
11710
+ * Get the full filesystem path for a storage path, with a traversal guard
11711
+ * that keeps the result inside the bucket directory.
11712
+ *
11713
+ * Defaults the bucket the way `putObject` does.
11714
+ *
11715
+ * `putObject` has always written into `default` when given no bucket, while
11716
+ * the read side resolved a bare key against the storage root — where
11717
+ * nothing is. The two disagreed silently: `getObject` returned null (reads
11718
+ * as "file missing"), `deleteObject` deleted nothing (404s are swallowed by
11719
+ * design), and `listObjects` returned an empty page. So the obvious
11720
+ * `putObject({ key })` → `getObject(key)` did not round-trip and nothing
11721
+ * said why. One default, applied everywhere, removes the whole class.
11032
11722
  */
11033
11723
  getFullPath(storagePath, bucket) {
11034
- const bucketPath = bucket ? path$1.join(this.basePath, bucket) : this.basePath;
11724
+ const bucketPath = path$1.join(this.basePath, bucket ?? "default");
11035
11725
  const resolved = path$1.resolve(path$1.join(bucketPath, storagePath));
11036
11726
  if (!resolved.startsWith(bucketPath + path$1.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
11037
11727
  return resolved;
@@ -11192,8 +11882,10 @@ var LocalStorageController = class {
11192
11882
  let count = 0;
11193
11883
  const maxResults = options?.maxResults ?? 1e3;
11194
11884
  const startIndex = options?.pageToken ? parseInt(options.pageToken, 10) : 0;
11885
+ let scanned = startIndex;
11195
11886
  for (let i = startIndex; i < entries.length && count < maxResults; i++) {
11196
11887
  const entry = entries[i];
11888
+ scanned = i + 1;
11197
11889
  if (entry.name.endsWith(".metadata.json")) continue;
11198
11890
  const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
11199
11891
  const bucket = options?.bucket ?? "default";
@@ -11212,7 +11904,7 @@ var LocalStorageController = class {
11212
11904
  return {
11213
11905
  items,
11214
11906
  prefixes,
11215
- nextPageToken: startIndex + count < entries.length ? String(startIndex + count) : void 0
11907
+ nextPageToken: scanned < entries.length ? String(scanned) : void 0
11216
11908
  };
11217
11909
  } catch (error) {
11218
11910
  const code = error?.code;
@@ -11486,7 +12178,7 @@ var gcsModule = null;
11486
12178
  async function loadGCS() {
11487
12179
  if (gcsModule) return gcsModule;
11488
12180
  try {
11489
- gcsModule = await import("./src-B9tjYmqP.js");
12181
+ gcsModule = await import("@google-cloud/storage");
11490
12182
  return gcsModule;
11491
12183
  } catch {
11492
12184
  throw new Error("Google Cloud Storage is required for the GCS storage controller. Install it with: pnpm add @google-cloud/storage");
@@ -11692,6 +12384,47 @@ async function loadGoogleAuth() {
11692
12384
  return _googleAuth;
11693
12385
  }
11694
12386
  /**
12387
+ * Verify a Google **access token** and resolve it to a profile.
12388
+ *
12389
+ * SECURITY: an access token must be checked against Google's `tokeninfo`
12390
+ * endpoint so we can confirm its `aud` (the OAuth client the token was issued
12391
+ * to) matches *our* `clientId`. The `userinfo` endpoint does **not** verify
12392
+ * audience — it returns a profile for any valid Google access token — so
12393
+ * trusting it alone lets an attacker present a token they obtained for a
12394
+ * *different* client (e.g. their own app, via a phished consent) and log in as
12395
+ * whatever account that token belongs to. The `aud` check is what binds the
12396
+ * token to this application.
12397
+ *
12398
+ * Identity (`sub` / `email` / `email_verified`) is taken from the
12399
+ * audience-checked `tokeninfo` response. `userinfo` is used only, best-effort,
12400
+ * to enrich display fields (name, picture).
12401
+ */
12402
+ async function verifyGoogleAccessToken(accessToken, expectedClientId) {
12403
+ const tokenInfoRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(accessToken)}`);
12404
+ if (!tokenInfoRes.ok) throw new Error(`Google tokeninfo request failed with status ${tokenInfoRes.status}`);
12405
+ const tokenInfo = await tokenInfoRes.json();
12406
+ if (tokenInfo.aud !== expectedClientId) throw new Error("Google access token audience mismatch: the token was not issued for this application");
12407
+ if (!tokenInfo.sub || !tokenInfo.email) throw new Error("Google access token missing sub or email (the email scope is required)");
12408
+ const emailVerified = tokenInfo.email_verified === true || tokenInfo.email_verified === "true";
12409
+ let displayName = null;
12410
+ let photoUrl = null;
12411
+ try {
12412
+ const res = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { Authorization: `Bearer ${accessToken}` } });
12413
+ if (res.ok) {
12414
+ const info = await res.json();
12415
+ displayName = info.name || null;
12416
+ photoUrl = info.picture || null;
12417
+ }
12418
+ } catch {}
12419
+ return {
12420
+ providerId: tokenInfo.sub,
12421
+ email: tokenInfo.email,
12422
+ displayName,
12423
+ photoUrl,
12424
+ emailVerified
12425
+ };
12426
+ }
12427
+ /**
11695
12428
  * Creates a Google OAuth Provider integration.
11696
12429
  *
11697
12430
  * Supports three verification paths:
@@ -11741,22 +12474,10 @@ function createGoogleProvider(config) {
11741
12474
  email: content.email || "",
11742
12475
  displayName: content.name || null,
11743
12476
  photoUrl: content.picture || null,
11744
- emailVerified: true
11745
- };
11746
- }
11747
- if (payload.accessToken) {
11748
- const res = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { Authorization: `Bearer ${payload.accessToken}` } });
11749
- if (!res.ok) throw new Error(`Google userinfo request failed with status ${res.status}`);
11750
- const info = await res.json();
11751
- if (!info.sub || !info.email) throw new Error("Google userinfo response missing sub or email");
11752
- return {
11753
- providerId: info.sub,
11754
- email: info.email,
11755
- displayName: info.name || null,
11756
- photoUrl: info.picture || null,
11757
- emailVerified: true
12477
+ emailVerified: content.email_verified === true
11758
12478
  };
11759
12479
  }
12480
+ if (payload.accessToken) return await verifyGoogleAccessToken(payload.accessToken, clientId);
11760
12481
  if (payload.code && payload.redirectUri) {
11761
12482
  if (!clientSecret) throw new Error("Google authorization code flow requires clientSecret. Configure GOOGLE_CLIENT_SECRET in your environment.");
11762
12483
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
@@ -11787,22 +12508,10 @@ function createGoogleProvider(config) {
11787
12508
  email: content.email || "",
11788
12509
  displayName: content.name || null,
11789
12510
  photoUrl: content.picture || null,
11790
- emailVerified: true
11791
- };
11792
- }
11793
- if (tokenData.access_token) {
11794
- const userInfoRes = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", { headers: { Authorization: `Bearer ${tokenData.access_token}` } });
11795
- if (!userInfoRes.ok) throw new Error(`Google userinfo request failed after code exchange (${userInfoRes.status})`);
11796
- const info = await userInfoRes.json();
11797
- if (!info.sub || !info.email) return null;
11798
- return {
11799
- providerId: info.sub,
11800
- email: info.email,
11801
- displayName: info.name || null,
11802
- photoUrl: info.picture || null,
11803
- emailVerified: true
12511
+ emailVerified: content.email_verified === true
11804
12512
  };
11805
12513
  }
12514
+ if (tokenData.access_token) return await verifyGoogleAccessToken(tokenData.access_token, clientId);
11806
12515
  throw new Error("Google token exchange returned neither id_token nor access_token");
11807
12516
  }
11808
12517
  throw new Error("No valid Google credential provided (expected idToken, accessToken, or code+redirectUri)");
@@ -11992,12 +12701,13 @@ function createMicrosoftProvider(config) {
11992
12701
  logger.error("Microsoft user has no email");
11993
12702
  return null;
11994
12703
  }
12704
+ const emailVerified = Boolean(profileData.mail);
11995
12705
  return {
11996
12706
  providerId: profileData.id,
11997
12707
  email,
11998
12708
  displayName: profileData.displayName || null,
11999
12709
  photoUrl: null,
12000
- emailVerified: true
12710
+ emailVerified
12001
12711
  };
12002
12712
  } catch (error) {
12003
12713
  logger.error("Microsoft OAuth error", { error });
@@ -12794,7 +13504,7 @@ function createApiKeyRoutes(options) {
12794
13504
  if (parsed <= /* @__PURE__ */ new Date()) throw ApiError.badRequest("expires_at must be in the future", "INVALID_INPUT");
12795
13505
  }
12796
13506
  const user = c.get("user");
12797
- const createdBy = user && typeof user === "object" && "userId" in user ? user.userId : "unknown";
13507
+ const createdBy = user && typeof user === "object" && "uid" in user ? user.uid : "unknown";
12798
13508
  const request = {
12799
13509
  name: name.trim(),
12800
13510
  permissions,
@@ -12890,6 +13600,7 @@ function createCustomAuthAdapter(options) {
12890
13600
  //#endregion
12891
13601
  //#region src/auth/index.ts
12892
13602
  var auth_exports = /* @__PURE__ */ __exportAll({
13603
+ MemoryRateLimitStore: () => MemoryRateLimitStore,
12893
13604
  apiKeyKeyGenerator: () => apiKeyKeyGenerator,
12894
13605
  configureJwt: () => configureJwt,
12895
13606
  createAdapterAuthMiddleware: () => createAdapterAuthMiddleware,
@@ -12902,6 +13613,7 @@ var auth_exports = /* @__PURE__ */ __exportAll({
12902
13613
  createBitbucketProvider: () => createBitbucketProvider,
12903
13614
  createBuiltinAuthAdapter: () => createBuiltinAuthAdapter,
12904
13615
  createCustomAuthAdapter: () => createCustomAuthAdapter,
13616
+ createDataRateLimiter: () => createDataRateLimiter,
12905
13617
  createDiscordProvider: () => createDiscordProvider,
12906
13618
  createFacebookProvider: () => createFacebookProvider,
12907
13619
  createGitHubProvider: () => createGitHubProvider,
@@ -13143,12 +13855,14 @@ var UPLOAD_EXPIRY_MS = 1440 * 60 * 1e3;
13143
13855
  var TusHandler = class {
13144
13856
  storageController;
13145
13857
  storageRegistry;
13858
+ authorizeUpload;
13146
13859
  uploads = /* @__PURE__ */ new Map();
13147
13860
  tusDir;
13148
13861
  cleanupTimer;
13149
- constructor(storageBaseDir, storageController, storageRegistry) {
13862
+ constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload) {
13150
13863
  this.storageController = storageController;
13151
13864
  this.storageRegistry = storageRegistry;
13865
+ this.authorizeUpload = authorizeUpload;
13152
13866
  this.tusDir = join(storageBaseDir, ".tus-uploads");
13153
13867
  }
13154
13868
  /** Ensure the temp directory exists. */
@@ -13212,6 +13926,10 @@ var TusHandler = class {
13212
13926
  if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
13213
13927
  if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
13214
13928
  const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
13929
+ if (this.authorizeUpload) {
13930
+ const key = metadata.key || metadata.filename || "";
13931
+ await this.authorizeUpload(c, key, metadata.bucket || "default");
13932
+ }
13215
13933
  const id = randomUUID$1();
13216
13934
  const filePath = join(this.tusDir, id);
13217
13935
  await writeFile(filePath, Buffer.alloc(0));
@@ -13397,7 +14115,7 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
13397
14115
  } }, 401);
13398
14116
  }
13399
14117
  if (authenticatedUser) c.set("user", {
13400
- userId: authenticatedUser.uid,
14118
+ uid: authenticatedUser.uid,
13401
14119
  email: authenticatedUser.email,
13402
14120
  roles: authenticatedUser.roles
13403
14121
  });
@@ -13419,7 +14137,34 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
13419
14137
  function createStorageRoutes(config) {
13420
14138
  const router = new Hono();
13421
14139
  router.onError(errorHandler);
13422
- const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter } = config;
14140
+ const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize } = config;
14141
+ /**
14142
+ * Run the per-object authorization hook, if one is configured.
14143
+ *
14144
+ * Denials are 403 rather than 404: the route already established that the
14145
+ * caller is authenticated, so hiding existence buys nothing, and a
14146
+ * distinguishable status is what makes a misconfigured policy debuggable.
14147
+ * A hook that throws denies too — an ownership lookup that fails must not
14148
+ * fall open.
14149
+ */
14150
+ const checkAuthorized = async (c, operation, key, bucket, storageId) => {
14151
+ if (!authorize) return;
14152
+ const user = c.get("user") ?? null;
14153
+ if (user?.uid === "download-token" || user?.uid === "public") return;
14154
+ let allowed;
14155
+ try {
14156
+ allowed = await authorize({
14157
+ key,
14158
+ bucket,
14159
+ operation,
14160
+ user,
14161
+ storageId: storageId ?? void 0
14162
+ });
14163
+ } catch {
14164
+ allowed = false;
14165
+ }
14166
+ if (!allowed) throw ApiError.forbidden("Not authorized for this object");
14167
+ };
13423
14168
  /**
13424
14169
  * Resolve the storage controller for a request.
13425
14170
  * Looks up by `storageId` in the registry, falls back to the single
@@ -13469,6 +14214,7 @@ function createStorageRoutes(config) {
13469
14214
  const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
13470
14215
  const metadata = {};
13471
14216
  for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
14217
+ await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
13472
14218
  const result = await resolveController(storageId).putObject({
13473
14219
  file: uploadedFile,
13474
14220
  key: finalKey,
@@ -13489,7 +14235,12 @@ function createStorageRoutes(config) {
13489
14235
  const rawPath = extractWildcardPath(c);
13490
14236
  if (!rawPath) throw ApiError.notFound("File not found");
13491
14237
  const filePath = decodeURIComponent(rawPath);
13492
- const resolved = resolveController(c.req.query("storageId"));
14238
+ const storageId = c.req.query("storageId");
14239
+ const resolved = resolveController(storageId);
14240
+ {
14241
+ const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14242
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
14243
+ }
13493
14244
  const transformOpts = parseTransformOptions(c.req.query());
13494
14245
  if (resolved.getType() === "local") {
13495
14246
  const localController = resolved;
@@ -13552,8 +14303,10 @@ function createStorageRoutes(config) {
13552
14303
  fileNotFound: true
13553
14304
  }, 404);
13554
14305
  const filePath = decodeURIComponent(rawPath);
13555
- const resolved = resolveController(c.req.query("storageId"));
14306
+ const storageId = c.req.query("storageId");
14307
+ const resolved = resolveController(storageId);
13556
14308
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14309
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
13557
14310
  const downloadConfig = await resolved.getSignedUrl(resolvedPath, bucket);
13558
14311
  if (downloadConfig.fileNotFound) throw ApiError.notFound("File not found");
13559
14312
  if (downloadConfig.metadata) {
@@ -13579,8 +14332,10 @@ function createStorageRoutes(config) {
13579
14332
  message: "No file to delete"
13580
14333
  });
13581
14334
  const filePath = decodeURIComponent(rawPath);
13582
- const resolved = resolveController(c.req.query("storageId"));
14335
+ const storageId = c.req.query("storageId");
14336
+ const resolved = resolveController(storageId);
13583
14337
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14338
+ await checkAuthorized(c, "delete", resolvedPath, bucket, storageId);
13584
14339
  await resolved.deleteObject(resolvedPath, bucket);
13585
14340
  return c.json({
13586
14341
  success: true,
@@ -13595,7 +14350,9 @@ function createStorageRoutes(config) {
13595
14350
  const bucket = c.req.query("bucket");
13596
14351
  const maxResults = c.req.query("maxResults");
13597
14352
  const pageToken = c.req.query("pageToken");
13598
- const resolved = resolveController(c.req.query("storageId"));
14353
+ const storageId = c.req.query("storageId");
14354
+ const resolved = resolveController(storageId);
14355
+ await checkAuthorized(c, "list", storagePrefix, bucket ?? "default", storageId);
13599
14356
  const result = await resolved.listObjects(storagePrefix, {
13600
14357
  bucket: bucket ?? (resolved.getType() === "local" ? "default" : void 0),
13601
14358
  maxResults: maxResults ? parseInt(maxResults, 10) : void 0,
@@ -13618,6 +14375,7 @@ function createStorageRoutes(config) {
13618
14375
  const resolved = resolveController(storageId);
13619
14376
  const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
13620
14377
  if (!resolvedPath || resolvedPath.trim() === "") throw ApiError.badRequest("Invalid folder path");
14378
+ await checkAuthorized(c, "write", resolvedPath, bucket, storageId);
13621
14379
  if (resolved.getType() === "local") {
13622
14380
  const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
13623
14381
  fs$1.mkdirSync(absolutePath, { recursive: true });
@@ -13635,7 +14393,9 @@ function createStorageRoutes(config) {
13635
14393
  }, 201);
13636
14394
  });
13637
14395
  const defaultCtrl = getDefaultController();
13638
- const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry);
14396
+ const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
14397
+ await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
14398
+ } : void 0);
13639
14399
  tusHandler.startCleanup();
13640
14400
  router.options("/tus", (_c) => tusHandler.options());
13641
14401
  router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
@@ -13776,7 +14536,7 @@ async function initializeStorage(storageConfig, isProduction) {
13776
14536
  const toController = async (entry, label) => {
13777
14537
  if (typeof entry.putObject === "function") return entry;
13778
14538
  const conf = entry;
13779
- if (isProduction && conf.type === "local") logger.warn(`Storage backend "${label}" uses local filesystem in production. Files will be lost on container restart. Configure S3-compatible storage or a custom StorageController.`);
14539
+ if (isProduction && conf.type === "local" && !process.env.FORCE_LOCAL_STORAGE) throw new Error(`Storage backend "${label}" is set to "local" in production. Local storage is the container filesystem, so uploaded files are destroyed on the next restart or redeploy. Configure S3-compatible storage (STORAGE_TYPE=s3) or GCS (STORAGE_TYPE=gcs), or pass a custom StorageController. If this deployment really does have a durable volume mounted at the storage path, set FORCE_LOCAL_STORAGE=true to proceed.`);
13780
14540
  return await createStorageController(conf);
13781
14541
  };
13782
14542
  if (typeof storageConfig === "object" && ("type" in storageConfig || typeof storageConfig.putObject === "function")) controllers[DEFAULT_STORAGE_ID] = await toController(storageConfig, DEFAULT_STORAGE_ID);
@@ -13796,7 +14556,7 @@ async function initializeStorage(storageConfig, isProduction) {
13796
14556
  //#region src/init/docs.ts
13797
14557
  async function mountOpenApiDocs(app, basePath, enableSwagger, activeCollections, requireAuth) {
13798
14558
  if (enableSwagger === false || activeCollections.length === 0) return;
13799
- const { generateOpenApiSpec } = await import("./openapi-generator-Z9oYWLf_.js");
14559
+ const { generateOpenApiSpec } = await import("./openapi-generator-DqSIwNLV.js");
13800
14560
  app.get(`${basePath}/docs`, (c) => {
13801
14561
  const spec = generateOpenApiSpec(activeCollections, {
13802
14562
  basePath,
@@ -13950,6 +14710,38 @@ function createShutdown(config) {
13950
14710
  };
13951
14711
  }
13952
14712
  //#endregion
14713
+ //#region src/auth/collection-callback-warning.ts
14714
+ /** The data callbacks the auth write path does not run. */
14715
+ var DATA_CALLBACKS = [
14716
+ "beforeSave",
14717
+ "afterSave",
14718
+ "beforeDelete",
14719
+ "afterDelete"
14720
+ ];
14721
+ /**
14722
+ * Warn when the auth collection hangs data callbacks that auth will not fire.
14723
+ *
14724
+ * Creating a user through the auth subsystem — registration, OAuth, the admin
14725
+ * user routes — writes to the user store directly, because that path owns
14726
+ * password hashing, identity rows and its own transaction. It deliberately does
14727
+ * not go through the collection save pipeline: a `beforeSave` able to rewrite
14728
+ * `password_hash` on its way to the database is a footgun, not a feature, and
14729
+ * the auth hooks (`afterUserCreate`, `beforeUserCreate`, …) exist to hang
14730
+ * behaviour off those events with the right contract.
14731
+ *
14732
+ * The cost is a reasonable expectation quietly not being met: someone puts
14733
+ * "send the welcome email" in `afterSave` on their users collection, tests it
14734
+ * by creating a user in the admin, and it works — because *that* is a
14735
+ * collection write. Then a real signup does nothing at all. Which is why this
14736
+ * is said at boot, naming the callbacks that will not run.
14737
+ */
14738
+ function warnOnAuthCollectionDataCallbacks(collection) {
14739
+ if (!collection?.callbacks) return;
14740
+ const declared = DATA_CALLBACKS.filter((name) => typeof collection.callbacks?.[name] === "function");
14741
+ if (declared.length === 0) return;
14742
+ logger.warn(`[Auth] The auth collection "${collection.slug}" defines ${declared.join("/")} callback(s), but these do NOT fire when users are created or updated through the auth system (registration, admin, OAuth) — that path bypasses the collection save pipeline. Use auth hooks (afterUserCreate, beforeUserCreate, afterUserDelete, …) for those side effects.`);
14743
+ }
14744
+ //#endregion
13953
14745
  //#region ../client/dist/index.es.js
13954
14746
  function rebaseReviver(_key, value) {
13955
14747
  if (value && typeof value === "object" && "__type" in value) {
@@ -14001,6 +14793,28 @@ function buildQueryString(params) {
14001
14793
  }
14002
14794
  return parts.length > 0 ? "?" + parts.join("&") : "";
14003
14795
  }
14796
+ /**
14797
+ * The base every request and every caller-built URL resolves against.
14798
+ *
14799
+ * `baseUrl` is optional because the common production shape is a Rebase
14800
+ * backend serving its own SPA, where the API is simply the page's origin.
14801
+ * Leaving it unset is therefore the *correct* configuration there — and the
14802
+ * one that keeps working when a second hostname (a custom domain) points at
14803
+ * the same app.
14804
+ *
14805
+ * When unset in a browser this resolves to the page origin rather than "".
14806
+ * Requests behave identically either way, but the empty string is a trap for
14807
+ * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
14808
+ * throws, so apps "fixed" it by baking an absolute host into their bundle —
14809
+ * which is exactly what breaks the day a custom domain is added, and which no
14810
+ * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
14811
+ * not sent cross-site either.
14812
+ */
14813
+ function resolveBaseUrl(configured) {
14814
+ if (configured) return configured.replace(/\/$/, "");
14815
+ if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
14816
+ return "";
14817
+ }
14004
14818
  function createTransport(config) {
14005
14819
  const fetchFn = config.fetch || globalThis.fetch;
14006
14820
  const apiPath = config.apiPath || "/api";
@@ -14015,7 +14829,7 @@ function createTransport(config) {
14015
14829
  };
14016
14830
  }
14017
14831
  async function request(path, init) {
14018
- const url = (config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "") + apiPath + path;
14832
+ const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
14019
14833
  let activeToken = token;
14020
14834
  if (tokenGetter) try {
14021
14835
  const fetched = await tokenGetter();
@@ -14090,7 +14904,7 @@ function createTransport(config) {
14090
14904
  onUnauthorizedHandler = handler;
14091
14905
  },
14092
14906
  get baseUrl() {
14093
- return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
14907
+ return resolveBaseUrl(config.baseUrl);
14094
14908
  },
14095
14909
  get apiPath() {
14096
14910
  return apiPath;
@@ -14550,6 +15364,30 @@ function createAuth(transport, options) {
14550
15364
  })
14551
15365
  });
14552
15366
  }
15367
+ /**
15368
+ * Link an OAuth provider to the **currently signed-in** account.
15369
+ *
15370
+ * Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
15371
+ * with that email already exists under a different sign-in method — or to
15372
+ * attach a provider whose email differs from the account's.
15373
+ *
15374
+ * The payload is the same one the provider's sign-in method takes, e.g.
15375
+ * `linkProvider("google", { idToken })`.
15376
+ *
15377
+ * Unlike sign-in, this does not require the provider to have verified the
15378
+ * email, and the emails need not match: the active session already proves
15379
+ * account ownership.
15380
+ *
15381
+ * Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
15382
+ * attached to a different user. Succeeds idempotently (`alreadyLinked:
15383
+ * true`) if it is already attached to the current one.
15384
+ */
15385
+ async function linkProvider(providerId, payload) {
15386
+ return transport.request(authPath + "/link/" + providerId, {
15387
+ method: "POST",
15388
+ body: JSON.stringify(payload)
15389
+ });
15390
+ }
14553
15391
  async function sendVerificationEmail() {
14554
15392
  return transport.request(authPath + "/send-verification", { method: "POST" });
14555
15393
  }
@@ -14671,6 +15509,7 @@ function createAuth(transport, options) {
14671
15509
  resetPasswordForEmail,
14672
15510
  resetPassword,
14673
15511
  changePassword,
15512
+ linkProvider,
14674
15513
  sendVerificationEmail,
14675
15514
  verifyEmail,
14676
15515
  sendMagicLink,
@@ -14933,7 +15772,7 @@ var SDKQueryBuilder = class {
14933
15772
  * Listen to realtime updates matching this query.
14934
15773
  */
14935
15774
  listen(onUpdate, onError) {
14936
- if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
15775
+ if (!this.collection.listen) throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl, and not when it was created with realtime: false.");
14937
15776
  return this.collection.listen(this.params, onUpdate, onError);
14938
15777
  }
14939
15778
  };
@@ -14966,6 +15805,17 @@ function createCollectionClient(transport, slug, ws) {
14966
15805
  body: JSON.stringify(body)
14967
15806
  });
14968
15807
  },
15808
+ async createMany(data, options) {
15809
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
15810
+ if (data.length === 0) return [];
15811
+ return (await transport.request(`${basePath}/bulk`, {
15812
+ method: "POST",
15813
+ body: JSON.stringify({
15814
+ rows: data,
15815
+ ...options?.upsert ? { upsert: true } : {}
15816
+ })
15817
+ })).data || [];
15818
+ },
14969
15819
  async update(id, data) {
14970
15820
  return await transport.request(`${basePath}/${encodeURIComponent(String(id))}`, {
14971
15821
  method: "PUT",
@@ -15274,12 +16124,29 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
15274
16124
  function extractMessageError(message) {
15275
16125
  const payload = message.payload;
15276
16126
  const errPayload = payload?.error;
16127
+ const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
16128
+ const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
15277
16129
  return {
15278
- errorMessage: typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error",
15279
- errorCode: typeof errPayload === "object" ? errPayload.code : payload?.code
16130
+ errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
16131
+ errorCode
15280
16132
  };
15281
16133
  }
15282
16134
  /**
16135
+ * Broadcast and presence frames.
16136
+ *
16137
+ * Fire-and-forget (the server sends no response envelope), and exempt from the
16138
+ * client-side auth gate — a public channel is usable without an account.
16139
+ */
16140
+ var CHANNEL_MESSAGE_TYPES = new Set([
16141
+ "join_channel",
16142
+ "leave_channel",
16143
+ "broadcast",
16144
+ "presence_track",
16145
+ "presence_untrack",
16146
+ "presence_state",
16147
+ "channel_history"
16148
+ ]);
16149
+ /**
15283
16150
  * Low-level realtime WebSocket client.
15284
16151
  *
15285
16152
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
@@ -15294,6 +16161,36 @@ var RebaseWebSocketClient = class {
15294
16161
  getAuthToken;
15295
16162
  subscriptions = /* @__PURE__ */ new Map();
15296
16163
  listeners = /* @__PURE__ */ new Map();
16164
+ /** Channel-name → handlers, for broadcast and presence frames. */
16165
+ channelHandlers = /* @__PURE__ */ new Map();
16166
+ /** Set by `close()`. Blocks any later operation from silently redialling. */
16167
+ closedByCaller = false;
16168
+ /**
16169
+ * Whether a socket exists at all (open or still opening).
16170
+ *
16171
+ * Lets callers distinguish "authenticate the live socket" from "there is
16172
+ * nothing to authenticate yet", without that question forcing a dial.
16173
+ */
16174
+ get hasSocket() {
16175
+ return this.ws !== null;
16176
+ }
16177
+ /** So the "no WebSocket in this environment" warning is said once, not per call. */
16178
+ warnedNoWebSocket = false;
16179
+ /** Subscribe to broadcast/presence frames for one channel. */
16180
+ onChannelMessage(channel, handler) {
16181
+ if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
16182
+ this.channelHandlers.get(channel).add(handler);
16183
+ return () => {
16184
+ const handlers = this.channelHandlers.get(channel);
16185
+ if (!handlers) return;
16186
+ handlers.delete(handler);
16187
+ if (handlers.size === 0) this.channelHandlers.delete(channel);
16188
+ };
16189
+ }
16190
+ /** Notified after the socket comes back, so channels can re-join. */
16191
+ onReconnect(handler) {
16192
+ return this.on("reconnect", handler);
16193
+ }
15297
16194
  on(event, cb) {
15298
16195
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
15299
16196
  this.listeners.get(event).add(cb);
@@ -15312,6 +16209,7 @@ var RebaseWebSocketClient = class {
15312
16209
  isConnected = false;
15313
16210
  messageQueue = [];
15314
16211
  requestTimeoutMs = 3e4;
16212
+ subscriptionTimeoutMs = 3e4;
15315
16213
  reconnectTimeout = null;
15316
16214
  isAuthenticated = false;
15317
16215
  authPromise = null;
@@ -15323,8 +16221,25 @@ var RebaseWebSocketClient = class {
15323
16221
  this.getAuthToken = config.getAuthToken;
15324
16222
  this.onUnauthorized = config.onUnauthorized;
15325
16223
  this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
15326
- if (!this.WebSocketConstructor) console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
15327
- else this.initWebSocket();
16224
+ }
16225
+ /**
16226
+ * Open the socket if it is not open (or opening) already.
16227
+ *
16228
+ * Idempotent, synchronous, and safe to call on every operation that needs a
16229
+ * live socket — `initWebSocket` already no-ops on an open socket and is
16230
+ * re-entrant, since the reconnect path has always called it.
16231
+ */
16232
+ ensureConnected() {
16233
+ if (this.closedByCaller) return;
16234
+ if (!this.WebSocketConstructor) {
16235
+ if (!this.warnedNoWebSocket) {
16236
+ this.warnedNoWebSocket = true;
16237
+ console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
16238
+ }
16239
+ return;
16240
+ }
16241
+ if (this.ws || this.reconnectTimeout) return;
16242
+ this.initWebSocket();
15328
16243
  }
15329
16244
  /**
15330
16245
  * Authenticate the WebSocket connection
@@ -15374,7 +16289,16 @@ var RebaseWebSocketClient = class {
15374
16289
  });
15375
16290
  }
15376
16291
  }
15377
- disconnect() {
16292
+ /**
16293
+ * Drop the socket.
16294
+ *
16295
+ * `permanent` distinguishes the two callers. Signing out drops the socket
16296
+ * but the client stays usable — a later subscribe should reconnect
16297
+ * anonymously. `client.close()` is the caller saying they are done, and
16298
+ * must not be undone by a stray queued frame.
16299
+ */
16300
+ disconnect(permanent = false) {
16301
+ if (permanent) this.closedByCaller = true;
15378
16302
  this.isAuthenticated = false;
15379
16303
  this.authPromise = null;
15380
16304
  if (this.reconnectTimeout) {
@@ -15417,6 +16341,7 @@ var RebaseWebSocketClient = class {
15417
16341
  this.emit(wasReconnect ? "reconnect" : "connect");
15418
16342
  this.processMessageQueue();
15419
16343
  if (wasReconnect) this.resubscribeAll();
16344
+ this.armPendingSubscribeWatchdogs();
15420
16345
  };
15421
16346
  this.ws.onmessage = (event) => {
15422
16347
  try {
@@ -15431,6 +16356,7 @@ var RebaseWebSocketClient = class {
15431
16356
  this.isConnected = false;
15432
16357
  this.isAuthenticated = false;
15433
16358
  this.authPromise = null;
16359
+ this.suspendSubscribeWatchdogs();
15434
16360
  this.emit("disconnect");
15435
16361
  for (const [reqId, request] of this.pendingRequests.entries()) {
15436
16362
  if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
@@ -15462,6 +16388,7 @@ var RebaseWebSocketClient = class {
15462
16388
  attemptReconnect() {
15463
16389
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
15464
16390
  console.error("Max reconnection attempts reached");
16391
+ this.failAllPendingSubscriptions(new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" }));
15465
16392
  return;
15466
16393
  }
15467
16394
  this.reconnectAttempts++;
@@ -15516,29 +16443,18 @@ var RebaseWebSocketClient = class {
15516
16443
  subscription.backendSubscriptionId = newBackendId;
15517
16444
  backendKeyMap.delete(oldBackendId);
15518
16445
  backendKeyMap.set(newBackendId, subscriptionKey);
15519
- this.sendMessage({
15520
- type: messageType,
15521
- payload: {
15522
- ...subscription.props,
15523
- subscriptionId: newBackendId
15524
- }
15525
- }).catch((error) => {
15526
- console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
15527
- subscription.callbacks.forEach((callback) => {
15528
- if (callback.onError) callback.onError(error);
15529
- });
15530
- });
15531
- } else {
15532
- const { errorMessage, errorCode } = extractMessageError(message);
15533
- const error = new RebaseApiError(errorMessage, { code: errorCode });
15534
- subscription.callbacks.forEach((callback) => {
15535
- if (callback.onError) callback.onError(error);
15536
- });
16446
+ if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
16447
+ else this.sendEntitySubscribe(subscriptionKey);
16448
+ return;
15537
16449
  }
16450
+ const { errorMessage, errorCode } = extractMessageError(message);
16451
+ const error = new RebaseApiError(errorMessage, { code: errorCode });
16452
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
16453
+ else this.failEntitySubscription(subscriptionKey, error);
15538
16454
  }).catch((err) => {
15539
- subscription.callbacks.forEach((callback) => {
15540
- if (callback.onError) callback.onError(err);
15541
- });
16455
+ const error = err instanceof Error ? err : new Error(String(err));
16456
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
16457
+ else this.failEntitySubscription(subscriptionKey, error);
15542
16458
  });
15543
16459
  }
15544
16460
  handleWebSocketMessage(message) {
@@ -15567,16 +16483,30 @@ var RebaseWebSocketClient = class {
15567
16483
  }
15568
16484
  return;
15569
16485
  }
16486
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
16487
+ const handlers = this.channelHandlers.get(message.channel);
16488
+ if (handlers) for (const handler of [...handlers]) try {
16489
+ handler(message);
16490
+ } catch (error) {
16491
+ console.error("Error in channel handler:", error);
16492
+ }
16493
+ return;
16494
+ }
15570
16495
  if (subscriptionId && type === "collection_update") {
15571
16496
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
15572
16497
  if (subscriptionKey) {
15573
16498
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
15574
16499
  if (collectionSub) {
15575
16500
  const incomingRows = message.rows || [];
15576
- const rows = this.mergeRows(collectionSub.latestData, incomingRows);
16501
+ const updatePks = message.pks;
16502
+ if (updatePks) collectionSub.pks = updatePks;
16503
+ const rows = this.mergeRows(collectionSub.latestData, incomingRows, collectionSub.pks);
15577
16504
  collectionSub.latestData = rows;
15578
16505
  collectionSub.lastUpdated = Date.now();
15579
16506
  collectionSub.isInitialDataReceived = true;
16507
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
16508
+ collectionSub.subscribeTimeout = void 0;
16509
+ collectionSub.subscribeInFlight = false;
15580
16510
  collectionSub.callbacks.forEach((callback) => {
15581
16511
  try {
15582
16512
  callback.onUpdate(rows);
@@ -15595,12 +16525,14 @@ var RebaseWebSocketClient = class {
15595
16525
  const collectionSub = this.collectionSubscriptions.get(subscriptionKey);
15596
16526
  if (collectionSub && collectionSub.isInitialDataReceived && collectionSub.latestData) {
15597
16527
  const patchWireEntity = message.row ?? null;
15598
- const patchEntityId = message.id;
16528
+ const patchMessage = message;
16529
+ const patchEntityId = patchMessage.id;
16530
+ if (patchMessage.pks) collectionSub.pks = patchMessage.pks;
15599
16531
  const patchRow = patchWireEntity ? patchWireEntity : null;
15600
16532
  let updated;
15601
- if (patchRow === null) updated = collectionSub.latestData.filter((e) => String(e.id) !== String(patchEntityId));
16533
+ if (patchRow === null) updated = collectionSub.latestData.filter((e) => this.rowAddress(e, collectionSub.pks) !== String(patchEntityId));
15602
16534
  else {
15603
- const idx = collectionSub.latestData.findIndex((e) => String(e.id) === String(patchRow.id));
16535
+ const idx = collectionSub.latestData.findIndex((e) => this.rowAddress(e, collectionSub.pks) === String(patchEntityId));
15604
16536
  if (idx >= 0) {
15605
16537
  updated = [...collectionSub.latestData];
15606
16538
  updated[idx] = patchRow;
@@ -15630,6 +16562,9 @@ var RebaseWebSocketClient = class {
15630
16562
  entitySub.latestData = row;
15631
16563
  entitySub.lastUpdated = Date.now();
15632
16564
  entitySub.isInitialDataReceived = true;
16565
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
16566
+ entitySub.subscribeTimeout = void 0;
16567
+ entitySub.subscribeInFlight = false;
15633
16568
  entitySub.callbacks.forEach((callback) => {
15634
16569
  try {
15635
16570
  callback.onUpdate(row);
@@ -15651,6 +16586,9 @@ var RebaseWebSocketClient = class {
15651
16586
  this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
15652
16587
  return;
15653
16588
  }
16589
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
16590
+ collectionSub.subscribeTimeout = void 0;
16591
+ collectionSub.subscribeInFlight = false;
15654
16592
  const { errorMessage, errorCode } = extractMessageError(message);
15655
16593
  const error = new RebaseApiError(errorMessage, { code: errorCode });
15656
16594
  collectionSub.callbacks.forEach((callback) => {
@@ -15667,6 +16605,9 @@ var RebaseWebSocketClient = class {
15667
16605
  this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
15668
16606
  return;
15669
16607
  }
16608
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
16609
+ entitySub.subscribeTimeout = void 0;
16610
+ entitySub.subscribeInFlight = false;
15670
16611
  const { errorMessage, errorCode } = extractMessageError(message);
15671
16612
  const error = new RebaseApiError(errorMessage, { code: errorCode });
15672
16613
  entitySub.callbacks.forEach((callback) => {
@@ -15739,21 +16680,28 @@ var RebaseWebSocketClient = class {
15739
16680
  throw error;
15740
16681
  }
15741
16682
  }
16683
+ /**
16684
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
16685
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
16686
+ */
15742
16687
  sendMessage(message) {
15743
16688
  const queuedMsg = message;
15744
16689
  if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
15745
- if (!this.isConnected || !this.ws) return new Promise((resolve, reject) => {
15746
- const queueable = message;
15747
- queueable._queuedResolve = resolve;
15748
- queueable._queuedReject = reject;
15749
- this.messageQueue.push(message);
15750
- });
16690
+ if (!this.isConnected || !this.ws) {
16691
+ this.ensureConnected();
16692
+ return new Promise((resolve, reject) => {
16693
+ const queueable = message;
16694
+ queueable._queuedResolve = resolve;
16695
+ queueable._queuedReject = reject;
16696
+ this.messageQueue.push(message);
16697
+ });
16698
+ }
15751
16699
  return new Promise((resolve, reject) => {
15752
16700
  this.doSendMessage(message, resolve, reject);
15753
16701
  });
15754
16702
  }
15755
16703
  async doSendMessage(message, resolve, reject) {
15756
- if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
16704
+ if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
15757
16705
  await this.ensureAuthenticated();
15758
16706
  } catch (error) {
15759
16707
  reject(new RebaseApiError(error instanceof Error ? error.message : "Authentication required"));
@@ -15761,17 +16709,7 @@ var RebaseWebSocketClient = class {
15761
16709
  }
15762
16710
  const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
15763
16711
  message.requestId = requestId;
15764
- const expectsResponse = ![
15765
- "subscribe_collection",
15766
- "subscribe_one",
15767
- "unsubscribe",
15768
- "join_channel",
15769
- "leave_channel",
15770
- "broadcast",
15771
- "presence_track",
15772
- "presence_untrack",
15773
- "presence_state"
15774
- ].includes(message.type);
16712
+ const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
15775
16713
  if (expectsResponse && !this.pendingRequests.has(requestId)) {
15776
16714
  const timeoutHandle = setTimeout(() => {
15777
16715
  if (this.pendingRequests.has(requestId)) {
@@ -15841,6 +16779,9 @@ var RebaseWebSocketClient = class {
15841
16779
  async fetchAvailableRoles() {
15842
16780
  return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
15843
16781
  }
16782
+ async fetchApplicationRoles() {
16783
+ return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
16784
+ }
15844
16785
  async fetchCurrentDatabase() {
15845
16786
  return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
15846
16787
  }
@@ -15950,17 +16891,39 @@ var RebaseWebSocketClient = class {
15950
16891
  return val;
15951
16892
  }
15952
16893
  /**
16894
+ * The address of a row, for matching it against another copy of itself.
16895
+ *
16896
+ * A row is exactly its columns and carries no address, so it is derived
16897
+ * from the key columns the server named — including the ordinary case where
16898
+ * that key is `id`, which the server reports like any other.
16899
+ *
16900
+ * Undefined when there are no keys, which means the server could not
16901
+ * resolve any: such rows genuinely cannot be recognised, and guessing at a
16902
+ * column called `id` would be inventing an identity for a table that has
16903
+ * none.
16904
+ */
16905
+ rowAddress(row, pks) {
16906
+ if (!pks || pks.length === 0) return void 0;
16907
+ const address = buildCompositeId(row, pks);
16908
+ if (!address || address.split(":::").every((part) => part === "")) return void 0;
16909
+ return address;
16910
+ }
16911
+ /**
15953
16912
  * Merge incoming rows with cached data, preserving cached references
15954
16913
  * for rows whose values haven't changed. This avoids unnecessary
15955
16914
  * React re-renders when the server refetches all rows but most
15956
16915
  * haven't actually changed.
15957
16916
  */
15958
- mergeRows(cached, incoming) {
16917
+ mergeRows(cached, incoming, pks) {
15959
16918
  if (!cached || cached.length === 0) return incoming;
15960
16919
  const cachedById = /* @__PURE__ */ new Map();
15961
- for (const row of cached) cachedById.set(row.id, row);
16920
+ for (const row of cached) {
16921
+ const address = this.rowAddress(row, pks);
16922
+ if (address !== void 0) cachedById.set(address, row);
16923
+ }
15962
16924
  return incoming.map((incomingRow) => {
15963
- const cachedRow = cachedById.get(incomingRow.id);
16925
+ const address = this.rowAddress(incomingRow, pks);
16926
+ const cachedRow = address === void 0 ? void 0 : cachedById.get(address);
15964
16927
  if (!cachedRow) return incomingRow;
15965
16928
  const normCached = this.normalizeForComparison(cachedRow);
15966
16929
  const normIncoming = this.normalizeForComparison(incomingRow);
@@ -15972,12 +16935,13 @@ var RebaseWebSocketClient = class {
15972
16935
  cached: normCached[key],
15973
16936
  incoming: normIncoming[key]
15974
16937
  };
15975
- console.debug(`[RebaseWS] Row ${incomingRow.id} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
16938
+ console.debug(`[RebaseWS] Row ${address} refetch mismatch:\n`, JSON.stringify(mismatches, null, 2));
15976
16939
  }
15977
16940
  return incomingRow;
15978
16941
  });
15979
16942
  }
15980
16943
  listenCollection(props, onUpdate, onError) {
16944
+ this.ensureConnected();
15981
16945
  const subscriptionKey = this.createCollectionSubscriptionKey(props);
15982
16946
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
15983
16947
  const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
@@ -15993,9 +16957,12 @@ var RebaseWebSocketClient = class {
15993
16957
  console.error("Error in collection subscription callback:", error);
15994
16958
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
15995
16959
  }
16960
+ else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
15996
16961
  return () => {
15997
16962
  callbackMap.delete(callbackId);
15998
16963
  if (callbackMap.size === 0) {
16964
+ if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
16965
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
15999
16966
  this.collectionSubscriptions.delete(subscriptionKey);
16000
16967
  this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
16001
16968
  if (this.isConnected && this.ws) this.sendMessage({
@@ -16017,21 +16984,14 @@ var RebaseWebSocketClient = class {
16017
16984
  props
16018
16985
  });
16019
16986
  this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
16020
- this.sendMessage({
16021
- type: "subscribe_collection",
16022
- payload: {
16023
- ...props,
16024
- subscriptionId: backendSubscriptionId
16025
- }
16026
- }).catch((error) => {
16027
- if (onError) onError(error);
16028
- });
16987
+ this.sendCollectionSubscribe(subscriptionKey);
16029
16988
  return () => {
16030
16989
  const subscription = this.collectionSubscriptions.get(subscriptionKey);
16031
16990
  if (subscription) {
16032
16991
  const callbacks = subscription.callbacks;
16033
16992
  callbacks.delete(callbackId);
16034
16993
  if (callbacks.size === 0) {
16994
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
16035
16995
  this.collectionSubscriptions.delete(subscriptionKey);
16036
16996
  this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
16037
16997
  if (this.isConnected && this.ws) this.sendMessage({
@@ -16043,6 +17003,7 @@ var RebaseWebSocketClient = class {
16043
17003
  };
16044
17004
  }
16045
17005
  listenOne(props, onUpdate, onError) {
17006
+ this.ensureConnected();
16046
17007
  const subscriptionKey = this.createSingleSubscriptionKey(props);
16047
17008
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
16048
17009
  const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
@@ -16058,9 +17019,12 @@ var RebaseWebSocketClient = class {
16058
17019
  console.error("Error in row subscription callback:", error);
16059
17020
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
16060
17021
  }
17022
+ else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
16061
17023
  return () => {
16062
17024
  callbackMap.delete(callbackId);
16063
17025
  if (callbackMap.size === 0) {
17026
+ if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
17027
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
16064
17028
  this.singleSubscriptions.delete(subscriptionKey);
16065
17029
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
16066
17030
  if (this.isConnected && this.ws) this.sendMessage({
@@ -16082,15 +17046,7 @@ var RebaseWebSocketClient = class {
16082
17046
  props
16083
17047
  });
16084
17048
  this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
16085
- this.sendMessage({
16086
- type: "subscribe_one",
16087
- payload: {
16088
- ...props,
16089
- subscriptionId: backendSubscriptionId
16090
- }
16091
- }).catch((error) => {
16092
- if (onError) onError(error);
16093
- });
17049
+ this.sendEntitySubscribe(subscriptionKey);
16094
17050
  return () => {
16095
17051
  const subscription = this.singleSubscriptions.get(subscriptionKey);
16096
17052
  if (subscription) {
@@ -16108,6 +17064,157 @@ var RebaseWebSocketClient = class {
16108
17064
  };
16109
17065
  }
16110
17066
  /**
17067
+ * Send a `subscribe_collection` for an already-registered subscription and
17068
+ * arm its watchdog.
17069
+ *
17070
+ * Every path that registers a collection subscription goes through here, so
17071
+ * that a subscribe which never lands — a rejected send, or a server that
17072
+ * never answers — always ends up in `failCollectionSubscription` rather than
17073
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
17074
+ */
17075
+ sendCollectionSubscribe(subscriptionKey) {
17076
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
17077
+ if (!subscription) return;
17078
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17079
+ subscription.subscribeInFlight = true;
17080
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17081
+ subscription.subscribeTimeout = void 0;
17082
+ if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
17083
+ this.sendMessage({
17084
+ type: "subscribe_collection",
17085
+ payload: {
17086
+ ...subscription.props,
17087
+ subscriptionId: backendSubscriptionId
17088
+ }
17089
+ }).catch((error) => {
17090
+ const current = this.collectionSubscriptions.get(subscriptionKey);
17091
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17092
+ this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
17093
+ });
17094
+ }
17095
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
17096
+ sendEntitySubscribe(subscriptionKey) {
17097
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
17098
+ if (!subscription) return;
17099
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17100
+ subscription.subscribeInFlight = true;
17101
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17102
+ subscription.subscribeTimeout = void 0;
17103
+ if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
17104
+ this.sendMessage({
17105
+ type: "subscribe_one",
17106
+ payload: {
17107
+ ...subscription.props,
17108
+ subscriptionId: backendSubscriptionId
17109
+ }
17110
+ }).catch((error) => {
17111
+ const current = this.singleSubscriptions.get(subscriptionKey);
17112
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17113
+ this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
17114
+ });
17115
+ }
17116
+ /**
17117
+ * Report a subscribe failure to every listener and drop the registration.
17118
+ *
17119
+ * Dropping it is the point: the callbacks stay live (their components are
17120
+ * still mounted and have been told), but the next `listenCollection` for
17121
+ * these params finds no entry and issues a fresh subscribe instead of
17122
+ * silently attaching to a dead one.
17123
+ */
17124
+ failCollectionSubscription(subscriptionKey, error) {
17125
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
17126
+ if (!subscription) return;
17127
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17128
+ subscription.subscribeInFlight = false;
17129
+ this.collectionSubscriptions.delete(subscriptionKey);
17130
+ this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
17131
+ subscription.callbacks.forEach((callback) => {
17132
+ if (callback.onError) try {
17133
+ callback.onError(error);
17134
+ } catch (callbackError) {
17135
+ console.error("Error in collection subscription error callback:", callbackError);
17136
+ }
17137
+ });
17138
+ }
17139
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
17140
+ failEntitySubscription(subscriptionKey, error) {
17141
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
17142
+ if (!subscription) return;
17143
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17144
+ subscription.subscribeInFlight = false;
17145
+ this.singleSubscriptions.delete(subscriptionKey);
17146
+ this.backendToEntityKey.delete(subscription.backendSubscriptionId);
17147
+ subscription.callbacks.forEach((callback) => {
17148
+ if (callback.onError) try {
17149
+ callback.onError(error);
17150
+ } catch (callbackError) {
17151
+ console.error("Error in row subscription error callback:", callbackError);
17152
+ }
17153
+ });
17154
+ }
17155
+ /**
17156
+ * Stop the watchdogs without failing anything — used when the socket drops,
17157
+ * since the reconnect path re-subscribes everything anyway and a watchdog
17158
+ * firing mid-reconnect would tear down healthy subscriptions.
17159
+ */
17160
+ suspendSubscribeWatchdogs() {
17161
+ for (const sub of this.collectionSubscriptions.values()) {
17162
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
17163
+ sub.subscribeTimeout = void 0;
17164
+ sub.subscribeInFlight = false;
17165
+ }
17166
+ for (const sub of this.singleSubscriptions.values()) {
17167
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
17168
+ sub.subscribeTimeout = void 0;
17169
+ sub.subscribeInFlight = false;
17170
+ }
17171
+ }
17172
+ /**
17173
+ * Arm watchdogs for subscribes that were requested while offline and have
17174
+ * just been flushed to the socket. Their timers were deliberately not set at
17175
+ * request time, so without this they would have no timeout at all.
17176
+ */
17177
+ armPendingSubscribeWatchdogs() {
17178
+ for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
17179
+ for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
17180
+ }
17181
+ sendCollectionSubscribeWatchdog(subscriptionKey) {
17182
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
17183
+ if (!subscription) return;
17184
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17185
+ subscription.subscribeTimeout = setTimeout(() => {
17186
+ const current = this.collectionSubscriptions.get(subscriptionKey);
17187
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17188
+ if (!current.subscribeInFlight) return;
17189
+ this.failCollectionSubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
17190
+ }, this.subscriptionTimeoutMs);
17191
+ }
17192
+ sendEntitySubscribeWatchdog(subscriptionKey) {
17193
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
17194
+ if (!subscription) return;
17195
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17196
+ subscription.subscribeTimeout = setTimeout(() => {
17197
+ const current = this.singleSubscriptions.get(subscriptionKey);
17198
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17199
+ if (!current.subscribeInFlight) return;
17200
+ this.failEntitySubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
17201
+ }, this.subscriptionTimeoutMs);
17202
+ }
17203
+ /**
17204
+ * Fail every subscription that never received data. Called when reconnection
17205
+ * is given up on, so views surface an error instead of spinning forever.
17206
+ */
17207
+ failAllPendingSubscriptions(error) {
17208
+ for (const key of [...this.collectionSubscriptions.keys()]) {
17209
+ const sub = this.collectionSubscriptions.get(key);
17210
+ if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
17211
+ }
17212
+ for (const key of [...this.singleSubscriptions.keys()]) {
17213
+ const sub = this.singleSubscriptions.get(key);
17214
+ if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
17215
+ }
17216
+ }
17217
+ /**
16111
17218
  * Re-send all active subscriptions to the backend after a reconnect.
16112
17219
  * The server wipes subscription state when a client disconnects, so
16113
17220
  * we need to re-register everything to resume receiving updates.
@@ -16120,15 +17227,7 @@ var RebaseWebSocketClient = class {
16120
17227
  sub.backendSubscriptionId = newBackendId;
16121
17228
  this.backendToCollectionKey.delete(oldBackendId);
16122
17229
  this.backendToCollectionKey.set(newBackendId, key);
16123
- this.sendMessage({
16124
- type: "subscribe_collection",
16125
- payload: {
16126
- ...sub.props,
16127
- subscriptionId: newBackendId
16128
- }
16129
- }).catch((error) => {
16130
- console.error("[WS] Failed to re-subscribe collection:", key, error);
16131
- });
17230
+ this.sendCollectionSubscribe(key);
16132
17231
  }
16133
17232
  for (const [key, sub] of this.singleSubscriptions.entries()) {
16134
17233
  const oldBackendId = sub.backendSubscriptionId;
@@ -16136,15 +17235,7 @@ var RebaseWebSocketClient = class {
16136
17235
  sub.backendSubscriptionId = newBackendId;
16137
17236
  this.backendToEntityKey.delete(oldBackendId);
16138
17237
  this.backendToEntityKey.set(newBackendId, key);
16139
- this.sendMessage({
16140
- type: "subscribe_one",
16141
- payload: {
16142
- ...sub.props,
16143
- subscriptionId: newBackendId
16144
- }
16145
- }).catch((error) => {
16146
- console.error("[WS] Failed to re-subscribe row:", key, error);
16147
- });
17238
+ this.sendEntitySubscribe(key);
16148
17239
  }
16149
17240
  }
16150
17241
  createCollectionSubscriptionKey(props) {
@@ -16171,6 +17262,377 @@ var RebaseWebSocketClient = class {
16171
17262
  }
16172
17263
  };
16173
17264
  /**
17265
+ * Re-send presence comfortably inside the server's 30s expiry.
17266
+ *
17267
+ * Two-thirds of the window: one lost heartbeat still leaves time for the next
17268
+ * before the entry is reaped, so a single dropped frame is not a disappearance.
17269
+ */
17270
+ var PRESENCE_HEARTBEAT_MS = 2e4;
17271
+ /**
17272
+ * How long live messages are held back waiting for a catch-up response.
17273
+ *
17274
+ * Short, because the cost of waiting is visible — on a collaborative document
17275
+ * this is a stall in everyone else's edits appearing. Long enough that a slow
17276
+ * replay of a busy channel is not abandoned needlessly.
17277
+ */
17278
+ var CATCH_UP_TIMEOUT_MS = 1e4;
17279
+ var RebaseRealtimeChannel = class {
17280
+ name;
17281
+ transport;
17282
+ presenceHandlers = /* @__PURE__ */ new Set();
17283
+ broadcastHandlers = /* @__PURE__ */ new Set();
17284
+ unsubscribers = [];
17285
+ /** Last known roster, kept so handlers always get a full picture. */
17286
+ presences = {};
17287
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
17288
+ trackedState = null;
17289
+ heartbeat = null;
17290
+ joined = false;
17291
+ /** Whether this handle asks the server to replay missed messages. */
17292
+ wantsHistory;
17293
+ /**
17294
+ * Highest sequence number delivered to handlers so far.
17295
+ *
17296
+ * This is the resume point sent as `sinceSeq`, and the watermark that makes
17297
+ * replay idempotent: catch-up ranges overlap with what arrived live, and
17298
+ * anything at or below this has already been seen.
17299
+ */
17300
+ lastSeq = 0;
17301
+ /**
17302
+ * Live messages that arrived while a catch-up was in flight.
17303
+ *
17304
+ * Without this they would be delivered ahead of the older messages being
17305
+ * fetched, and — worse — would advance {@link lastSeq} past them, so the
17306
+ * catch-up response would then be discarded as already-seen and those
17307
+ * messages would be lost for good. Held here and flushed, in order, once
17308
+ * the replay lands.
17309
+ */
17310
+ pendingLive = [];
17311
+ catchUpInFlight = false;
17312
+ /**
17313
+ * Deadline for a catch-up response.
17314
+ *
17315
+ * Buffering live messages is only safe because the wait is bounded. A
17316
+ * catch-up frame that never arrives — a server that dropped it, a socket
17317
+ * that died between request and reply — would otherwise leave the channel
17318
+ * silently holding every subsequent edit forever, which is a worse failure
17319
+ * than the one replay was added to fix.
17320
+ */
17321
+ catchUpTimeout = null;
17322
+ /**
17323
+ * Callers of {@link history} awaiting the next `channel_history` frame.
17324
+ *
17325
+ * These frames are addressed by channel rather than by request id, so they
17326
+ * are matched in arrival order. Requests on one channel are serialized by
17327
+ * the socket, so FIFO is the right correlation here.
17328
+ */
17329
+ historyWaiters = [];
17330
+ constructor(name, transport, options = {}) {
17331
+ this.name = name;
17332
+ this.transport = transport;
17333
+ this.wantsHistory = options.history ?? false;
17334
+ }
17335
+ /**
17336
+ * Turn on catch-up for a handle that was created without it.
17337
+ *
17338
+ * The client hands back the same channel object for a given name, so a
17339
+ * later `channel(name, { history: true })` has no new object to configure —
17340
+ * it upgrades this one instead. Idempotent, and never downgrades: one
17341
+ * caller asking for history must not be switched off by another that did
17342
+ * not ask.
17343
+ */
17344
+ enableHistory() {
17345
+ if (this.wantsHistory) return;
17346
+ this.wantsHistory = true;
17347
+ if (this.joined) this.requestHistory();
17348
+ }
17349
+ /**
17350
+ * Join the channel and ask for the current roster.
17351
+ *
17352
+ * Called automatically by `track`, `broadcast`, `onPresence` and
17353
+ * `onBroadcast`; calling it directly is only needed to start receiving
17354
+ * before there is anything to send.
17355
+ */
17356
+ /**
17357
+ * Send a channel message.
17358
+ *
17359
+ * Every channel message is read by the server out of a `payload` envelope
17360
+ * (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
17361
+ * fields flat does not error: `payload?.channel` simply reads as
17362
+ * `undefined`, so the client is registered into channel `undefined` with
17363
+ * empty state, and the echo comes back with no `channel` for
17364
+ * `onChannelMessage` to match — presence and broadcast both go quiet with
17365
+ * nothing logged. Funnelled through one place so a new message type cannot
17366
+ * reintroduce that.
17367
+ */
17368
+ send(type, fields = {}) {
17369
+ return this.transport.sendMessage({
17370
+ type,
17371
+ payload: {
17372
+ channel: this.name,
17373
+ ...fields
17374
+ }
17375
+ });
17376
+ }
17377
+ async join() {
17378
+ if (this.joined) return;
17379
+ this.joined = true;
17380
+ this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
17381
+ this.unsubscribers.push(this.transport.onReconnect(() => {
17382
+ this.rejoin();
17383
+ }));
17384
+ await this.send("join_channel");
17385
+ await this.send("presence_state");
17386
+ if (this.wantsHistory) await this.requestHistory();
17387
+ }
17388
+ async rejoin() {
17389
+ try {
17390
+ await this.send("join_channel");
17391
+ await this.send("presence_state");
17392
+ if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
17393
+ if (this.wantsHistory) await this.requestHistory();
17394
+ } catch {}
17395
+ }
17396
+ /**
17397
+ * Ask the server for everything after {@link lastSeq}.
17398
+ *
17399
+ * Live messages are buffered from here until the answer arrives — see
17400
+ * {@link pendingLive}.
17401
+ */
17402
+ async requestHistory(limit) {
17403
+ this.catchUpInFlight = true;
17404
+ if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
17405
+ this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
17406
+ this.catchUpTimeout.unref?.();
17407
+ try {
17408
+ await this.send("channel_history", {
17409
+ sinceSeq: this.lastSeq,
17410
+ ...limit !== void 0 ? { limit } : {}
17411
+ });
17412
+ } catch {
17413
+ this.abandonCatchUp();
17414
+ }
17415
+ }
17416
+ /**
17417
+ * Give up waiting for a catch-up and release what was held back.
17418
+ *
17419
+ * The buffered messages are still the freshest thing this client has, so
17420
+ * they are delivered rather than dropped. Callers of {@link history} are
17421
+ * answered with `retained: false` — accurate in the sense that matters:
17422
+ * this client has no history to work from and has to resync.
17423
+ */
17424
+ abandonCatchUp() {
17425
+ if (this.catchUpTimeout) {
17426
+ clearTimeout(this.catchUpTimeout);
17427
+ this.catchUpTimeout = null;
17428
+ }
17429
+ if (!this.catchUpInFlight) return;
17430
+ this.catchUpInFlight = false;
17431
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17432
+ messages: [],
17433
+ retained: false
17434
+ });
17435
+ this.flushPendingLive();
17436
+ }
17437
+ /**
17438
+ * Publish this client's presence state, and keep publishing it.
17439
+ *
17440
+ * Calling `track` again replaces the state (and restarts the heartbeat),
17441
+ * which is how you update e.g. a cursor position.
17442
+ */
17443
+ async track(state) {
17444
+ await this.join();
17445
+ this.trackedState = state;
17446
+ await this.send("presence_track", { state });
17447
+ if (!this.heartbeat) {
17448
+ this.heartbeat = setInterval(() => {
17449
+ if (!this.trackedState) return;
17450
+ this.send("presence_track", { state: this.trackedState }).catch(() => {});
17451
+ }, PRESENCE_HEARTBEAT_MS);
17452
+ this.heartbeat.unref?.();
17453
+ }
17454
+ }
17455
+ /** Stop publishing presence, without leaving the channel. */
17456
+ async untrack() {
17457
+ this.stopHeartbeat();
17458
+ this.trackedState = null;
17459
+ if (this.joined) await this.send("presence_untrack");
17460
+ }
17461
+ /**
17462
+ * Observe the roster. The handler fires immediately with what is already
17463
+ * known, then on every change.
17464
+ */
17465
+ onPresence(handler) {
17466
+ this.presenceHandlers.add(handler);
17467
+ this.join();
17468
+ if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
17469
+ return () => this.presenceHandlers.delete(handler);
17470
+ }
17471
+ /** Send a broadcast. The sender does not receive its own message. */
17472
+ async broadcast(event, payload) {
17473
+ await this.join();
17474
+ await this.send("broadcast", {
17475
+ event,
17476
+ payload
17477
+ });
17478
+ }
17479
+ onBroadcast(eventOrHandler, maybeHandler) {
17480
+ const wrapped = typeof eventOrHandler === "string" ? (e) => {
17481
+ if (e.event === eventOrHandler) maybeHandler(e.payload);
17482
+ } : eventOrHandler;
17483
+ this.broadcastHandlers.add(wrapped);
17484
+ this.join();
17485
+ return () => this.broadcastHandlers.delete(wrapped);
17486
+ }
17487
+ /**
17488
+ * The last sequence number this channel has delivered.
17489
+ *
17490
+ * Zero on a channel that retains nothing. Persist it if you want catch-up
17491
+ * to survive a page reload as well as a reconnect, and pass it back via
17492
+ * {@link history}.
17493
+ */
17494
+ get sequence() {
17495
+ return this.lastSeq;
17496
+ }
17497
+ /**
17498
+ * Fetch retained messages explicitly, instead of waiting for join or
17499
+ * reconnect to do it.
17500
+ *
17501
+ * Defaults to resuming from {@link sequence}. Messages are delivered to
17502
+ * `onBroadcast` handlers as usual — the returned value is for callers that
17503
+ * want to inspect the batch, or to learn from `retained` that the channel
17504
+ * keeps no history at all.
17505
+ */
17506
+ async history(options = {}) {
17507
+ await this.join();
17508
+ if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
17509
+ const result = new Promise((resolve) => {
17510
+ this.historyWaiters.push(resolve);
17511
+ });
17512
+ await this.requestHistory(options.limit);
17513
+ return result;
17514
+ }
17515
+ /** Leave the channel and release every listener and timer. */
17516
+ async leave() {
17517
+ this.stopHeartbeat();
17518
+ this.trackedState = null;
17519
+ this.presences = {};
17520
+ this.presenceHandlers.clear();
17521
+ this.broadcastHandlers.clear();
17522
+ this.lastSeq = 0;
17523
+ this.pendingLive = [];
17524
+ this.catchUpInFlight = false;
17525
+ if (this.catchUpTimeout) {
17526
+ clearTimeout(this.catchUpTimeout);
17527
+ this.catchUpTimeout = null;
17528
+ }
17529
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17530
+ messages: [],
17531
+ retained: false
17532
+ });
17533
+ for (const off of this.unsubscribers) off();
17534
+ this.unsubscribers = [];
17535
+ if (this.joined) {
17536
+ this.joined = false;
17537
+ await this.send("leave_channel");
17538
+ }
17539
+ }
17540
+ stopHeartbeat() {
17541
+ if (this.heartbeat) {
17542
+ clearInterval(this.heartbeat);
17543
+ this.heartbeat = null;
17544
+ }
17545
+ }
17546
+ /** Fold an incoming frame into the roster and fan it out. */
17547
+ handle(message) {
17548
+ switch (message.type) {
17549
+ case "presence_state":
17550
+ this.presences = message.presences ?? {};
17551
+ this.emitPresence();
17552
+ break;
17553
+ case "presence_diff": {
17554
+ const joins = message.joins ?? {};
17555
+ const leaves = message.leaves ?? {};
17556
+ for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
17557
+ for (const id of Object.keys(leaves)) delete this.presences[id];
17558
+ this.emitPresence({
17559
+ joins,
17560
+ leaves
17561
+ });
17562
+ break;
17563
+ }
17564
+ case "broadcast": {
17565
+ const seq = typeof message.seq === "number" ? message.seq : void 0;
17566
+ const event = {
17567
+ event: message.event,
17568
+ payload: message.payload,
17569
+ ...seq !== void 0 ? { seq } : {}
17570
+ };
17571
+ if (seq === void 0) {
17572
+ this.deliver(event);
17573
+ break;
17574
+ }
17575
+ if (this.catchUpInFlight) {
17576
+ this.pendingLive.push(event);
17577
+ break;
17578
+ }
17579
+ if (seq <= this.lastSeq) break;
17580
+ this.lastSeq = seq;
17581
+ this.deliver(event);
17582
+ break;
17583
+ }
17584
+ case "channel_history": {
17585
+ this.catchUpInFlight = false;
17586
+ if (this.catchUpTimeout) {
17587
+ clearTimeout(this.catchUpTimeout);
17588
+ this.catchUpTimeout = null;
17589
+ }
17590
+ const entries = message.messages ?? [];
17591
+ const retained = message.retained === true;
17592
+ const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
17593
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17594
+ messages: entries,
17595
+ retained,
17596
+ latestSeq
17597
+ });
17598
+ for (const entry of entries) {
17599
+ if (entry.seq <= this.lastSeq) continue;
17600
+ this.lastSeq = entry.seq;
17601
+ this.deliver({
17602
+ event: entry.event,
17603
+ payload: entry.payload,
17604
+ seq: entry.seq,
17605
+ replayed: true
17606
+ });
17607
+ }
17608
+ this.flushPendingLive();
17609
+ break;
17610
+ }
17611
+ }
17612
+ }
17613
+ /** Deliver everything held back during a catch-up, in sequence order. */
17614
+ flushPendingLive() {
17615
+ if (this.pendingLive.length === 0) return;
17616
+ const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
17617
+ this.pendingLive = [];
17618
+ for (const event of buffered) {
17619
+ const seq = event.seq;
17620
+ if (seq !== void 0) {
17621
+ if (seq <= this.lastSeq) continue;
17622
+ this.lastSeq = seq;
17623
+ }
17624
+ this.deliver(event);
17625
+ }
17626
+ }
17627
+ deliver(event) {
17628
+ for (const handler of [...this.broadcastHandlers]) handler(event);
17629
+ }
17630
+ emitPresence(diff) {
17631
+ const snapshot = { ...this.presences };
17632
+ for (const handler of this.presenceHandlers) handler(snapshot, diff);
17633
+ }
17634
+ };
17635
+ /**
16174
17636
  * Derive a WebSocket URL from an HTTP base URL.
16175
17637
  * `http://` → `ws://`, `https://` → `wss://`.
16176
17638
  */
@@ -16217,8 +17679,10 @@ function createRebaseClient(options) {
16217
17679
  });
16218
17680
  return storageSourcesPromise;
16219
17681
  };
16220
- const resolvedWsUrl = options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl);
17682
+ const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
16221
17683
  let ws;
17684
+ /** One channel object per name — see `realtime.channel`. */
17685
+ const realtimeChannels = /* @__PURE__ */ new Map();
16222
17686
  if (resolvedWsUrl) {
16223
17687
  ws = new RebaseWebSocketClient({
16224
17688
  websocketUrl: resolvedWsUrl,
@@ -16242,7 +17706,7 @@ function createRebaseClient(options) {
16242
17706
  if (!ws) return;
16243
17707
  if (event === "SIGNED_OUT") ws.disconnect();
16244
17708
  else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
16245
- if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
17709
+ if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
16246
17710
  }
16247
17711
  });
16248
17712
  }
@@ -16329,6 +17793,36 @@ function createRebaseClient(options) {
16329
17793
  createStorageSource,
16330
17794
  fetchStorageSources,
16331
17795
  ws,
17796
+ realtime: {
17797
+ /**
17798
+ * Join a broadcast/presence channel.
17799
+ *
17800
+ * Repeated calls with the same name return the same channel, so
17801
+ * separate components can attach handlers without each opening its
17802
+ * own membership — and `leave()` from one would otherwise silently
17803
+ * cut off the others.
17804
+ */
17805
+ channel: (name, options) => {
17806
+ if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
17807
+ let existing = realtimeChannels.get(name);
17808
+ if (!existing) {
17809
+ existing = new RebaseRealtimeChannel(name, ws, options);
17810
+ realtimeChannels.set(name, existing);
17811
+ } else if (options?.history) existing.enableHistory();
17812
+ return existing;
17813
+ } },
17814
+ /**
17815
+ * Release the realtime socket and its reconnect timer.
17816
+ *
17817
+ * Until this returns, the open socket keeps the Node event loop alive
17818
+ * and the process will not exit on its own. Safe to call when realtime
17819
+ * was never started, and safe to call twice.
17820
+ */
17821
+ close: () => {
17822
+ for (const channel of realtimeChannels.values()) channel.leave();
17823
+ realtimeChannels.clear();
17824
+ ws?.disconnect(true);
17825
+ },
16332
17826
  setToken: transport.setToken,
16333
17827
  setAuthTokenGetter: transport.setAuthTokenGetter,
16334
17828
  setOnUnauthorized: transport.setOnUnauthorized,
@@ -16763,16 +18257,7 @@ async function _initializeRebaseBackend(config) {
16763
18257
  const authDs = resolveDataSource(safeAuthConfig.collection, dataSourceRegistry);
16764
18258
  if (authDs.key !== "(default)") logger.warn(`[Auth] The auth collection "${safeAuthConfig.collection.slug}" is on data source "${authDs.key}", but the built-in auth system always uses the default data source. Move the auth collection to the default data source, or replace auth with an AuthAdapter that manages users in "${authDs.key}".`);
16765
18259
  }
16766
- if (safeAuthConfig.collection?.callbacks) {
16767
- const cb = safeAuthConfig.collection.callbacks;
16768
- const dataCallbacks = [
16769
- "beforeSave",
16770
- "afterSave",
16771
- "beforeDelete",
16772
- "afterDelete"
16773
- ].filter((k) => typeof cb[k] === "function");
16774
- if (dataCallbacks.length > 0) logger.warn(`[Auth] The auth collection "${safeAuthConfig.collection.slug}" defines ${dataCallbacks.join("/")} callback(s), but these do NOT fire when users are created or updated through the auth system (registration, admin, OAuth) — that path bypasses the collection save pipeline. Use auth hooks (afterUserCreate, beforeUserCreate, afterUserDelete, …) for those side effects.`);
16775
- }
18260
+ warnOnAuthCollectionDataCallbacks(safeAuthConfig.collection);
16776
18261
  safeAuthConfig.collection && safeAuthConfig.collection.auth;
16777
18262
  if (safeAuthConfig.jwtSecret) configureJwt({
16778
18263
  secret: safeAuthConfig.jwtSecret,
@@ -16800,6 +18285,33 @@ async function _initializeRebaseBackend(config) {
16800
18285
  }
16801
18286
  logger.info("Entity history initialized");
16802
18287
  } else logger.warn("History requested but default bootstrapper does not support initializeHistory");
18288
+ const internalServiceKey = serviceKey || randomBytes(48).toString("base64");
18289
+ if (!serviceKey) logger.info("No REBASE_SERVICE_KEY configured. Generated internal per-boot key for singleton control-plane APIs.");
18290
+ if (authAdapter && !authAdapter.serviceKey) authAdapter.serviceKey = internalServiceKey;
18291
+ let apiKeyStore;
18292
+ const apiKeyStoreResult = createApiKeyStore(defaultDriver);
18293
+ if (apiKeyStoreResult) {
18294
+ apiKeyStore = apiKeyStoreResult;
18295
+ await apiKeyStore.ensureTable();
18296
+ logger.info("Service API Keys initialized");
18297
+ }
18298
+ const apiKeyPreAuth = apiKeyStore ? createApiKeyPreAuth({
18299
+ store: apiKeyStore,
18300
+ driver: defaultDriver
18301
+ }) : void 0;
18302
+ if (apiKeyPreAuth) config.app.use(`${basePath}/admin/*`, apiKeyPreAuth);
18303
+ if (apiKeyStore) {
18304
+ const apiKeyRoutes = createApiKeyRoutes({
18305
+ store: apiKeyStore,
18306
+ serviceKey: internalServiceKey
18307
+ });
18308
+ config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
18309
+ logger.info("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
18310
+ }
18311
+ const rateLimitConfig = config.rateLimit?.enabled !== false ? {
18312
+ ...config.rateLimit,
18313
+ store: config.rateLimit?.store ?? new MemoryRateLimitStore(config.rateLimit?.windowMs ?? 900 * 1e3)
18314
+ } : void 0;
16803
18315
  const { storageRegistry, storageController } = await initializeStorage(config.storage, isProduction);
16804
18316
  if (config.auth) {
16805
18317
  config.app.get(`${basePath}/auth/config`, async (c) => {
@@ -16892,7 +18404,7 @@ async function _initializeRebaseBackend(config) {
16892
18404
  allowUserLookup: safeAuthConfig.allowUserLookup ?? false,
16893
18405
  defaultRole: safeAuthConfig.defaultRole,
16894
18406
  oauthProviders,
16895
- serviceKey,
18407
+ serviceKey: serviceKey || internalServiceKey,
16896
18408
  authHooks: safeAuthConfig.hooks,
16897
18409
  collectionAuthConfig,
16898
18410
  enableMagicLink: safeAuthConfig.magicLink ?? false,
@@ -16917,33 +18429,31 @@ async function _initializeRebaseBackend(config) {
16917
18429
  }
16918
18430
  }
16919
18431
  }
16920
- const internalServiceKey = serviceKey || randomBytes(48).toString("base64");
16921
- if (!serviceKey) logger.info("No REBASE_SERVICE_KEY configured. Generated internal per-boot key for singleton control-plane APIs.");
16922
- if (authAdapter && !authAdapter.serviceKey) authAdapter.serviceKey = internalServiceKey;
16923
- let apiKeyStore;
16924
- const apiKeyStoreResult = createApiKeyStore(defaultDriver);
16925
- if (apiKeyStoreResult) {
16926
- apiKeyStore = apiKeyStoreResult;
16927
- await apiKeyStore.ensureTable();
16928
- logger.info("Service API Keys initialized");
16929
- const apiKeyRoutes = createApiKeyRoutes({
16930
- store: apiKeyStore,
16931
- serviceKey: internalServiceKey
16932
- });
16933
- config.app.route(`${basePath}/admin/api-keys`, apiKeyRoutes);
16934
- logger.info("API key admin routes mounted", { path: `${basePath}/admin/api-keys` });
16935
- }
18432
+ const adminSurfacesGated = !!authAdapter && (isAuthAdapter(config.auth) || config.auth.requireAuth !== false && !!config.auth.jwtSecret);
18433
+ const applyAdminGate = (router, surface) => {
18434
+ if (!adminSurfacesGated) {
18435
+ logger.warn(`${surface} routes are mounted WITHOUT an auth gate (no auth configured, requireAuth: false, or no jwtSecret) — anyone who can reach the server can call them.`);
18436
+ return;
18437
+ }
18438
+ if (apiKeyPreAuth) router.use("/*", apiKeyPreAuth);
18439
+ router.use("/*", createRequireAuth({ serviceKey: internalServiceKey }), requireAdmin);
18440
+ };
16936
18441
  const schemaEditorEnabled = config.schemaEditor ?? (!!config.collectionsDir && process.env.NODE_ENV !== "production" && mode === "cms");
16937
18442
  if (schemaEditorEnabled && !config.collectionsDir) logger.warn("schemaEditor is enabled but no collectionsDir is set — the schema editor has nowhere to write. Skipping.");
16938
18443
  if (schemaEditorEnabled && config.collectionsDir) {
16939
- const { createSchemaEditorRoutes } = await import("./schema-editor-routes-D3ef8zu1.js");
16940
- const schemaEditorRoutes = createSchemaEditorRoutes(config.collectionsDir);
16941
- if (authAdapter && !isAuthAdapter(config.auth)) {
16942
- const safeAuth = config.auth;
16943
- if (safeAuth.requireAuth !== false && !!safeAuth.jwtSecret) schemaEditorRoutes.use("/*", requireAuth, requireAdmin);
16944
- } else if (authAdapter) schemaEditorRoutes.use("/*", requireAuth, requireAdmin);
16945
- config.app.route(`${basePath}/schema-editor`, schemaEditorRoutes);
16946
- logger.info("Schema Editor mounted", { path: `${basePath}/schema-editor` });
18444
+ let editorModule;
18445
+ try {
18446
+ editorModule = await import("./schema-editor-routes-D3ef8zu1.js");
18447
+ } catch (err) {
18448
+ 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.");
18449
+ else throw err;
18450
+ }
18451
+ if (editorModule) {
18452
+ const schemaEditorRoutes = editorModule.createSchemaEditorRoutes(config.collectionsDir);
18453
+ applyAdminGate(schemaEditorRoutes, "Schema editor");
18454
+ config.app.route(`${basePath}/schema-editor`, schemaEditorRoutes);
18455
+ logger.info("Schema Editor mounted", { path: `${basePath}/schema-editor` });
18456
+ }
16947
18457
  }
16948
18458
  if (storageController) {
16949
18459
  const storageMaxSize = (config.storage && typeof config.storage === "object" && "type" in config.storage ? config.storage.maxFileSize : void 0) ?? 50 * 1024 * 1024;
@@ -16952,9 +18462,12 @@ async function _initializeRebaseBackend(config) {
16952
18462
  registry: storageRegistry,
16953
18463
  sources: config.storageSources,
16954
18464
  requireAuth: resolveRequireAuth(config.auth),
16955
- authAdapter
18465
+ authAdapter,
18466
+ authorize: config.storageAuthorize
16956
18467
  });
16957
- storageRoutes.use("/upload", bodyLimit({
18468
+ const storageRouter = new Hono();
18469
+ if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
18470
+ storageRouter.use("/upload", bodyLimit({
16958
18471
  maxSize: storageMaxSize,
16959
18472
  onError: (c) => {
16960
18473
  return c.json({ error: {
@@ -16963,7 +18476,8 @@ async function _initializeRebaseBackend(config) {
16963
18476
  } }, 413);
16964
18477
  }
16965
18478
  }));
16966
- config.app.route(`${basePath}/storage`, storageRoutes);
18479
+ storageRouter.route("/", storageRoutes);
18480
+ config.app.route(`${basePath}/storage`, storageRouter);
16967
18481
  }
16968
18482
  if (activeCollections.length > 0) {
16969
18483
  const dataRouter = new Hono();
@@ -16992,7 +18506,7 @@ async function _initializeRebaseBackend(config) {
16992
18506
  serviceKey: internalServiceKey,
16993
18507
  apiKeyStore
16994
18508
  }));
16995
- if (apiKeyStore) dataRouter.use("/*", createApiKeyRateLimiter());
18509
+ if (rateLimitConfig) dataRouter.use("/*", createDataRateLimiter(rateLimitConfig));
16996
18510
  if (historyConfigResult && historyConfigResult.historyService) {
16997
18511
  const historyRoutes = createHistoryRoutes({
16998
18512
  historyService: historyConfigResult.historyService,
@@ -17037,6 +18551,10 @@ async function _initializeRebaseBackend(config) {
17037
18551
  dataAsAdmin: serverData
17038
18552
  });
17039
18553
  logger.info("Native data plane attached to singleton (bypasses HTTP loop)");
18554
+ if (storageController) {
18555
+ Object.assign(serverClient, { storage: storageController });
18556
+ logger.info("Native storage attached to singleton (bypasses HTTP loop)");
18557
+ }
17040
18558
  let emailService;
17041
18559
  if (authConfigResult?.emailService) emailService = authConfigResult.emailService;
17042
18560
  else if (config.auth && !isAuthAdapter(config.auth) && config.auth.email) emailService = createEmailService(config.auth.email);
@@ -17081,6 +18599,11 @@ async function _initializeRebaseBackend(config) {
17081
18599
  serviceKey: internalServiceKey,
17082
18600
  apiKeyStore
17083
18601
  }));
18602
+ functionsRouter.use("/*", createFunctionApiKeyGuard(`${basePath}/functions`));
18603
+ if (rateLimitConfig) functionsRouter.use("/*", createDataRateLimiter({
18604
+ ...rateLimitConfig,
18605
+ anonymous: null
18606
+ }));
17084
18607
  const fnRoutes = createFunctionRoutes(loadedFunctions);
17085
18608
  functionsRouter.route("/", fnRoutes);
17086
18609
  config.app.route(`${basePath}/functions`, functionsRouter);
@@ -17107,10 +18630,7 @@ async function _initializeRebaseBackend(config) {
17107
18630
  cronScheduler.setStore(store);
17108
18631
  }
17109
18632
  const cronRouter = new Hono();
17110
- if (authAdapter && !isAuthAdapter(config.auth)) {
17111
- const safeAuth = config.auth;
17112
- if (safeAuth.requireAuth !== false && !!safeAuth.jwtSecret) cronRouter.use("/*", requireAuth, requireAdmin);
17113
- } else if (authAdapter) cronRouter.use("/*", requireAuth, requireAdmin);
18633
+ applyAdminGate(cronRouter, "Cron");
17114
18634
  cronRouter.route("/", createCronRoutes(cronScheduler));
17115
18635
  config.app.route(`${basePath}/cron`, cronRouter);
17116
18636
  cronScheduler.start();
@@ -17123,10 +18643,7 @@ async function _initializeRebaseBackend(config) {
17123
18643
  {
17124
18644
  const { createBackupRoutes, parseBackupDestination } = await Promise.resolve().then(() => backup_exports);
17125
18645
  const backupRouter = new Hono();
17126
- if (authAdapter && !isAuthAdapter(config.auth)) {
17127
- const safeAuth = config.auth;
17128
- if (safeAuth.requireAuth !== false && !!safeAuth.jwtSecret) backupRouter.use("/*", requireAuth, requireAdmin);
17129
- } else if (authAdapter) backupRouter.use("/*", requireAuth, requireAdmin);
18646
+ applyAdminGate(backupRouter, "Backup");
17130
18647
  backupRouter.route("/", createBackupRoutes({
17131
18648
  getDestination: () => {
17132
18649
  const out = process.env.BACKUP_DESTINATION?.trim();
@@ -17137,6 +18654,14 @@ async function _initializeRebaseBackend(config) {
17137
18654
  config.app.route(`${basePath}/admin/backups`, backupRouter);
17138
18655
  logger.info("Backup admin routes mounted", { path: `${basePath}/admin/backups` });
17139
18656
  }
18657
+ {
18658
+ const { default: logsRoutes } = await Promise.resolve().then(() => logs_routes_exports);
18659
+ const logsRouter = new Hono();
18660
+ applyAdminGate(logsRouter, "Logs");
18661
+ logsRouter.route("/", logsRoutes);
18662
+ config.app.route(`${basePath}/logs`, logsRouter);
18663
+ logger.info("Logs routes mounted", { path: `${basePath}/logs` });
18664
+ }
17140
18665
  const effectiveRealtimeService = Object.keys(realtimeServices).length > 1 ? createRoutedRealtimeService({
17141
18666
  providers: realtimeServices,
17142
18667
  defaultKey: defaultDriverId,
@@ -17150,9 +18675,34 @@ async function _initializeRebaseBackend(config) {
17150
18675
  cronScheduler,
17151
18676
  realtimeServices
17152
18677
  });
18678
+ /**
18679
+ * Every registry a driver might resolve callbacks from, plus the backend's
18680
+ * own. Each holds its own normalized copy of a collection, so callbacks have
18681
+ * to be written to all of them.
18682
+ */
18683
+ const callbackTargets = () => {
18684
+ const targets = [collectionRegistry];
18685
+ for (const key of [DEFAULT_DRIVER_ID, ...driverRegistry.list()]) {
18686
+ const d = driverRegistry.get(key);
18687
+ if (d?.registry && typeof d.registry.get === "function" && !targets.includes(d.registry)) targets.push(d.registry);
18688
+ }
18689
+ return targets;
18690
+ };
18691
+ const setCollectionCallbacks = (slug, callbacks) => {
18692
+ let attached = 0;
18693
+ for (const registry of callbackTargets()) {
18694
+ const collection = registry.get(slug);
18695
+ if (collection) {
18696
+ collection.callbacks = callbacks;
18697
+ attached++;
18698
+ }
18699
+ }
18700
+ if (attached === 0) logger.warn(`[callbacks] Collection "${slug}" not found in any registry — callbacks not attached.`);
18701
+ };
17153
18702
  return {
17154
18703
  driverRegistry,
17155
18704
  driver: defaultDriver,
18705
+ setCollectionCallbacks,
17156
18706
  realtimeServices,
17157
18707
  realtimeService: effectiveRealtimeService,
17158
18708
  auth: authConfigResult,
@@ -17182,7 +18732,7 @@ async function loadFunctionsFromDirectory(directory, importModule = nativeDynami
17182
18732
  const problems = [];
17183
18733
  if (!fs$2.existsSync(directory)) return functions;
17184
18734
  const files = fs$2.readdirSync(directory);
17185
- for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
18735
+ for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
17186
18736
  const filePath = path$1.join(directory, file);
17187
18737
  try {
17188
18738
  const fileUrl = pathToFileURL(filePath).href;
@@ -17311,7 +18861,7 @@ async function loadCronJobsFromDirectory(directory, importModule = nativeDynamic
17311
18861
  const jobs = [];
17312
18862
  if (!fs$2.existsSync(directory)) return jobs;
17313
18863
  const files = fs$2.readdirSync(directory);
17314
- for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
18864
+ for (const file of files) if ((file.endsWith(".ts") || file.endsWith(".js")) && !file.startsWith(".") && !file.includes(".test.") && !file.endsWith(".d.ts") && file !== "index.ts" && file !== "index.js") {
17315
18865
  const filePath = path$1.join(directory, file);
17316
18866
  try {
17317
18867
  const fileUrl = pathToFileURL(filePath).href;
@@ -17585,6 +19135,7 @@ var CronScheduler = class {
17585
19135
  logger.warn("[cron] Failed to seed job stats from database", { error: err });
17586
19136
  });
17587
19137
  for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
19138
+ if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
17588
19139
  logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
17589
19140
  }
17590
19141
  /**
@@ -17703,6 +19254,19 @@ var CronScheduler = class {
17703
19254
  this.scheduleNext(id);
17704
19255
  return;
17705
19256
  }
19257
+ if (this.store?.tryClaimRun) {
19258
+ let claimed = true;
19259
+ try {
19260
+ claimed = await this.store.tryClaimRun(id, nextRun.toISOString());
19261
+ } catch (err) {
19262
+ logger.warn(`[cron] Claim check threw for "${id}" — running uncoordinated`, { error: err });
19263
+ }
19264
+ if (!claimed) {
19265
+ logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
19266
+ if (this.started && job.enabled) this.scheduleNext(id);
19267
+ return;
19268
+ }
19269
+ }
17706
19270
  await this.executeJob(job, false);
17707
19271
  if (this.started && job.enabled) this.scheduleNext(id);
17708
19272
  }, Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS));
@@ -17871,6 +19435,25 @@ function createCronRoutes(scheduler) {
17871
19435
  //#region src/cron/cron-store.ts
17872
19436
  var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
17873
19437
  var TABLE = "rebase.cron_logs";
19438
+ var CLAIMS_TABLE = "rebase.cron_claims";
19439
+ /** Claims older than this are garbage-collected on startup. */
19440
+ var CLAIM_RETENTION_DAYS = 7;
19441
+ /**
19442
+ * Detect a unique-constraint violation anywhere in an error's cause chain.
19443
+ * Drizzle wraps the PG error — match the SQLSTATE code, never message text.
19444
+ * Also covers SQLite ("UNIQUE constraint failed") and MySQL (ER_DUP_ENTRY 1062)
19445
+ * for future SQL drivers.
19446
+ */
19447
+ function isUniqueViolation(err) {
19448
+ let current = err;
19449
+ for (let depth = 0; depth < 10 && current; depth++) if (typeof current === "object") {
19450
+ const e = current;
19451
+ if (e.code === "23505" || e.errno === 1062) return true;
19452
+ if (typeof e.message === "string" && e.message.includes("UNIQUE constraint failed")) return true;
19453
+ current = e.cause;
19454
+ } else break;
19455
+ return false;
19456
+ }
17874
19457
  function createCronStore(driver) {
17875
19458
  const admin = driver.admin;
17876
19459
  if (!isSQLAdmin(admin)) {
@@ -17900,6 +19483,15 @@ function createCronStore(driver) {
17900
19483
  CREATE INDEX IF NOT EXISTS idx_cron_logs_job
17901
19484
  ON ${TABLE}(job_id, started_at DESC)
17902
19485
  `);
19486
+ await exec(`
19487
+ CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (
19488
+ job_id TEXT NOT NULL,
19489
+ slot TIMESTAMPTZ NOT NULL,
19490
+ claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
19491
+ PRIMARY KEY (job_id, slot)
19492
+ )
19493
+ `);
19494
+ await exec(`DELETE FROM ${CLAIMS_TABLE} WHERE claimed_at < now() - make_interval(days => $1)`, { params: [CLAIM_RETENTION_DAYS] });
17903
19495
  logger.info("✅ Cron logs table ready");
17904
19496
  } catch (err) {
17905
19497
  logger.error("❌ Failed to create cron logs table", { error: err });
@@ -17959,6 +19551,18 @@ function createCronStore(driver) {
17959
19551
  logger.error("[cron-store] Failed to fetch job stats", { error: err });
17960
19552
  }
17961
19553
  return stats;
19554
+ },
19555
+ async tryClaimRun(jobId, slot) {
19556
+ try {
19557
+ return (await exec(`INSERT INTO ${CLAIMS_TABLE} (job_id, slot)
19558
+ VALUES ($1, $2)
19559
+ ON CONFLICT (job_id, slot) DO NOTHING
19560
+ RETURNING job_id`, { params: [jobId, slot] })).length > 0;
19561
+ } catch (err) {
19562
+ if (isUniqueViolation(err)) return false;
19563
+ logger.warn(`[cron-store] Claim check failed for "${jobId}" — running uncoordinated`, { error: err });
19564
+ return true;
19565
+ }
17962
19566
  }
17963
19567
  };
17964
19568
  }
@@ -18138,10 +19742,10 @@ var backup_exports = /* @__PURE__ */ __exportAll({
18138
19742
  /**
18139
19743
  * Returns a SQL chunk calling `auth.uid()` — the current user's ID.
18140
19744
  * This is a PostgreSQL RLS helper function created in the `auth` schema
18141
- * that reads `app.user_id` set per-transaction by `withAuth()`.
19745
+ * that reads `app.uid` set per-transaction by `withAuth()`.
18142
19746
  *
18143
19747
  * @example
18144
- * sql`${table.user_id} = ${authUid()}`
19748
+ * sql`${table.uid} = ${authUid()}`
18145
19749
  */
18146
19750
  var authUid = () => {
18147
19751
  return sql`auth.uid()`;
@@ -18247,14 +19851,21 @@ var rebaseEnvSchema = object({
18247
19851
  DATABASE_DIRECT_URL: string().url().optional(),
18248
19852
  DATABASE_READ_URL: string().url().optional(),
18249
19853
  FORCE_LOCAL_STORAGE: optionalBoolString,
18250
- STORAGE_TYPE: _enum(["local", "s3"]).default("local"),
19854
+ STORAGE_TYPE: _enum([
19855
+ "local",
19856
+ "s3",
19857
+ "gcs"
19858
+ ]).default("local"),
18251
19859
  STORAGE_PATH: string().optional(),
18252
19860
  S3_BUCKET: string().optional(),
18253
19861
  S3_REGION: string().optional(),
18254
19862
  S3_ACCESS_KEY_ID: string().optional(),
18255
19863
  S3_SECRET_ACCESS_KEY: string().optional(),
18256
19864
  S3_ENDPOINT: string().url().optional(),
18257
- S3_FORCE_PATH_STYLE: optionalBoolString
19865
+ S3_FORCE_PATH_STYLE: optionalBoolString,
19866
+ GCS_BUCKET: string().optional(),
19867
+ GCS_PROJECT_ID: string().optional(),
19868
+ GCS_KEY_FILENAME: string().optional()
18258
19869
  });
18259
19870
  function loadEnv(options) {
18260
19871
  const isProduction = process.env.NODE_ENV === "production";
@@ -18415,7 +20026,11 @@ function writeStateFile(projectRoot, port, serviceKey) {
18415
20026
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
18416
20027
  };
18417
20028
  if (serviceKey) state.serviceKey = serviceKey;
18418
- fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), "utf-8");
20029
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), {
20030
+ encoding: "utf-8",
20031
+ mode: 384
20032
+ });
20033
+ fs.chmodSync(stateFile, 384);
18419
20034
  } catch {}
18420
20035
  }
18421
20036
  //#endregion
@@ -18436,7 +20051,11 @@ function serveSPA(app, config) {
18436
20051
  logger.warn(" SPA serving is disabled. Build your frontend first.");
18437
20052
  return;
18438
20053
  }
18439
- app.use("/*", serveStatic({ root: path$1.relative(process.cwd(), frontendPath) }));
20054
+ app.use("/*", responseCompression());
20055
+ app.use("/*", serveStatic({
20056
+ root: path$1.relative(process.cwd(), frontendPath),
20057
+ precompressed: true
20058
+ }));
18440
20059
  const allExcludePaths = [apiBasePath, ...excludePaths];
18441
20060
  let cachedHtml = null;
18442
20061
  app.get("*", async (c, next) => {