7 Commits

10 changed files with 571 additions and 103 deletions
+100
View File
@@ -0,0 +1,100 @@
# EMI Backend Agent Notes
## What this service is
- Node.js + Express API for EMI social features (profiles, posts, groups/courses, songs, payments, Bible/subsplash integrations).
- Main entrypoint: `index.js`.
- MongoDB Atlas-backed via `MONGO_URL` using `mongodb@3.6.x`.
## Runbook
- Install: `npm install`
- Start: `npm start` (binds to `PORT`, default `3000`)
- Test: `npm test` (single auth test file)
- API docs: `GET /api-docs`
## High-level architecture
- `index.js`: middleware setup, auth routes, route mounting, Swagger, web-push setup.
- `mongoDB.js`: creates shared DB object + collections + utility methods, then extends with:
- `dbTools/profile.js`
- `dbTools/post.js`
- `dbTools/payments.js`
- `dbTools/songs.js`
- `middleware/sessionChecker.js`: cookie/session validation and profile context hydration.
- `routes/*.js`: feature-specific routers.
- `def/*.js`: lightweight constructors for `Profile`, `Post`, `Songs`.
## Auth + session model
- Cookies used:
- `user_sid`
- `session_id`
- `profile_id`
- `sessionChecker` verifies ObjectId format, then checks session in `tokens` collection.
- On missing/invalid session/profile, user is redirected to `/login`.
- Most app routes are protected with `sessionChecker` except:
- `/signup`, `/login`, `/logout`, `/resetPassword`
- `/payments/*`
- `/subsplash/*`
- `/invite/:email`
## Key route surfaces
- `routes/profile.js`:
- Profile CRUD, invites, follow/unfollow, group/course discovery, subscribe/approve/reject flows.
- `routes/post.js`:
- Feed endpoints, tags/media filters, create/edit/delete posts, reactions/comments/bookmarks.
- Merges organic + non-organic posts (news/popular recommendations).
- `routes/payments.js`:
- Stripe payment intent creation + result registration; can toggle subscription timestamp.
- `routes/songs.js`:
- Song CRUD (ownership checks are effectively placeholder).
- `routes/bible.js`:
- Proxies scripture.api.bible endpoints using hardcoded API key in source.
- `routes/subsplash.js`:
- Scrapes Subsplash HTML with cheerio for events/media.
## Data model (collections)
- `users`: auth identity + password hash + optional customer.
- `tokens`: session documents (`uid` points to user).
- `invitation`: invite gating for signup.
- `profiles`: user/group/course/chat profile documents.
- `posts`: feed posts, reactions, comments, bookmarks, tags, non-organic type.
- `payments`: intent and payment result records.
- `songs`: song content metadata and reactions/comments.
## Important operational dependencies
- Mongo connection is required before server starts listening (`index.js` waits for `DB.getDB`).
- Notifications:
- Email via `nodemailer` SMTP (`mail.emmint.com`, env `EMAILPASS`).
- Mobile push via Expo (`expo-server-sdk`).
- Web push VAPID keys (`PUBLIC_VAPID_KEY`, `PRIVATE_VAPID_KEY`, `WEB_PUSH_EMAIL`).
- Analytics via PostHog (`POSTHOG_API_KEY`).
- Stripe via `STRIPE`.
## Environment/cookie/cors behavior
- Cookies configured in `config/cookiesOptions.js`:
- production or `COOKIE_SECURE=true` => `secure: true`, `sameSite: none`
- local HTTP => `secure: false`, `sameSite: lax`
- Allowed CORS origins in `config/corsOptions.js` are explicit list-based.
## Known code risks and maintenance hotspots
- Mixed ESM/CommonJS utility scripts (`AITools.js` uses ESM style while app is CommonJS).
- `routes/bible.js` has duplicate `/books` route and a probable bug in `/books/:bookId` (`bibleId` reference).
- Hardcoded external API key in `routes/bible.js` should be moved to env.
- `routes/songs.js` `songBelongsToUser` always returns true (authorization gap).
- Some endpoints return redirect-to-login for API callers instead of structured 401 JSON.
- Inconsistent error handling/response shapes across routes.
- Legacy driver/runtime tension:
- Dependency is `mongodb@3.6.x`
- `Dockerfile` uses Node 22, but code warns Node 22 is not fully tested; Node 20 LTS is safer.
## Testing state
- Only `test/auth.test.js` exists; no broad coverage for routes/db tools.
- Auth test expects existing seeded user behavior, so reliability depends on DB fixture state.
## Suggested workflow for future changes
- Keep fixes scoped and defensive (null checks + stable JSON).
- For auth/session changes:
- update both `sessionChecker` and `utils/sessionUtils.js`.
- For profile/post behavior:
- confirm DB helper method side effects in `dbTools/*`.
- For production incidents:
- first validate `MONGO_URL` connectivity and cookie security mode alignment.
-77
View File
@@ -1,77 +0,0 @@
# Password Security Hardening Plan
## Scope
- Applies to auth and password flows in:
- `index.js`
- `auth/authEmail.js`
- `mongoDB.js`
## 1. Replace insecure reset flow with single-use token login
- Problem:
- Current flow resets by username and emails a plaintext temporary password.
- Implementation:
- Keep `POST /resetPassword` as token request endpoint:
- Accept identifier (email/username).
- Always return generic success response.
- If account exists, create one-time login token with short TTL (15-30 min), store hashed token, email link.
- Add `POST /password/token-login`:
- Accept token.
- Validate token (exists, not expired, unused), mark used atomically, then create normal auth session cookies.
- Data model:
- New collection `password_login_tokens` with fields:
- `userId`, `tokenHash`, `expiresAt`, `usedAt`, `createdAt`, `requestMeta`.
- Add TTL index on `expiresAt`.
## 2. Remove credential handling from GET/query
- Problem:
- `/signup` and `/login` accept GET and query params for credentials.
- Implementation:
- Change auth routes to `POST` only.
- Read credentials from JSON body only.
- Reject query-based credential inputs with `400`.
- Update Swagger docs and clients accordingly.
## 3. Add account-aware brute-force protection
- Problem:
- Only global IP limiter exists; auth endpoints are not sufficiently protected.
- Implementation:
- Add dedicated limiter middleware for auth endpoints (`/login`, `/password/request-reset`, `/password/confirm-reset`):
- Combined key: normalized username/email + source IP.
- Lower thresholds and progressive backoff/lockout window.
- Add telemetry for blocked attempts.
- Optionally store counters in Redis if horizontally scaled.
## 4. Prevent account enumeration
- Problem:
- API exposes different responses for unknown user vs wrong password.
- Implementation:
- Login: same response for invalid credentials regardless of user existence.
- Reset request: always same response regardless of account existence.
- Keep detailed reason only in internal logs/analytics.
## 5. Keep strong password hashing policy (clarify bcrypt behavior)
- Problem:
- Existing TODO comments incorrectly state bcrypt needs manual salt handling.
- Implementation:
- Keep bcrypt (or migrate to Argon2id in a separate change set).
- Centralize hash policy:
- cost factor (benchmark-backed; start at 12 if acceptable latency).
- minimum password length and strength checks.
- On login, detect outdated hash params and rehash after successful auth.
## Suggested rollout order
1. Tokenized login flow (new endpoint + DB token store).
2. POST-only auth route enforcement.
3. Generic auth/reset responses.
4. Dedicated auth rate limiting.
5. Hash policy tuning + opportunistic rehash.
## Validation checklist
- Unit/integration tests:
- token creation, expiry, one-time use, invalid token paths.
- login generic error response behavior.
- auth rate limiter trigger and cooldown.
- query credential rejection.
- Manual:
- verify no plaintext password emails are sent.
- verify token cannot be reused after first successful consumption.
-10
View File
@@ -47,7 +47,6 @@ const createSessionFromUser = async ({ DB, user, req, res }) => {
// When new users are subscribed, they have a single profile, which is the personal one.
// Other profiles can be link to that user, like groups or courses.
const signup = async function (req, res) {
// SECURITY FIX (#2): only accept credentials from request body.
const username = (req.body.username || "").trim().toLowerCase();
const password = req.body.password;
const email = (req.body.email || "").trim().toLowerCase();
@@ -68,9 +67,6 @@ const signup = async function (req, res) {
}
let isUserAlreadyRegistered = await DB.getUser(email);
if (isUserAlreadyRegistered && isUserAlreadyRegistered._id) return res.json({ status: "This user is already registered" });
// SECURITY PLAN (point #5):
// bcrypt.hash already includes a per-password salt.
// Future hardening: centralize cost factor policy (and consider rehash-on-login).
const hashedPassword = await bcrypt.hash(password, 10);
const newUserObject = await DB.newUser({
username,
@@ -115,7 +111,6 @@ const login = async function (req, res) {
if (userInfo) return res.redirect('/');
}
const invalidCredentials = () => res.status(401).json({ status: "Invalid credentials" });
// SECURITY FIX (#2): only accept credentials from request body.
const username = (req.body.username || req.body.email || "").trim().toLowerCase();
const password = req.body.password || "";
if (!username || !password) return invalidCredentials();
@@ -128,11 +123,7 @@ const login = async function (req, res) {
properties: { username },
});
}
// SECURITY PLAN (point #5):
// bcrypt.compare validates salted hashes directly; no manual salt parameter is needed.
// SECURITY FIX (#4): compare against dummy hash when user doesn't exist to reduce timing side-channel.
const isSamePassword = await bcrypt.compare(password, user?.password || DUMMY_BCRYPT_HASH);
// SECURITY FIX (#4): same response for non-existing user and wrong password.
if (!user || !isSamePassword) return invalidCredentials();
try {
return res.json(await createSessionFromUser({ DB, user, req, res }));
@@ -170,7 +161,6 @@ const logout = async function (req, res) {
const resetPassword = async function (req, res) {
const DB = await MongoDB.getDB;
// SECURITY FIX (#1): issue a single-use token instead of sending/changing passwords.
const genericResetResponse = {
status: "ok",
details: "If the account exists, check your email for next steps"
+62
View File
@@ -0,0 +1,62 @@
const DBName = "EMI_SOCIAL";
const chatDB = (DB) => {
DB.chatMessagesCol = DB.db.db(DBName).collection("chat_messages");
DB.chatMessagesCol.createIndex({ createdAt: -1 }).catch(console.error);
DB.addChatMessage = async ({ senderId, senderProfileId, senderName, text, sourceLang }) => {
const safeText = (text || "").trim();
if (!safeText) return false;
const message = {
senderId: senderId ? senderId + "" : "",
senderProfileId: senderProfileId ? senderProfileId + "" : "",
senderName: senderName || "Anonymous",
text: safeText,
sourceLang: sourceLang || "en",
translations: {},
createdAt: new Date(),
};
const result = await DB.chatMessagesCol.insertOne(message).catch((err) => {
console.log(err);
return false;
});
if (!result || !result.insertedId) return false;
return {
...message,
_id: result.insertedId,
};
};
DB.getRecentChatMessages = async (limit = 100) => {
const safeLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200);
const messages = await DB.chatMessagesCol.find({})
.sort({ createdAt: -1 })
.limit(safeLimit)
.toArray()
.catch((err) => {
console.log(err);
return [];
});
return messages.reverse();
};
DB.setChatMessageTranslation = async ({ messageId, targetLang, text, provider, model }) => {
if (!messageId || !targetLang || !text) return false;
const _id = typeof messageId === "string" ? DB.ObjectID(messageId) : messageId;
const fieldBase = `translations.${targetLang}`;
const update = {
$set: {
[`${fieldBase}.text`]: text,
[`${fieldBase}.provider`]: provider || "openai",
[`${fieldBase}.model`]: model || "",
[`${fieldBase}.updatedAt`]: new Date(),
},
};
return DB.chatMessagesCol.updateOne({ _id }, update).catch((err) => {
console.log(err);
return false;
});
};
};
module.exports = chatDB;
+28 -2
View File
@@ -23,7 +23,9 @@ userDB = (DB) => {
DB.updateProfile = async (profileid, profileObj) => {
let tempProfile = profileObj.toObj();
const query = { _id: profileid };
if (!DB.ObjectID.isValid(profileid)) return false;
const _id = DB.ObjectID(profileid);
const query = { _id };
const update = {
$set: {
profile: tempProfile.profile,
@@ -34,6 +36,7 @@ userDB = (DB) => {
console.log(err);
return false;
});
if (userProfileCache[profileid]) delete userProfileCache[profileid];
return r;
}
@@ -281,13 +284,36 @@ userDB = (DB) => {
postid,
commentIndx,
actorid,
viewed: false,
}
}
}
return DB.profileCols.updateOne({ _id }, update).catch((err) => {
const r = await DB.profileCols.updateOne({ _id }, update).catch((err) => {
console.log(err);
return false;
});
if (userProfileCache[profileid]) delete userProfileCache[profileid];
return r;
}
DB.markNotificationsViewed = async (profileid) => {
const _id = DB.ObjectID(profileid);
const update = {
$set: {
"notifications.$[n].viewed": true
}
};
const options = {
arrayFilters: [
{ "n.viewed": { $ne: true } }
]
};
const r = await DB.profileCols.updateOne({ _id }, update, options).catch((err) => {
console.log(err);
return false;
});
if (userProfileCache[profileid]) delete userProfileCache[profileid];
return r;
}
DB.isSubscriptor = async (profileid) => {
+2 -4
View File
@@ -72,7 +72,6 @@ const { authRateLimiter } = require('./middleware/authRateLimiter');
* 400:
* description: Bad request.
*/
// SECURITY FIX (#2): POST-only signup to avoid query-string credential leakage.
app.post('/signup', signup);
/**
* @swagger
@@ -106,7 +105,6 @@ app.post('/signup', signup);
* 401:
* description: Invalid credentials.
*/
// SECURITY FIX (#2): POST-only login to avoid query-string credential leakage.
app.post('/login', authRateLimiter('login'), login);
/**
* @swagger
@@ -156,8 +154,6 @@ app.get('/logout', logout);
* description: Bad request.
*/
app.route('/resetPassword').post(authRateLimiter('reset'), resetPassword);
// SECURITY FIX (#1):
// Single-use token login endpoint for password recovery flow.
/**
* @swagger
* /password/token-login:
@@ -187,6 +183,7 @@ const postRoute = require('./routes/post.js');
const songsRoute = require('./routes/songs.js');
const paymentsRoute = require('./routes/payments.js');
const bibleRoute = require('./routes/bible.js');
const chatRoute = require('./routes/chat.js');
const sessionChecker = require('./middleware/sessionChecker');
// -- Private Routes
app.use('/user', sessionChecker, profileRoute);
@@ -194,6 +191,7 @@ app.use('/post', sessionChecker, postRoute);
app.use('/payments', paymentsRoute);
app.use('/bible', sessionChecker, bibleRoute);
app.use('/songs', sessionChecker, songsRoute);
app.use('/chat', sessionChecker, chatRoute);
// -- Public Routes
const subsplashRoute = require('./routes/subsplash.js');
app.use('/subsplash', subsplashRoute);
+2
View File
@@ -9,6 +9,7 @@ const postDB = require("./dbTools/post.js");
const profileDB = require("./dbTools/profile.js");
const paymentDB = require("./dbTools/payments.js");
const songsDB = require("./dbTools/songs.js");
const chatDB = require("./dbTools/chat.js");
console.log("Connecting to MongoDB...");
const nodeMajorVersion = parseInt((process.versions.node || "0").split(".")[0], 10);
@@ -177,6 +178,7 @@ const getDB = new Promise((resolve, reject) => {
profileDB(DB);
paymentDB(DB);
songsDB(DB);
chatDB(DB);
resolve(DB);
});
+238
View File
@@ -0,0 +1,238 @@
var express = require('express');
var router = express.Router();
const DB = require("../mongoDB.js");
const { getUserId, getProfileId } = require("../utils/sessionUtils.js");
const { normalizeLanguageCode, translateText } = require("../utils/chatTranslation.js");
const ACTIVE_WINDOW_MS = 120000;
const MESSAGE_MAX_LENGTH = 500;
const activeUsers = new Map();
const translationInflight = new Map();
const toDisplayName = (profile, fallbackName) => {
const firstName = profile?.profile?.firstName || "";
const lastName = profile?.profile?.lastName || "";
const displayName = (firstName + " " + lastName).trim();
return displayName || fallbackName || "Anonymous";
};
const pruneActiveUsers = () => {
const now = Date.now();
for (const [profileId, entry] of activeUsers.entries()) {
if (now - entry.lastSeen > ACTIVE_WINDOW_MS) {
activeUsers.delete(profileId);
}
}
};
const getActiveUsersList = () => {
pruneActiveUsers();
return Array.from(activeUsers.values())
.sort((a, b) => b.lastSeen - a.lastSeen)
.map((entry) => ({
profileId: entry.profileId,
userId: entry.userId,
displayName: entry.displayName,
lastSeen: entry.lastSeen,
}));
};
DB.getDB.then((DB) => {
const resolveTargetLanguage = (req) => {
const requested = req.query?.lang || req.headers["x-app-language"] || req.headers["accept-language"] || "en";
return normalizeLanguageCode(requested);
};
const mapChatMessageForLanguage = async (message, targetLang) => {
const normalizedTarget = normalizeLanguageCode(targetLang);
const sourceLang = normalizeLanguageCode(message?.sourceLang || "auto");
const originalText = message?.text || "";
if (!originalText) {
return {
...message,
textOriginal: "",
text: "",
displayLang: sourceLang,
};
}
if (sourceLang === normalizedTarget) {
return {
...message,
textOriginal: originalText,
text: originalText,
displayLang: sourceLang,
};
}
const cachedTranslation = message?.translations?.[normalizedTarget]?.text;
if (cachedTranslation) {
return {
...message,
textOriginal: originalText,
text: cachedTranslation,
displayLang: normalizedTarget,
};
}
const translationKey = `${message?._id?.toString?.() || ""}:${normalizedTarget}`;
if (translationInflight.has(translationKey)) {
await translationInflight.get(translationKey);
const refreshed = await DB.chatMessagesCol.findOne({ _id: message._id }).catch(() => null);
const refreshedCached = refreshed?.translations?.[normalizedTarget]?.text;
if (refreshedCached) {
return {
...message,
translations: refreshed.translations,
textOriginal: originalText,
text: refreshedCached,
displayLang: normalizedTarget,
};
}
return {
...message,
textOriginal: originalText,
text: originalText,
displayLang: sourceLang,
};
}
const inFlightTask = (async () => {
const translated = await translateText({
text: originalText,
sourceLang,
targetLang: normalizedTarget,
});
if (!translated?.translatedText) return null;
await DB.setChatMessageTranslation({
messageId: message._id,
targetLang: normalizedTarget,
text: translated.translatedText,
provider: translated.provider,
model: translated.model,
});
return translated.translatedText;
})();
translationInflight.set(translationKey, inFlightTask);
let translatedText = null;
try {
translatedText = await inFlightTask;
} finally {
translationInflight.delete(translationKey);
}
return {
...message,
textOriginal: originalText,
text: translatedText || originalText,
displayLang: translatedText ? normalizedTarget : sourceLang,
};
};
const markActiveUser = async (req) => {
const userId = getUserId(req);
const profileId = req.profileInfo?._id || getProfileId(req);
if (!profileId || !userId) return null;
const profile = await DB.getProfileCache(profileId);
const displayName = toDisplayName(profile, req.userInfo?.username);
activeUsers.set(profileId + "", {
profileId: profileId + "",
userId: userId + "",
displayName,
lastSeen: Date.now(),
});
return activeUsers.get(profileId + "");
};
router.get("/messages", async (req, res) => {
try {
await markActiveUser(req);
const targetLang = resolveTargetLanguage(req);
const messages = await DB.getRecentChatMessages(req.query.limit || 100);
const translatedMessages = await Promise.all(messages.map((message) => mapChatMessageForLanguage(message, targetLang)));
return res.json({
status: "ok",
requestedLang: targetLang,
messages: translatedMessages,
});
} catch (error) {
console.error("Error getting chat messages", error);
return res.status(500).json({ status: "Internal server error", messages: [] });
}
});
router.post("/messages", async (req, res) => {
try {
const userId = getUserId(req);
const profileId = req.profileInfo?._id || getProfileId(req);
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
const sourceLang = normalizeLanguageCode(req.body?.sourceLang || req.headers["x-app-language"] || "en");
if (!text) {
return res.status(400).json({ status: "Message text is required" });
}
if (text.length > MESSAGE_MAX_LENGTH) {
return res.status(400).json({ status: `Message too long (${MESSAGE_MAX_LENGTH} max chars)` });
}
const profile = await DB.getProfileCache(profileId);
const senderName = toDisplayName(profile, req.userInfo?.username);
const message = await DB.addChatMessage({
senderId: userId,
senderProfileId: profileId,
senderName,
text,
sourceLang,
});
if (!message) {
return res.status(500).json({ status: "Could not save message" });
}
activeUsers.set(profileId + "", {
profileId: profileId + "",
userId: userId + "",
displayName: senderName,
lastSeen: Date.now(),
});
return res.json({
status: "ok",
message,
activeUsers: getActiveUsersList(),
});
} catch (error) {
console.error("Error posting chat message", error);
return res.status(500).json({ status: "Internal server error" });
}
});
router.get("/active", async (req, res) => {
try {
await markActiveUser(req);
return res.json({
status: "ok",
activeUsers: getActiveUsersList(),
});
} catch (error) {
console.error("Error getting active chat users", error);
return res.status(500).json({ status: "Internal server error", activeUsers: [] });
}
});
router.post("/ping", async (req, res) => {
try {
await markActiveUser(req);
return res.json({
status: "ok",
activeUsers: getActiveUsersList(),
});
} catch (error) {
console.error("Error updating chat presence", error);
return res.status(500).json({ status: "Internal server error", activeUsers: [] });
}
});
});
module.exports = router;
+33 -1
View File
@@ -760,16 +760,48 @@ DB.getDB.then((DB) => {
* type: string
*/
router.post("/myProfile", async (req, res) => {
try {
let profile = {
userid: getUserId(req),
profile: req.body.profile,
data: req.body.data
};
let profileObj = new Profile(profile); //validates profile
DB.updateProfile(getProfileId(req), profileObj);
const updateRes = await DB.updateProfile(getProfileId(req), profileObj);
if (!updateRes || !updateRes.matchedCount) {
return res.status(400).json({
status: "Could not update profile"
});
}
return res.json({
status: "ok"
});
} catch (error) {
console.error("Error updating myProfile", error);
return res.status(500).json({
status: "Internal server error"
});
}
});
router.post("/notifications/viewed", async (req, res) => {
try {
const profileid = getProfileId(req);
const result = await DB.markNotificationsViewed(profileid);
if (!result) {
return res.status(400).json({
status: "Could not update notifications"
});
}
return res.json({
status: "ok"
});
} catch (error) {
console.error("Error marking notifications as viewed", error);
return res.status(500).json({
status: "Internal server error"
});
}
});
/**
+97
View File
@@ -0,0 +1,97 @@
const axios = require("axios");
const DEFAULT_MODEL = process.env.OPENAI_TRANSLATION_MODEL || process.env.OPENAI_MODEL || "gpt-4o-mini";
const normalizeLanguageCode = (rawLanguage) => {
if (!rawLanguage || typeof rawLanguage !== "string") return "en";
const firstValue = rawLanguage.split(",")[0].trim().toLowerCase();
if (!firstValue) return "en";
const noQuality = firstValue.split(";")[0].trim();
const shortCode = noQuality.split("-")[0].trim();
return shortCode || "en";
};
const extractOutputText = (data) => {
if (!data) return "";
if (typeof data.output_text === "string" && data.output_text.trim()) {
return data.output_text.trim();
}
if (!Array.isArray(data.output)) return "";
const chunks = [];
data.output.forEach((item) => {
if (!Array.isArray(item?.content)) return;
item.content.forEach((entry) => {
if (entry?.type === "output_text" && typeof entry?.text === "string") {
chunks.push(entry.text);
}
});
});
return chunks.join("\n").trim();
};
const translateText = async ({ text, sourceLang, targetLang }) => {
const normalizedSource = normalizeLanguageCode(sourceLang);
const normalizedTarget = normalizeLanguageCode(targetLang);
if (!text || !normalizedTarget || normalizedSource === normalizedTarget) {
return {
translatedText: text,
provider: "none",
model: "none",
};
}
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return null;
try {
const response = await axios.post(
"https://api.openai.com/v1/responses",
{
model: DEFAULT_MODEL,
input: [
{
role: "system",
content: [
{
type: "input_text",
text: "You translate chat messages. Keep meaning, tone, emojis, names, and references. Return only the translated text.",
},
],
},
{
role: "user",
content: [
{
type: "input_text",
text: `Translate this message from ${normalizedSource} to ${normalizedTarget}:\n\n${text}`,
},
],
},
],
},
{
timeout: 15000,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
}
);
const translatedText = extractOutputText(response?.data);
if (!translatedText) return null;
return {
translatedText,
provider: "openai",
model: DEFAULT_MODEL,
};
} catch (error) {
console.error("Error translating chat message", error?.response?.data || error?.message || error);
return null;
}
};
module.exports = {
normalizeLanguageCode,
translateText,
};