@scalemule/nextjs 0.0.35 → 0.0.37
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/client.d.mts +5 -1
- package/dist/client.d.ts +5 -1
- package/dist/{index-BoENfro3.d.mts → index-BIIUrnPr.d.mts} +54 -1
- package/dist/{index-BoENfro3.d.ts → index-BIIUrnPr.d.ts} +54 -1
- package/dist/index.d.mts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +439 -78
- package/dist/index.mjs +439 -78
- package/dist/server/auth.js +266 -6
- package/dist/server/auth.mjs +266 -6
- package/dist/server/index.d.mts +68 -4
- package/dist/server/index.d.ts +68 -4
- package/dist/server/index.js +273 -6
- package/dist/server/index.mjs +267 -7
- package/dist/server/webhook-handler.d.mts +2 -2
- package/dist/server/webhook-handler.d.ts +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{webhook-handler-CjSSCdvI.d.ts → webhook-handler-BobYTuNP.d.ts} +1 -1
- package/dist/{webhook-handler-Cz9jtet2.d.mts → webhook-handler-CNKQ1k2a.d.mts} +1 -1
- package/package.json +2 -2
package/dist/server/auth.js
CHANGED
|
@@ -767,6 +767,7 @@ function createServerClient(config) {
|
|
|
767
767
|
}
|
|
768
768
|
var SESSION_COOKIE_NAME = "sm_session";
|
|
769
769
|
var USER_ID_COOKIE_NAME = "sm_user_id";
|
|
770
|
+
var KNOWN_ACCOUNTS_COOKIE_NAME = "sm_known_accounts";
|
|
770
771
|
({
|
|
771
772
|
secure: process.env.NODE_ENV === "production"});
|
|
772
773
|
function createCookieHeader(name, value, options = {}) {
|
|
@@ -847,6 +848,163 @@ async function getSession() {
|
|
|
847
848
|
// Note: actual expiry is managed by ScaleMule backend
|
|
848
849
|
};
|
|
849
850
|
}
|
|
851
|
+
var MAX_KNOWN_ACCOUNTS = 10;
|
|
852
|
+
function maskEmail(email) {
|
|
853
|
+
const [local, domain] = email.split("@");
|
|
854
|
+
if (!domain) return "***@***.***";
|
|
855
|
+
const tldDot = domain.lastIndexOf(".");
|
|
856
|
+
const tld = tldDot > 0 ? domain.slice(tldDot) : "";
|
|
857
|
+
const domainBase = tldDot > 0 ? domain.slice(0, tldDot) : domain;
|
|
858
|
+
return `${local[0] || "*"}***@${domainBase[0] || "*"}***${tld}`;
|
|
859
|
+
}
|
|
860
|
+
function stableColorIndex(userId) {
|
|
861
|
+
let hash = 0;
|
|
862
|
+
for (let i = 0; i < userId.length; i++) {
|
|
863
|
+
hash = (hash << 5) - hash + userId.charCodeAt(i) | 0;
|
|
864
|
+
}
|
|
865
|
+
return Math.abs(hash) % 8;
|
|
866
|
+
}
|
|
867
|
+
function applyPrivacyToEntry(entry, privacy) {
|
|
868
|
+
switch (privacy) {
|
|
869
|
+
case "full":
|
|
870
|
+
return entry;
|
|
871
|
+
case "masked":
|
|
872
|
+
return {
|
|
873
|
+
userId: entry.userId,
|
|
874
|
+
email: entry.email ? maskEmail(entry.email) : void 0,
|
|
875
|
+
fullName: entry.fullName ? `${entry.fullName[0].toUpperCase()}.` : void 0,
|
|
876
|
+
provider: entry.provider,
|
|
877
|
+
lastActiveAt: entry.lastActiveAt,
|
|
878
|
+
colorIndex: stableColorIndex(entry.userId)
|
|
879
|
+
};
|
|
880
|
+
case "minimal":
|
|
881
|
+
return {
|
|
882
|
+
userId: entry.userId,
|
|
883
|
+
provider: entry.provider,
|
|
884
|
+
lastActiveAt: entry.lastActiveAt,
|
|
885
|
+
displayLabel: "Account",
|
|
886
|
+
colorIndex: stableColorIndex(entry.userId)
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function appendKnownAccountCookie(headers, account, existingCookie, options = {}, privacy) {
|
|
891
|
+
let accounts = {};
|
|
892
|
+
if (existingCookie) {
|
|
893
|
+
try {
|
|
894
|
+
accounts = JSON.parse(decodeURIComponent(existingCookie));
|
|
895
|
+
} catch {
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const effectivePrivacy = privacy || "full";
|
|
899
|
+
for (const [userId, entry] of Object.entries(accounts)) {
|
|
900
|
+
accounts[userId] = applyPrivacyToEntry(entry, effectivePrivacy);
|
|
901
|
+
}
|
|
902
|
+
accounts[account.userId] = applyPrivacyToEntry(account, effectivePrivacy);
|
|
903
|
+
const entries = Object.entries(accounts);
|
|
904
|
+
if (entries.length > MAX_KNOWN_ACCOUNTS) {
|
|
905
|
+
entries.sort((a, b) => new Date(b[1].lastActiveAt).getTime() - new Date(a[1].lastActiveAt).getTime());
|
|
906
|
+
accounts = Object.fromEntries(entries.slice(0, MAX_KNOWN_ACCOUNTS));
|
|
907
|
+
}
|
|
908
|
+
const maxAge = 365 * 24 * 60 * 60;
|
|
909
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
910
|
+
const sameSite = options.sameSite ?? "lax";
|
|
911
|
+
const path = options.path ?? "/";
|
|
912
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(accounts))}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
|
913
|
+
if (secure) {
|
|
914
|
+
cookie += "; Secure";
|
|
915
|
+
}
|
|
916
|
+
if (options.domain) {
|
|
917
|
+
cookie += `; Domain=${options.domain}`;
|
|
918
|
+
}
|
|
919
|
+
headers.append("Set-Cookie", cookie);
|
|
920
|
+
}
|
|
921
|
+
function removeKnownAccountFromCookie(headers, userId, existingCookie, options = {}) {
|
|
922
|
+
let accounts = {};
|
|
923
|
+
if (existingCookie) {
|
|
924
|
+
try {
|
|
925
|
+
accounts = JSON.parse(decodeURIComponent(existingCookie));
|
|
926
|
+
} catch {
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
delete accounts[userId];
|
|
930
|
+
const maxAge = 365 * 24 * 60 * 60;
|
|
931
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
932
|
+
const sameSite = options.sameSite ?? "lax";
|
|
933
|
+
const path = options.path ?? "/";
|
|
934
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(accounts))}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
|
935
|
+
if (secure) {
|
|
936
|
+
cookie += "; Secure";
|
|
937
|
+
}
|
|
938
|
+
if (options.domain) {
|
|
939
|
+
cookie += `; Domain=${options.domain}`;
|
|
940
|
+
}
|
|
941
|
+
headers.append("Set-Cookie", cookie);
|
|
942
|
+
}
|
|
943
|
+
function clearKnownAccountsCookie(headers, options = {}) {
|
|
944
|
+
const path = options.path ?? "/";
|
|
945
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=; Path=${path}; Max-Age=0`;
|
|
946
|
+
if (options.domain) {
|
|
947
|
+
cookie += `; Domain=${options.domain}`;
|
|
948
|
+
}
|
|
949
|
+
headers.append("Set-Cookie", cookie);
|
|
950
|
+
}
|
|
951
|
+
function getKnownAccountsFromRequest(request) {
|
|
952
|
+
const cookieHeader = request.headers.get("cookie");
|
|
953
|
+
if (!cookieHeader) return [];
|
|
954
|
+
const cookies3 = Object.fromEntries(
|
|
955
|
+
cookieHeader.split(";").map((c) => {
|
|
956
|
+
const [key, ...rest] = c.trim().split("=");
|
|
957
|
+
return [key, decodeURIComponent(rest.join("="))];
|
|
958
|
+
})
|
|
959
|
+
);
|
|
960
|
+
const raw = cookies3[KNOWN_ACCOUNTS_COOKIE_NAME];
|
|
961
|
+
if (!raw) return [];
|
|
962
|
+
try {
|
|
963
|
+
const accounts = JSON.parse(raw);
|
|
964
|
+
return Object.values(accounts);
|
|
965
|
+
} catch {
|
|
966
|
+
return [];
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function getKnownAccountsCookieRaw(request) {
|
|
970
|
+
const cookieHeader = request.headers.get("cookie");
|
|
971
|
+
if (!cookieHeader) return null;
|
|
972
|
+
const cookies3 = Object.fromEntries(
|
|
973
|
+
cookieHeader.split(";").map((c) => {
|
|
974
|
+
const [key, ...rest] = c.trim().split("=");
|
|
975
|
+
return [key, decodeURIComponent(rest.join("="))];
|
|
976
|
+
})
|
|
977
|
+
);
|
|
978
|
+
return cookies3[KNOWN_ACCOUNTS_COOKIE_NAME] || null;
|
|
979
|
+
}
|
|
980
|
+
function normalizeKnownAccountsCookie(request, privacy, options = {}) {
|
|
981
|
+
if (!privacy || privacy === "full") return null;
|
|
982
|
+
const raw = getKnownAccountsCookieRaw(request);
|
|
983
|
+
if (!raw) return null;
|
|
984
|
+
let accounts = {};
|
|
985
|
+
try {
|
|
986
|
+
accounts = JSON.parse(raw);
|
|
987
|
+
} catch {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
let changed = false;
|
|
991
|
+
for (const [userId, entry] of Object.entries(accounts)) {
|
|
992
|
+
const normalized = applyPrivacyToEntry(entry, privacy);
|
|
993
|
+
if (JSON.stringify(normalized) !== JSON.stringify(entry)) {
|
|
994
|
+
accounts[userId] = normalized;
|
|
995
|
+
changed = true;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (!changed) return null;
|
|
999
|
+
const maxAge = 365 * 24 * 60 * 60;
|
|
1000
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
1001
|
+
const sameSite = options.sameSite ?? "lax";
|
|
1002
|
+
const path = options.path ?? "/";
|
|
1003
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(accounts))}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
|
1004
|
+
if (secure) cookie += "; Secure";
|
|
1005
|
+
if (options.domain) cookie += `; Domain=${options.domain}`;
|
|
1006
|
+
return cookie;
|
|
1007
|
+
}
|
|
850
1008
|
|
|
851
1009
|
// src/server/timing.ts
|
|
852
1010
|
function constantTimeEqual(a, b) {
|
|
@@ -933,7 +1091,25 @@ function createAuthRoutes(config = {}) {
|
|
|
933
1091
|
} catch {
|
|
934
1092
|
return successResponse({ user: registeredUser, message: "Registration successful" }, 201);
|
|
935
1093
|
}
|
|
936
|
-
|
|
1094
|
+
const registerResponse = withSession(loginData, { user: registeredUser, sessionToken: loginData.session_token, userId: registeredUser.id }, cookieOptions);
|
|
1095
|
+
if (config.enableAccountSwitcher) {
|
|
1096
|
+
const existingKnown = getKnownAccountsCookieRaw(request);
|
|
1097
|
+
appendKnownAccountCookie(
|
|
1098
|
+
registerResponse.headers,
|
|
1099
|
+
{
|
|
1100
|
+
userId: registeredUser.id,
|
|
1101
|
+
email: registeredUser.email,
|
|
1102
|
+
fullName: registeredUser.full_name ?? void 0,
|
|
1103
|
+
avatarUrl: registeredUser.avatar_url ?? void 0,
|
|
1104
|
+
provider: "email",
|
|
1105
|
+
lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1106
|
+
},
|
|
1107
|
+
existingKnown,
|
|
1108
|
+
cookieOptions,
|
|
1109
|
+
config.accountSwitcherPrivacy
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
return registerResponse;
|
|
937
1113
|
}
|
|
938
1114
|
// ==================== Login ====================
|
|
939
1115
|
case "login": {
|
|
@@ -964,7 +1140,25 @@ function createAuthRoutes(config = {}) {
|
|
|
964
1140
|
email: loginData.user.email
|
|
965
1141
|
});
|
|
966
1142
|
}
|
|
967
|
-
|
|
1143
|
+
const loginResponse = withSession(loginData, { user: loginData.user, sessionToken: loginData.session_token, userId: loginData.user.id }, cookieOptions);
|
|
1144
|
+
if (config.enableAccountSwitcher) {
|
|
1145
|
+
const existingKnown = getKnownAccountsCookieRaw(request);
|
|
1146
|
+
appendKnownAccountCookie(
|
|
1147
|
+
loginResponse.headers,
|
|
1148
|
+
{
|
|
1149
|
+
userId: loginData.user.id,
|
|
1150
|
+
email: loginData.user.email,
|
|
1151
|
+
fullName: loginData.user.full_name ?? void 0,
|
|
1152
|
+
avatarUrl: loginData.user.avatar_url ?? void 0,
|
|
1153
|
+
provider: "email",
|
|
1154
|
+
lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1155
|
+
},
|
|
1156
|
+
existingKnown,
|
|
1157
|
+
cookieOptions,
|
|
1158
|
+
config.accountSwitcherPrivacy
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
return loginResponse;
|
|
968
1162
|
}
|
|
969
1163
|
// ==================== Logout ====================
|
|
970
1164
|
case "logout": {
|
|
@@ -1111,6 +1305,59 @@ function createAuthRoutes(config = {}) {
|
|
|
1111
1305
|
}
|
|
1112
1306
|
return successResponse({ message: "Password changed successfully" });
|
|
1113
1307
|
}
|
|
1308
|
+
// ==================== Switch Account ====================
|
|
1309
|
+
// Clears the active session so the user can re-authenticate as a different account.
|
|
1310
|
+
// The known accounts cookie is preserved — only the session cookie is cleared.
|
|
1311
|
+
case "switch-account": {
|
|
1312
|
+
if (!config.enableAccountSwitcher) {
|
|
1313
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1314
|
+
}
|
|
1315
|
+
const session = await getSession();
|
|
1316
|
+
if (session) {
|
|
1317
|
+
try {
|
|
1318
|
+
await sm.auth.logout(session.sessionToken);
|
|
1319
|
+
} catch {
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
const switchResponse = clearSession({ message: "Session cleared for account switch" }, cookieOptions);
|
|
1323
|
+
const knownAccounts = getKnownAccountsFromRequest(request);
|
|
1324
|
+
return new Response(
|
|
1325
|
+
JSON.stringify({ success: true, data: { message: "Session cleared for account switch", knownAccounts } }),
|
|
1326
|
+
{ status: 200, headers: switchResponse.headers }
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
// ==================== Forget Account ====================
|
|
1330
|
+
// Remove a specific account from the known accounts cookie.
|
|
1331
|
+
case "forget-account": {
|
|
1332
|
+
if (!config.enableAccountSwitcher) {
|
|
1333
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1334
|
+
}
|
|
1335
|
+
const { user_id } = body;
|
|
1336
|
+
if (!user_id) {
|
|
1337
|
+
return errorResponse("VALIDATION_ERROR", "user_id required", 400);
|
|
1338
|
+
}
|
|
1339
|
+
const headers = new Headers();
|
|
1340
|
+
headers.set("Content-Type", "application/json");
|
|
1341
|
+
const existingKnown = getKnownAccountsCookieRaw(request);
|
|
1342
|
+
removeKnownAccountFromCookie(headers, user_id, existingKnown, cookieOptions);
|
|
1343
|
+
return new Response(
|
|
1344
|
+
JSON.stringify({ success: true, data: { message: "Account forgotten" } }),
|
|
1345
|
+
{ status: 200, headers }
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
// ==================== Forget All Accounts ====================
|
|
1349
|
+
case "forget-all-accounts": {
|
|
1350
|
+
if (!config.enableAccountSwitcher) {
|
|
1351
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1352
|
+
}
|
|
1353
|
+
const headers = new Headers();
|
|
1354
|
+
headers.set("Content-Type", "application/json");
|
|
1355
|
+
clearKnownAccountsCookie(headers, cookieOptions);
|
|
1356
|
+
return new Response(
|
|
1357
|
+
JSON.stringify({ success: true, data: { message: "All accounts forgotten" } }),
|
|
1358
|
+
{ status: 200, headers }
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1114
1361
|
default:
|
|
1115
1362
|
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1116
1363
|
}
|
|
@@ -1126,20 +1373,25 @@ function createAuthRoutes(config = {}) {
|
|
|
1126
1373
|
switch (path) {
|
|
1127
1374
|
// ==================== Get Current User ====================
|
|
1128
1375
|
case "me": {
|
|
1376
|
+
const normCookie = config.enableAccountSwitcher ? normalizeKnownAccountsCookie(request, config.accountSwitcherPrivacy, cookieOptions) : null;
|
|
1377
|
+
const withNorm = (resp) => {
|
|
1378
|
+
if (normCookie) resp.headers.append("Set-Cookie", normCookie);
|
|
1379
|
+
return resp;
|
|
1380
|
+
};
|
|
1129
1381
|
const session = await getSession();
|
|
1130
1382
|
if (!session) {
|
|
1131
|
-
return errorResponse("UNAUTHORIZED", "Authentication required", 401);
|
|
1383
|
+
return withNorm(errorResponse("UNAUTHORIZED", "Authentication required", 401));
|
|
1132
1384
|
}
|
|
1133
1385
|
let userData;
|
|
1134
1386
|
try {
|
|
1135
1387
|
userData = await sm.auth.me(session.sessionToken);
|
|
1136
1388
|
} catch {
|
|
1137
|
-
return clearSession(
|
|
1389
|
+
return withNorm(clearSession(
|
|
1138
1390
|
{ error: { code: "SESSION_EXPIRED", message: "Session expired" } },
|
|
1139
1391
|
cookieOptions
|
|
1140
|
-
);
|
|
1392
|
+
));
|
|
1141
1393
|
}
|
|
1142
|
-
return successResponse({ user: userData, sessionToken: session.sessionToken, userId: session.userId });
|
|
1394
|
+
return withNorm(successResponse({ user: userData, sessionToken: session.sessionToken, userId: session.userId }));
|
|
1143
1395
|
}
|
|
1144
1396
|
// ==================== Get Session Status ====================
|
|
1145
1397
|
case "session": {
|
|
@@ -1149,6 +1401,14 @@ function createAuthRoutes(config = {}) {
|
|
|
1149
1401
|
userId: session?.userId || null
|
|
1150
1402
|
});
|
|
1151
1403
|
}
|
|
1404
|
+
// ==================== Get Known Accounts ====================
|
|
1405
|
+
case "known-accounts": {
|
|
1406
|
+
if (!config.enableAccountSwitcher) {
|
|
1407
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1408
|
+
}
|
|
1409
|
+
const knownAccounts = getKnownAccountsFromRequest(request);
|
|
1410
|
+
return successResponse({ knownAccounts });
|
|
1411
|
+
}
|
|
1152
1412
|
default:
|
|
1153
1413
|
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1154
1414
|
}
|
package/dist/server/auth.mjs
CHANGED
|
@@ -765,6 +765,7 @@ function createServerClient(config) {
|
|
|
765
765
|
}
|
|
766
766
|
var SESSION_COOKIE_NAME = "sm_session";
|
|
767
767
|
var USER_ID_COOKIE_NAME = "sm_user_id";
|
|
768
|
+
var KNOWN_ACCOUNTS_COOKIE_NAME = "sm_known_accounts";
|
|
768
769
|
({
|
|
769
770
|
secure: process.env.NODE_ENV === "production"});
|
|
770
771
|
function createCookieHeader(name, value, options = {}) {
|
|
@@ -845,6 +846,163 @@ async function getSession() {
|
|
|
845
846
|
// Note: actual expiry is managed by ScaleMule backend
|
|
846
847
|
};
|
|
847
848
|
}
|
|
849
|
+
var MAX_KNOWN_ACCOUNTS = 10;
|
|
850
|
+
function maskEmail(email) {
|
|
851
|
+
const [local, domain] = email.split("@");
|
|
852
|
+
if (!domain) return "***@***.***";
|
|
853
|
+
const tldDot = domain.lastIndexOf(".");
|
|
854
|
+
const tld = tldDot > 0 ? domain.slice(tldDot) : "";
|
|
855
|
+
const domainBase = tldDot > 0 ? domain.slice(0, tldDot) : domain;
|
|
856
|
+
return `${local[0] || "*"}***@${domainBase[0] || "*"}***${tld}`;
|
|
857
|
+
}
|
|
858
|
+
function stableColorIndex(userId) {
|
|
859
|
+
let hash = 0;
|
|
860
|
+
for (let i = 0; i < userId.length; i++) {
|
|
861
|
+
hash = (hash << 5) - hash + userId.charCodeAt(i) | 0;
|
|
862
|
+
}
|
|
863
|
+
return Math.abs(hash) % 8;
|
|
864
|
+
}
|
|
865
|
+
function applyPrivacyToEntry(entry, privacy) {
|
|
866
|
+
switch (privacy) {
|
|
867
|
+
case "full":
|
|
868
|
+
return entry;
|
|
869
|
+
case "masked":
|
|
870
|
+
return {
|
|
871
|
+
userId: entry.userId,
|
|
872
|
+
email: entry.email ? maskEmail(entry.email) : void 0,
|
|
873
|
+
fullName: entry.fullName ? `${entry.fullName[0].toUpperCase()}.` : void 0,
|
|
874
|
+
provider: entry.provider,
|
|
875
|
+
lastActiveAt: entry.lastActiveAt,
|
|
876
|
+
colorIndex: stableColorIndex(entry.userId)
|
|
877
|
+
};
|
|
878
|
+
case "minimal":
|
|
879
|
+
return {
|
|
880
|
+
userId: entry.userId,
|
|
881
|
+
provider: entry.provider,
|
|
882
|
+
lastActiveAt: entry.lastActiveAt,
|
|
883
|
+
displayLabel: "Account",
|
|
884
|
+
colorIndex: stableColorIndex(entry.userId)
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function appendKnownAccountCookie(headers, account, existingCookie, options = {}, privacy) {
|
|
889
|
+
let accounts = {};
|
|
890
|
+
if (existingCookie) {
|
|
891
|
+
try {
|
|
892
|
+
accounts = JSON.parse(decodeURIComponent(existingCookie));
|
|
893
|
+
} catch {
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const effectivePrivacy = privacy || "full";
|
|
897
|
+
for (const [userId, entry] of Object.entries(accounts)) {
|
|
898
|
+
accounts[userId] = applyPrivacyToEntry(entry, effectivePrivacy);
|
|
899
|
+
}
|
|
900
|
+
accounts[account.userId] = applyPrivacyToEntry(account, effectivePrivacy);
|
|
901
|
+
const entries = Object.entries(accounts);
|
|
902
|
+
if (entries.length > MAX_KNOWN_ACCOUNTS) {
|
|
903
|
+
entries.sort((a, b) => new Date(b[1].lastActiveAt).getTime() - new Date(a[1].lastActiveAt).getTime());
|
|
904
|
+
accounts = Object.fromEntries(entries.slice(0, MAX_KNOWN_ACCOUNTS));
|
|
905
|
+
}
|
|
906
|
+
const maxAge = 365 * 24 * 60 * 60;
|
|
907
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
908
|
+
const sameSite = options.sameSite ?? "lax";
|
|
909
|
+
const path = options.path ?? "/";
|
|
910
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(accounts))}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
|
911
|
+
if (secure) {
|
|
912
|
+
cookie += "; Secure";
|
|
913
|
+
}
|
|
914
|
+
if (options.domain) {
|
|
915
|
+
cookie += `; Domain=${options.domain}`;
|
|
916
|
+
}
|
|
917
|
+
headers.append("Set-Cookie", cookie);
|
|
918
|
+
}
|
|
919
|
+
function removeKnownAccountFromCookie(headers, userId, existingCookie, options = {}) {
|
|
920
|
+
let accounts = {};
|
|
921
|
+
if (existingCookie) {
|
|
922
|
+
try {
|
|
923
|
+
accounts = JSON.parse(decodeURIComponent(existingCookie));
|
|
924
|
+
} catch {
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
delete accounts[userId];
|
|
928
|
+
const maxAge = 365 * 24 * 60 * 60;
|
|
929
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
930
|
+
const sameSite = options.sameSite ?? "lax";
|
|
931
|
+
const path = options.path ?? "/";
|
|
932
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(accounts))}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
|
933
|
+
if (secure) {
|
|
934
|
+
cookie += "; Secure";
|
|
935
|
+
}
|
|
936
|
+
if (options.domain) {
|
|
937
|
+
cookie += `; Domain=${options.domain}`;
|
|
938
|
+
}
|
|
939
|
+
headers.append("Set-Cookie", cookie);
|
|
940
|
+
}
|
|
941
|
+
function clearKnownAccountsCookie(headers, options = {}) {
|
|
942
|
+
const path = options.path ?? "/";
|
|
943
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=; Path=${path}; Max-Age=0`;
|
|
944
|
+
if (options.domain) {
|
|
945
|
+
cookie += `; Domain=${options.domain}`;
|
|
946
|
+
}
|
|
947
|
+
headers.append("Set-Cookie", cookie);
|
|
948
|
+
}
|
|
949
|
+
function getKnownAccountsFromRequest(request) {
|
|
950
|
+
const cookieHeader = request.headers.get("cookie");
|
|
951
|
+
if (!cookieHeader) return [];
|
|
952
|
+
const cookies3 = Object.fromEntries(
|
|
953
|
+
cookieHeader.split(";").map((c) => {
|
|
954
|
+
const [key, ...rest] = c.trim().split("=");
|
|
955
|
+
return [key, decodeURIComponent(rest.join("="))];
|
|
956
|
+
})
|
|
957
|
+
);
|
|
958
|
+
const raw = cookies3[KNOWN_ACCOUNTS_COOKIE_NAME];
|
|
959
|
+
if (!raw) return [];
|
|
960
|
+
try {
|
|
961
|
+
const accounts = JSON.parse(raw);
|
|
962
|
+
return Object.values(accounts);
|
|
963
|
+
} catch {
|
|
964
|
+
return [];
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
function getKnownAccountsCookieRaw(request) {
|
|
968
|
+
const cookieHeader = request.headers.get("cookie");
|
|
969
|
+
if (!cookieHeader) return null;
|
|
970
|
+
const cookies3 = Object.fromEntries(
|
|
971
|
+
cookieHeader.split(";").map((c) => {
|
|
972
|
+
const [key, ...rest] = c.trim().split("=");
|
|
973
|
+
return [key, decodeURIComponent(rest.join("="))];
|
|
974
|
+
})
|
|
975
|
+
);
|
|
976
|
+
return cookies3[KNOWN_ACCOUNTS_COOKIE_NAME] || null;
|
|
977
|
+
}
|
|
978
|
+
function normalizeKnownAccountsCookie(request, privacy, options = {}) {
|
|
979
|
+
if (!privacy || privacy === "full") return null;
|
|
980
|
+
const raw = getKnownAccountsCookieRaw(request);
|
|
981
|
+
if (!raw) return null;
|
|
982
|
+
let accounts = {};
|
|
983
|
+
try {
|
|
984
|
+
accounts = JSON.parse(raw);
|
|
985
|
+
} catch {
|
|
986
|
+
return null;
|
|
987
|
+
}
|
|
988
|
+
let changed = false;
|
|
989
|
+
for (const [userId, entry] of Object.entries(accounts)) {
|
|
990
|
+
const normalized = applyPrivacyToEntry(entry, privacy);
|
|
991
|
+
if (JSON.stringify(normalized) !== JSON.stringify(entry)) {
|
|
992
|
+
accounts[userId] = normalized;
|
|
993
|
+
changed = true;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (!changed) return null;
|
|
997
|
+
const maxAge = 365 * 24 * 60 * 60;
|
|
998
|
+
const secure = options.secure ?? process.env.NODE_ENV === "production";
|
|
999
|
+
const sameSite = options.sameSite ?? "lax";
|
|
1000
|
+
const path = options.path ?? "/";
|
|
1001
|
+
let cookie = `${KNOWN_ACCOUNTS_COOKIE_NAME}=${encodeURIComponent(JSON.stringify(accounts))}; Path=${path}; Max-Age=${maxAge}; SameSite=${sameSite}`;
|
|
1002
|
+
if (secure) cookie += "; Secure";
|
|
1003
|
+
if (options.domain) cookie += `; Domain=${options.domain}`;
|
|
1004
|
+
return cookie;
|
|
1005
|
+
}
|
|
848
1006
|
|
|
849
1007
|
// src/server/timing.ts
|
|
850
1008
|
function constantTimeEqual(a, b) {
|
|
@@ -931,7 +1089,25 @@ function createAuthRoutes(config = {}) {
|
|
|
931
1089
|
} catch {
|
|
932
1090
|
return successResponse({ user: registeredUser, message: "Registration successful" }, 201);
|
|
933
1091
|
}
|
|
934
|
-
|
|
1092
|
+
const registerResponse = withSession(loginData, { user: registeredUser, sessionToken: loginData.session_token, userId: registeredUser.id }, cookieOptions);
|
|
1093
|
+
if (config.enableAccountSwitcher) {
|
|
1094
|
+
const existingKnown = getKnownAccountsCookieRaw(request);
|
|
1095
|
+
appendKnownAccountCookie(
|
|
1096
|
+
registerResponse.headers,
|
|
1097
|
+
{
|
|
1098
|
+
userId: registeredUser.id,
|
|
1099
|
+
email: registeredUser.email,
|
|
1100
|
+
fullName: registeredUser.full_name ?? void 0,
|
|
1101
|
+
avatarUrl: registeredUser.avatar_url ?? void 0,
|
|
1102
|
+
provider: "email",
|
|
1103
|
+
lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1104
|
+
},
|
|
1105
|
+
existingKnown,
|
|
1106
|
+
cookieOptions,
|
|
1107
|
+
config.accountSwitcherPrivacy
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
return registerResponse;
|
|
935
1111
|
}
|
|
936
1112
|
// ==================== Login ====================
|
|
937
1113
|
case "login": {
|
|
@@ -962,7 +1138,25 @@ function createAuthRoutes(config = {}) {
|
|
|
962
1138
|
email: loginData.user.email
|
|
963
1139
|
});
|
|
964
1140
|
}
|
|
965
|
-
|
|
1141
|
+
const loginResponse = withSession(loginData, { user: loginData.user, sessionToken: loginData.session_token, userId: loginData.user.id }, cookieOptions);
|
|
1142
|
+
if (config.enableAccountSwitcher) {
|
|
1143
|
+
const existingKnown = getKnownAccountsCookieRaw(request);
|
|
1144
|
+
appendKnownAccountCookie(
|
|
1145
|
+
loginResponse.headers,
|
|
1146
|
+
{
|
|
1147
|
+
userId: loginData.user.id,
|
|
1148
|
+
email: loginData.user.email,
|
|
1149
|
+
fullName: loginData.user.full_name ?? void 0,
|
|
1150
|
+
avatarUrl: loginData.user.avatar_url ?? void 0,
|
|
1151
|
+
provider: "email",
|
|
1152
|
+
lastActiveAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1153
|
+
},
|
|
1154
|
+
existingKnown,
|
|
1155
|
+
cookieOptions,
|
|
1156
|
+
config.accountSwitcherPrivacy
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
return loginResponse;
|
|
966
1160
|
}
|
|
967
1161
|
// ==================== Logout ====================
|
|
968
1162
|
case "logout": {
|
|
@@ -1109,6 +1303,59 @@ function createAuthRoutes(config = {}) {
|
|
|
1109
1303
|
}
|
|
1110
1304
|
return successResponse({ message: "Password changed successfully" });
|
|
1111
1305
|
}
|
|
1306
|
+
// ==================== Switch Account ====================
|
|
1307
|
+
// Clears the active session so the user can re-authenticate as a different account.
|
|
1308
|
+
// The known accounts cookie is preserved — only the session cookie is cleared.
|
|
1309
|
+
case "switch-account": {
|
|
1310
|
+
if (!config.enableAccountSwitcher) {
|
|
1311
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1312
|
+
}
|
|
1313
|
+
const session = await getSession();
|
|
1314
|
+
if (session) {
|
|
1315
|
+
try {
|
|
1316
|
+
await sm.auth.logout(session.sessionToken);
|
|
1317
|
+
} catch {
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
const switchResponse = clearSession({ message: "Session cleared for account switch" }, cookieOptions);
|
|
1321
|
+
const knownAccounts = getKnownAccountsFromRequest(request);
|
|
1322
|
+
return new Response(
|
|
1323
|
+
JSON.stringify({ success: true, data: { message: "Session cleared for account switch", knownAccounts } }),
|
|
1324
|
+
{ status: 200, headers: switchResponse.headers }
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
// ==================== Forget Account ====================
|
|
1328
|
+
// Remove a specific account from the known accounts cookie.
|
|
1329
|
+
case "forget-account": {
|
|
1330
|
+
if (!config.enableAccountSwitcher) {
|
|
1331
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1332
|
+
}
|
|
1333
|
+
const { user_id } = body;
|
|
1334
|
+
if (!user_id) {
|
|
1335
|
+
return errorResponse("VALIDATION_ERROR", "user_id required", 400);
|
|
1336
|
+
}
|
|
1337
|
+
const headers = new Headers();
|
|
1338
|
+
headers.set("Content-Type", "application/json");
|
|
1339
|
+
const existingKnown = getKnownAccountsCookieRaw(request);
|
|
1340
|
+
removeKnownAccountFromCookie(headers, user_id, existingKnown, cookieOptions);
|
|
1341
|
+
return new Response(
|
|
1342
|
+
JSON.stringify({ success: true, data: { message: "Account forgotten" } }),
|
|
1343
|
+
{ status: 200, headers }
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
// ==================== Forget All Accounts ====================
|
|
1347
|
+
case "forget-all-accounts": {
|
|
1348
|
+
if (!config.enableAccountSwitcher) {
|
|
1349
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1350
|
+
}
|
|
1351
|
+
const headers = new Headers();
|
|
1352
|
+
headers.set("Content-Type", "application/json");
|
|
1353
|
+
clearKnownAccountsCookie(headers, cookieOptions);
|
|
1354
|
+
return new Response(
|
|
1355
|
+
JSON.stringify({ success: true, data: { message: "All accounts forgotten" } }),
|
|
1356
|
+
{ status: 200, headers }
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1112
1359
|
default:
|
|
1113
1360
|
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1114
1361
|
}
|
|
@@ -1124,20 +1371,25 @@ function createAuthRoutes(config = {}) {
|
|
|
1124
1371
|
switch (path) {
|
|
1125
1372
|
// ==================== Get Current User ====================
|
|
1126
1373
|
case "me": {
|
|
1374
|
+
const normCookie = config.enableAccountSwitcher ? normalizeKnownAccountsCookie(request, config.accountSwitcherPrivacy, cookieOptions) : null;
|
|
1375
|
+
const withNorm = (resp) => {
|
|
1376
|
+
if (normCookie) resp.headers.append("Set-Cookie", normCookie);
|
|
1377
|
+
return resp;
|
|
1378
|
+
};
|
|
1127
1379
|
const session = await getSession();
|
|
1128
1380
|
if (!session) {
|
|
1129
|
-
return errorResponse("UNAUTHORIZED", "Authentication required", 401);
|
|
1381
|
+
return withNorm(errorResponse("UNAUTHORIZED", "Authentication required", 401));
|
|
1130
1382
|
}
|
|
1131
1383
|
let userData;
|
|
1132
1384
|
try {
|
|
1133
1385
|
userData = await sm.auth.me(session.sessionToken);
|
|
1134
1386
|
} catch {
|
|
1135
|
-
return clearSession(
|
|
1387
|
+
return withNorm(clearSession(
|
|
1136
1388
|
{ error: { code: "SESSION_EXPIRED", message: "Session expired" } },
|
|
1137
1389
|
cookieOptions
|
|
1138
|
-
);
|
|
1390
|
+
));
|
|
1139
1391
|
}
|
|
1140
|
-
return successResponse({ user: userData, sessionToken: session.sessionToken, userId: session.userId });
|
|
1392
|
+
return withNorm(successResponse({ user: userData, sessionToken: session.sessionToken, userId: session.userId }));
|
|
1141
1393
|
}
|
|
1142
1394
|
// ==================== Get Session Status ====================
|
|
1143
1395
|
case "session": {
|
|
@@ -1147,6 +1399,14 @@ function createAuthRoutes(config = {}) {
|
|
|
1147
1399
|
userId: session?.userId || null
|
|
1148
1400
|
});
|
|
1149
1401
|
}
|
|
1402
|
+
// ==================== Get Known Accounts ====================
|
|
1403
|
+
case "known-accounts": {
|
|
1404
|
+
if (!config.enableAccountSwitcher) {
|
|
1405
|
+
return errorResponse("NOT_FOUND", "Account switcher not enabled", 404);
|
|
1406
|
+
}
|
|
1407
|
+
const knownAccounts = getKnownAccountsFromRequest(request);
|
|
1408
|
+
return successResponse({ knownAccounts });
|
|
1409
|
+
}
|
|
1150
1410
|
default:
|
|
1151
1411
|
return errorResponse("NOT_FOUND", `Unknown endpoint: ${path}`, 404);
|
|
1152
1412
|
}
|