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