@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/api/types.d.ts +2 -2
- package/dist/auth/jwt.d.ts +8 -3
- package/dist/auth/middleware.d.ts +2 -2
- package/dist/cron/cron-store.d.ts +18 -0
- package/dist/env.d.ts +3 -0
- package/dist/index.es.js +1018 -199
- package/dist/index.es.js.map +1 -1
- package/dist/init.d.ts +21 -0
- package/dist/{jwt-BJzQOa8a.js → jwt-B3zjddCa.js} +5 -5
- package/dist/{jwt-BJzQOa8a.js.map → jwt-B3zjddCa.js.map} +1 -1
- package/dist/src-CsHhSKbi.js.map +1 -1
- package/dist/storage/LocalStorageController.d.ts +19 -2
- package/dist/storage/routes.d.ts +8 -1
- package/dist/storage/tus-handler.d.ts +21 -1
- package/dist/storage/types.d.ts +33 -0
- package/package.json +7 -6
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-
|
|
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
|
|
4475
|
+
const uid = `api-key:${apiKey.id}`;
|
|
4476
4476
|
const roles = apiKey.admin ? ["admin", "service"] : ["service"];
|
|
4477
4477
|
c.set("user", {
|
|
4478
|
-
|
|
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
|
|
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
|
-
|
|
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 = ("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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?.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
9861
|
-
const accessToken = generateAccessToken(userCtx.
|
|
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.
|
|
9864
|
-
if (ops.onMfaVerified) ops.onMfaVerified(userCtx.
|
|
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.
|
|
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.
|
|
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.
|
|
9902
|
-
await authRepo.deleteMfaFactor(factorId, userCtx.
|
|
9903
|
-
if (!await authRepo.hasVerifiedMfaFactors(userCtx.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
10502
|
-
|
|
10503
|
-
|
|
10504
|
-
|
|
10505
|
-
|
|
10506
|
-
|
|
10507
|
-
|
|
10508
|
-
|
|
10509
|
-
|
|
10510
|
-
|
|
10511
|
-
|
|
10512
|
-
|
|
10513
|
-
|
|
10514
|
-
|
|
10515
|
-
if (
|
|
10516
|
-
|
|
10517
|
-
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10525
|
-
|
|
10526
|
-
|
|
10527
|
-
|
|
10528
|
-
|
|
10529
|
-
|
|
10530
|
-
|
|
10531
|
-
|
|
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
|
-
|
|
10534
|
-
|
|
10535
|
-
|
|
10536
|
-
|
|
10537
|
-
|
|
10538
|
-
|
|
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
|
-
|
|
10542
|
-
|
|
10543
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
11420
|
-
if (
|
|
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
|
-
*
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
15938
|
-
errorCode
|
|
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
|
-
|
|
15986
|
-
|
|
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
|
-
|
|
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.
|
|
16179
|
-
|
|
16180
|
-
|
|
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
|
-
|
|
16199
|
-
|
|
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)
|
|
16409
|
-
|
|
16410
|
-
|
|
16411
|
-
|
|
16412
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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";
|