@rebasepro/server 0.9.1-canary.ff338b5 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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";
@@ -3539,12 +3539,23 @@ var ApiError = class ApiError extends Error {
3539
3539
  statusCode;
3540
3540
  code;
3541
3541
  details;
3542
- constructor(statusCode, code, message, details) {
3542
+ /**
3543
+ * Whether this outcome is a routine part of normal operation rather than
3544
+ * something an operator should look at. Expected errors log at debug; every
3545
+ * other operational error logs at warn.
3546
+ *
3547
+ * The motivating case is `POST /auth/refresh` with no session: clients
3548
+ * refresh on page load before they know whether one exists, so every
3549
+ * anonymous page view is a 401 — correct, and not worth a warning line.
3550
+ */
3551
+ expected;
3552
+ constructor(statusCode, code, message, details, expected = false) {
3543
3553
  super(message);
3544
3554
  this.name = "ApiError";
3545
3555
  this.statusCode = statusCode;
3546
3556
  this.code = code;
3547
3557
  this.details = details;
3558
+ this.expected = expected;
3548
3559
  }
3549
3560
  static badRequest(message, code = "BAD_REQUEST", details) {
3550
3561
  return new ApiError(400, code, message, details);
@@ -3552,6 +3563,13 @@ var ApiError = class ApiError extends Error {
3552
3563
  static unauthorized(message, code = "UNAUTHORIZED") {
3553
3564
  return new ApiError(401, code, message);
3554
3565
  }
3566
+ /**
3567
+ * A 401 that is a normal outcome, not an incident — logged at debug.
3568
+ * See {@link ApiError.expected}.
3569
+ */
3570
+ static unauthenticated(message, code = "UNAUTHORIZED") {
3571
+ return new ApiError(401, code, message, void 0, true);
3572
+ }
3555
3573
  static forbidden(message, code = "FORBIDDEN") {
3556
3574
  return new ApiError(403, code, message);
3557
3575
  }
@@ -3584,7 +3602,10 @@ var errorHandler = (err, c) => {
3584
3602
  const error = err;
3585
3603
  const reqId = typeof c.get === "function" ? c.get("requestId") : void 0;
3586
3604
  if (error instanceof ApiError || error.name === "ApiError") {
3587
- logger.warn(`⚠️ [API] ${c.req.method} ${c.req.path} ${error.statusCode} ${error.code}: ${error.message}` + (reqId ? ` [${reqId}]` : ""));
3605
+ const expected = error instanceof ApiError && error.expected;
3606
+ const line = `[API] ${c.req.method} ${c.req.path} → ${error.statusCode} ${error.code}: ${error.message}` + (reqId ? ` [${reqId}]` : "");
3607
+ if (expected) logger.debug(line);
3608
+ else logger.warn(`⚠️ ${line}`);
3588
3609
  return c.json({ error: {
3589
3610
  message: error.message,
3590
3611
  code: error.code || "INTERNAL_ERROR",
@@ -3826,6 +3847,8 @@ function parseQueryOptions(query) {
3826
3847
  * columns, so the set is exact);
3827
3848
  * - the foreign-key column behind an owning relation, which callers may write
3828
3849
  * directly instead of through the relation property;
3850
+ * - anything named in `options.extraKnownFields` — for an auth collection the
3851
+ * credential keys the auth adapter consumes before a row is ever built;
3829
3852
  * - nothing else. `id` in particular is not automatically known — see below.
3830
3853
  */
3831
3854
  function assertKnownWriteFields(values, collection, options) {
@@ -3833,6 +3856,7 @@ function assertKnownWriteFields(values, collection, options) {
3833
3856
  if (!collection.properties || Object.keys(collection.properties).length === 0) return;
3834
3857
  const known = new Set(Object.keys(collection.properties));
3835
3858
  for (const relation of Object.values(resolveCollectionRelations(collection))) if (relation.localKey) known.add(relation.localKey);
3859
+ for (const field of options?.extraKnownFields ?? []) known.add(field);
3836
3860
  const unknown = Object.keys(values).filter((key) => !known.has(key));
3837
3861
  if (unknown.length === 0) return;
3838
3862
  const where = options?.rowIndex !== void 0 ? `Row ${options.rowIndex}: ` : "";
@@ -4081,9 +4105,13 @@ var RestApiGenerator = class {
4081
4105
  const body = await parseJsonBody(c);
4082
4106
  const isAuth = collection.auth;
4083
4107
  const isAuthCollection = isAuth === true || isAuth && typeof isAuth === "object" && isAuth.enabled === true;
4108
+ const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
4084
4109
  if (!isAuthCollection) assertKnownWriteFields(body, resolvedCollection);
4110
+ else {
4111
+ const contract = this.authAdapter?.describeUserCreationContract?.(collectionAuthConfig);
4112
+ if (contract?.validate) assertKnownWriteFields(body, resolvedCollection, { extraKnownFields: contract.extraFields });
4113
+ }
4085
4114
  if (isAuthCollection && this.authAdapter?.prepareUserCreation) {
4086
- const collectionAuthConfig = typeof isAuth === "object" ? isAuth : void 0;
4087
4115
  const prepared = await this.authAdapter.prepareUserCreation(body, collectionAuthConfig);
4088
4116
  const entity = await driver.save({
4089
4117
  path,
@@ -4472,10 +4500,10 @@ async function validateApiKey(c, token, options) {
4472
4500
  message: "API key has expired",
4473
4501
  code: "UNAUTHORIZED"
4474
4502
  } }, 401);
4475
- const userId = `api-key:${apiKey.id}`;
4503
+ const uid = `api-key:${apiKey.id}`;
4476
4504
  const roles = apiKey.admin ? ["admin", "service"] : ["service"];
4477
4505
  c.set("user", {
4478
- userId,
4506
+ uid,
4479
4507
  roles
4480
4508
  });
4481
4509
  const masked = {
@@ -4495,7 +4523,7 @@ async function validateApiKey(c, token, options) {
4495
4523
  c.set("apiKey", masked);
4496
4524
  try {
4497
4525
  const scopedDriver = await scopeDataDriver(driver, {
4498
- uid: userId,
4526
+ uid,
4499
4527
  roles
4500
4528
  });
4501
4529
  c.set("driver", scopedDriver);
@@ -4663,7 +4691,7 @@ function createRequireAuth(options) {
4663
4691
  const token = authHeader.substring(7);
4664
4692
  if (safeCompare(token, key)) {
4665
4693
  c.set("user", {
4666
- userId: "service",
4694
+ uid: "service",
4667
4695
  roles: ["admin"]
4668
4696
  });
4669
4697
  return next();
@@ -4742,11 +4770,11 @@ function createAuthMiddleware(options) {
4742
4770
  if (validator) try {
4743
4771
  const authResult = await validator(c);
4744
4772
  if (authResult && typeof authResult === "object") {
4745
- const id = ("userId" in authResult ? authResult.userId : void 0) || ("uid" in authResult ? authResult.uid : void 0);
4773
+ const id = ("uid" in authResult ? authResult.uid : void 0) || ("uid" in authResult ? authResult.uid : void 0);
4746
4774
  if (id) {
4747
4775
  const roles = authResult.roles || [];
4748
4776
  c.set("user", {
4749
- userId: id,
4777
+ uid: id,
4750
4778
  roles
4751
4779
  });
4752
4780
  const user = {
@@ -4761,7 +4789,7 @@ function createAuthMiddleware(options) {
4761
4789
  }));
4762
4790
  } else if (authResult === true) {
4763
4791
  c.set("user", {
4764
- userId: "default",
4792
+ uid: "default",
4765
4793
  roles: []
4766
4794
  });
4767
4795
  c.set("driver", await scopeDataDriver(driver, {
@@ -4784,7 +4812,7 @@ function createAuthMiddleware(options) {
4784
4812
  const token = authHeader.substring(7);
4785
4813
  if (serviceKey && safeCompare(token, serviceKey)) {
4786
4814
  c.set("user", {
4787
- userId: "service",
4815
+ uid: "service",
4788
4816
  roles: ["admin"]
4789
4817
  });
4790
4818
  try {
@@ -4811,7 +4839,7 @@ function createAuthMiddleware(options) {
4811
4839
  c.set("user", payload);
4812
4840
  try {
4813
4841
  const user = {
4814
- uid: payload.userId,
4842
+ uid: payload.uid,
4815
4843
  roles: payload.roles
4816
4844
  };
4817
4845
  c.set("driver", await scopeDataDriver(driver, user));
@@ -4892,7 +4920,7 @@ var publicObjectAuth = async (c, next) => {
4892
4920
  const idx = fullPath.indexOf(prefix);
4893
4921
  const rawPath = idx < 0 ? "" : fullPath.substring(idx + prefix.length + 1);
4894
4922
  if (rawPath && isPublicStoragePath(decodeURIComponent(rawPath))) c.set("user", {
4895
- userId: "public",
4923
+ uid: "public",
4896
4924
  roles: ["public"]
4897
4925
  });
4898
4926
  return next();
@@ -4958,7 +4986,7 @@ var fileTokenAuth = async (c, next) => {
4958
4986
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
4959
4987
  if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
4960
4988
  c.set("user", {
4961
- userId: "download-token",
4989
+ uid: "download-token",
4962
4990
  roles: ["reader"]
4963
4991
  });
4964
4992
  return next();
@@ -4982,7 +5010,7 @@ var fileTokenAuth = async (c, next) => {
4982
5010
  const { bucket, resolvedPath } = parseBucketPath(decodeURIComponent(rawPath));
4983
5011
  if (isPathMatch(`${bucket}/${resolvedPath}`, payload.path)) {
4984
5012
  c.set("user", {
4985
- userId: "download-token",
5013
+ uid: "download-token",
4986
5014
  roles: ["reader"]
4987
5015
  });
4988
5016
  return next();
@@ -5031,7 +5059,7 @@ function createAdapterAuthMiddleware(options) {
5031
5059
  }
5032
5060
  if (authenticatedUser) {
5033
5061
  c.set("user", {
5034
- userId: authenticatedUser.uid,
5062
+ uid: authenticatedUser.uid,
5035
5063
  email: authenticatedUser.email,
5036
5064
  roles: authenticatedUser.roles
5037
5065
  });
@@ -5914,14 +5942,14 @@ function createDataRateLimiter(config = {}) {
5914
5942
  const key = c.get("apiKey");
5915
5943
  if (key) return `api-key:${key.id}`;
5916
5944
  const user = c.get("user");
5917
- if (user?.userId && user.userId !== "anon") return `user:${user.userId}`;
5945
+ if (user?.uid && user.uid !== "anon") return `user:${user.uid}`;
5918
5946
  return `ip:${defaultKeyGenerator(c)}`;
5919
5947
  },
5920
5948
  resolveLimit: (c) => {
5921
5949
  const key = c.get("apiKey");
5922
5950
  if (key) return key.rate_limit ?? apiKeyLimit;
5923
5951
  const user = c.get("user");
5924
- if (user?.userId && user.userId !== "anon") return userLimit;
5952
+ if (user?.uid && user.uid !== "anon") return userLimit;
5925
5953
  return anonLimit;
5926
5954
  }
5927
5955
  });
@@ -9773,7 +9801,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9773
9801
  const body = await c.req.json().catch(() => ({}));
9774
9802
  const friendlyName = typeof body.friendlyName === "string" ? body.friendlyName : void 0;
9775
9803
  const issuer = typeof body.issuer === "string" ? body.issuer : emailConfig?.appName || "Rebase";
9776
- const user = await authRepo.getUserById(userCtx.userId);
9804
+ const user = await authRepo.getUserById(userCtx.uid);
9777
9805
  if (!user) throw ApiError.notFound("User not found");
9778
9806
  const { secret, uri } = generateTotpSecret(issuer, user.email);
9779
9807
  const encryptedSecret = encryptTotpSecret(secret);
@@ -9807,7 +9835,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9807
9835
  code: string().length(6, "Code must be 6 digits")
9808
9836
  }), await c.req.json());
9809
9837
  const factor = await authRepo.getMfaFactorById(factorId);
9810
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9838
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9811
9839
  if (factor.verified) throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
9812
9840
  if (!verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code)) throw ApiError.unauthorized("Invalid TOTP code", "INVALID_CODE");
9813
9841
  await authRepo.verifyMfaFactor(factorId);
@@ -9825,7 +9853,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9825
9853
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9826
9854
  const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
9827
9855
  const factor = await authRepo.getMfaFactorById(factorId);
9828
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9856
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9829
9857
  if (!factor.verified) throw ApiError.badRequest("MFA factor is not yet verified", "FACTOR_NOT_VERIFIED");
9830
9858
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
9831
9859
  const challenge = await authRepo.createMfaChallenge(factorId, ipAddress);
@@ -9849,19 +9877,19 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9849
9877
  const challenge = await authRepo.getMfaChallengeById(challengeId);
9850
9878
  if (!challenge) throw ApiError.badRequest("Invalid or expired challenge", "INVALID_CHALLENGE");
9851
9879
  const factor = await authRepo.getMfaFactorById(challenge.factorId);
9852
- if (!factor || factor.userId !== userCtx.userId) throw ApiError.notFound("MFA factor not found");
9880
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9853
9881
  let isValid = verifyTotp(base32Decode(decryptTotpSecret(factor.secretEncrypted)), code);
9854
9882
  if (!isValid) {
9855
9883
  const codeHash = hashRecoveryCode(code);
9856
- isValid = await authRepo.useRecoveryCode(userCtx.userId, codeHash);
9884
+ isValid = await authRepo.useRecoveryCode(userCtx.uid, codeHash);
9857
9885
  }
9858
9886
  if (!isValid) throw ApiError.unauthorized("Invalid verification code", "INVALID_CODE");
9859
9887
  await authRepo.verifyMfaChallenge(challengeId);
9860
- const roleIds = (await authRepo.getUserRoles(userCtx.userId)).map((r) => r.id);
9861
- const accessToken = generateAccessToken(userCtx.userId, roleIds, "aal2");
9888
+ const roleIds = (await authRepo.getUserRoles(userCtx.uid)).map((r) => r.id);
9889
+ const accessToken = generateAccessToken(userCtx.uid, roleIds, "aal2");
9862
9890
  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) => {
9891
+ await authRepo.createRefreshToken(userCtx.uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
9892
+ if (ops.onMfaVerified) ops.onMfaVerified(userCtx.uid, factor.id).catch((err) => {
9865
9893
  logger.error("[AuthHooks] onMfaVerified error", { error: err instanceof Error ? err.message : err });
9866
9894
  });
9867
9895
  let mfaResponse = { tokens: {
@@ -9869,7 +9897,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9869
9897
  refreshToken,
9870
9898
  accessTokenExpiresAt: getAccessTokenExpiry()
9871
9899
  } };
9872
- if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.userId);
9900
+ if (applyTransformHook) mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.uid);
9873
9901
  return c.json(mfaResponse);
9874
9902
  });
9875
9903
  /**
@@ -9879,7 +9907,7 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9879
9907
  router.get("/mfa/factors", requireAuth, async (c) => {
9880
9908
  const userCtx = c.get("user");
9881
9909
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
9882
- const factors = await authRepo.getMfaFactors(userCtx.userId);
9910
+ const factors = await authRepo.getMfaFactors(userCtx.uid);
9883
9911
  return c.json({ factors: factors.map((f) => ({
9884
9912
  id: f.id,
9885
9913
  factorType: f.factorType,
@@ -9898,9 +9926,9 @@ function mountMfaRoutes(router, config, ops, parseBody, applyTransformHook) {
9898
9926
  if (userCtx.aal !== "aal2") throw ApiError.forbidden("MFA verification required to unenroll. Please re-authenticate with your second factor.", "AAL2_REQUIRED");
9899
9927
  const { factorId } = parseBody(object({ factorId: string().min(1, "Factor ID is required") }), await c.req.json());
9900
9928
  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);
9929
+ if (!factor || factor.uid !== userCtx.uid) throw ApiError.notFound("MFA factor not found");
9930
+ await authRepo.deleteMfaFactor(factorId, userCtx.uid);
9931
+ if (!await authRepo.hasVerifiedMfaFactors(userCtx.uid)) await authRepo.deleteAllRecoveryCodes(userCtx.uid);
9904
9932
  return c.json({
9905
9933
  success: true,
9906
9934
  message: "MFA factor removed"
@@ -10002,9 +10030,9 @@ function mountSessionRoutes(opts) {
10002
10030
  clearRefreshCookie(c, config.cookieAuth);
10003
10031
  const authHeader = c.req.header("authorization");
10004
10032
  if (ops.afterLogout && authHeader?.startsWith("Bearer ")) {
10005
- const { verifyAccessToken } = await import("./jwt-BJzQOa8a.js").then((n) => n.c);
10033
+ const { verifyAccessToken } = await import("./jwt-B3zjddCa.js").then((n) => n.c);
10006
10034
  const payload = verifyAccessToken(authHeader.substring(7));
10007
- if (payload) ops.afterLogout(payload.userId).catch((err) => {
10035
+ if (payload) ops.afterLogout(payload.uid).catch((err) => {
10008
10036
  logger.error("[AuthHooks] afterLogout error", { error: err instanceof Error ? err.message : err });
10009
10037
  });
10010
10038
  }
@@ -10019,7 +10047,7 @@ function mountSessionRoutes(opts) {
10019
10047
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10020
10048
  const currentRefreshToken = c.req.header("x-refresh-token");
10021
10049
  const currentTokenHash = currentRefreshToken ? hashRefreshToken(currentRefreshToken) : null;
10022
- const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.userId)).map((s) => ({
10050
+ const mappedSessions = (await authRepo.listRefreshTokensForUser(userCtx.uid)).map((s) => ({
10023
10051
  id: s.id,
10024
10052
  userAgent: s.userAgent,
10025
10053
  ipAddress: s.ipAddress,
@@ -10035,7 +10063,7 @@ function mountSessionRoutes(opts) {
10035
10063
  router.delete("/sessions", requireAuth, async (c) => {
10036
10064
  const userCtx = c.get("user");
10037
10065
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10038
- await authRepo.deleteAllRefreshTokensForUser(userCtx.userId);
10066
+ await authRepo.deleteAllRefreshTokensForUser(userCtx.uid);
10039
10067
  return c.json({
10040
10068
  success: true,
10041
10069
  message: "All sessions revoked successfully"
@@ -10050,7 +10078,7 @@ function mountSessionRoutes(opts) {
10050
10078
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10051
10079
  const id = c.req.param("id");
10052
10080
  if (!id) throw ApiError.badRequest("Session ID is required", "INVALID_INPUT");
10053
- await authRepo.deleteRefreshTokenById(id, userCtx.userId);
10081
+ await authRepo.deleteRefreshTokenById(id, userCtx.uid);
10054
10082
  return c.json({
10055
10083
  success: true,
10056
10084
  message: "Session revoked successfully"
@@ -10063,7 +10091,7 @@ function mountSessionRoutes(opts) {
10063
10091
  router.get("/me", requireAuth, async (c) => {
10064
10092
  const userCtx = c.get("user");
10065
10093
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10066
- const result = await authRepo.getUserWithRoles(userCtx.userId);
10094
+ const result = await authRepo.getUserWithRoles(userCtx.uid);
10067
10095
  if (!result) throw ApiError.notFound("User not found");
10068
10096
  return c.json({ user: {
10069
10097
  uid: result.user.id,
@@ -10103,11 +10131,11 @@ function mountSessionRoutes(opts) {
10103
10131
  const userCtx = c.get("user");
10104
10132
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10105
10133
  const { displayName, photoURL } = parseBody(updateProfileSchema, await c.req.json());
10106
- if (!await authRepo.updateUser(userCtx.userId, {
10134
+ if (!await authRepo.updateUser(userCtx.uid, {
10107
10135
  displayName: displayName !== void 0 ? displayName : void 0,
10108
10136
  photoUrl: photoURL !== void 0 ? photoURL : void 0
10109
10137
  })) throw ApiError.notFound("User not found");
10110
- const result = await authRepo.getUserWithRoles(userCtx.userId);
10138
+ const result = await authRepo.getUserWithRoles(userCtx.uid);
10111
10139
  if (!result) throw ApiError.notFound("User not found");
10112
10140
  return c.json({ user: {
10113
10141
  uid: result.user.id,
@@ -10169,7 +10197,7 @@ function mountSessionRoutes(opts) {
10169
10197
  router.post("/anonymous/link", requireAuth, async (c) => {
10170
10198
  const userCtx = c.get("user");
10171
10199
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10172
- const user = await authRepo.getUserById(userCtx.userId);
10200
+ const user = await authRepo.getUserById(userCtx.uid);
10173
10201
  if (!user?.isAnonymous) throw ApiError.badRequest("User is not anonymous", "NOT_ANONYMOUS");
10174
10202
  const { email, password } = parseBody(linkSchema, await c.req.json());
10175
10203
  const passwordValidation = ops.validatePasswordStrength(password);
@@ -10258,7 +10286,7 @@ function mountMagicLinkRoutes(deps) {
10258
10286
  const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
10259
10287
  if (!storedToken) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
10260
10288
  await authRepo.markMagicLinkTokenUsed(tokenHash);
10261
- const user = await authRepo.getUserById(storedToken.userId);
10289
+ const user = await authRepo.getUserById(storedToken.uid);
10262
10290
  if (!user) throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
10263
10291
  if (!user.emailVerified) {
10264
10292
  await authRepo.setEmailVerified(user.id, true);
@@ -10316,11 +10344,11 @@ function createAuthRoutes(config) {
10316
10344
  * Errors are caught and logged — the untransformed response is returned
10317
10345
  * as a graceful fallback so auth never breaks due to a hook failure.
10318
10346
  */
10319
- async function applyTransformHook(response, method, request, userId) {
10347
+ async function applyTransformHook(response, method, request, uid) {
10320
10348
  if (!ops.transformAuthResponse) return response;
10321
10349
  try {
10322
10350
  return await ops.transformAuthResponse(response, {
10323
- userId,
10351
+ uid,
10324
10352
  method,
10325
10353
  request
10326
10354
  });
@@ -10347,7 +10375,7 @@ function createAuthRoutes(config) {
10347
10375
  oldPassword: string().min(1, "Old password is required").max(128),
10348
10376
  newPassword: string().min(1, "New password is required").max(128)
10349
10377
  });
10350
- const refreshSchema = object({ refreshToken: config.cookieAuth ? string().optional() : string().min(1, "Refresh token is required") });
10378
+ const refreshSchema = object({ refreshToken: string().min(1).optional() });
10351
10379
  object({ refreshToken: string().optional() });
10352
10380
  object({
10353
10381
  displayName: string().max(255).optional(),
@@ -10398,23 +10426,23 @@ function createAuthRoutes(config) {
10398
10426
  /**
10399
10427
  * Helper to generate and store session tokens
10400
10428
  */
10401
- async function createSessionAndTokens(userId, userAgent, ipAddress) {
10402
- const roleIds = (await authRepo.getUserRoles(userId)).map((r) => r.id);
10429
+ async function createSessionAndTokens(uid, userAgent, ipAddress) {
10430
+ const roleIds = (await authRepo.getUserRoles(uid)).map((r) => r.id);
10403
10431
  let customClaims;
10404
10432
  if (ops.customizeAccessToken) {
10405
- const user = await authRepo.getUserById(userId);
10433
+ const user = await authRepo.getUserById(uid);
10406
10434
  if (user) {
10407
10435
  const defaultClaims = {
10408
- userId,
10436
+ uid,
10409
10437
  roles: roleIds,
10410
10438
  aal: "aal1"
10411
10439
  };
10412
10440
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
10413
10441
  }
10414
10442
  }
10415
- const accessToken = generateAccessToken(userId, roleIds, "aal1", customClaims);
10443
+ const accessToken = generateAccessToken(uid, roleIds, "aal1", customClaims);
10416
10444
  const refreshToken = generateRefreshToken();
10417
- await authRepo.createRefreshToken(userId, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
10445
+ await authRepo.createRefreshToken(uid, hashRefreshToken(refreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
10418
10446
  return {
10419
10447
  roleIds,
10420
10448
  accessToken,
@@ -10478,7 +10506,7 @@ function createAuthRoutes(config) {
10478
10506
  logger.warn("[Security Audit] Auth login failure", {
10479
10507
  eventType: "auth.login.failure",
10480
10508
  email,
10481
- userId: user.id
10509
+ uid: user.id
10482
10510
  });
10483
10511
  throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
10484
10512
  }
@@ -10489,7 +10517,7 @@ function createAuthRoutes(config) {
10489
10517
  });
10490
10518
  logger.info("[Security Audit] Auth login success", {
10491
10519
  eventType: "auth.login.success",
10492
- userId: user.id,
10520
+ uid: user.id,
10493
10521
  email
10494
10522
  });
10495
10523
  const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, "password"), "login", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
@@ -10498,54 +10526,101 @@ function createAuthRoutes(config) {
10498
10526
  /**
10499
10527
  * Dynamically mount OAuth provider routes
10500
10528
  */
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 });
10529
+ if (config.oauthProviders && config.oauthProviders.length > 0) for (const provider of config.oauthProviders) {
10530
+ router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
10531
+ const payload = parseBody(provider.schema, await c.req.json());
10532
+ let externalUser;
10533
+ try {
10534
+ externalUser = await provider.verify(payload);
10535
+ } catch (err) {
10536
+ const msg = err instanceof Error ? err.message : String(err);
10537
+ throw ApiError.unauthorized(`${provider.id} login failed: ${msg}`, "OAUTH_ERROR");
10538
+ }
10539
+ if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10540
+ let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10541
+ if (!user) {
10542
+ user = await authRepo.getUserByEmail(externalUser.email);
10543
+ if (user) {
10544
+ if (!externalUser.emailVerified) throw ApiError.forbidden(`An account with this email already exists with a different sign-in method. ${provider.id} has not verified this email address, so it cannot be linked automatically. Sign in with your existing method, then POST to /auth/link/${provider.id} to link ${provider.id} to your account.`, "EMAIL_NOT_VERIFIED");
10545
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10546
+ await authRepo.updateUser(user.id, {
10547
+ displayName: user.displayName || externalUser.displayName || void 0,
10548
+ photoUrl: user.photoUrl || externalUser.photoUrl || void 0
10549
+ });
10550
+ } else {
10551
+ user = await authRepo.createUser({
10552
+ email: externalUser.email.toLowerCase(),
10553
+ displayName: externalUser.displayName || void 0,
10554
+ photoUrl: externalUser.photoUrl || void 0
10555
+ });
10556
+ await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
10557
+ if (ops.afterUserCreate) try {
10558
+ await ops.afterUserCreate(user);
10559
+ } catch (err) {
10560
+ logger.error("[AuthHooks] afterUserCreate error", { error: err instanceof Error ? err.message : err });
10561
+ }
10562
+ const allUsers = await authRepo.listUsers();
10563
+ if (allUsers.length === 1 && allUsers[0].id === user.id) await authRepo.setUserRoles(user.id, ["admin"]);
10564
+ else if (config.defaultRole) await authRepo.assignDefaultRole(user.id, config.defaultRole);
10565
+ sendWelcomeEmail({
10566
+ email: user.email,
10567
+ displayName: user.displayName
10568
+ });
10532
10569
  }
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
- });
10570
+ } else await authRepo.updateUser(user.id, {
10571
+ displayName: externalUser.displayName || user.displayName || void 0,
10572
+ photoUrl: externalUser.photoUrl || user.photoUrl || void 0
10573
+ });
10574
+ const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(user.id, c.req.header("user-agent") || "unknown", c.req.header("x-forwarded-for") || "unknown");
10575
+ const finalResponse = redactRefreshToken(await applyTransformHook(buildAuthResponse(user, roleIds, accessToken, refreshToken, provider.id), "oauth", c.req.raw, user.id), c, refreshToken, config.cookieAuth);
10576
+ return c.json(finalResponse);
10577
+ });
10578
+ /**
10579
+ * POST /auth/link/:provider
10580
+ * Attach an OAuth identity to the *already authenticated* account.
10581
+ *
10582
+ * This is the escape hatch from the `EMAIL_NOT_VERIFIED` rejection
10583
+ * on the sign-in route above, and the way to attach a provider
10584
+ * whose email differs from the account's.
10585
+ *
10586
+ * Note the deliberate asymmetry with sign-in: linking here does
10587
+ * NOT require the provider to have verified the email, and does
10588
+ * not require the emails to match at all. On the sign-in route the
10589
+ * provider's email is the *only* evidence tying the incoming
10590
+ * identity to an existing account, so an unverified address would
10591
+ * let an attacker claim someone else's account. Here the caller
10592
+ * has already proven ownership by holding a valid session, and the
10593
+ * OAuth credential proves control of the provider identity — the
10594
+ * email plays no part in the decision, so its verification status
10595
+ * is irrelevant.
10596
+ */
10597
+ router.post(`/link/${provider.id}`, defaultAuthLimiter, requireAuth, async (c) => {
10598
+ const userCtx = c.get("user");
10599
+ if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10600
+ const payload = parseBody(provider.schema, await c.req.json());
10601
+ let externalUser;
10602
+ try {
10603
+ externalUser = await provider.verify(payload);
10604
+ } catch (err) {
10605
+ const msg = err instanceof Error ? err.message : String(err);
10606
+ throw ApiError.unauthorized(`${provider.id} link failed: ${msg}`, "OAUTH_ERROR");
10540
10607
  }
10541
- } else await authRepo.updateUser(user.id, {
10542
- displayName: externalUser.displayName || user.displayName || void 0,
10543
- photoUrl: externalUser.photoUrl || user.photoUrl || void 0
10608
+ if (!externalUser) throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
10609
+ const identityOwner = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
10610
+ if (identityOwner && identityOwner.id !== userCtx.uid) throw ApiError.conflict(`That ${provider.id} account is already linked to a different user.`, "IDENTITY_ALREADY_LINKED");
10611
+ if (identityOwner) return c.json({
10612
+ success: true,
10613
+ provider: provider.id,
10614
+ alreadyLinked: true
10615
+ });
10616
+ await authRepo.linkUserIdentity(userCtx.uid, provider.id, externalUser.providerId, { email: externalUser.email });
10617
+ return c.json({
10618
+ success: true,
10619
+ provider: provider.id,
10620
+ alreadyLinked: false
10621
+ });
10544
10622
  });
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
- });
10623
+ }
10549
10624
  /**
10550
10625
  * POST /auth/forgot-password
10551
10626
  * Request password reset email
@@ -10597,10 +10672,10 @@ function createAuthRoutes(config) {
10597
10672
  const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
10598
10673
  if (!storedToken) throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
10599
10674
  const passwordHash = await ops.hashPassword(password);
10600
- await authRepo.updatePassword(storedToken.userId, passwordHash);
10675
+ await authRepo.updatePassword(storedToken.uid, passwordHash);
10601
10676
  await authRepo.markPasswordResetTokenUsed(tokenHash);
10602
- await authRepo.deleteAllRefreshTokensForUser(storedToken.userId);
10603
- if (ops.onPasswordReset) ops.onPasswordReset(storedToken.userId).catch((err) => {
10677
+ await authRepo.deleteAllRefreshTokensForUser(storedToken.uid);
10678
+ if (ops.onPasswordReset) ops.onPasswordReset(storedToken.uid).catch((err) => {
10604
10679
  logger.error("[AuthHooks] onPasswordReset error", { error: err instanceof Error ? err.message : err });
10605
10680
  });
10606
10681
  return c.json({
@@ -10616,7 +10691,7 @@ function createAuthRoutes(config) {
10616
10691
  const userCtx = c.get("user");
10617
10692
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10618
10693
  const { oldPassword, newPassword } = parseBody(changePasswordSchema, await c.req.json());
10619
- const user = await authRepo.getUserById(userCtx.userId);
10694
+ const user = await authRepo.getUserById(userCtx.uid);
10620
10695
  if (!user || !user.passwordHash) throw ApiError.badRequest("Cannot change password for this account", "INVALID_ACCOUNT");
10621
10696
  if (!await ops.verifyPassword(oldPassword, user.passwordHash)) throw ApiError.unauthorized("Current password is incorrect", "INVALID_CREDENTIALS");
10622
10697
  const passwordValidation = ops.validatePasswordStrength(newPassword);
@@ -10637,7 +10712,7 @@ function createAuthRoutes(config) {
10637
10712
  const userCtx = c.get("user");
10638
10713
  if (!userCtx) throw ApiError.unauthorized("Not authenticated");
10639
10714
  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);
10715
+ const user = await authRepo.getUserById(userCtx.uid);
10641
10716
  if (!user) throw ApiError.notFound("User not found");
10642
10717
  if (user.emailVerified) throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
10643
10718
  const token = generateSecureToken();
@@ -10683,8 +10758,8 @@ function createAuthRoutes(config) {
10683
10758
  * Refresh access token using refresh token
10684
10759
  */
10685
10760
  router.post("/refresh", async (c) => {
10686
- const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json()), config.cookieAuth);
10687
- if (!refreshToken) throw ApiError.badRequest("Refresh token is required", "INVALID_INPUT");
10761
+ const refreshToken = readRefreshToken(c, parseBody(refreshSchema, await c.req.json().catch(() => ({}))), config.cookieAuth);
10762
+ if (!refreshToken) throw ApiError.unauthenticated("No refresh token presented", "NO_SESSION");
10688
10763
  const tokenHash = hashRefreshToken(refreshToken);
10689
10764
  const storedToken = await authRepo.findRefreshTokenByHash(tokenHash);
10690
10765
  if (!storedToken) {
@@ -10696,10 +10771,10 @@ function createAuthRoutes(config) {
10696
10771
  clearRefreshCookie(c, config.cookieAuth);
10697
10772
  throw ApiError.unauthorized("Refresh token expired", "TOKEN_EXPIRED");
10698
10773
  }
10699
- const roleIds = (await authRepo.getUserRoles(storedToken.userId)).map((r) => r.id);
10700
- const user = await authRepo.getUserById(storedToken.userId).catch((err) => {
10774
+ const roleIds = (await authRepo.getUserRoles(storedToken.uid)).map((r) => r.id);
10775
+ const user = await authRepo.getUserById(storedToken.uid).catch((err) => {
10701
10776
  logger.warn("[Auth] Could not load user during token refresh; returning tokens only", {
10702
- userId: storedToken.userId,
10777
+ uid: storedToken.uid,
10703
10778
  error: err instanceof Error ? err.message : String(err)
10704
10779
  });
10705
10780
  return null;
@@ -10707,18 +10782,18 @@ function createAuthRoutes(config) {
10707
10782
  let customClaims;
10708
10783
  if (ops.customizeAccessToken && user) {
10709
10784
  const defaultClaims = {
10710
- userId: storedToken.userId,
10785
+ uid: storedToken.uid,
10711
10786
  roles: roleIds,
10712
10787
  aal: "aal1"
10713
10788
  };
10714
10789
  customClaims = await ops.customizeAccessToken(defaultClaims, user);
10715
10790
  }
10716
- const newAccessToken = generateAccessToken(storedToken.userId, roleIds, "aal1", customClaims);
10791
+ const newAccessToken = generateAccessToken(storedToken.uid, roleIds, "aal1", customClaims);
10717
10792
  const newRefreshToken = generateRefreshToken();
10718
10793
  const userAgent = c.req.header("user-agent") || "unknown";
10719
10794
  const ipAddress = c.req.header("x-forwarded-for") || "unknown";
10720
10795
  await authRepo.deleteRefreshToken(tokenHash);
10721
- await authRepo.createRefreshToken(storedToken.userId, hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
10796
+ await authRepo.createRefreshToken(storedToken.uid, hashRefreshToken(newRefreshToken), getRefreshTokenExpiry(), userAgent, ipAddress);
10722
10797
  const tokensOnlyResponse = { tokens: {
10723
10798
  accessToken: newAccessToken,
10724
10799
  refreshToken: newRefreshToken,
@@ -10731,7 +10806,7 @@ function createAuthRoutes(config) {
10731
10806
  logger.warn("[Auth] Could not build enriched refresh response; returning tokens only", { error: err instanceof Error ? err.message : String(err) });
10732
10807
  refreshResponse = tokensOnlyResponse;
10733
10808
  }
10734
- const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.userId), c, newRefreshToken, config.cookieAuth);
10809
+ const finalResponse = redactRefreshToken(await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.uid), c, newRefreshToken, config.cookieAuth);
10735
10810
  return c.json(finalResponse);
10736
10811
  });
10737
10812
  mountSessionRoutes({
@@ -10768,7 +10843,7 @@ function createAuthRoutes(config) {
10768
10843
  /**
10769
10844
  * Create a standalone admin route for resetting user passwords.
10770
10845
  *
10771
- * Mounts: POST /users/:userId/reset-password
10846
+ * Mounts: POST /users/:uid/reset-password
10772
10847
  */
10773
10848
  function createResetPasswordRoute(config) {
10774
10849
  const router = new Hono();
@@ -10777,9 +10852,9 @@ function createResetPasswordRoute(config) {
10777
10852
  const ops = resolveAuthHooks(config.authHooks);
10778
10853
  router.onError(errorHandler);
10779
10854
  router.use("/*", createRequireAuth({ serviceKey: config.serviceKey }));
10780
- router.post("/users/:userId/reset-password", requireAdmin, async (c) => {
10781
- const userId = c.req.param("userId");
10782
- const existing = await authRepo.getUserById(userId);
10855
+ router.post("/users/:uid/reset-password", requireAdmin, async (c) => {
10856
+ const uid = c.req.param("uid");
10857
+ const existing = await authRepo.getUserById(uid);
10783
10858
  if (!existing) throw ApiError.notFound("User not found");
10784
10859
  let invitationSent = false;
10785
10860
  let temporaryPassword;
@@ -10893,10 +10968,10 @@ function createAdminRolesRoute(config) {
10893
10968
  *
10894
10969
  * Mounts:
10895
10970
  * GET /users
10896
- * GET /users/:userId
10971
+ * GET /users/:uid
10897
10972
  * POST /users
10898
- * PUT /users/:userId
10899
- * DELETE /users/:userId
10973
+ * PUT /users/:uid
10974
+ * DELETE /users/:uid
10900
10975
  * POST /bootstrap
10901
10976
  */
10902
10977
  function createAdminUsersRoute(config) {
@@ -10931,9 +11006,9 @@ function createAdminUsersRoute(config) {
10931
11006
  break;
10932
11007
  }
10933
11008
  if (hasAdmin) throw ApiError.forbidden("Admin users already exist. Bootstrap not allowed.", "BOOTSTRAP_COMPLETED");
10934
- const userId = "userId" in user ? user.userId : "uid" in user ? user.uid : void 0;
10935
- if (!userId) throw ApiError.unauthorized("User ID not found in auth context");
10936
- if (!await authRepo.getUserById(userId)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
11009
+ const uid = "uid" in user ? user.uid : "uid" in user ? user.uid : void 0;
11010
+ if (!uid) throw ApiError.unauthorized("User ID not found in auth context");
11011
+ if (!await authRepo.getUserById(uid)) throw ApiError.notFound("Authenticated user does not exist in the database.", "USER_NOT_FOUND");
10937
11012
  if (users.length > 0) {
10938
11013
  const earliest = users.reduce((a, b) => {
10939
11014
  const at = new Date(a.createdAt).getTime();
@@ -10941,26 +11016,26 @@ function createAdminUsersRoute(config) {
10941
11016
  if (at !== bt) return at < bt ? a : b;
10942
11017
  return a.id < b.id ? a : b;
10943
11018
  });
10944
- if (earliest.id !== userId) {
11019
+ if (earliest.id !== uid) {
10945
11020
  logger.warn("[Security Audit] Bootstrap denied: caller is not the earliest-registered user", {
10946
11021
  eventType: "auth.bootstrap.denied",
10947
- callerId: userId,
11022
+ callerId: uid,
10948
11023
  earliestUserId: earliest.id
10949
11024
  });
10950
11025
  throw ApiError.forbidden("Only the first registered user may claim the initial admin role. Ask that user to bootstrap, or assign the admin role using the service key.", "BOOTSTRAP_NOT_FIRST_USER");
10951
11026
  }
10952
11027
  }
10953
- await authRepo.setUserRoles(userId, ["admin"]);
11028
+ await authRepo.setUserRoles(uid, ["admin"]);
10954
11029
  logger.info("[Security Audit] Initial admin bootstrapped", {
10955
11030
  eventType: "auth.bootstrap.success",
10956
- userId
11031
+ uid
10957
11032
  });
10958
11033
  if (config.setBootstrapCompleted) await config.setBootstrapCompleted();
10959
11034
  return c.json({
10960
11035
  success: true,
10961
11036
  message: "You are now an admin",
10962
11037
  user: {
10963
- uid: userId,
11038
+ uid,
10964
11039
  roles: ["admin"]
10965
11040
  }
10966
11041
  });
@@ -10991,9 +11066,9 @@ function createAdminUsersRoute(config) {
10991
11066
  offset: result.offset
10992
11067
  });
10993
11068
  });
10994
- router.get("/users/:userId", requireAdmin, async (c) => {
10995
- const userId = c.req.param("userId");
10996
- const result = await authRepo.getUserWithRoles(userId);
11069
+ router.get("/users/:uid", requireAdmin, async (c) => {
11070
+ const uid = c.req.param("uid");
11071
+ const result = await authRepo.getUserWithRoles(uid);
10997
11072
  if (!result) throw ApiError.notFound("User not found");
10998
11073
  const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
10999
11074
  return c.json({ user: adminUser });
@@ -11036,10 +11111,10 @@ function createAdminUsersRoute(config) {
11036
11111
  ...finalizeResult.emailDeliveryFailed ? { emailDeliveryFailed: true } : {}
11037
11112
  }, 201);
11038
11113
  });
11039
- router.put("/users/:userId", requireAdmin, async (c) => {
11040
- const userId = c.req.param("userId");
11114
+ router.put("/users/:uid", requireAdmin, async (c) => {
11115
+ const uid = c.req.param("uid");
11041
11116
  const { password, email, displayName, roles } = await c.req.json();
11042
- if (!await authRepo.getUserById(userId)) throw ApiError.notFound("User not found");
11117
+ if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
11043
11118
  const updates = {};
11044
11119
  if (email !== void 0) updates.email = email.toLowerCase();
11045
11120
  if (displayName !== void 0) updates.displayName = displayName;
@@ -11048,9 +11123,9 @@ function createAdminUsersRoute(config) {
11048
11123
  if (!validation.valid) throw ApiError.badRequest(`Password too weak: ${validation.errors.join(". ")}`);
11049
11124
  updates.passwordHash = await ops.hashPassword(password);
11050
11125
  }
11051
- if (Object.keys(updates).length > 0) await authRepo.updateUser(userId, updates);
11126
+ if (Object.keys(updates).length > 0) await authRepo.updateUser(uid, updates);
11052
11127
  if (roles !== void 0 && Array.isArray(roles)) {
11053
- const wasAdmin = (await authRepo.getUserRoleIds(userId)).includes("admin");
11128
+ const wasAdmin = (await authRepo.getUserRoleIds(uid)).includes("admin");
11054
11129
  const willBeAdmin = roles.includes("admin");
11055
11130
  if (wasAdmin && !willBeAdmin) {
11056
11131
  if ((await authRepo.listUsersPaginated({
@@ -11058,24 +11133,23 @@ function createAdminUsersRoute(config) {
11058
11133
  limit: 1
11059
11134
  })).total <= 1) throw ApiError.forbidden("Cannot demote the last administrator", "LAST_ADMIN");
11060
11135
  }
11061
- await authRepo.setUserRoles(userId, roles);
11136
+ await authRepo.setUserRoles(uid, roles);
11062
11137
  }
11063
- const result = await authRepo.getUserWithRoles(userId);
11138
+ const result = await authRepo.getUserWithRoles(uid);
11064
11139
  const adminUser = toAdminUser(result.user, result.roles.map((r) => r.id));
11065
11140
  return c.json({ user: adminUser });
11066
11141
  });
11067
- router.delete("/users/:userId", requireAdmin, async (c) => {
11068
- 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");
11071
- if (!await authRepo.getUserById(userId)) throw ApiError.notFound("User not found");
11072
- if ((await authRepo.getUserRoleIds(userId)).includes("admin")) {
11142
+ router.delete("/users/:uid", requireAdmin, async (c) => {
11143
+ const uid = c.req.param("uid");
11144
+ if (c.get("user")?.uid === uid) throw ApiError.badRequest("Cannot delete your own account", "SELF_DELETE");
11145
+ if (!await authRepo.getUserById(uid)) throw ApiError.notFound("User not found");
11146
+ if ((await authRepo.getUserRoleIds(uid)).includes("admin")) {
11073
11147
  if ((await authRepo.listUsersPaginated({
11074
11148
  roleId: "admin",
11075
11149
  limit: 1
11076
11150
  })).total <= 1) throw ApiError.forbidden("Cannot delete the last administrator", "LAST_ADMIN");
11077
11151
  }
11078
- await authRepo.deleteUser(userId);
11152
+ await authRepo.deleteUser(uid);
11079
11153
  return c.json({ success: true });
11080
11154
  });
11081
11155
  return router;
@@ -11110,16 +11184,16 @@ function createBuiltinAuthAdapter(config) {
11110
11184
  if (!payload) return null;
11111
11185
  let roles = payload.roles || [];
11112
11186
  try {
11113
- roles = await authRepository.getUserRoleIds(payload.userId);
11187
+ roles = await authRepository.getUserRoleIds(payload.uid);
11114
11188
  } catch (err) {
11115
11189
  logger.warn("Role lookup from repository failed, using token roles as fallback", {
11116
- userId: payload.userId,
11190
+ uid: payload.uid,
11117
11191
  error: err
11118
11192
  });
11119
11193
  }
11120
11194
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
11121
11195
  return {
11122
- uid: payload.userId,
11196
+ uid: payload.uid,
11123
11197
  email: payload.email ?? "",
11124
11198
  displayName: payload.displayName ?? null,
11125
11199
  roles,
@@ -11139,16 +11213,16 @@ function createBuiltinAuthAdapter(config) {
11139
11213
  if (!payload) return null;
11140
11214
  let roles = payload.roles || [];
11141
11215
  try {
11142
- roles = await authRepository.getUserRoleIds(payload.userId);
11216
+ roles = await authRepository.getUserRoleIds(payload.uid);
11143
11217
  } catch (err) {
11144
11218
  logger.warn("Role lookup from repository failed, using token roles as fallback", {
11145
- userId: payload.userId,
11219
+ uid: payload.uid,
11146
11220
  error: err
11147
11221
  });
11148
11222
  }
11149
11223
  const isAdmin = roles.some((r) => r === "admin" || r === "schema-admin");
11150
11224
  return {
11151
- uid: payload.userId,
11225
+ uid: payload.uid,
11152
11226
  email: payload.email ?? "",
11153
11227
  displayName: payload.displayName ?? null,
11154
11228
  roles,
@@ -11207,6 +11281,16 @@ function createBuiltinAuthAdapter(config) {
11207
11281
  collectionAuthConfig: collectionAuth ?? collectionAuthConfig
11208
11282
  });
11209
11283
  },
11284
+ describeUserCreationContract(collectionAuth) {
11285
+ if ((collectionAuth ?? collectionAuthConfig)?.onCreateUser || resolvedOps.onAdminCreateUser) return {
11286
+ validate: false,
11287
+ extraFields: []
11288
+ };
11289
+ return {
11290
+ validate: true,
11291
+ extraFields: ["password"]
11292
+ };
11293
+ },
11210
11294
  async finalizeUserCreation(entity, clearPassword) {
11211
11295
  return finalizeAdminUserCreation(entity, clearPassword, {
11212
11296
  authRepo: authRepository,
@@ -11296,11 +11380,11 @@ function createUserManagementFromRepo(repo, resolvedOps) {
11296
11380
  logger.error("[AuthHooks] afterUserDelete error", { error: err instanceof Error ? err.message : err });
11297
11381
  });
11298
11382
  },
11299
- async getUserRoles(userId) {
11300
- return repo.getUserRoleIds(userId);
11383
+ async getUserRoles(uid) {
11384
+ return repo.getUserRoleIds(uid);
11301
11385
  },
11302
- async setUserRoles(userId, roleIds) {
11303
- await repo.setUserRoles(userId, roleIds);
11386
+ async setUserRoles(uid, roleIds) {
11387
+ await repo.setUserRoles(uid, roleIds);
11304
11388
  }
11305
11389
  };
11306
11390
  }
@@ -11416,8 +11500,8 @@ function requestLogger(options) {
11416
11500
  const reqId = c.get("requestId");
11417
11501
  if (reqId) data.requestId = reqId;
11418
11502
  if (contentLength) data.contentLength = parseInt(contentLength, 10);
11419
- const userId = c.get("userId");
11420
- if (userId) data.userId = userId;
11503
+ const uid = c.get("user")?.uid;
11504
+ if (uid) data.uid = uid;
11421
11505
  if (status >= 500) logger.error("request", data);
11422
11506
  else if (status >= 400) logger.warn("request", data);
11423
11507
  else logger.info("request", data);
@@ -13420,7 +13504,7 @@ function createApiKeyRoutes(options) {
13420
13504
  if (parsed <= /* @__PURE__ */ new Date()) throw ApiError.badRequest("expires_at must be in the future", "INVALID_INPUT");
13421
13505
  }
13422
13506
  const user = c.get("user");
13423
- const createdBy = user && typeof user === "object" && "userId" in user ? user.userId : "unknown";
13507
+ const createdBy = user && typeof user === "object" && "uid" in user ? user.uid : "unknown";
13424
13508
  const request = {
13425
13509
  name: name.trim(),
13426
13510
  permissions,
@@ -14031,7 +14115,7 @@ function buildAdapterAuthMiddleware(adapter, requireAuth, publicRead) {
14031
14115
  } }, 401);
14032
14116
  }
14033
14117
  if (authenticatedUser) c.set("user", {
14034
- userId: authenticatedUser.uid,
14118
+ uid: authenticatedUser.uid,
14035
14119
  email: authenticatedUser.email,
14036
14120
  roles: authenticatedUser.roles
14037
14121
  });
@@ -14066,7 +14150,7 @@ function createStorageRoutes(config) {
14066
14150
  const checkAuthorized = async (c, operation, key, bucket, storageId) => {
14067
14151
  if (!authorize) return;
14068
14152
  const user = c.get("user") ?? null;
14069
- if (user?.userId === "download-token" || user?.userId === "public") return;
14153
+ if (user?.uid === "download-token" || user?.uid === "public") return;
14070
14154
  let allowed;
14071
14155
  try {
14072
14156
  allowed = await authorize({
@@ -14709,6 +14793,28 @@ function buildQueryString(params) {
14709
14793
  }
14710
14794
  return parts.length > 0 ? "?" + parts.join("&") : "";
14711
14795
  }
14796
+ /**
14797
+ * The base every request and every caller-built URL resolves against.
14798
+ *
14799
+ * `baseUrl` is optional because the common production shape is a Rebase
14800
+ * backend serving its own SPA, where the API is simply the page's origin.
14801
+ * Leaving it unset is therefore the *correct* configuration there — and the
14802
+ * one that keeps working when a second hostname (a custom domain) points at
14803
+ * the same app.
14804
+ *
14805
+ * When unset in a browser this resolves to the page origin rather than "".
14806
+ * Requests behave identically either way, but the empty string is a trap for
14807
+ * anything that builds a URL from `client.baseUrl`: `new URL("" + path)`
14808
+ * throws, so apps "fixed" it by baking an absolute host into their bundle —
14809
+ * which is exactly what breaks the day a custom domain is added, and which no
14810
+ * amount of CORS configuration repairs, because a SameSite=Lax auth cookie is
14811
+ * not sent cross-site either.
14812
+ */
14813
+ function resolveBaseUrl(configured) {
14814
+ if (configured) return configured.replace(/\/$/, "");
14815
+ if (typeof window !== "undefined" && window.location?.origin) return window.location.origin;
14816
+ return "";
14817
+ }
14712
14818
  function createTransport(config) {
14713
14819
  const fetchFn = config.fetch || globalThis.fetch;
14714
14820
  const apiPath = config.apiPath || "/api";
@@ -14723,7 +14829,7 @@ function createTransport(config) {
14723
14829
  };
14724
14830
  }
14725
14831
  async function request(path, init) {
14726
- const url = (config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "") + apiPath + path;
14832
+ const url = resolveBaseUrl(config.baseUrl) + apiPath + path;
14727
14833
  let activeToken = token;
14728
14834
  if (tokenGetter) try {
14729
14835
  const fetched = await tokenGetter();
@@ -14798,7 +14904,7 @@ function createTransport(config) {
14798
14904
  onUnauthorizedHandler = handler;
14799
14905
  },
14800
14906
  get baseUrl() {
14801
- return config.baseUrl ? config.baseUrl.replace(/\/$/, "") : "";
14907
+ return resolveBaseUrl(config.baseUrl);
14802
14908
  },
14803
14909
  get apiPath() {
14804
14910
  return apiPath;
@@ -15258,6 +15364,30 @@ function createAuth(transport, options) {
15258
15364
  })
15259
15365
  });
15260
15366
  }
15367
+ /**
15368
+ * Link an OAuth provider to the **currently signed-in** account.
15369
+ *
15370
+ * Use this when `signIn*` failed with `EMAIL_NOT_VERIFIED` — an account
15371
+ * with that email already exists under a different sign-in method — or to
15372
+ * attach a provider whose email differs from the account's.
15373
+ *
15374
+ * The payload is the same one the provider's sign-in method takes, e.g.
15375
+ * `linkProvider("google", { idToken })`.
15376
+ *
15377
+ * Unlike sign-in, this does not require the provider to have verified the
15378
+ * email, and the emails need not match: the active session already proves
15379
+ * account ownership.
15380
+ *
15381
+ * Throws `IDENTITY_ALREADY_LINKED` (409) if that provider identity is
15382
+ * attached to a different user. Succeeds idempotently (`alreadyLinked:
15383
+ * true`) if it is already attached to the current one.
15384
+ */
15385
+ async function linkProvider(providerId, payload) {
15386
+ return transport.request(authPath + "/link/" + providerId, {
15387
+ method: "POST",
15388
+ body: JSON.stringify(payload)
15389
+ });
15390
+ }
15261
15391
  async function sendVerificationEmail() {
15262
15392
  return transport.request(authPath + "/send-verification", { method: "POST" });
15263
15393
  }
@@ -15379,6 +15509,7 @@ function createAuth(transport, options) {
15379
15509
  resetPasswordForEmail,
15380
15510
  resetPassword,
15381
15511
  changePassword,
15512
+ linkProvider,
15382
15513
  sendVerificationEmail,
15383
15514
  verifyEmail,
15384
15515
  sendMagicLink,
@@ -16012,7 +16143,8 @@ var CHANNEL_MESSAGE_TYPES = new Set([
16012
16143
  "broadcast",
16013
16144
  "presence_track",
16014
16145
  "presence_untrack",
16015
- "presence_state"
16146
+ "presence_state",
16147
+ "channel_history"
16016
16148
  ]);
16017
16149
  /**
16018
16150
  * Low-level realtime WebSocket client.
@@ -16351,7 +16483,7 @@ var RebaseWebSocketClient = class {
16351
16483
  }
16352
16484
  return;
16353
16485
  }
16354
- if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff")) {
16486
+ if (typeof message.channel === "string" && (type === "broadcast" || type === "presence_state" || type === "presence_diff" || type === "channel_history")) {
16355
16487
  const handlers = this.channelHandlers.get(message.channel);
16356
16488
  if (handlers) for (const handler of [...handlers]) try {
16357
16489
  handler(message);
@@ -16647,6 +16779,9 @@ var RebaseWebSocketClient = class {
16647
16779
  async fetchAvailableRoles() {
16648
16780
  return (await this.sendMessage({ type: "FETCH_ROLES" })).roles || [];
16649
16781
  }
16782
+ async fetchApplicationRoles() {
16783
+ return (await this.sendMessage({ type: "FETCH_APPLICATION_ROLES" })).roles || [];
16784
+ }
16650
16785
  async fetchCurrentDatabase() {
16651
16786
  return (await this.sendMessage({ type: "FETCH_CURRENT_DATABASE" })).database;
16652
16787
  }
@@ -17133,6 +17268,14 @@ var RebaseWebSocketClient = class {
17133
17268
  * before the entry is reaped, so a single dropped frame is not a disappearance.
17134
17269
  */
17135
17270
  var PRESENCE_HEARTBEAT_MS = 2e4;
17271
+ /**
17272
+ * How long live messages are held back waiting for a catch-up response.
17273
+ *
17274
+ * Short, because the cost of waiting is visible — on a collaborative document
17275
+ * this is a stall in everyone else's edits appearing. Long enough that a slow
17276
+ * replay of a busy channel is not abandoned needlessly.
17277
+ */
17278
+ var CATCH_UP_TIMEOUT_MS = 1e4;
17136
17279
  var RebaseRealtimeChannel = class {
17137
17280
  name;
17138
17281
  transport;
@@ -17145,9 +17288,63 @@ var RebaseRealtimeChannel = class {
17145
17288
  trackedState = null;
17146
17289
  heartbeat = null;
17147
17290
  joined = false;
17148
- constructor(name, transport) {
17291
+ /** Whether this handle asks the server to replay missed messages. */
17292
+ wantsHistory;
17293
+ /**
17294
+ * Highest sequence number delivered to handlers so far.
17295
+ *
17296
+ * This is the resume point sent as `sinceSeq`, and the watermark that makes
17297
+ * replay idempotent: catch-up ranges overlap with what arrived live, and
17298
+ * anything at or below this has already been seen.
17299
+ */
17300
+ lastSeq = 0;
17301
+ /**
17302
+ * Live messages that arrived while a catch-up was in flight.
17303
+ *
17304
+ * Without this they would be delivered ahead of the older messages being
17305
+ * fetched, and — worse — would advance {@link lastSeq} past them, so the
17306
+ * catch-up response would then be discarded as already-seen and those
17307
+ * messages would be lost for good. Held here and flushed, in order, once
17308
+ * the replay lands.
17309
+ */
17310
+ pendingLive = [];
17311
+ catchUpInFlight = false;
17312
+ /**
17313
+ * Deadline for a catch-up response.
17314
+ *
17315
+ * Buffering live messages is only safe because the wait is bounded. A
17316
+ * catch-up frame that never arrives — a server that dropped it, a socket
17317
+ * that died between request and reply — would otherwise leave the channel
17318
+ * silently holding every subsequent edit forever, which is a worse failure
17319
+ * than the one replay was added to fix.
17320
+ */
17321
+ catchUpTimeout = null;
17322
+ /**
17323
+ * Callers of {@link history} awaiting the next `channel_history` frame.
17324
+ *
17325
+ * These frames are addressed by channel rather than by request id, so they
17326
+ * are matched in arrival order. Requests on one channel are serialized by
17327
+ * the socket, so FIFO is the right correlation here.
17328
+ */
17329
+ historyWaiters = [];
17330
+ constructor(name, transport, options = {}) {
17149
17331
  this.name = name;
17150
17332
  this.transport = transport;
17333
+ this.wantsHistory = options.history ?? false;
17334
+ }
17335
+ /**
17336
+ * Turn on catch-up for a handle that was created without it.
17337
+ *
17338
+ * The client hands back the same channel object for a given name, so a
17339
+ * later `channel(name, { history: true })` has no new object to configure —
17340
+ * it upgrades this one instead. Idempotent, and never downgrades: one
17341
+ * caller asking for history must not be switched off by another that did
17342
+ * not ask.
17343
+ */
17344
+ enableHistory() {
17345
+ if (this.wantsHistory) return;
17346
+ this.wantsHistory = true;
17347
+ if (this.joined) this.requestHistory();
17151
17348
  }
17152
17349
  /**
17153
17350
  * Join the channel and ask for the current roster.
@@ -17186,15 +17383,58 @@ var RebaseRealtimeChannel = class {
17186
17383
  }));
17187
17384
  await this.send("join_channel");
17188
17385
  await this.send("presence_state");
17386
+ if (this.wantsHistory) await this.requestHistory();
17189
17387
  }
17190
17388
  async rejoin() {
17191
17389
  try {
17192
17390
  await this.send("join_channel");
17193
17391
  await this.send("presence_state");
17194
17392
  if (this.trackedState) await this.send("presence_track", { state: this.trackedState });
17393
+ if (this.wantsHistory) await this.requestHistory();
17195
17394
  } catch {}
17196
17395
  }
17197
17396
  /**
17397
+ * Ask the server for everything after {@link lastSeq}.
17398
+ *
17399
+ * Live messages are buffered from here until the answer arrives — see
17400
+ * {@link pendingLive}.
17401
+ */
17402
+ async requestHistory(limit) {
17403
+ this.catchUpInFlight = true;
17404
+ if (this.catchUpTimeout) clearTimeout(this.catchUpTimeout);
17405
+ this.catchUpTimeout = setTimeout(() => this.abandonCatchUp(), CATCH_UP_TIMEOUT_MS);
17406
+ this.catchUpTimeout.unref?.();
17407
+ try {
17408
+ await this.send("channel_history", {
17409
+ sinceSeq: this.lastSeq,
17410
+ ...limit !== void 0 ? { limit } : {}
17411
+ });
17412
+ } catch {
17413
+ this.abandonCatchUp();
17414
+ }
17415
+ }
17416
+ /**
17417
+ * Give up waiting for a catch-up and release what was held back.
17418
+ *
17419
+ * The buffered messages are still the freshest thing this client has, so
17420
+ * they are delivered rather than dropped. Callers of {@link history} are
17421
+ * answered with `retained: false` — accurate in the sense that matters:
17422
+ * this client has no history to work from and has to resync.
17423
+ */
17424
+ abandonCatchUp() {
17425
+ if (this.catchUpTimeout) {
17426
+ clearTimeout(this.catchUpTimeout);
17427
+ this.catchUpTimeout = null;
17428
+ }
17429
+ if (!this.catchUpInFlight) return;
17430
+ this.catchUpInFlight = false;
17431
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17432
+ messages: [],
17433
+ retained: false
17434
+ });
17435
+ this.flushPendingLive();
17436
+ }
17437
+ /**
17198
17438
  * Publish this client's presence state, and keep publishing it.
17199
17439
  *
17200
17440
  * Calling `track` again replaces the state (and restarts the heartbeat),
@@ -17244,6 +17484,34 @@ var RebaseRealtimeChannel = class {
17244
17484
  this.join();
17245
17485
  return () => this.broadcastHandlers.delete(wrapped);
17246
17486
  }
17487
+ /**
17488
+ * The last sequence number this channel has delivered.
17489
+ *
17490
+ * Zero on a channel that retains nothing. Persist it if you want catch-up
17491
+ * to survive a page reload as well as a reconnect, and pass it back via
17492
+ * {@link history}.
17493
+ */
17494
+ get sequence() {
17495
+ return this.lastSeq;
17496
+ }
17497
+ /**
17498
+ * Fetch retained messages explicitly, instead of waiting for join or
17499
+ * reconnect to do it.
17500
+ *
17501
+ * Defaults to resuming from {@link sequence}. Messages are delivered to
17502
+ * `onBroadcast` handlers as usual — the returned value is for callers that
17503
+ * want to inspect the batch, or to learn from `retained` that the channel
17504
+ * keeps no history at all.
17505
+ */
17506
+ async history(options = {}) {
17507
+ await this.join();
17508
+ if (options.sinceSeq !== void 0) this.lastSeq = options.sinceSeq;
17509
+ const result = new Promise((resolve) => {
17510
+ this.historyWaiters.push(resolve);
17511
+ });
17512
+ await this.requestHistory(options.limit);
17513
+ return result;
17514
+ }
17247
17515
  /** Leave the channel and release every listener and timer. */
17248
17516
  async leave() {
17249
17517
  this.stopHeartbeat();
@@ -17251,6 +17519,17 @@ var RebaseRealtimeChannel = class {
17251
17519
  this.presences = {};
17252
17520
  this.presenceHandlers.clear();
17253
17521
  this.broadcastHandlers.clear();
17522
+ this.lastSeq = 0;
17523
+ this.pendingLive = [];
17524
+ this.catchUpInFlight = false;
17525
+ if (this.catchUpTimeout) {
17526
+ clearTimeout(this.catchUpTimeout);
17527
+ this.catchUpTimeout = null;
17528
+ }
17529
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17530
+ messages: [],
17531
+ retained: false
17532
+ });
17254
17533
  for (const off of this.unsubscribers) off();
17255
17534
  this.unsubscribers = [];
17256
17535
  if (this.joined) {
@@ -17283,15 +17562,71 @@ var RebaseRealtimeChannel = class {
17283
17562
  break;
17284
17563
  }
17285
17564
  case "broadcast": {
17565
+ const seq = typeof message.seq === "number" ? message.seq : void 0;
17286
17566
  const event = {
17287
17567
  event: message.event,
17288
- payload: message.payload
17568
+ payload: message.payload,
17569
+ ...seq !== void 0 ? { seq } : {}
17289
17570
  };
17290
- for (const handler of this.broadcastHandlers) handler(event);
17571
+ if (seq === void 0) {
17572
+ this.deliver(event);
17573
+ break;
17574
+ }
17575
+ if (this.catchUpInFlight) {
17576
+ this.pendingLive.push(event);
17577
+ break;
17578
+ }
17579
+ if (seq <= this.lastSeq) break;
17580
+ this.lastSeq = seq;
17581
+ this.deliver(event);
17582
+ break;
17583
+ }
17584
+ case "channel_history": {
17585
+ this.catchUpInFlight = false;
17586
+ if (this.catchUpTimeout) {
17587
+ clearTimeout(this.catchUpTimeout);
17588
+ this.catchUpTimeout = null;
17589
+ }
17590
+ const entries = message.messages ?? [];
17591
+ const retained = message.retained === true;
17592
+ const latestSeq = typeof message.latestSeq === "number" ? message.latestSeq : void 0;
17593
+ for (const resolve of this.historyWaiters.splice(0)) resolve({
17594
+ messages: entries,
17595
+ retained,
17596
+ latestSeq
17597
+ });
17598
+ for (const entry of entries) {
17599
+ if (entry.seq <= this.lastSeq) continue;
17600
+ this.lastSeq = entry.seq;
17601
+ this.deliver({
17602
+ event: entry.event,
17603
+ payload: entry.payload,
17604
+ seq: entry.seq,
17605
+ replayed: true
17606
+ });
17607
+ }
17608
+ this.flushPendingLive();
17291
17609
  break;
17292
17610
  }
17293
17611
  }
17294
17612
  }
17613
+ /** Deliver everything held back during a catch-up, in sequence order. */
17614
+ flushPendingLive() {
17615
+ if (this.pendingLive.length === 0) return;
17616
+ const buffered = this.pendingLive.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
17617
+ this.pendingLive = [];
17618
+ for (const event of buffered) {
17619
+ const seq = event.seq;
17620
+ if (seq !== void 0) {
17621
+ if (seq <= this.lastSeq) continue;
17622
+ this.lastSeq = seq;
17623
+ }
17624
+ this.deliver(event);
17625
+ }
17626
+ }
17627
+ deliver(event) {
17628
+ for (const handler of [...this.broadcastHandlers]) handler(event);
17629
+ }
17295
17630
  emitPresence(diff) {
17296
17631
  const snapshot = { ...this.presences };
17297
17632
  for (const handler of this.presenceHandlers) handler(snapshot, diff);
@@ -17467,13 +17802,13 @@ function createRebaseClient(options) {
17467
17802
  * own membership — and `leave()` from one would otherwise silently
17468
17803
  * cut off the others.
17469
17804
  */
17470
- channel: (name) => {
17805
+ channel: (name, options) => {
17471
17806
  if (!ws) throw new RebaseClientError("Realtime is disabled on this client (realtime: false), so channels are unavailable.");
17472
17807
  let existing = realtimeChannels.get(name);
17473
17808
  if (!existing) {
17474
- existing = new RebaseRealtimeChannel(name, ws);
17809
+ existing = new RebaseRealtimeChannel(name, ws, options);
17475
17810
  realtimeChannels.set(name, existing);
17476
- }
17811
+ } else if (options?.history) existing.enableHistory();
17477
17812
  return existing;
17478
17813
  } },
17479
17814
  /**
@@ -19407,10 +19742,10 @@ var backup_exports = /* @__PURE__ */ __exportAll({
19407
19742
  /**
19408
19743
  * Returns a SQL chunk calling `auth.uid()` — the current user's ID.
19409
19744
  * This is a PostgreSQL RLS helper function created in the `auth` schema
19410
- * that reads `app.user_id` set per-transaction by `withAuth()`.
19745
+ * that reads `app.uid` set per-transaction by `withAuth()`.
19411
19746
  *
19412
19747
  * @example
19413
- * sql`${table.user_id} = ${authUid()}`
19748
+ * sql`${table.uid} = ${authUid()}`
19414
19749
  */
19415
19750
  var authUid = () => {
19416
19751
  return sql`auth.uid()`;