@rebasepro/server 0.9.1-canary.f2f61da → 0.9.1-canary.faee831

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.es.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
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-BJzQOa8a.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
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
7
  import { t as nativeDynamicImport } from "./dynamic-import-Dvh-K5fl.js";
@@ -3305,7 +3305,7 @@ var BackendCollectionRegistry = class extends CollectionRegistry {
3305
3305
  //#endregion
3306
3306
  //#region src/collections/loader.ts
3307
3307
  function isCollectionFile(file) {
3308
- 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";
3309
3309
  }
3310
3310
  async function importModule(filePath) {
3311
3311
  return await import(pathToFileURL(filePath).href);
@@ -4472,10 +4472,10 @@ async function validateApiKey(c, token, options) {
4472
4472
  message: "API key has expired",
4473
4473
  code: "UNAUTHORIZED"
4474
4474
  } }, 401);
4475
- const userId = `api-key:${apiKey.id}`;
4475
+ const uid = `api-key:${apiKey.id}`;
4476
4476
  const roles = apiKey.admin ? ["admin", "service"] : ["service"];
4477
4477
  c.set("user", {
4478
- userId,
4478
+ uid,
4479
4479
  roles
4480
4480
  });
4481
4481
  const masked = {
@@ -4495,7 +4495,7 @@ async function validateApiKey(c, token, options) {
4495
4495
  c.set("apiKey", masked);
4496
4496
  try {
4497
4497
  const scopedDriver = await scopeDataDriver(driver, {
4498
- uid: userId,
4498
+ uid,
4499
4499
  roles
4500
4500
  });
4501
4501
  c.set("driver", scopedDriver);
@@ -4663,7 +4663,7 @@ function createRequireAuth(options) {
4663
4663
  const token = authHeader.substring(7);
4664
4664
  if (safeCompare(token, key)) {
4665
4665
  c.set("user", {
4666
- userId: "service",
4666
+ uid: "service",
4667
4667
  roles: ["admin"]
4668
4668
  });
4669
4669
  return next();
@@ -4742,11 +4742,11 @@ function createAuthMiddleware(options) {
4742
4742
  if (validator) try {
4743
4743
  const authResult = await validator(c);
4744
4744
  if (authResult && typeof authResult === "object") {
4745
- const id = ("userId" in authResult ? authResult.userId : void 0) || ("uid" in authResult ? authResult.uid : void 0);
4745
+ const id = ("uid" in authResult ? authResult.uid : void 0) || ("userId" in authResult ? authResult.userId : void 0);
4746
4746
  if (id) {
4747
4747
  const roles = authResult.roles || [];
4748
4748
  c.set("user", {
4749
- userId: id,
4749
+ uid: id,
4750
4750
  roles
4751
4751
  });
4752
4752
  const user = {
@@ -4761,7 +4761,7 @@ function createAuthMiddleware(options) {
4761
4761
  }));
4762
4762
  } else if (authResult === true) {
4763
4763
  c.set("user", {
4764
- userId: "default",
4764
+ uid: "default",
4765
4765
  roles: []
4766
4766
  });
4767
4767
  c.set("driver", await scopeDataDriver(driver, {
@@ -4784,7 +4784,7 @@ function createAuthMiddleware(options) {
4784
4784
  const token = authHeader.substring(7);
4785
4785
  if (serviceKey && safeCompare(token, serviceKey)) {
4786
4786
  c.set("user", {
4787
- userId: "service",
4787
+ uid: "service",
4788
4788
  roles: ["admin"]
4789
4789
  });
4790
4790
  try {
@@ -4811,7 +4811,7 @@ function createAuthMiddleware(options) {
4811
4811
  c.set("user", payload);
4812
4812
  try {
4813
4813
  const user = {
4814
- uid: payload.userId,
4814
+ uid: payload.uid,
4815
4815
  roles: payload.roles
4816
4816
  };
4817
4817
  c.set("driver", await scopeDataDriver(driver, user));
@@ -4892,7 +4892,7 @@ var publicObjectAuth = async (c, next) => {
4892
4892
  const idx = fullPath.indexOf(prefix);
4893
4893
  const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
4894
4894
  if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
4895
- userId: "public",
4895
+ uid: "public",
4896
4896
  roles: ["public"]
4897
4897
  });
4898
4898
  return next();
@@ -4958,7 +4958,7 @@ var fileTokenAuth = async (c, next) => {
4958
4958
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
4959
4959
  if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
4960
4960
  c.set("user", {
4961
- userId: "download-token",
4961
+ uid: "download-token",
4962
4962
  roles: ["reader"]
4963
4963
  });
4964
4964
  return next();
@@ -4982,7 +4982,7 @@ var fileTokenAuth = async (c, next) => {
4982
4982
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
4983
4983
  if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
4984
4984
  c.set("user", {
4985
- userId: "download-token",
4985
+ uid: "download-token",
4986
4986
  roles: ["reader"]
4987
4987
  });
4988
4988
  return next();
@@ -5031,7 +5031,7 @@ function createAdapterAuthMiddleware(options) {
5031
5031
  }
5032
5032
  if (authenticatedUser) {
5033
5033
  c.set("user", {
5034
- userId: authenticatedUser.uid,
5034
+ uid: authenticatedUser.uid,
5035
5035
  email: authenticatedUser.email,
5036
5036
  roles: authenticatedUser.roles
5037
5037
  });
@@ -5914,14 +5914,14 @@ function createDataRateLimiter(config = {}) {
5914
5914
  const key = c.get("apiKey");
5915
5915
  if (key) return `api-key:${key.id}`;
5916
5916
  const user = c.get("user");
5917
- if (user?.userId && user.userId !== "anon") return `user:${user.userId}`;
5917
+ if (user?.uid && user.uid !== "anon") return `user:${user.uid}`;
5918
5918
  return `ip:${defaultKeyGenerator(c)}`;
5919
5919
  },
5920
5920
  resolveLimit: (c) => {
5921
5921
  const key = c.get("apiKey");
5922
5922
  if (key) return key.rate_limit ?? apiKeyLimit;
5923
5923
  const user = c.get("user");
5924
- if (user?.userId && user.userId !== "anon") return userLimit;
5924
+ if (user?.uid && user.uid !== "anon") return userLimit;
5925
5925
  return anonLimit;
5926
5926
  }
5927
5927
  });
@@ -9773,7 +9773,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9773
9773
  const body = await c.req.json().catch(() => ({}));
9774
9774
  const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
9775
9775
  const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
9776
- const user = await authRepo.getUserById(userCtx.userId);
9776
+ const user = await authRepo.getUserById(userCtx.uid);
9777
9777
  if (!user) throw ApiError.notFound("User not found");
9778
9778
  const { secret, uri } = generateTotpSecret(issuer, user.email);
9779
9779
  const encryptedSecret = encryptTotpSecret(secret);
@@ -9807,7 +9807,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9807
9807
  code: string().length(6, "Code must be 6 digits")
9808
9808
  }), await c.req.json());
9809
9809
  const factor = await authRepo.getMfaFactorById(factorId);
9810
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9810
+ if (!factor || factor.userId !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9811
9811
  if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
9812
9812
  if (!verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
9813
9813
  await authRepo.verifyMfaFactor(factorId);
@@ -9825,7 +9825,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9825
9825
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9826
9826
  const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
9827
9827
  const factor = await authRepo.getMfaFactorById(factorId);
9828
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9828
+ if (!factor || factor.userId !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9829
9829
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
9830
9830
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
9831
9831
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
@@ -9849,19 +9849,19 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9849
9849
  const challenge = await authRepo.getMfaChallengeById(challengeId);
9850
9850
  if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
9851
9851
  const factor = await authRepo.getMfaFactorById(challenge.factorId);
9852
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9852
+ if (!factor || factor.userId !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9853
9853
  let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
9854
9854
  if (!isValid) {
9855
9855
  const codeHash = hashRecoveryCode(code);
9856
- isValid = await authRepo.useRecoveryCode(userCtx.userId, codeHash);
9856
+ isValid = await authRepo.useRecoveryCode(userCtx.uid, codeHash);
9857
9857
  }
9858
9858
  if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
9859
9859
  await authRepo.verifyMfaChallenge(challengeId);
9860
- const roleIds = (await authRepo.getUserRoles(userCtx.userId)).map((r) => r.id);
9861
- const accessToken = generateAccessToken(userCtx.userId, roleIds, "aal2");
9860
+ const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
9861
+ const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
9862
9862
  const refreshToken = generateRefreshToken();
9863
- await authRepo.createRefreshToken(userCtx.userId, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
9864
- if (ops.onMfaVerified) ops.onMfaVerified(userCtx.userId, factor.id).catch((err) => {
9863
+ await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
9864
+ if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
9865
9865
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
9866
9866
  });
9867
9867
  let mfaResponse = { tokens: {
@@ -9869,7 +9869,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9869
9869
  refreshToken,
9870
9870
  accessTokenExpiresAt: getAccessTokenExpiry()
9871
9871
  } };
9872
- if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.userId);
9872
+ if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
9873
9873
  return c.json(mfaResponse);
9874
9874
  });
9875
9875
  /**
@@ -9879,7 +9879,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9879
9879
  router.get("/mfa/factors", requireAuth, async (c) => {
9880
9880
  const userCtx = c.get("user");
9881
9881
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9882
- const factors = await authRepo.getMfaFactors(userCtx.userId);
9882
+ const factors = await authRepo.getMfaFactors(userCtx.uid);
9883
9883
  return c.json({ factors: factors.map((f) => ({
9884
9884
  id: f.id,
9885
9885
  factorType: f.factorType,
@@ -9898,9 +9898,9 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9898
9898
  if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
9899
9899
  const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
9900
9900
  const factor = await authRepo.getMfaFactorById(factorId);
9901
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9902
- await authRepo.deleteMfaFactor(factorId, userCtx.userId);
9903
- if (!await authRepo.hasVerifiedMfaFactors(userCtx.userId)) await authRepo.deleteAllRecoveryCodes(userCtx.userId);
9901
+ if (!factor || factor.userId !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9902
+ await authRepo.deleteMfaFactor(factorId, userCtx.uid);
9903
+ if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) await authRepo.deleteAllRecoveryCodes(userCtx.uid);
9904
9904
  return c.json({
9905
9905
  success: true,
9906
9906
  message: "MFA factor removed"
@@ -10002,9 +10002,9 @@ function mountSessionRoutes(opts) {
10002
10002
  clearRefreshCookie(c, config.cookieAuth);
10003
10003
  const authHeader = c.req.header("authorization");
10004
10004
  if (ops.afterLogout && authHeader?.startsWith("Bearer ")) {
10005
- const { verifyAccessToken } = await import("./jwt-BJzQOa8a.js").then((n) => n.c);
10005
+ const { verifyAccessToken } = await import("./jwt-B3zjddCa.js").then((n) => n.c);
10006
10006
  const payload = verifyAccessToken(authHeader.substring(7));
10007
- if (payload) ops.afterLogout(payload.userId).catch((err) => {
10007
+ if (payload) ops.afterLogout(payload.uid).catch((err) => {
10008
10008
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
10009
10009
  });
10010
10010
  }
@@ -10019,7 +10019,7 @@ function mountSessionRoutes(opts) {
10019
10019
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10020
10020
  const currentRefreshToken = c.req.header("x-refresh-token");
10021
10021
  const currentTokenHash = currentRefreshToken ? hashRefreshToken(currentRefreshToken) : null;
10022
- const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.userId)).map((s) => ({
10022
+ const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.uid)).map((s) => ({
10023
10023
  id: s.id,
10024
10024
  userAgent: s.userAgent,
10025
10025
  ipAddress: s.ipAddress,
@@ -10035,7 +10035,7 @@ function mountSessionRoutes(opts) {
10035
10035
  router.delete("/sessions", requireAuth, async (c) => {
10036
10036
  const userCtx = c.get("user");
10037
10037
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10038
- await authRepo.deleteAllRefreshTokensForUser(userCtx.userId);
10038
+ await authRepo.deleteAllRefreshTokensForUser(userCtx.uid);
10039
10039
  return c.json({
10040
10040
  success: true,
10041
10041
  message: "All sessions revoked successfully"
@@ -10050,7 +10050,7 @@ function mountSessionRoutes(opts) {
10050
10050
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10051
10051
  const id = c.req.param("id");
10052
10052
  if (!id) throw ApiError.badRequest("Session ID is required", "INVALID_INPUT");
10053
- await authRepo.deleteRefreshTokenById(id, userCtx.userId);
10053
+ await authRepo.deleteRefreshTokenById(id, userCtx.uid);
10054
10054
  return c.json({
10055
10055
  success: true,
10056
10056
  message: "Session revoked successfully"
@@ -10063,7 +10063,7 @@ function mountSessionRoutes(opts) {
10063
10063
  router.get("/me", requireAuth, async (c) => {
10064
10064
  const userCtx = c.get("user");
10065
10065
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10066
- const result = await authRepo.getUserWithRoles(userCtx.userId);
10066
+ const result = await authRepo.getUserWithRoles(userCtx.uid);
10067
10067
  if (!result) throw ApiError.notFound("User not found");
10068
10068
  return c.json({ user: {
10069
10069
  uid: result.user.id,
@@ -10103,11 +10103,11 @@ function mountSessionRoutes(opts) {
10103
10103
  const userCtx = c.get("user");
10104
10104
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10105
10105
  const { displayName, photoURL } = parseBody(updateProfileSchema, await c.req.json());
10106
- if (!await authRepo.updateUser(userCtx.userId, {
10106
+ if (!await authRepo.updateUser(userCtx.uid, {
10107
10107
  displayName: displayName !== void 0 ? displayName : void 0,
10108
10108
  photoUrl: photoURL !== void 0 ? photoURL : void 0
10109
10109
  })) throw ApiError.notFound("User not found");
10110
- const result = await authRepo.getUserWithRoles(userCtx.userId);
10110
+ const result = await authRepo.getUserWithRoles(userCtx.uid);
10111
10111
  if (!result) throw ApiError.notFound("User not found");
10112
10112
  return c.json({ user: {
10113
10113
  uid: result.user.id,
@@ -10169,7 +10169,7 @@ function mountSessionRoutes(opts) {
10169
10169
  router.post("/anonymous/link", requireAuth, async (c) => {
10170
10170
  const userCtx = c.get("user");
10171
10171
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10172
- const user = await authRepo.getUserById(userCtx.userId);
10172
+ const user = await authRepo.getUserById(userCtx.uid);
10173
10173
  if (!user?.isAnonymous) throw ApiError.badRequest("User is not anonymous", "NOT_ANONYMOUS");
10174
10174
  const { email, password } = parseBody(linkSchema, await c.req.json());
10175
10175
  const passwordValidation = ops.validatePasswordStrength(password);
@@ -10498,54 +10498,101 @@ function createAuthRoutes(config) {
10498
10498
  /**
10499
10499
  * Dynamically mount OAuth provider routes
10500
10500
  */
10501
- if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
10502
- const payload = parseBody(provider.schema, await c.req.json());
10503
- let externalUser;
10504
- try {
10505
- externalUser = await provider.verify(payload);
10506
- } catch (err) {
10507
- const msg = err instanceof Error ? err.message : String(err);
10508
- throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
10509
- }
10510
- if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10511
- let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10512
- if (!user) {
10513
- user = await authRepo.getUserByEmail(externalUser.email);
10514
- if (user) {
10515
- 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");
10516
- await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10517
- await authRepo.updateUser(user.id, {
10518
- displayName: user.displayName || externalUser.displayName || void 0,
10519
- photoUrl: user.photoUrl || externalUser.photoUrl || void 0
10520
- });
10521
- } else {
10522
- user = await authRepo.createUser({
10523
- email: externalUser.email.toLowerCase(),
10524
- displayName: externalUser.displayName || void 0,
10525
- photoUrl: externalUser.photoUrl || void 0
10526
- });
10527
- await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10528
- if (ops.afterUserCreate) try {
10529
- await ops.afterUserCreate(user);
10530
- } catch (err) {
10531
- logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
10501
+ if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
10502
+ router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
10503
+ const payload = parseBody(provider.schema, await c.req.json());
10504
+ let externalUser;
10505
+ try {
10506
+ externalUser = await provider.verify(payload);
10507
+ } catch (err) {
10508
+ const msg = err instanceof Error ? err.message : String(err);
10509
+ throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
10510
+ }
10511
+ if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10512
+ let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10513
+ if (!user) {
10514
+ user = await authRepo.getUserByEmail(externalUser.email);
10515
+ if (user) {
10516
+ 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");
10517
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10518
+ await authRepo.updateUser(user.id, {
10519
+ displayName: user.displayName || externalUser.displayName || void 0,
10520
+ photoUrl: user.photoUrl || externalUser.photoUrl || void 0
10521
+ });
10522
+ } else {
10523
+ user = await authRepo.createUser({
10524
+ email: externalUser.email.toLowerCase(),
10525
+ displayName: externalUser.displayName || void 0,
10526
+ photoUrl: externalUser.photoUrl || void 0
10527
+ });
10528
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10529
+ if (ops.afterUserCreate) try {
10530
+ await ops.afterUserCreate(user);
10531
+ } catch (err) {
10532
+ logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
10533
+ }
10534
+ const allUsers = await authRepo.listUsers();
10535
+ if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
10536
+ else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
10537
+ sendWelcomeEmail({
10538
+ email: user.email,
10539
+ displayName: user.displayName
10540
+ });
10532
10541
  }
10533
- const allUsers = await authRepo.listUsers();
10534
- if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
10535
- else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
10536
- sendWelcomeEmail({
10537
- email: user.email,
10538
- displayName: user.displayName
10539
- });
10542
+ } else await authRepo.updateUser(user.id, {
10543
+ displayName: externalUser.displayName || user.displayName || void 0,
10544
+ photoUrl: externalUser.photoUrl || user.photoUrl || void 0
10545
+ });
10546
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
10547
+ const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
10548
+ return c.json(finalResponse);
10549
+ });
10550
+ /**
10551
+ * POST /auth/link/:provider
10552
+ * Attach an OAuth identity to the *already authenticated* account.
10553
+ *
10554
+ * This is the escape hatch from the `EMAIL_NOT_VERIFIED` rejection
10555
+ * on the sign-in route above, and the way to attach a provider
10556
+ * whose email differs from the account's.
10557
+ *
10558
+ * Note the deliberate asymmetry with sign-in: linking here does
10559
+ * NOT require the provider to have verified the email, and does
10560
+ * not require the emails to match at all. On the sign-in route the
10561
+ * provider's email is the *only* evidence tying the incoming
10562
+ * identity to an existing account, so an unverified address would
10563
+ * let an attacker claim someone else's account. Here the caller
10564
+ * has already proven ownership by holding a valid session, and the
10565
+ * OAuth credential proves control of the provider identity — the
10566
+ * email plays no part in the decision, so its verification status
10567
+ * is irrelevant.
10568
+ */
10569
+ router.post(`/link/${provider.id}`, defaultAuthLimiter, requireAuth, async (c) => {
10570
+ const userCtx = c.get("user");
10571
+ if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10572
+ const payload = parseBody(provider.schema, await c.req.json());
10573
+ let externalUser;
10574
+ try {
10575
+ externalUser = await provider.verify(payload);
10576
+ } catch (err) {
10577
+ const msg = err instanceof Error ? err.message : String(err);
10578
+ throw ApiError.unauthorized(`${provider.id} link failed: ${msg}`, "OAUTH_ERROR");
10540
10579
  }
10541
- } else await authRepo.updateUser(user.id, {
10542
- displayName: externalUser.displayName || user.displayName || void 0,
10543
- photoUrl: externalUser.photoUrl || user.photoUrl || void 0
10580
+ if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10581
+ const identityOwner = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10582
+ if (identityOwner && identityOwner.id !== userCtx.uid) throw ApiError.conflict(`That ${provider.id} account is already linked to a different user.`, "IDENTITY_ALREADY_LINKED");
10583
+ if (identityOwner) return c.json({
10584
+ success: true,
10585
+ provider: provider.id,
10586
+ alreadyLinked: true
10587
+ });
10588
+ await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, { email: externalUser.email });
10589
+ return c.json({
10590
+ success: true,
10591
+ provider: provider.id,
10592
+ alreadyLinked: false
10593
+ });
10544
10594
  });
10545
- const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
10546
- const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
10547
- return c.json(finalResponse);
10548
- });
10595
+ }
10549
10596
  /**
10550
10597
  * POST /auth/forgot-password
10551
10598
  * Request password reset email
@@ -10616,7 +10663,7 @@ function createAuthRoutes(config) {
10616
10663
  const userCtx = c.get("user");
10617
10664
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10618
10665
  const { oldPassword, newPassword } = parseBody(changePasswordSchema, await c.req.json());
10619
- const user = await authRepo.getUserById(userCtx.userId);
10666
+ const user = await authRepo.getUserById(userCtx.uid);
10620
10667
  if (!user || !user.passwordHash) throw ApiError.badRequest("Cannot change password for this account", "INVALID_ACCOUNT");
10621
10668
  if (!await ops.verifyPassword(oldPassword, user.passwordHash)) throw ApiError.unauthorized("Current password is incorrect", "INVALID_CREDENTIALS");
10622
10669
  const passwordValidation = ops.validatePasswordStrength(newPassword);
@@ -10637,7 +10684,7 @@ function createAuthRoutes(config) {
10637
10684
  const userCtx = c.get("user");
10638
10685
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10639
10686
  if (!isEmailConfigured()) throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
10640
- const user = await authRepo.getUserById(userCtx.userId);
10687
+ const user = await authRepo.getUserById(userCtx.uid);
10641
10688
  if (!user) throw ApiError.notFound("User not found");
10642
10689
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
10643
10690
  const token = generateSecureToken();
@@ -10683,7 +10730,7 @@ function createAuthRoutes(config) {
10683
10730
  * Refresh access token using refresh token
10684
10731
  */
10685
10732
  router.post("/refresh", async (c) => {
10686
- const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json()), config.cookieAuth);
10733
+ const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json().catch(() => ({}))), config.cookieAuth);
10687
10734
  if (!refreshToken) throw ApiError.badRequest("Refresh token is required", "INVALID_INPUT");
10688
10735
  const tokenHash = hashRefreshToken(refreshToken);
10689
10736
  const storedToken = await authRepo.findRefreshTokenByHash(tokenHash);
@@ -11066,8 +11113,7 @@ function createAdminUsersRoute(config) {
11066
11113
  });
11067
11114
  router.delete("/users/:userId", requireAdmin, async (c) => {
11068
11115
  const userId = c.req.param("userId");
11069
- const authUser = c.get("user");
11070
- if ((authUser?.uid || authUser?.userId) === userId) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
11116
+ if (c.get("user")?.uid === userId) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
11071
11117
  if (!await authRepo.getUserById(userId)) throw ApiError.notFound("User not found");
11072
11118
  if ((await authRepo.getUserRoleIds(userId)).includes("admin")) {
11073
11119
  if ((await authRepo.listUsersPaginated({
@@ -11110,16 +11156,16 @@ function createBuiltinAuthAdapter(config) {
11110
11156
  if (!payload) return null;
11111
11157
  let roles = payload.roles || [];
11112
11158
  try {
11113
- roles = await authRepository.getUserRoleIds(payload.userId);
11159
+ roles = await authRepository.getUserRoleIds(payload.uid);
11114
11160
  } catch (err) {
11115
11161
  logger.warn("Role lookup from repository failed, using token roles as fallback", {
11116
- userId: payload.userId,
11162
+ uid: payload.uid,
11117
11163
  error: err
11118
11164
  });
11119
11165
  }
11120
11166
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
11121
11167
  return {
11122
- uid: payload.userId,
11168
+ uid: payload.uid,
11123
11169
  email: payload.email ?? "",
11124
11170
  displayName: payload.displayName ?? null,
11125
11171
  roles,
@@ -11139,16 +11185,16 @@ function createBuiltinAuthAdapter(config) {
11139
11185
  if (!payload) return null;
11140
11186
  let roles = payload.roles || [];
11141
11187
  try {
11142
- roles = await authRepository.getUserRoleIds(payload.userId);
11188
+ roles = await authRepository.getUserRoleIds(payload.uid);
11143
11189
  } catch (err) {
11144
11190
  logger.warn("Role lookup from repository failed, using token roles as fallback", {
11145
- userId: payload.userId,
11191
+ uid: payload.uid,
11146
11192
  error: err
11147
11193
  });
11148
11194
  }
11149
11195
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
11150
11196
  return {
11151
- uid: payload.userId,
11197
+ uid: payload.uid,
11152
11198
  email: payload.email ?? "",
11153
11199
  displayName: payload.displayName ?? null,
11154
11200
  roles,
@@ -11416,8 +11462,8 @@ function requestLogger(options) {
11416
11462
  const reqId = c.get("requestId");
11417
11463
  if (reqId) data.requestId = reqId;
11418
11464
  if (contentLength) data.contentLength = parseInt(contentLength, 10);
11419
- const userId = c.get("userId");
11420
- if (userId) data.userId = userId;
11465
+ const uid = c.get("user")?.uid;
11466
+ if (uid) data.uid = uid;
11421
11467
  if (status >= 500) logger.error("request", data);
11422
11468
  else if (status >= 400) logger.warn("request", data);
11423
11469
  else logger.info("request", data);
@@ -11623,11 +11669,21 @@ var LocalStorageController = class {
11623
11669
  }
11624
11670
  }
11625
11671
  /**
11626
- * Get the full filesystem path for a storage path.
11627
- * Includes a path traversal guard to prevent escaping the base directory.
11672
+ * Get the full filesystem path for a storage path, with a traversal guard
11673
+ * that keeps the result inside the bucket directory.
11674
+ *
11675
+ * Defaults the bucket the way `putObject` does.
11676
+ *
11677
+ * `putObject` has always written into `default` when given no bucket, while
11678
+ * the read side resolved a bare key against the storage root — where
11679
+ * nothing is. The two disagreed silently: `getObject` returned null (reads
11680
+ * as "file missing"), `deleteObject` deleted nothing (404s are swallowed by
11681
+ * design), and `listObjects` returned an empty page. So the obvious
11682
+ * `putObject({ key })` → `getObject(key)` did not round-trip and nothing
11683
+ * said why. One default, applied everywhere, removes the whole class.
11628
11684
  */
11629
11685
  getFullPath(storagePath, bucket) {
11630
- const bucketPath = bucket ? path$1.join(this.basePath, bucket) : this.basePath;
11686
+ const bucketPath = path$1.join(this.basePath, bucket ?? "default");
11631
11687
  const resolved = path$1.resolve(path$1.join(bucketPath, storagePath));
11632
11688
  if (!resolved.startsWith(bucketPath + path$1.sep) && resolved !== bucketPath) throw new Error("Path traversal detected: resolved storage path is outside the bucket directory.");
11633
11689
  return resolved;
@@ -11788,8 +11844,10 @@ var LocalStorageController = class {
11788
11844
  let count = 0;
11789
11845
  const maxResults = options?.maxResults ?? 1e3;
11790
11846
  const startIndex = options?.pageToken ? parseInt(options.pageToken, 10) : 0;
11847
+ let scanned = startIndex;
11791
11848
  for (let i = startIndex; i < entries.length && count < maxResults; i++) {
11792
11849
  const entry = entries[i];
11850
+ scanned = i + 1;
11793
11851
  if (entry.name.endsWith(".metadata.json")) continue;
11794
11852
  const entryPath = prefix ? `${prefix}/${entry.name}` : entry.name;
11795
11853
  const bucket = options?.bucket ?? "default";
@@ -11808,7 +11866,7 @@ var LocalStorageController = class {
11808
11866
  return {
11809
11867
  items,
11810
11868
  prefixes,
11811
- nextPageToken: startIndex + count < entries.length ? String(startIndex + count) : void 0
11869
+ nextPageToken: scanned < entries.length ? String(scanned) : void 0
11812
11870
  };
11813
11871
  } catch (error) {
11814
11872
  const code = error?.code;
@@ -13759,12 +13817,14 @@ var UPLOAD_EXPIRY_MS = 1440 * 60 * 1e3;
13759
13817
  var TusHandler = class {
13760
13818
  storageController;
13761
13819
  storageRegistry;
13820
+ authorizeUpload;
13762
13821
  uploads = /* @__PURE__ */ new Map();
13763
13822
  tusDir;
13764
13823
  cleanupTimer;
13765
- constructor(storageBaseDir, storageController, storageRegistry) {
13824
+ constructor(storageBaseDir, storageController, storageRegistry, authorizeUpload) {
13766
13825
  this.storageController = storageController;
13767
13826
  this.storageRegistry = storageRegistry;
13827
+ this.authorizeUpload = authorizeUpload;
13768
13828
  this.tusDir = join(storageBaseDir, ".tus-uploads");
13769
13829
  }
13770
13830
  /** Ensure the temp directory exists. */
@@ -13828,6 +13888,10 @@ var TusHandler = class {
13828
13888
  if (Number.isNaN(uploadLength) || uploadLength <= 0) throw ApiError.badRequest("Invalid Upload-Length");
13829
13889
  if (uploadLength > MAX_UPLOAD_SIZE) throw new ApiError(413, "PAYLOAD_TOO_LARGE", `Upload-Length exceeds maximum of ${MAX_UPLOAD_SIZE} bytes`);
13830
13890
  const metadata = this.parseMetadata(c.req.header("Upload-Metadata") || "");
13891
+ if (this.authorizeUpload) {
13892
+ const key = metadata.key || metadata.filename || "";
13893
+ await this.authorizeUpload(c, key, metadata.bucket || "default");
13894
+ }
13831
13895
  const id = randomUUID$1();
13832
13896
  const filePath = join(this.tusDir, id);
13833
13897
  await writeFile(filePath, Buffer.alloc(0));
@@ -14013,7 +14077,7 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
14013
14077
  } }, 401);
14014
14078
  }
14015
14079
  if (authenticatedUser) c.set("user", {
14016
- userId: authenticatedUser.uid,
14080
+ uid: authenticatedUser.uid,
14017
14081
  email: authenticatedUser.email,
14018
14082
  roles: authenticatedUser.roles
14019
14083
  });
@@ -14035,7 +14099,34 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
14035
14099
  function createStorageRoutes(config) {
14036
14100
  const router = new Hono();
14037
14101
  router.onError(errorHandler);
14038
- const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter } = config;
14102
+ const { controller, registry, sources: declaredSources, requireAuth: requireAuth$1 = true, publicRead = false, authAdapter, authorize } = config;
14103
+ /**
14104
+ * Run the per-object authorization hook, if one is configured.
14105
+ *
14106
+ * Denials are 403 rather than 404: the route already established that the
14107
+ * caller is authenticated, so hiding existence buys nothing, and a
14108
+ * distinguishable status is what makes a misconfigured policy debuggable.
14109
+ * A hook that throws denies too — an ownership lookup that fails must not
14110
+ * fall open.
14111
+ */
14112
+ const checkAuthorized = async (c, operation, key, bucket, storageId) => {
14113
+ if (!authorize) return;
14114
+ const user = c.get("user") ?? null;
14115
+ if (user?.uid === "download-token" || user?.uid === "public") return;
14116
+ let allowed;
14117
+ try {
14118
+ allowed = await authorize({
14119
+ key,
14120
+ bucket,
14121
+ operation,
14122
+ user,
14123
+ storageId: storageId ?? void 0
14124
+ });
14125
+ } catch {
14126
+ allowed = false;
14127
+ }
14128
+ if (!allowed) throw ApiError.forbidden("Not authorized for this object");
14129
+ };
14039
14130
  /**
14040
14131
  * Resolve the storage controller for a request.
14041
14132
  * Looks up by `storageId` in the registry, falls back to the single
@@ -14085,6 +14176,7 @@ function createStorageRoutes(config) {
14085
14176
  const finalKey = sanitizeStorageKey(key || uploadedFile.name || "unnamed");
14086
14177
  const metadata = {};
14087
14178
  for (const [k, value] of Object.entries(body)) if (k.startsWith("metadata_")) metadata[k.replace("metadata_", "")] = value;
14179
+ await checkAuthorized(c, "write", finalKey, bucket ?? "default", storageId);
14088
14180
  const result = await resolveController(storageId).putObject({
14089
14181
  file: uploadedFile,
14090
14182
  key: finalKey,
@@ -14105,7 +14197,12 @@ function createStorageRoutes(config) {
14105
14197
  const rawPath = extractWildcardPath(c);
14106
14198
  if (!rawPath) throw ApiError.notFound("File not found");
14107
14199
  const filePath = decodeURIComponent(rawPath);
14108
- const resolved = resolveController(c.req.query("storageId"));
14200
+ const storageId = c.req.query("storageId");
14201
+ const resolved = resolveController(storageId);
14202
+ {
14203
+ const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14204
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
14205
+ }
14109
14206
  const transformOpts = parseTransformOptions(c.req.query());
14110
14207
  if (resolved.getType() === "local") {
14111
14208
  const localController = resolved;
@@ -14168,8 +14265,10 @@ function createStorageRoutes(config) {
14168
14265
  fileNotFound: true
14169
14266
  }, 404);
14170
14267
  const filePath = decodeURIComponent(rawPath);
14171
- const resolved = resolveController(c.req.query("storageId"));
14268
+ const storageId = c.req.query("storageId");
14269
+ const resolved = resolveController(storageId);
14172
14270
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14271
+ await checkAuthorized(c, "read", resolvedPath, bucket, storageId);
14173
14272
  const downloadConfig = await resolved.getSignedUrl(resolvedPath, bucket);
14174
14273
  if (downloadConfig.fileNotFound) throw ApiError.notFound("File not found");
14175
14274
  if (downloadConfig.metadata) {
@@ -14195,8 +14294,10 @@ function createStorageRoutes(config) {
14195
14294
  message: "No file to delete"
14196
14295
  });
14197
14296
  const filePath = decodeURIComponent(rawPath);
14198
- const resolved = resolveController(c.req.query("storageId"));
14297
+ const storageId = c.req.query("storageId");
14298
+ const resolved = resolveController(storageId);
14199
14299
  const { bucket, resolvedPath } = parseBucketAndPath(filePath);
14300
+ await checkAuthorized(c, "delete", resolvedPath, bucket, storageId);
14200
14301
  await resolved.deleteObject(resolvedPath, bucket);
14201
14302
  return c.json({
14202
14303
  success: true,
@@ -14211,7 +14312,9 @@ function createStorageRoutes(config) {
14211
14312
  const bucket = c.req.query("bucket");
14212
14313
  const maxResults = c.req.query("maxResults");
14213
14314
  const pageToken = c.req.query("pageToken");
14214
- const resolved = resolveController(c.req.query("storageId"));
14315
+ const storageId = c.req.query("storageId");
14316
+ const resolved = resolveController(storageId);
14317
+ await checkAuthorized(c, "list", storagePrefix, bucket ?? "default", storageId);
14215
14318
  const result = await resolved.listObjects(storagePrefix, {
14216
14319
  bucket: bucket ?? (resolved.getType() === "local" ? "default" : void 0),
14217
14320
  maxResults: maxResults ? parseInt(maxResults, 10) : void 0,
@@ -14234,6 +14337,7 @@ function createStorageRoutes(config) {
14234
14337
  const resolved = resolveController(storageId);
14235
14338
  const { bucket, resolvedPath } = parseBucketAndPath(folderPath);
14236
14339
  if (!resolvedPath || resolvedPath.trim() === "") throw ApiError.badRequest("Invalid folder path");
14340
+ await checkAuthorized(c, "write", resolvedPath, bucket, storageId);
14237
14341
  if (resolved.getType() === "local") {
14238
14342
  const absolutePath = resolved.getAbsolutePath(resolvedPath, bucket);
14239
14343
  fs$1.mkdirSync(absolutePath, { recursive: true });
@@ -14251,7 +14355,9 @@ function createStorageRoutes(config) {
14251
14355
  }, 201);
14252
14356
  });
14253
14357
  const defaultCtrl = getDefaultController();
14254
- const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry);
14358
+ const tusHandler = new TusHandler(defaultCtrl.getType() === "local" ? defaultCtrl.getBasePath() : process.env.STORAGE_PATH || "./uploads", defaultCtrl, registry, authorize ? async (c, key, bucket) => {
14359
+ await checkAuthorized(c, "write", sanitizeStorageKey(key), bucket, c.req.query("storageId"));
14360
+ } : void 0);
14255
14361
  tusHandler.startCleanup();
14256
14362
  router.options("/tus", (_c) => tusHandler.options());
14257
14363
  router.post("/tus", writeAuthMiddleware, async (c) => tusHandler.create(c));
@@ -14392,7 +14498,7 @@ async function initializeStorage(storageConfig, isProduction) {
14392
14498
  const toController = async (entry, label) => {
14393
14499
  if (typeof entry.putObject === "function") return entry;
14394
14500
  const conf = entry;
14395
- 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.`);
14501
+ 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.`);
14396
14502
  return await createStorageController(conf);
14397
14503
  };
14398
14504
  if (typeof storageConfig === "object" && ("type" in storageConfig || typeof storageConfig.putObject === "function")) controllers[DEFAULT_STORAGE_ID] = await toController(storageConfig, DEFAULT_STORAGE_ID);
@@ -14649,6 +14755,28 @@ function buildQueryString(params) {
14649
14755
  }
14650
14756
  return parts.length > 0 ? "?" + parts.join("&") : "";
14651
14757
  }
14758
+ /**
14759
+ * The base every request and every caller-built URL resolves against.
14760
+ *
14761
+ * `baseUrl` is optional because the common production shape is a Rebase
14762
+ * backend serving its own SPA, where the API is simply the page's origin.
14763
+ * Leaving it unset is therefore the *correct* configuration there — and the
14764
+ * one that keeps working when a second hostname (a custom domain) points at
14765
+ * the same app.
14766
+ *
14767
+ * When unset in a browser this resolves to the page origin rather than "".
14768
+ * Requests behave identically either way, but the empty string is a trap for
14769
+ * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
14770
+ * throws, so apps "fixed" it by baking an absolute host into their bundle —
14771
+ * which is exactly what breaks the day a custom domain is added, and which no
14772
+ * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
14773
+ * not sent cross-site either.
14774
+ */
14775
+ function resolveBaseUrl(configured) {
14776
+ if (configured) return configured.replace(/\/$/, "");
14777
+ if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
14778
+ return "";
14779
+ }
14652
14780
  function createTransport(config) {
14653
14781
  const fetchFn = config.fetch || globalThis.fetch;
14654
14782
  const apiPath = config.apiPath || "/api";
@@ -14663,7 +14791,7 @@ function createTransport(config) {
14663
14791
  };
14664
14792
  }
14665
14793
  async function request(path, init) {
14666
- const url = (config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "") + apiPath + path;
14794
+ const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
14667
14795
  let activeToken = token;
14668
14796
  if (tokenGetter) try {
14669
14797
  const fetched = await tokenGetter();
@@ -14738,7 +14866,7 @@ function createTransport(config) {
14738
14866
  onUnauthorizedHandler = handler;
14739
14867
  },
14740
14868
  get baseUrl() {
14741
- return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
14869
+ return resolveBaseUrl(config.baseUrl);
14742
14870
  },
14743
14871
  get apiPath() {
14744
14872
  return apiPath;
@@ -15198,6 +15326,30 @@ function createAuth(transport, options) {
15198
15326
  })
15199
15327
  });
15200
15328
  }
15329
+ /**
15330
+ * Link an OAuth provider to the **currently signed-in** account.
15331
+ *
15332
+ * Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
15333
+ * with that email already exists under a different sign-in method — or to
15334
+ * attach a provider whose email differs from the account's.
15335
+ *
15336
+ * The payload is the same one the provider's sign-in method takes, e.g.
15337
+ * `linkProvider("google", { idToken })`.
15338
+ *
15339
+ * Unlike sign-in, this does not require the provider to have verified the
15340
+ * email, and the emails need not match: the active session already proves
15341
+ * account ownership.
15342
+ *
15343
+ * Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
15344
+ * attached to a different user. Succeeds idempotently (`alreadyLinked:
15345
+ * true`) if it is already attached to the current one.
15346
+ */
15347
+ async function linkProvider(providerId, payload) {
15348
+ return transport.request(authPath + "/link/" + providerId, {
15349
+ method: "POST",
15350
+ body: JSON.stringify(payload)
15351
+ });
15352
+ }
15201
15353
  async function sendVerificationEmail() {
15202
15354
  return transport.request(authPath + "/send-verification", { method: "POST" });
15203
15355
  }
@@ -15319,6 +15471,7 @@ function createAuth(transport, options) {
15319
15471
  resetPasswordForEmail,
15320
15472
  resetPassword,
15321
15473
  changePassword,
15474
+ linkProvider,
15322
15475
  sendVerificationEmail,
15323
15476
  verifyEmail,
15324
15477
  sendMagicLink,
@@ -15933,12 +16086,29 @@ var ClientStorageSourceRegistry = class ClientStorageSourceRegistry {
15933
16086
  function extractMessageError(message) {
15934
16087
  const payload = message.payload;
15935
16088
  const errPayload = payload?.error;
16089
+ const errorMessage = typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error";
16090
+ const errorCode = typeof errPayload === "object" ? errPayload.code : payload?.code;
15936
16091
  return {
15937
- errorMessage: typeof errPayload === "object" ? errPayload.message : payload?.message || (typeof errPayload === "string" ? errPayload : void 0) || message.error || "Unknown error",
15938
- errorCode: typeof errPayload === "object" ? errPayload.code : payload?.code
16092
+ errorMessage: typeof errorMessage === "string" ? errorMessage : errorMessage == null ? "Unknown error" : JSON.stringify(errorMessage),
16093
+ errorCode
15939
16094
  };
15940
16095
  }
15941
16096
  /**
16097
+ * Broadcast and presence frames.
16098
+ *
16099
+ * Fire-and-forget (the server sends no response envelope), and exempt from the
16100
+ * client-side auth gate — a public channel is usable without an account.
16101
+ */
16102
+ var CHANNEL_MESSAGE_TYPES = new Set([
16103
+ "join_channel",
16104
+ "leave_channel",
16105
+ "broadcast",
16106
+ "presence_track",
16107
+ "presence_untrack",
16108
+ "presence_state",
16109
+ "channel_history"
16110
+ ]);
16111
+ /**
15942
16112
  * Low-level realtime WebSocket client.
15943
16113
  *
15944
16114
  * @internal Not a stable app-facing API. `createRebaseClient()` constructs and
@@ -15953,6 +16123,36 @@ var RebaseWebSocketClient = class {
15953
16123
  getAuthToken;
15954
16124
  subscriptions = /* @__PURE__ */ new Map();
15955
16125
  listeners = /* @__PURE__ */ new Map();
16126
+ /** Channel-name → handlers, for broadcast and presence frames. */
16127
+ channelHandlers = /* @__PURE__ */ new Map();
16128
+ /** Set by `close()`. Blocks any later operation from silently redialling. */
16129
+ closedByCaller = false;
16130
+ /**
16131
+ * Whether a socket exists at all (open or still opening).
16132
+ *
16133
+ * Lets callers distinguish "authenticate the live socket" from "there is
16134
+ * nothing to authenticate yet", without that question forcing a dial.
16135
+ */
16136
+ get hasSocket() {
16137
+ return this.ws !== null;
16138
+ }
16139
+ /** So the "no WebSocket in this environment" warning is said once, not per call. */
16140
+ warnedNoWebSocket = false;
16141
+ /** Subscribe to broadcast/presence frames for one channel. */
16142
+ onChannelMessage(channel, handler) {
16143
+ if (!this.channelHandlers.has(channel)) this.channelHandlers.set(channel, /* @__PURE__ */ new Set());
16144
+ this.channelHandlers.get(channel).add(handler);
16145
+ return () => {
16146
+ const handlers = this.channelHandlers.get(channel);
16147
+ if (!handlers) return;
16148
+ handlers.delete(handler);
16149
+ if (handlers.size === 0) this.channelHandlers.delete(channel);
16150
+ };
16151
+ }
16152
+ /** Notified after the socket comes back, so channels can re-join. */
16153
+ onReconnect(handler) {
16154
+ return this.on("reconnect", handler);
16155
+ }
15956
16156
  on(event, cb) {
15957
16157
  if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
15958
16158
  this.listeners.get(event).add(cb);
@@ -15971,6 +16171,7 @@ var RebaseWebSocketClient = class {
15971
16171
  isConnected = false;
15972
16172
  messageQueue = [];
15973
16173
  requestTimeoutMs = 3e4;
16174
+ subscriptionTimeoutMs = 3e4;
15974
16175
  reconnectTimeout = null;
15975
16176
  isAuthenticated = false;
15976
16177
  authPromise = null;
@@ -15982,8 +16183,25 @@ var RebaseWebSocketClient = class {
15982
16183
  this.getAuthToken = config.getAuthToken;
15983
16184
  this.onUnauthorized = config.onUnauthorized;
15984
16185
  this.WebSocketConstructor = config.WebSocket || (typeof WebSocket !== "undefined" ? WebSocket : void 0);
15985
- 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.");
15986
- else this.initWebSocket();
16186
+ }
16187
+ /**
16188
+ * Open the socket if it is not open (or opening) already.
16189
+ *
16190
+ * Idempotent, synchronous, and safe to call on every operation that needs a
16191
+ * live socket — `initWebSocket` already no-ops on an open socket and is
16192
+ * re-entrant, since the reconnect path has always called it.
16193
+ */
16194
+ ensureConnected() {
16195
+ if (this.closedByCaller) return;
16196
+ if (!this.WebSocketConstructor) {
16197
+ if (!this.warnedNoWebSocket) {
16198
+ this.warnedNoWebSocket = true;
16199
+ console.warn("WebSocket is not defined in this environment. Realtime subscriptions will not work unless you provide a WebSocket implementation in the config.");
16200
+ }
16201
+ return;
16202
+ }
16203
+ if (this.ws || this.reconnectTimeout) return;
16204
+ this.initWebSocket();
15987
16205
  }
15988
16206
  /**
15989
16207
  * Authenticate the WebSocket connection
@@ -16033,7 +16251,16 @@ var RebaseWebSocketClient = class {
16033
16251
  });
16034
16252
  }
16035
16253
  }
16036
- disconnect() {
16254
+ /**
16255
+ * Drop the socket.
16256
+ *
16257
+ * `permanent` distinguishes the two callers. Signing out drops the socket
16258
+ * but the client stays usable — a later subscribe should reconnect
16259
+ * anonymously. `client.close()` is the caller saying they are done, and
16260
+ * must not be undone by a stray queued frame.
16261
+ */
16262
+ disconnect(permanent = false) {
16263
+ if (permanent) this.closedByCaller = true;
16037
16264
  this.isAuthenticated = false;
16038
16265
  this.authPromise = null;
16039
16266
  if (this.reconnectTimeout) {
@@ -16076,6 +16303,7 @@ var RebaseWebSocketClient = class {
16076
16303
  this.emit(wasReconnect ? "reconnect" : "connect");
16077
16304
  this.processMessageQueue();
16078
16305
  if (wasReconnect) this.resubscribeAll();
16306
+ this.armPendingSubscribeWatchdogs();
16079
16307
  };
16080
16308
  this.ws.onmessage = (event) => {
16081
16309
  try {
@@ -16090,6 +16318,7 @@ var RebaseWebSocketClient = class {
16090
16318
  this.isConnected = false;
16091
16319
  this.isAuthenticated = false;
16092
16320
  this.authPromise = null;
16321
+ this.suspendSubscribeWatchdogs();
16093
16322
  this.emit("disconnect");
16094
16323
  for (const [reqId, request] of this.pendingRequests.entries()) {
16095
16324
  if (reqId.startsWith("auth_")) request.reject(/* @__PURE__ */ new Error("Connection closed during authentication"));
@@ -16121,6 +16350,7 @@ var RebaseWebSocketClient = class {
16121
16350
  attemptReconnect() {
16122
16351
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
16123
16352
  console.error("Max reconnection attempts reached");
16353
+ this.failAllPendingSubscriptions(new RebaseApiError("Connection lost", { code: "CONNECTION_LOST" }));
16124
16354
  return;
16125
16355
  }
16126
16356
  this.reconnectAttempts++;
@@ -16175,29 +16405,18 @@ var RebaseWebSocketClient = class {
16175
16405
  subscription.backendSubscriptionId = newBackendId;
16176
16406
  backendKeyMap.delete(oldBackendId);
16177
16407
  backendKeyMap.set(newBackendId, subscriptionKey);
16178
- this.sendMessage({
16179
- type: messageType,
16180
- payload: {
16181
- ...subscription.props,
16182
- subscriptionId: newBackendId
16183
- }
16184
- }).catch((error) => {
16185
- console.error(`[WS] Failed to re-subscribe ${idPrefix} after auth refresh:`, subscriptionKey, error);
16186
- subscription.callbacks.forEach((callback) => {
16187
- if (callback.onError) callback.onError(error);
16188
- });
16189
- });
16190
- } else {
16191
- const { errorMessage, errorCode } = extractMessageError(message);
16192
- const error = new RebaseApiError(errorMessage, { code: errorCode });
16193
- subscription.callbacks.forEach((callback) => {
16194
- if (callback.onError) callback.onError(error);
16195
- });
16408
+ if (messageType === "subscribe_collection") this.sendCollectionSubscribe(subscriptionKey);
16409
+ else this.sendEntitySubscribe(subscriptionKey);
16410
+ return;
16196
16411
  }
16412
+ const { errorMessage, errorCode } = extractMessageError(message);
16413
+ const error = new RebaseApiError(errorMessage, { code: errorCode });
16414
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
16415
+ else this.failEntitySubscription(subscriptionKey, error);
16197
16416
  }).catch((err) => {
16198
- subscription.callbacks.forEach((callback) => {
16199
- if (callback.onError) callback.onError(err);
16200
- });
16417
+ const error = err instanceof Error ? err : new Error(String(err));
16418
+ if (messageType === "subscribe_collection") this.failCollectionSubscription(subscriptionKey, error);
16419
+ else this.failEntitySubscription(subscriptionKey, error);
16201
16420
  });
16202
16421
  }
16203
16422
  handleWebSocketMessage(message) {
@@ -16226,6 +16445,15 @@ var RebaseWebSocketClient = class {
16226
16445
  }
16227
16446
  return;
16228
16447
  }
16448
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
16449
+ const handlers = this.channelHandlers.get(message.channel);
16450
+ if (handlers) for (const handler of [...handlers]) try {
16451
+ handler(message);
16452
+ } catch (error) {
16453
+ console.error("Error in channel handler:", error);
16454
+ }
16455
+ return;
16456
+ }
16229
16457
  if (subscriptionId && type === "collection_update") {
16230
16458
  const subscriptionKey = this.backendToCollectionKey.get(subscriptionId);
16231
16459
  if (subscriptionKey) {
@@ -16238,6 +16466,9 @@ var RebaseWebSocketClient = class {
16238
16466
  collectionSub.latestData = rows;
16239
16467
  collectionSub.lastUpdated = Date.now();
16240
16468
  collectionSub.isInitialDataReceived = true;
16469
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
16470
+ collectionSub.subscribeTimeout = void 0;
16471
+ collectionSub.subscribeInFlight = false;
16241
16472
  collectionSub.callbacks.forEach((callback) => {
16242
16473
  try {
16243
16474
  callback.onUpdate(rows);
@@ -16293,6 +16524,9 @@ var RebaseWebSocketClient = class {
16293
16524
  entitySub.latestData = row;
16294
16525
  entitySub.lastUpdated = Date.now();
16295
16526
  entitySub.isInitialDataReceived = true;
16527
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
16528
+ entitySub.subscribeTimeout = void 0;
16529
+ entitySub.subscribeInFlight = false;
16296
16530
  entitySub.callbacks.forEach((callback) => {
16297
16531
  try {
16298
16532
  callback.onUpdate(row);
@@ -16314,6 +16548,9 @@ var RebaseWebSocketClient = class {
16314
16548
  this.resubscribeAfterAuthRefresh(message, collectionSub, collectionKey, "collection", this.backendToCollectionKey, "subscribe_collection");
16315
16549
  return;
16316
16550
  }
16551
+ if (collectionSub.subscribeTimeout) clearTimeout(collectionSub.subscribeTimeout);
16552
+ collectionSub.subscribeTimeout = void 0;
16553
+ collectionSub.subscribeInFlight = false;
16317
16554
  const { errorMessage, errorCode } = extractMessageError(message);
16318
16555
  const error = new RebaseApiError(errorMessage, { code: errorCode });
16319
16556
  collectionSub.callbacks.forEach((callback) => {
@@ -16330,6 +16567,9 @@ var RebaseWebSocketClient = class {
16330
16567
  this.resubscribeAfterAuthRefresh(message, entitySub, entityKey, "row", this.backendToEntityKey, "subscribe_one");
16331
16568
  return;
16332
16569
  }
16570
+ if (entitySub.subscribeTimeout) clearTimeout(entitySub.subscribeTimeout);
16571
+ entitySub.subscribeTimeout = void 0;
16572
+ entitySub.subscribeInFlight = false;
16333
16573
  const { errorMessage, errorCode } = extractMessageError(message);
16334
16574
  const error = new RebaseApiError(errorMessage, { code: errorCode });
16335
16575
  entitySub.callbacks.forEach((callback) => {
@@ -16402,21 +16642,28 @@ var RebaseWebSocketClient = class {
16402
16642
  throw error;
16403
16643
  }
16404
16644
  }
16645
+ /**
16646
+ * Public because `RebaseRealtimeChannel` sends channel frames through it.
16647
+ * Not part of the stable surface — prefer `client.realtime.channel(name)`.
16648
+ */
16405
16649
  sendMessage(message) {
16406
16650
  const queuedMsg = message;
16407
16651
  if (queuedMsg._queuedResolve && queuedMsg._queuedReject) return this.doSendMessage(message, queuedMsg._queuedResolve, queuedMsg._queuedReject);
16408
- if (!this.isConnected || !this.ws) return new Promise((resolve, reject) => {
16409
- const queueable = message;
16410
- queueable._queuedResolve = resolve;
16411
- queueable._queuedReject = reject;
16412
- this.messageQueue.push(message);
16413
- });
16652
+ if (!this.isConnected || !this.ws) {
16653
+ this.ensureConnected();
16654
+ return new Promise((resolve, reject) => {
16655
+ const queueable = message;
16656
+ queueable._queuedResolve = resolve;
16657
+ queueable._queuedReject = reject;
16658
+ this.messageQueue.push(message);
16659
+ });
16660
+ }
16414
16661
  return new Promise((resolve, reject) => {
16415
16662
  this.doSendMessage(message, resolve, reject);
16416
16663
  });
16417
16664
  }
16418
16665
  async doSendMessage(message, resolve, reject) {
16419
- if (message.type !== "AUTHENTICATE" && this.getAuthToken && !this.isAuthenticated) try {
16666
+ if (message.type !== "AUTHENTICATE" && !CHANNEL_MESSAGE_TYPES.has(message.type) && this.getAuthToken && !this.isAuthenticated) try {
16420
16667
  await this.ensureAuthenticated();
16421
16668
  } catch (error) {
16422
16669
  reject(new RebaseApiError(error instanceof Error ? error.message : "Authentication required"));
@@ -16424,17 +16671,7 @@ var RebaseWebSocketClient = class {
16424
16671
  }
16425
16672
  const requestId = message.requestId || `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
16426
16673
  message.requestId = requestId;
16427
- const expectsResponse = ![
16428
- "subscribe_collection",
16429
- "subscribe_one",
16430
- "unsubscribe",
16431
- "join_channel",
16432
- "leave_channel",
16433
- "broadcast",
16434
- "presence_track",
16435
- "presence_untrack",
16436
- "presence_state"
16437
- ].includes(message.type);
16674
+ const expectsResponse = !(message.type === "subscribe_collection" || message.type === "subscribe_one" || message.type === "unsubscribe" || CHANNEL_MESSAGE_TYPES.has(message.type));
16438
16675
  if (expectsResponse && !this.pendingRequests.has(requestId)) {
16439
16676
  const timeoutHandle = setTimeout(() => {
16440
16677
  if (this.pendingRequests.has(requestId)) {
@@ -16504,6 +16741,9 @@ var RebaseWebSocketClient = class {
16504
16741
  async fetchAvailableRoles() {
16505
16742
  return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
16506
16743
  }
16744
+ async fetchApplicationRoles() {
16745
+ return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
16746
+ }
16507
16747
  async fetchCurrentDatabase() {
16508
16748
  return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
16509
16749
  }
@@ -16663,6 +16903,7 @@ var RebaseWebSocketClient = class {
16663
16903
  });
16664
16904
  }
16665
16905
  listenCollection(props, onUpdate, onError) {
16906
+ this.ensureConnected();
16666
16907
  const subscriptionKey = this.createCollectionSubscriptionKey(props);
16667
16908
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
16668
16909
  const existingSubscription = this.collectionSubscriptions.get(subscriptionKey);
@@ -16678,9 +16919,12 @@ var RebaseWebSocketClient = class {
16678
16919
  console.error("Error in collection subscription callback:", error);
16679
16920
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
16680
16921
  }
16922
+ else if (!existingSubscription.subscribeInFlight) this.sendCollectionSubscribe(subscriptionKey);
16681
16923
  return () => {
16682
16924
  callbackMap.delete(callbackId);
16683
16925
  if (callbackMap.size === 0) {
16926
+ if (this.collectionSubscriptions.get(subscriptionKey) !== existingSubscription) return;
16927
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
16684
16928
  this.collectionSubscriptions.delete(subscriptionKey);
16685
16929
  this.backendToCollectionKey.delete(existingSubscription.backendSubscriptionId);
16686
16930
  if (this.isConnected && this.ws) this.sendMessage({
@@ -16702,21 +16946,14 @@ var RebaseWebSocketClient = class {
16702
16946
  props
16703
16947
  });
16704
16948
  this.backendToCollectionKey.set(backendSubscriptionId, subscriptionKey);
16705
- this.sendMessage({
16706
- type: "subscribe_collection",
16707
- payload: {
16708
- ...props,
16709
- subscriptionId: backendSubscriptionId
16710
- }
16711
- }).catch((error) => {
16712
- if (onError) onError(error);
16713
- });
16949
+ this.sendCollectionSubscribe(subscriptionKey);
16714
16950
  return () => {
16715
16951
  const subscription = this.collectionSubscriptions.get(subscriptionKey);
16716
16952
  if (subscription) {
16717
16953
  const callbacks = subscription.callbacks;
16718
16954
  callbacks.delete(callbackId);
16719
16955
  if (callbacks.size === 0) {
16956
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
16720
16957
  this.collectionSubscriptions.delete(subscriptionKey);
16721
16958
  this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
16722
16959
  if (this.isConnected && this.ws) this.sendMessage({
@@ -16728,6 +16965,7 @@ var RebaseWebSocketClient = class {
16728
16965
  };
16729
16966
  }
16730
16967
  listenOne(props, onUpdate, onError) {
16968
+ this.ensureConnected();
16731
16969
  const subscriptionKey = this.createSingleSubscriptionKey(props);
16732
16970
  const callbackId = `callback_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
16733
16971
  const existingSubscription = this.singleSubscriptions.get(subscriptionKey);
@@ -16743,9 +16981,12 @@ var RebaseWebSocketClient = class {
16743
16981
  console.error("Error in row subscription callback:", error);
16744
16982
  if (onError) onError(error instanceof Error ? error : new Error(String(error)));
16745
16983
  }
16984
+ else if (!existingSubscription.subscribeInFlight) this.sendEntitySubscribe(subscriptionKey);
16746
16985
  return () => {
16747
16986
  callbackMap.delete(callbackId);
16748
16987
  if (callbackMap.size === 0) {
16988
+ if (this.singleSubscriptions.get(subscriptionKey) !== existingSubscription) return;
16989
+ if (existingSubscription.subscribeTimeout) clearTimeout(existingSubscription.subscribeTimeout);
16749
16990
  this.singleSubscriptions.delete(subscriptionKey);
16750
16991
  this.backendToEntityKey.delete(existingSubscription.backendSubscriptionId);
16751
16992
  if (this.isConnected && this.ws) this.sendMessage({
@@ -16767,15 +17008,7 @@ var RebaseWebSocketClient = class {
16767
17008
  props
16768
17009
  });
16769
17010
  this.backendToEntityKey.set(backendSubscriptionId, subscriptionKey);
16770
- this.sendMessage({
16771
- type: "subscribe_one",
16772
- payload: {
16773
- ...props,
16774
- subscriptionId: backendSubscriptionId
16775
- }
16776
- }).catch((error) => {
16777
- if (onError) onError(error);
16778
- });
17011
+ this.sendEntitySubscribe(subscriptionKey);
16779
17012
  return () => {
16780
17013
  const subscription = this.singleSubscriptions.get(subscriptionKey);
16781
17014
  if (subscription) {
@@ -16793,6 +17026,157 @@ var RebaseWebSocketClient = class {
16793
17026
  };
16794
17027
  }
16795
17028
  /**
17029
+ * Send a `subscribe_collection` for an already-registered subscription and
17030
+ * arm its watchdog.
17031
+ *
17032
+ * Every path that registers a collection subscription goes through here, so
17033
+ * that a subscribe which never lands — a rejected send, or a server that
17034
+ * never answers — always ends up in `failCollectionSubscription` rather than
17035
+ * leaving the entry parked with `isInitialDataReceived === false` forever.
17036
+ */
17037
+ sendCollectionSubscribe(subscriptionKey) {
17038
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
17039
+ if (!subscription) return;
17040
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17041
+ subscription.subscribeInFlight = true;
17042
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17043
+ subscription.subscribeTimeout = void 0;
17044
+ if (this.isConnected) this.sendCollectionSubscribeWatchdog(subscriptionKey);
17045
+ this.sendMessage({
17046
+ type: "subscribe_collection",
17047
+ payload: {
17048
+ ...subscription.props,
17049
+ subscriptionId: backendSubscriptionId
17050
+ }
17051
+ }).catch((error) => {
17052
+ const current = this.collectionSubscriptions.get(subscriptionKey);
17053
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17054
+ this.failCollectionSubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
17055
+ });
17056
+ }
17057
+ /** The `listenOne` counterpart of {@link sendCollectionSubscribe}. */
17058
+ sendEntitySubscribe(subscriptionKey) {
17059
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
17060
+ if (!subscription) return;
17061
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17062
+ subscription.subscribeInFlight = true;
17063
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17064
+ subscription.subscribeTimeout = void 0;
17065
+ if (this.isConnected) this.sendEntitySubscribeWatchdog(subscriptionKey);
17066
+ this.sendMessage({
17067
+ type: "subscribe_one",
17068
+ payload: {
17069
+ ...subscription.props,
17070
+ subscriptionId: backendSubscriptionId
17071
+ }
17072
+ }).catch((error) => {
17073
+ const current = this.singleSubscriptions.get(subscriptionKey);
17074
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17075
+ this.failEntitySubscription(subscriptionKey, error instanceof Error ? error : new Error(String(error)));
17076
+ });
17077
+ }
17078
+ /**
17079
+ * Report a subscribe failure to every listener and drop the registration.
17080
+ *
17081
+ * Dropping it is the point: the callbacks stay live (their components are
17082
+ * still mounted and have been told), but the next `listenCollection` for
17083
+ * these params finds no entry and issues a fresh subscribe instead of
17084
+ * silently attaching to a dead one.
17085
+ */
17086
+ failCollectionSubscription(subscriptionKey, error) {
17087
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
17088
+ if (!subscription) return;
17089
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17090
+ subscription.subscribeInFlight = false;
17091
+ this.collectionSubscriptions.delete(subscriptionKey);
17092
+ this.backendToCollectionKey.delete(subscription.backendSubscriptionId);
17093
+ subscription.callbacks.forEach((callback) => {
17094
+ if (callback.onError) try {
17095
+ callback.onError(error);
17096
+ } catch (callbackError) {
17097
+ console.error("Error in collection subscription error callback:", callbackError);
17098
+ }
17099
+ });
17100
+ }
17101
+ /** The `listenOne` counterpart of {@link failCollectionSubscription}. */
17102
+ failEntitySubscription(subscriptionKey, error) {
17103
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
17104
+ if (!subscription) return;
17105
+ if (subscription.subscribeTimeout) clearTimeout(subscription.subscribeTimeout);
17106
+ subscription.subscribeInFlight = false;
17107
+ this.singleSubscriptions.delete(subscriptionKey);
17108
+ this.backendToEntityKey.delete(subscription.backendSubscriptionId);
17109
+ subscription.callbacks.forEach((callback) => {
17110
+ if (callback.onError) try {
17111
+ callback.onError(error);
17112
+ } catch (callbackError) {
17113
+ console.error("Error in row subscription error callback:", callbackError);
17114
+ }
17115
+ });
17116
+ }
17117
+ /**
17118
+ * Stop the watchdogs without failing anything — used when the socket drops,
17119
+ * since the reconnect path re-subscribes everything anyway and a watchdog
17120
+ * firing mid-reconnect would tear down healthy subscriptions.
17121
+ */
17122
+ suspendSubscribeWatchdogs() {
17123
+ for (const sub of this.collectionSubscriptions.values()) {
17124
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
17125
+ sub.subscribeTimeout = void 0;
17126
+ sub.subscribeInFlight = false;
17127
+ }
17128
+ for (const sub of this.singleSubscriptions.values()) {
17129
+ if (sub.subscribeTimeout) clearTimeout(sub.subscribeTimeout);
17130
+ sub.subscribeTimeout = void 0;
17131
+ sub.subscribeInFlight = false;
17132
+ }
17133
+ }
17134
+ /**
17135
+ * Arm watchdogs for subscribes that were requested while offline and have
17136
+ * just been flushed to the socket. Their timers were deliberately not set at
17137
+ * request time, so without this they would have no timeout at all.
17138
+ */
17139
+ armPendingSubscribeWatchdogs() {
17140
+ for (const [key, sub] of this.collectionSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendCollectionSubscribeWatchdog(key);
17141
+ for (const [key, sub] of this.singleSubscriptions.entries()) if (sub.subscribeInFlight && !sub.subscribeTimeout) this.sendEntitySubscribeWatchdog(key);
17142
+ }
17143
+ sendCollectionSubscribeWatchdog(subscriptionKey) {
17144
+ const subscription = this.collectionSubscriptions.get(subscriptionKey);
17145
+ if (!subscription) return;
17146
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17147
+ subscription.subscribeTimeout = setTimeout(() => {
17148
+ const current = this.collectionSubscriptions.get(subscriptionKey);
17149
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17150
+ if (!current.subscribeInFlight) return;
17151
+ this.failCollectionSubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
17152
+ }, this.subscriptionTimeoutMs);
17153
+ }
17154
+ sendEntitySubscribeWatchdog(subscriptionKey) {
17155
+ const subscription = this.singleSubscriptions.get(subscriptionKey);
17156
+ if (!subscription) return;
17157
+ const backendSubscriptionId = subscription.backendSubscriptionId;
17158
+ subscription.subscribeTimeout = setTimeout(() => {
17159
+ const current = this.singleSubscriptions.get(subscriptionKey);
17160
+ if (!current || current.backendSubscriptionId !== backendSubscriptionId) return;
17161
+ if (!current.subscribeInFlight) return;
17162
+ this.failEntitySubscription(subscriptionKey, new RebaseApiError("Subscription timed out", { code: "SUBSCRIPTION_TIMEOUT" }));
17163
+ }, this.subscriptionTimeoutMs);
17164
+ }
17165
+ /**
17166
+ * Fail every subscription that never received data. Called when reconnection
17167
+ * is given up on, so views surface an error instead of spinning forever.
17168
+ */
17169
+ failAllPendingSubscriptions(error) {
17170
+ for (const key of [...this.collectionSubscriptions.keys()]) {
17171
+ const sub = this.collectionSubscriptions.get(key);
17172
+ if (sub && !sub.isInitialDataReceived) this.failCollectionSubscription(key, error);
17173
+ }
17174
+ for (const key of [...this.singleSubscriptions.keys()]) {
17175
+ const sub = this.singleSubscriptions.get(key);
17176
+ if (sub && !sub.isInitialDataReceived) this.failEntitySubscription(key, error);
17177
+ }
17178
+ }
17179
+ /**
16796
17180
  * Re-send all active subscriptions to the backend after a reconnect.
16797
17181
  * The server wipes subscription state when a client disconnects, so
16798
17182
  * we need to re-register everything to resume receiving updates.
@@ -16805,15 +17189,7 @@ var RebaseWebSocketClient = class {
16805
17189
  sub.backendSubscriptionId = newBackendId;
16806
17190
  this.backendToCollectionKey.delete(oldBackendId);
16807
17191
  this.backendToCollectionKey.set(newBackendId, key);
16808
- this.sendMessage({
16809
- type: "subscribe_collection",
16810
- payload: {
16811
- ...sub.props,
16812
- subscriptionId: newBackendId
16813
- }
16814
- }).catch((error) => {
16815
- console.error("[WS] Failed to re-subscribe collection:", key, error);
16816
- });
17192
+ this.sendCollectionSubscribe(key);
16817
17193
  }
16818
17194
  for (const [key, sub] of this.singleSubscriptions.entries()) {
16819
17195
  const oldBackendId = sub.backendSubscriptionId;
@@ -16821,15 +17197,7 @@ var RebaseWebSocketClient = class {
16821
17197
  sub.backendSubscriptionId = newBackendId;
16822
17198
  this.backendToEntityKey.delete(oldBackendId);
16823
17199
  this.backendToEntityKey.set(newBackendId, key);
16824
- this.sendMessage({
16825
- type: "subscribe_one",
16826
- payload: {
16827
- ...sub.props,
16828
- subscriptionId: newBackendId
16829
- }
16830
- }).catch((error) => {
16831
- console.error("[WS] Failed to re-subscribe row:", key, error);
16832
- });
17200
+ this.sendEntitySubscribe(key);
16833
17201
  }
16834
17202
  }
16835
17203
  createCollectionSubscriptionKey(props) {
@@ -16856,6 +17224,377 @@ var RebaseWebSocketClient = class {
16856
17224
  }
16857
17225
  };
16858
17226
  /**
17227
+ * Re-send presence comfortably inside the server's 30s expiry.
17228
+ *
17229
+ * Two-thirds of the window: one lost heartbeat still leaves time for the next
17230
+ * before the entry is reaped, so a single dropped frame is not a disappearance.
17231
+ */
17232
+ var PRESENCE_HEARTBEAT_MS = 2e4;
17233
+ /**
17234
+ * How long live messages are held back waiting for a catch-up response.
17235
+ *
17236
+ * Short, because the cost of waiting is visible — on a collaborative document
17237
+ * this is a stall in everyone else's edits appearing. Long enough that a slow
17238
+ * replay of a busy channel is not abandoned needlessly.
17239
+ */
17240
+ var CATCH_UP_TIMEOUT_MS = 1e4;
17241
+ var RebaseRealtimeChannel = class {
17242
+ name;
17243
+ transport;
17244
+ presenceHandlers = /* @__PURE__ */ new Set();
17245
+ broadcastHandlers = /* @__PURE__ */ new Set();
17246
+ unsubscribers = [];
17247
+ /** Last known roster, kept so handlers always get a full picture. */
17248
+ presences = {};
17249
+ /** What this client last tracked, replayed on reconnect and heartbeat. */
17250
+ trackedState = null;
17251
+ heartbeat = null;
17252
+ joined = false;
17253
+ /** Whether this handle asks the server to replay missed messages. */
17254
+ wantsHistory;
17255
+ /**
17256
+ * Highest sequence number delivered to handlers so far.
17257
+ *
17258
+ * This is the resume point sent as `sinceSeq`, and the watermark that makes
17259
+ * replay idempotent: catch-up ranges overlap with what arrived live, and
17260
+ * anything at or below this has already been seen.
17261
+ */
17262
+ lastSeq = 0;
17263
+ /**
17264
+ * Live messages that arrived while a catch-up was in flight.
17265
+ *
17266
+ * Without this they would be delivered ahead of the older messages being
17267
+ * fetched, and — worse — would advance {@link lastSeq} past them, so the
17268
+ * catch-up response would then be discarded as already-seen and those
17269
+ * messages would be lost for good. Held here and flushed, in order, once
17270
+ * the replay lands.
17271
+ */
17272
+ pendingLive = [];
17273
+ catchUpInFlight = false;
17274
+ /**
17275
+ * Deadline for a catch-up response.
17276
+ *
17277
+ * Buffering live messages is only safe because the wait is bounded. A
17278
+ * catch-up frame that never arrives — a server that dropped it, a socket
17279
+ * that died between request and reply — would otherwise leave the channel
17280
+ * silently holding every subsequent edit forever, which is a worse failure
17281
+ * than the one replay was added to fix.
17282
+ */
17283
+ catchUpTimeout = null;
17284
+ /**
17285
+ * Callers of {@link history} awaiting the next `channel_history` frame.
17286
+ *
17287
+ * These frames are addressed by channel rather than by request id, so they
17288
+ * are matched in arrival order. Requests on one channel are serialized by
17289
+ * the socket, so FIFO is the right correlation here.
17290
+ */
17291
+ historyWaiters = [];
17292
+ constructor(name, transport, options = {}) {
17293
+ this.name = name;
17294
+ this.transport = transport;
17295
+ this.wantsHistory = options.history ?? false;
17296
+ }
17297
+ /**
17298
+ * Turn on catch-up for a handle that was created without it.
17299
+ *
17300
+ * The client hands back the same channel object for a given name, so a
17301
+ * later `channel(name, { history: true })` has no new object to configure —
17302
+ * it upgrades this one instead. Idempotent, and never downgrades: one
17303
+ * caller asking for history must not be switched off by another that did
17304
+ * not ask.
17305
+ */
17306
+ enableHistory() {
17307
+ if (this.wantsHistory) return;
17308
+ this.wantsHistory = true;
17309
+ if (this.joined) this.requestHistory();
17310
+ }
17311
+ /**
17312
+ * Join the channel and ask for the current roster.
17313
+ *
17314
+ * Called automatically by `track`, `broadcast`, `onPresence` and
17315
+ * `onBroadcast`; calling it directly is only needed to start receiving
17316
+ * before there is anything to send.
17317
+ */
17318
+ /**
17319
+ * Send a channel message.
17320
+ *
17321
+ * Every channel message is read by the server out of a `payload` envelope
17322
+ * (`payload?.channel`, `payload?.state`, `payload?.event`). Sending those
17323
+ * fields flat does not error: `payload?.channel` simply reads as
17324
+ * `undefined`, so the client is registered into channel `undefined` with
17325
+ * empty state, and the echo comes back with no `channel` for
17326
+ * `onChannelMessage` to match — presence and broadcast both go quiet with
17327
+ * nothing logged. Funnelled through one place so a new message type cannot
17328
+ * reintroduce that.
17329
+ */
17330
+ send(type, fields = {}) {
17331
+ return this.transport.sendMessage({
17332
+ type,
17333
+ payload: {
17334
+ channel: this.name,
17335
+ ...fields
17336
+ }
17337
+ });
17338
+ }
17339
+ async join() {
17340
+ if (this.joined) return;
17341
+ this.joined = true;
17342
+ this.unsubscribers.push(this.transport.onChannelMessage(this.name, (message) => this.handle(message)));
17343
+ this.unsubscribers.push(this.transport.onReconnect(() => {
17344
+ this.rejoin();
17345
+ }));
17346
+ await this.send("join_channel");
17347
+ await this.send("presence_state");
17348
+ if (this.wantsHistory) await this.requestHistory();
17349
+ }
17350
+ async rejoin() {
17351
+ try {
17352
+ await this.send("join_channel");
17353
+ await this.send("presence_state");
17354
+ if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
17355
+ if (this.wantsHistory) await this.requestHistory();
17356
+ } catch {}
17357
+ }
17358
+ /**
17359
+ * Ask the server for everything after {@link lastSeq}.
17360
+ *
17361
+ * Live messages are buffered from here until the answer arrives — see
17362
+ * {@link pendingLive}.
17363
+ */
17364
+ async requestHistory(limit) {
17365
+ this.catchUpInFlight = true;
17366
+ if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
17367
+ this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
17368
+ this.catchUpTimeout.unref?.();
17369
+ try {
17370
+ await this.send("channel_history", {
17371
+ sinceSeq: this.lastSeq,
17372
+ ...limit !== void 0 ? { limit } : {}
17373
+ });
17374
+ } catch {
17375
+ this.abandonCatchUp();
17376
+ }
17377
+ }
17378
+ /**
17379
+ * Give up waiting for a catch-up and release what was held back.
17380
+ *
17381
+ * The buffered messages are still the freshest thing this client has, so
17382
+ * they are delivered rather than dropped. Callers of {@link history} are
17383
+ * answered with `retained: false` — accurate in the sense that matters:
17384
+ * this client has no history to work from and has to resync.
17385
+ */
17386
+ abandonCatchUp() {
17387
+ if (this.catchUpTimeout) {
17388
+ clearTimeout(this.catchUpTimeout);
17389
+ this.catchUpTimeout = null;
17390
+ }
17391
+ if (!this.catchUpInFlight) return;
17392
+ this.catchUpInFlight = false;
17393
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17394
+ messages: [],
17395
+ retained: false
17396
+ });
17397
+ this.flushPendingLive();
17398
+ }
17399
+ /**
17400
+ * Publish this client's presence state, and keep publishing it.
17401
+ *
17402
+ * Calling `track` again replaces the state (and restarts the heartbeat),
17403
+ * which is how you update e.g. a cursor position.
17404
+ */
17405
+ async track(state) {
17406
+ await this.join();
17407
+ this.trackedState = state;
17408
+ await this.send("presence_track", { state });
17409
+ if (!this.heartbeat) {
17410
+ this.heartbeat = setInterval(() => {
17411
+ if (!this.trackedState) return;
17412
+ this.send("presence_track", { state: this.trackedState }).catch(() => {});
17413
+ }, PRESENCE_HEARTBEAT_MS);
17414
+ this.heartbeat.unref?.();
17415
+ }
17416
+ }
17417
+ /** Stop publishing presence, without leaving the channel. */
17418
+ async untrack() {
17419
+ this.stopHeartbeat();
17420
+ this.trackedState = null;
17421
+ if (this.joined) await this.send("presence_untrack");
17422
+ }
17423
+ /**
17424
+ * Observe the roster. The handler fires immediately with what is already
17425
+ * known, then on every change.
17426
+ */
17427
+ onPresence(handler) {
17428
+ this.presenceHandlers.add(handler);
17429
+ this.join();
17430
+ if (Object.keys(this.presences).length > 0) handler({ ...this.presences });
17431
+ return () => this.presenceHandlers.delete(handler);
17432
+ }
17433
+ /** Send a broadcast. The sender does not receive its own message. */
17434
+ async broadcast(event, payload) {
17435
+ await this.join();
17436
+ await this.send("broadcast", {
17437
+ event,
17438
+ payload
17439
+ });
17440
+ }
17441
+ onBroadcast(eventOrHandler, maybeHandler) {
17442
+ const wrapped = typeof eventOrHandler === "string" ? (e) => {
17443
+ if (e.event === eventOrHandler) maybeHandler(e.payload);
17444
+ } : eventOrHandler;
17445
+ this.broadcastHandlers.add(wrapped);
17446
+ this.join();
17447
+ return () => this.broadcastHandlers.delete(wrapped);
17448
+ }
17449
+ /**
17450
+ * The last sequence number this channel has delivered.
17451
+ *
17452
+ * Zero on a channel that retains nothing. Persist it if you want catch-up
17453
+ * to survive a page reload as well as a reconnect, and pass it back via
17454
+ * {@link history}.
17455
+ */
17456
+ get sequence() {
17457
+ return this.lastSeq;
17458
+ }
17459
+ /**
17460
+ * Fetch retained messages explicitly, instead of waiting for join or
17461
+ * reconnect to do it.
17462
+ *
17463
+ * Defaults to resuming from {@link sequence}. Messages are delivered to
17464
+ * `onBroadcast` handlers as usual — the returned value is for callers that
17465
+ * want to inspect the batch, or to learn from `retained` that the channel
17466
+ * keeps no history at all.
17467
+ */
17468
+ async history(options = {}) {
17469
+ await this.join();
17470
+ if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
17471
+ const result = new Promise((resolve) => {
17472
+ this.historyWaiters.push(resolve);
17473
+ });
17474
+ await this.requestHistory(options.limit);
17475
+ return result;
17476
+ }
17477
+ /** Leave the channel and release every listener and timer. */
17478
+ async leave() {
17479
+ this.stopHeartbeat();
17480
+ this.trackedState = null;
17481
+ this.presences = {};
17482
+ this.presenceHandlers.clear();
17483
+ this.broadcastHandlers.clear();
17484
+ this.lastSeq = 0;
17485
+ this.pendingLive = [];
17486
+ this.catchUpInFlight = false;
17487
+ if (this.catchUpTimeout) {
17488
+ clearTimeout(this.catchUpTimeout);
17489
+ this.catchUpTimeout = null;
17490
+ }
17491
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17492
+ messages: [],
17493
+ retained: false
17494
+ });
17495
+ for (const off of this.unsubscribers) off();
17496
+ this.unsubscribers = [];
17497
+ if (this.joined) {
17498
+ this.joined = false;
17499
+ await this.send("leave_channel");
17500
+ }
17501
+ }
17502
+ stopHeartbeat() {
17503
+ if (this.heartbeat) {
17504
+ clearInterval(this.heartbeat);
17505
+ this.heartbeat = null;
17506
+ }
17507
+ }
17508
+ /** Fold an incoming frame into the roster and fan it out. */
17509
+ handle(message) {
17510
+ switch (message.type) {
17511
+ case "presence_state":
17512
+ this.presences = message.presences ?? {};
17513
+ this.emitPresence();
17514
+ break;
17515
+ case "presence_diff": {
17516
+ const joins = message.joins ?? {};
17517
+ const leaves = message.leaves ?? {};
17518
+ for (const [id, state] of Object.entries(joins)) this.presences[id] = state;
17519
+ for (const id of Object.keys(leaves)) delete this.presences[id];
17520
+ this.emitPresence({
17521
+ joins,
17522
+ leaves
17523
+ });
17524
+ break;
17525
+ }
17526
+ case "broadcast": {
17527
+ const seq = typeof message.seq === "number" ? message.seq : void 0;
17528
+ const event = {
17529
+ event: message.event,
17530
+ payload: message.payload,
17531
+ ...seq !== void 0 ? { seq } : {}
17532
+ };
17533
+ if (seq === void 0) {
17534
+ this.deliver(event);
17535
+ break;
17536
+ }
17537
+ if (this.catchUpInFlight) {
17538
+ this.pendingLive.push(event);
17539
+ break;
17540
+ }
17541
+ if (seq <= this.lastSeq) break;
17542
+ this.lastSeq = seq;
17543
+ this.deliver(event);
17544
+ break;
17545
+ }
17546
+ case "channel_history": {
17547
+ this.catchUpInFlight = false;
17548
+ if (this.catchUpTimeout) {
17549
+ clearTimeout(this.catchUpTimeout);
17550
+ this.catchUpTimeout = null;
17551
+ }
17552
+ const entries = message.messages ?? [];
17553
+ const retained = message.retained === true;
17554
+ const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
17555
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17556
+ messages: entries,
17557
+ retained,
17558
+ latestSeq
17559
+ });
17560
+ for (const entry of entries) {
17561
+ if (entry.seq <= this.lastSeq) continue;
17562
+ this.lastSeq = entry.seq;
17563
+ this.deliver({
17564
+ event: entry.event,
17565
+ payload: entry.payload,
17566
+ seq: entry.seq,
17567
+ replayed: true
17568
+ });
17569
+ }
17570
+ this.flushPendingLive();
17571
+ break;
17572
+ }
17573
+ }
17574
+ }
17575
+ /** Deliver everything held back during a catch-up, in sequence order. */
17576
+ flushPendingLive() {
17577
+ if (this.pendingLive.length === 0) return;
17578
+ const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
17579
+ this.pendingLive = [];
17580
+ for (const event of buffered) {
17581
+ const seq = event.seq;
17582
+ if (seq !== void 0) {
17583
+ if (seq <= this.lastSeq) continue;
17584
+ this.lastSeq = seq;
17585
+ }
17586
+ this.deliver(event);
17587
+ }
17588
+ }
17589
+ deliver(event) {
17590
+ for (const handler of [...this.broadcastHandlers]) handler(event);
17591
+ }
17592
+ emitPresence(diff) {
17593
+ const snapshot = { ...this.presences };
17594
+ for (const handler of this.presenceHandlers) handler(snapshot, diff);
17595
+ }
17596
+ };
17597
+ /**
16859
17598
  * Derive a WebSocket URL from an HTTP base URL.
16860
17599
  * `http://` → `ws://`, `https://` → `wss://`.
16861
17600
  */
@@ -16904,6 +17643,8 @@ function createRebaseClient(options) {
16904
17643
  };
16905
17644
  const resolvedWsUrl = options.realtime !== false ? options.websocketUrl ?? deriveWebSocketUrl(options.baseUrl) : void 0;
16906
17645
  let ws;
17646
+ /** One channel object per name — see `realtime.channel`. */
17647
+ const realtimeChannels = /* @__PURE__ */ new Map();
16907
17648
  if (resolvedWsUrl) {
16908
17649
  ws = new RebaseWebSocketClient({
16909
17650
  websocketUrl: resolvedWsUrl,
@@ -16927,7 +17668,7 @@ function createRebaseClient(options) {
16927
17668
  if (!ws) return;
16928
17669
  if (event === "SIGNED_OUT") ws.disconnect();
16929
17670
  else if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
16930
- if (session?.accessToken) ws.authenticate(session.accessToken).catch(console.warn);
17671
+ if (session?.accessToken && ws.hasSocket) ws.authenticate(session.accessToken).catch(console.warn);
16931
17672
  }
16932
17673
  });
16933
17674
  }
@@ -17014,6 +17755,24 @@ function createRebaseClient(options) {
17014
17755
  createStorageSource,
17015
17756
  fetchStorageSources,
17016
17757
  ws,
17758
+ realtime: {
17759
+ /**
17760
+ * Join a broadcast/presence channel.
17761
+ *
17762
+ * Repeated calls with the same name return the same channel, so
17763
+ * separate components can attach handlers without each opening its
17764
+ * own membership — and `leave()` from one would otherwise silently
17765
+ * cut off the others.
17766
+ */
17767
+ channel: (name, options) => {
17768
+ if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
17769
+ let existing = realtimeChannels.get(name);
17770
+ if (!existing) {
17771
+ existing = new RebaseRealtimeChannel(name, ws, options);
17772
+ realtimeChannels.set(name, existing);
17773
+ } else if (options?.history) existing.enableHistory();
17774
+ return existing;
17775
+ } },
17017
17776
  /**
17018
17777
  * Release the realtime socket and its reconnect timer.
17019
17778
  *
@@ -17022,7 +17781,9 @@ function createRebaseClient(options) {
17022
17781
  * was never started, and safe to call twice.
17023
17782
  */
17024
17783
  close: () => {
17025
- ws?.disconnect();
17784
+ for (const channel of realtimeChannels.values()) channel.leave();
17785
+ realtimeChannels.clear();
17786
+ ws?.disconnect(true);
17026
17787
  },
17027
17788
  setToken: transport.setToken,
17028
17789
  setAuthTokenGetter: transport.setAuthTokenGetter,
@@ -17663,7 +18424,8 @@ async function _initializeRebaseBackend(config) {
17663
18424
  registry: storageRegistry,
17664
18425
  sources: config.storageSources,
17665
18426
  requireAuth: resolveRequireAuth(config.auth),
17666
- authAdapter
18427
+ authAdapter,
18428
+ authorize: config.storageAuthorize
17667
18429
  });
17668
18430
  const storageRouter = new Hono();
17669
18431
  if (apiKeyPreAuth) storageRouter.use("/*", apiKeyPreAuth, createStorageApiKeyGuard());
@@ -17932,7 +18694,7 @@ async function loadFunctionsFromDirectory(directory, importModule = nativeDynami
17932
18694
  const problems = [];
17933
18695
  if (!fs$2.existsSync(directory)) return functions;
17934
18696
  const files = fs$2.readdirSync(directory);
17935
- 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") {
18697
+ 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") {
17936
18698
  const filePath = path$1.join(directory, file);
17937
18699
  try {
17938
18700
  const fileUrl = pathToFileURL(filePath).href;
@@ -18061,7 +18823,7 @@ async function loadCronJobsFromDirectory(directory, importModule = nativeDynamic
18061
18823
  const jobs = [];
18062
18824
  if (!fs$2.existsSync(directory)) return jobs;
18063
18825
  const files = fs$2.readdirSync(directory);
18064
- 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") {
18826
+ 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") {
18065
18827
  const filePath = path$1.join(directory, file);
18066
18828
  try {
18067
18829
  const fileUrl = pathToFileURL(filePath).href;
@@ -18335,6 +19097,7 @@ var CronScheduler = class {
18335
19097
  logger.warn("[cron] Failed to seed job stats from database", { error: err });
18336
19098
  });
18337
19099
  for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
19100
+ if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
18338
19101
  logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
18339
19102
  }
18340
19103
  /**
@@ -18453,6 +19216,19 @@ var CronScheduler = class {
18453
19216
  this.scheduleNext(id);
18454
19217
  return;
18455
19218
  }
19219
+ if (this.store?.tryClaimRun) {
19220
+ let claimed = true;
19221
+ try {
19222
+ claimed = await this.store.tryClaimRun(id, nextRun.toISOString());
19223
+ } catch (err) {
19224
+ logger.warn(`[cron] Claim check threw for "${id}" — running uncoordinated`, { error: err });
19225
+ }
19226
+ if (!claimed) {
19227
+ logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
19228
+ if (this.started && job.enabled) this.scheduleNext(id);
19229
+ return;
19230
+ }
19231
+ }
18456
19232
  await this.executeJob(job, false);
18457
19233
  if (this.started && job.enabled) this.scheduleNext(id);
18458
19234
  }, Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS));
@@ -18621,6 +19397,25 @@ function createCronRoutes(scheduler) {
18621
19397
  //#region src/cron/cron-store.ts
18622
19398
  var cron_store_exports = /* @__PURE__ */ __exportAll({ createCronStore: () => createCronStore });
18623
19399
  var TABLE = "rebase.cron_logs";
19400
+ var CLAIMS_TABLE = "rebase.cron_claims";
19401
+ /** Claims older than this are garbage-collected on startup. */
19402
+ var CLAIM_RETENTION_DAYS = 7;
19403
+ /**
19404
+ * Detect a unique-constraint violation anywhere in an error's cause chain.
19405
+ * Drizzle wraps the PG error — match the SQLSTATE code, never message text.
19406
+ * Also covers SQLite ("UNIQUE constraint failed") and MySQL (ER_DUP_ENTRY 1062)
19407
+ * for future SQL drivers.
19408
+ */
19409
+ function isUniqueViolation(err) {
19410
+ let current = err;
19411
+ for (let depth = 0; depth < 10 && current; depth++) if (typeof current === "object") {
19412
+ const e = current;
19413
+ if (e.code === "23505" || e.errno === 1062) return true;
19414
+ if (typeof e.message === "string" && e.message.includes("UNIQUE constraint failed")) return true;
19415
+ current = e.cause;
19416
+ } else break;
19417
+ return false;
19418
+ }
18624
19419
  function createCronStore(driver) {
18625
19420
  const admin = driver.admin;
18626
19421
  if (!isSQLAdmin(admin)) {
@@ -18650,6 +19445,15 @@ function createCronStore(driver) {
18650
19445
  CREATE INDEX IF NOT EXISTS idx_cron_logs_job
18651
19446
  ON ${TABLE}(job_id, started_at DESC)
18652
19447
  `);
19448
+ await exec(`
19449
+ CREATE TABLE IF NOT EXISTS ${CLAIMS_TABLE} (
19450
+ job_id TEXT NOT NULL,
19451
+ slot TIMESTAMPTZ NOT NULL,
19452
+ claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
19453
+ PRIMARY KEY (job_id, slot)
19454
+ )
19455
+ `);
19456
+ await exec(`DELETE FROM ${CLAIMS_TABLE} WHERE claimed_at < now() - make_interval(days => $1)`, { params: [CLAIM_RETENTION_DAYS] });
18653
19457
  logger.info("✅ Cron logs table ready");
18654
19458
  } catch (err) {
18655
19459
  logger.error("❌ Failed to create cron logs table", { error: err });
@@ -18709,6 +19513,18 @@ function createCronStore(driver) {
18709
19513
  logger.error("[cron-store] Failed to fetch job stats", { error: err });
18710
19514
  }
18711
19515
  return stats;
19516
+ },
19517
+ async tryClaimRun(jobId, slot) {
19518
+ try {
19519
+ return (await exec(`INSERT INTO ${CLAIMS_TABLE} (job_id, slot)
19520
+ VALUES ($1, $2)
19521
+ ON CONFLICT (job_id, slot) DO NOTHING
19522
+ RETURNING job_id`, { params: [jobId, slot] })).length > 0;
19523
+ } catch (err) {
19524
+ if (isUniqueViolation(err)) return false;
19525
+ logger.warn(`[cron-store] Claim check failed for "${jobId}" — running uncoordinated`, { error: err });
19526
+ return true;
19527
+ }
18712
19528
  }
18713
19529
  };
18714
19530
  }
@@ -19008,7 +19824,10 @@ var rebaseEnvSchema = object({
19008
19824
  S3_ACCESS_KEY_ID: string().optional(),
19009
19825
  S3_SECRET_ACCESS_KEY: string().optional(),
19010
19826
  S3_ENDPOINT: string().url().optional(),
19011
- S3_FORCE_PATH_STYLE: optionalBoolString
19827
+ S3_FORCE_PATH_STYLE: optionalBoolString,
19828
+ GCS_BUCKET: string().optional(),
19829
+ GCS_PROJECT_ID: string().optional(),
19830
+ GCS_KEY_FILENAME: string().optional()
19012
19831
  });
19013
19832
  function loadEnv(options) {
19014
19833
  const isProduction = process.env.NODE_ENV === "production";