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