diff --git a/backend/src/dal/sqliteCampaignRepository.js b/backend/src/dal/sqliteCampaignRepository.js index 3148b4b8..71792f44 100644 --- a/backend/src/dal/sqliteCampaignRepository.js +++ b/backend/src/dal/sqliteCampaignRepository.js @@ -86,7 +86,17 @@ function parseTagsFromRow(row) { } } +function parseTranslationsFromRow(row) { + try { + const parsed = JSON.parse(row.translations ?? '{}'); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + function rowToCampaign(row) { + const rawTranslations = parseTranslationsFromRow(row); const campaign = { id: String(row.id), name: row.name, @@ -107,8 +117,9 @@ function rowToCampaign(row) { status: row.status ?? 'draft', createdAt: row.created_at, updatedAt: row.updated_at ?? row.created_at, + available_locales: Object.keys(rawTranslations), + _rawTranslations: rawTranslations, }; - // Keep the computed status for backward compatibility with time-based status campaign.computedStatus = computeCampaignStatus(campaign); return campaign; } @@ -503,6 +514,46 @@ export function createSqliteCampaignRepository({ return getById(id); } + /** @param {string | number} id @returns {Record} */ + function getTranslations(id) { + const row = db.prepare('SELECT translations FROM campaigns WHERE id = ?').get(Number(id)); + if (!row) return {}; + return parseTranslationsFromRow(row); + } + + /** + * @param {string | number} id + * @param {string} locale + * @param {{ name?: string, description?: string }} data + */ + function upsertTranslation(id, locale, data) { + const current = getTranslations(id); + const updated = { ...current, [locale]: { ...data } }; + const updatedAt = new Date().toISOString(); + db.prepare('UPDATE campaigns SET translations = ?, updated_at = ? WHERE id = ?').run( + JSON.stringify(updated), + updatedAt, + Number(id), + ); + } + + /** + * @param {string | number} id + * @param {string} locale + */ + function deleteTranslation(id, locale) { + const current = getTranslations(id); + if (!(locale in current)) return false; + const { [locale]: _removed, ...rest } = current; + const updatedAt = new Date().toISOString(); + db.prepare('UPDATE campaigns SET translations = ?, updated_at = ? WHERE id = ?').run( + JSON.stringify(rest), + updatedAt, + Number(id), + ); + return true; + } + return { list, listCategories, @@ -515,6 +566,9 @@ export function createSqliteCampaignRepository({ clone, publish, archive, + getTranslations, + upsertTranslation, + deleteTranslation, ftsAvailable, }; } diff --git a/backend/src/db/migrations/027_campaign_translations.js b/backend/src/db/migrations/027_campaign_translations.js new file mode 100644 index 00000000..70b56d1a --- /dev/null +++ b/backend/src/db/migrations/027_campaign_translations.js @@ -0,0 +1,16 @@ +export const version = 27; +export const description = 'Add translations JSON column to campaigns'; + +export function up(db) { + const columns = db.prepare('PRAGMA table_info(campaigns)').all(); + const columnNames = new Set(columns.map((col) => col.name)); + + if (!columnNames.has('translations')) { + db.exec("ALTER TABLE campaigns ADD COLUMN translations TEXT NOT NULL DEFAULT '{}'"); + } +} + +export function down(db) { + // SQLite does not support DROP COLUMN in older versions; left as no-op. + void db; +} diff --git a/backend/src/index.js b/backend/src/index.js index bba3ae7a..11d88077 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -85,8 +85,65 @@ import { createIndexReadRoutes } from './routes/indexRead.js'; import { createSep10Routes, createRequireWalletAuth } from './routes/sep10.js'; import { createZkInputsRoutes } from './routes/zkInputs.js'; import { createOperatorBalanceJob } from './jobs/operatorBalanceJob.js'; +import { createModerationService } from './moderation/moderationService.js'; +import { createContentModerationMiddleware } from './middleware/contentModeration.js'; const DEFAULT_PORT = 3001; + +// BCP-47: language (2-3 chars) + optional script (4 chars) + optional region (2 chars) +const BCP47_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4}(-[A-Z]{2})?)?$/; + +/** @param {string} locale */ +export function isValidLocale(locale) { + return BCP47_RE.test(locale); +} + +/** @param {string | undefined} header */ +function parseAcceptLanguage(header) { + if (!header) return []; + return header + .split(',') + .map((part) => { + const [tag, qPart] = part.trim().split(';'); + const q = qPart ? parseFloat(qPart.replace(/.*=/, '')) : 1.0; + return { locale: tag.trim(), q: Number.isFinite(q) ? q : 1.0 }; + }) + .sort((a, b) => b.q - a.q) + .map(({ locale }) => locale) + .filter(Boolean); +} + +/** @param {import('express').Request} req @returns {string[]} */ +function getRequestLocales(req) { + const queryLocale = req.query?.locale; + if (typeof queryLocale === 'string' && queryLocale.trim()) { + return [queryLocale.trim()]; + } + return parseAcceptLanguage(req.headers['accept-language']); +} + +/** + * Strips `_rawTranslations` and applies locale negotiation to name/description. + * @param {Record} campaign + * @param {string[]} [locales] + * @returns {Record} + */ +function serializeCampaign(campaign, locales = []) { + const { _rawTranslations, ...pub } = campaign; + if (!_rawTranslations || !locales.length) return pub; + for (const locale of locales) { + if (locale === 'en' || locale.startsWith('en-')) break; + const trans = + _rawTranslations[locale] ?? _rawTranslations[locale.split('-')[0]] ?? null; + if (trans) { + if (trans.name) pub.name = trans.name; + if (trans.description) pub.description = trans.description; + break; + } + } + return pub; +} + const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000; const DEFAULT_RATE_LIMIT_MAX_REQUESTS = 60; const DEFAULT_AUTH_LOCKOUT_SOFT_THRESHOLD = 5; @@ -491,6 +548,20 @@ export async function createApp(options = {}) { const usageMeteringMiddleware = createUsageMeteringMiddleware({ usageMeteringService }); + const moderationService = + /** @type {any} */ (options.moderationService) ?? + createModerationService({ + provider: + /** @type {string} */ (options.moderationProvider) ?? process.env.MODERATION_PROVIDER, + openaiApiKey: + /** @type {string} */ (options.moderationApiKey) ?? process.env.OPENAI_API_KEY, + fetchImpl, + }); + const contentModerationMiddleware = createContentModerationMiddleware({ + moderationService, + log, + }); + const rateLimiter = createRateLimiter({ windowMs: rateLimitWindowMs, maxRequests: rateLimitMaxRequests, @@ -929,11 +1000,17 @@ export async function createApp(options = {}) { } /** @param {import('express').Request} req @param {import('express').Response} res */ - function listCampaigns(req, res) { - const cacheKey = `campaigns:${req.originalUrl}`; - const cached = shortCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return res.set('x-cache', 'HIT').json(cached.payload); + async function listCampaigns(req, res) { + // Exclude ?locale from cache key so locale variants share the same raw-data cache entry. + const cacheKey = `campaigns:${req.originalUrl.replace(/([?&])locale=[^&]*/g, '$1').replace(/[?&]$/, '')}`; + const locales = getRequestLocales(req); + const rawCached = shortCache.get(cacheKey); + if (rawCached && rawCached.expiresAt > Date.now()) { + const payload = { + ...rawCached.payload, + data: rawCached.payload.data.map((c) => serializeCampaign(c, locales)), + }; + return res.set('x-cache', 'HIT').json(payload); } const activeRaw = @@ -994,12 +1071,16 @@ export async function createApp(options = {}) { sortedItems = sortByUrgency(items); } - const payload = paginateItems(sortedItems, req.query); + const rawPayload = paginateItems(sortedItems, req.query); shortCache.set(cacheKey, { expiresAt: Date.now() + shortCacheTtlMs, - payload, + payload: rawPayload, }); res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=120'); + const payload = { + ...rawPayload, + data: rawPayload.data.map((c) => serializeCampaign(c, locales)), + }; return res.set('x-cache', 'MISS').json(payload); } @@ -1007,12 +1088,14 @@ export async function createApp(options = {}) { function getTrendingCampaigns(req, res) { const limitRaw = Number.parseInt(String(req.query.limit ?? '6'), 10); const limit = Number.isFinite(limitRaw) && limitRaw > 0 && limitRaw <= 50 ? limitRaw : 6; + const locales = getRequestLocales(req); const cacheKey = `trending:${limit}`; - const cached = shortCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { + const rawCached = shortCache.get(cacheKey); + if (rawCached && rawCached.expiresAt > Date.now()) { res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=120'); - return res.set('x-cache', 'HIT').json(cached.payload); + const payload = { ...rawCached.payload, data: rawCached.payload.data.map((c) => serializeCampaign(c, locales)) }; + return res.set('x-cache', 'HIT').json(payload); } const all = campaignRepository.list({ @@ -1020,11 +1103,12 @@ export async function createApp(options = {}) { sort: 'reward_per_action', order: 'desc', }); - const data = all.slice(0, limit); - const payload = { data, total: data.length }; + const rawData = all.slice(0, limit); + const rawPayload = { data: rawData, total: rawData.length }; - shortCache.set(cacheKey, { expiresAt: Date.now() + shortCacheTtlMs, payload }); + shortCache.set(cacheKey, { expiresAt: Date.now() + shortCacheTtlMs, payload: rawPayload }); res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=120'); + const payload = { ...rawPayload, data: rawPayload.data.map((c) => serializeCampaign(c, locales)) }; return res.set('x-cache', 'MISS').json(payload); } @@ -1034,7 +1118,7 @@ export async function createApp(options = {}) { if (!campaign) { return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); } - return res.json(campaign); + return res.json(serializeCampaign(campaign, getRequestLocales(req))); } /** @param {import('express').Request} req @param {import('express').Response} res */ @@ -1061,7 +1145,7 @@ export async function createApp(options = {}) { if (!campaign) { return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); } - return res.json(campaign); + return res.json(serializeCampaign(campaign, getRequestLocales(req))); } /** @param {import('express').Request} req @param {import('express').Response} res */ @@ -1142,7 +1226,7 @@ export async function createApp(options = {}) { } shortCache.clear(); - return res.status(201).json(campaign); + return res.status(201).json(serializeCampaign(campaign)); } catch (error) { if ( /** @type {any} */ (error).message?.includes('Tag') || @@ -1289,7 +1373,7 @@ export async function createApp(options = {}) { } shortCache.clear(); - return res.json(campaign); + return res.json(serializeCampaign(campaign)); } /** @param {import('express').Request} req @param {import('express').Response} res */ @@ -1353,7 +1437,7 @@ export async function createApp(options = {}) { }); shortCache.clear(); - return res.status(201).json(clonedCampaign); + return res.status(201).json(serializeCampaign(clonedCampaign)); } catch (error) { if (/** @type {any} */ (error).message?.includes('UNIQUE constraint failed')) { return res.status(409).json({ @@ -1398,7 +1482,7 @@ export async function createApp(options = {}) { }); shortCache.clear(); - return res.json(campaign); + return res.json(serializeCampaign(campaign)); } catch (error) { return res.status(400).json({ error: /** @type {Error} */ (error).message, @@ -1439,7 +1523,7 @@ export async function createApp(options = {}) { }); shortCache.clear(); - return res.json(campaign); + return res.json(serializeCampaign(campaign)); } catch (error) { return res.status(400).json({ error: /** @type {Error} */ (error).message, @@ -1785,7 +1869,7 @@ export async function createApp(options = {}) { app.get(`${prefix}/admin/audit/verify`, rateLimiter, requireMasterKey, verifyAuditChain); app.get(`${prefix}/indexer/cursor`, rateLimiter, getIndexerCursorState); app.post(`${prefix}/indexer/cursor`, rateLimiter, ...guard, setIndexerCursorState); - app.post(`${prefix}/campaigns`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), createCampaign); + app.post(`${prefix}/campaigns`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), contentModerationMiddleware, createCampaign); app.post(`${prefix}/campaigns/:id/clone`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), cloneCampaign); app.post(`${prefix}/campaigns/:id/image`, rateLimiter, ...guard, requireScope('campaigns:write'), (req, res, next) => { imageUpload.single('image')(req, res, (err) => { @@ -1799,7 +1883,7 @@ export async function createApp(options = {}) { return uploadCampaignImageHandler(req, res); }); }); - app.put(`${prefix}/campaigns/:id`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), updateCampaign); + app.put(`${prefix}/campaigns/:id`, rateLimiter, idempotencyMiddleware, ...guard, requireScope('campaigns:write'), contentModerationMiddleware, updateCampaign); app.delete(`${prefix}/campaigns/:id`, rateLimiter, ...guard, requireScope('campaigns:write'), deleteCampaign); app.put(`${prefix}/campaigns/:id`, rateLimiter, idempotencyMiddleware, requireApiKey, updateCampaign); app.put(`${prefix}/campaigns/:id/publish`, rateLimiter, idempotencyMiddleware, requireApiKey, publishCampaign); @@ -1810,6 +1894,93 @@ export async function createApp(options = {}) { app.put(`${prefix}/campaigns/:id/archive`, rateLimiter, idempotencyMiddleware, ...guard, archiveCampaign); app.delete(`${prefix}/campaigns/:id`, rateLimiter, ...guard, deleteCampaign); + // Campaign translations (i18n) + app.get(`${prefix}/campaigns/:id/translations`, rateLimiter, ...guard, (req, res) => { + const campaign = campaignRepository.getById(req.params.id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); + } + const translations = campaignRepository.getTranslations(req.params.id); + return res.json({ campaignId: campaign.id, translations }); + }); + + app.put( + `${prefix}/campaigns/:id/translations/:locale`, + rateLimiter, + idempotencyMiddleware, + ...guard, + requireScope('campaigns:write'), + (req, res) => { + const { locale } = req.params; + + if (!isValidLocale(locale)) { + return res.status(400).json({ + error: 'Invalid locale — must be a valid BCP-47 tag (e.g. es, fr, zh-CN)', + code: 'INVALID_LOCALE', + }); + } + + const campaign = campaignRepository.getById(req.params.id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); + } + + const { name, description } = req.body ?? {}; + if (!name && !description) { + return res.status(400).json({ + error: 'At least one of name or description is required', + code: 'VALIDATION_ERROR', + }); + } + + const translationPayload = {}; + if (name !== undefined) translationPayload.name = String(name); + if (description !== undefined) translationPayload.description = String(description); + + if (Buffer.byteLength(JSON.stringify(translationPayload), 'utf8') > 2048) { + return res.status(413).json({ + error: 'Translation exceeds the 2KB limit', + code: 'TRANSLATION_TOO_LARGE', + }); + } + + const current = campaignRepository.getTranslations(req.params.id); + const currentLocales = Object.keys(current); + if (!currentLocales.includes(locale) && currentLocales.length >= 10) { + return res.status(422).json({ + error: 'Maximum 10 locales per campaign', + code: 'LOCALE_LIMIT_EXCEEDED', + }); + } + + campaignRepository.upsertTranslation(req.params.id, locale, translationPayload); + shortCache.clear(); + + const updated = campaignRepository.getTranslations(req.params.id); + return res.json({ campaignId: campaign.id, locale, translation: updated[locale] }); + }, + ); + + app.delete( + `${prefix}/campaigns/:id/translations/:locale`, + rateLimiter, + ...guard, + requireScope('campaigns:write'), + (req, res) => { + const { locale } = req.params; + const campaign = campaignRepository.getById(req.params.id); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found', code: 'CAMPAIGN_NOT_FOUND' }); + } + const removed = campaignRepository.deleteTranslation(req.params.id, locale); + if (!removed) { + return res.status(404).json({ error: 'Locale not found', code: 'LOCALE_NOT_FOUND' }); + } + shortCache.clear(); + return res.status(204).end(); + }, + ); + app.post(`${prefix}/admin/api-keys`, rateLimiter, idempotencyMiddleware, requireMasterKey, createApiKeyHandler); app.get(`${prefix}/admin/api-keys`, rateLimiter, requireMasterKey, listApiKeysHandler); app.delete(`${prefix}/admin/api-keys/:id`, rateLimiter, requireMasterKey, revokeApiKeyHandler); @@ -1825,6 +1996,29 @@ export async function createApp(options = {}) { app.get(`${prefix}/admin/dashboard`, rateLimiter, requireMasterKey, getAdminDashboard); app.get(`${prefix}/admin/campaigns`, rateLimiter, requireMasterKey, listAdminCampaigns); + // Content moderation blocklist management + app.get(`${prefix}/admin/moderation/blocklist`, rateLimiter, requireMasterKey, (_req, res) => { + return res.json({ terms: moderationService.getTerms() }); + }); + app.post(`${prefix}/admin/moderation/blocklist`, rateLimiter, requireMasterKey, (req, res) => { + const { action, term } = req.body ?? {}; + if (!term || typeof term !== 'string' || !term.trim()) { + return res.status(400).json({ error: 'term is required', code: 'VALIDATION_ERROR' }); + } + if (action !== 'add' && action !== 'remove') { + return res.status(400).json({ + error: 'action must be "add" or "remove"', + code: 'VALIDATION_ERROR', + }); + } + if (action === 'add') { + moderationService.addTerm(term); + } else { + moderationService.removeTerm(term); + } + return res.json({ ok: true, terms: moderationService.getTerms() }); + }); + // Tenant usage metering (Issue #574) app.get(`${prefix}/usage`, rateLimiter, ...guard, (req, res) => { const orgId = req.auth?.orgId; diff --git a/backend/src/middleware/contentModeration.js b/backend/src/middleware/contentModeration.js new file mode 100644 index 00000000..d155505c --- /dev/null +++ b/backend/src/middleware/contentModeration.js @@ -0,0 +1,42 @@ +/** + * Content moderation middleware for campaign create/update. + * + * Checks name, description, and tags against the configured moderation + * provider. Admin (master-key) requests may pass `override_moderation: true` + * in the body to skip the check. + */ + +/** + * @param {{ + * moderationService: import('../moderation/moderationService.js').ReturnType, + * log: import('../middleware/logger.js').Logger, + * }} options + */ +export function createContentModerationMiddleware({ moderationService, log }) { + return async function contentModerationMiddleware(req, res, next) { + // Env-sourced API keys are operator-level (equivalent to admin); allow override. + if (req.body?.override_moderation === true && req.auth?.source === 'env') { + return next(); + } + + const { name, description, tags } = req.body ?? {}; + + try { + const result = await moderationService.check({ name, description, tags }); + + if (result.flagged) { + const campaignId = req.params?.id ?? 'new'; + log.info({ campaignId, categories: result.categories }, 'Content moderation flag'); + return res.status(422).json({ + error: 'Content violates community guidelines', + categories: result.categories, + }); + } + + return next(); + } catch (err) { + log.warn({ err }, 'Content moderation check failed, allowing request through'); + return next(); + } + }; +} diff --git a/backend/src/moderation/blocklist.json b/backend/src/moderation/blocklist.json new file mode 100644 index 00000000..065f21d1 --- /dev/null +++ b/backend/src/moderation/blocklist.json @@ -0,0 +1,14 @@ +{ + "terms": [ + "buy now", + "click here", + "free money", + "win now", + "you're a winner", + "urgent action required", + "verify your account", + "limited time offer", + "act now", + "make money fast" + ] +} diff --git a/backend/src/moderation/moderation.test.js b/backend/src/moderation/moderation.test.js new file mode 100644 index 00000000..a93d4796 --- /dev/null +++ b/backend/src/moderation/moderation.test.js @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import { createApp } from '../index.js'; +import { createModerationService } from './moderationService.js'; + +// ── Unit tests: moderationService ──────────────────────────────────────────── + +test('moderationService (local): flags content containing a blocked term', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam', 'buy now'] }); + const result = await svc.check({ name: 'Buy Now Exclusive', description: 'great deal' }); + assert.equal(result.flagged, true); + assert.deepEqual(result.categories, ['spam']); +}); + +test('moderationService (local): passes clean content', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam', 'buy now'] }); + const result = await svc.check({ name: 'Summer Referral', description: 'Earn points' }); + assert.equal(result.flagged, false); + assert.deepEqual(result.categories, []); +}); + +test('moderationService (local): checks description and tags too', async () => { + const svc = createModerationService({ provider: 'local', terms: ['free money'] }); + const result = await svc.check({ name: 'Good Name', description: 'Earn free money now', tags: [] }); + assert.equal(result.flagged, true); +}); + +test('moderationService (local): tags are checked', async () => { + const svc = createModerationService({ provider: 'local', terms: ['click here'] }); + const result = await svc.check({ name: 'Normal', description: 'Normal', tags: ['click here'] }); + assert.equal(result.flagged, true); +}); + +test('moderationService (none): always passes', async () => { + const svc = createModerationService({ provider: 'none', terms: ['spam'] }); + const result = await svc.check({ name: 'spam spam spam', description: 'buy now' }); + assert.equal(result.flagged, false); + assert.deepEqual(result.categories, []); +}); + +test('moderationService: addTerm and removeTerm update in-memory list', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam'] }); + + assert.equal((await svc.check({ name: 'new evil word' })).flagged, false); + svc.addTerm('evil word'); + assert.equal((await svc.check({ name: 'new evil word' })).flagged, true); + + svc.removeTerm('evil word'); + assert.equal((await svc.check({ name: 'new evil word' })).flagged, false); +}); + +test('moderationService: getTerms returns current list', () => { + const svc = createModerationService({ provider: 'local', terms: ['alpha', 'beta'] }); + assert.deepEqual(svc.getTerms().sort(), ['alpha', 'beta']); + svc.addTerm('gamma'); + assert.deepEqual(svc.getTerms().sort(), ['alpha', 'beta', 'gamma']); +}); + +// ── Integration tests: HTTP endpoints ──────────────────────────────────────── + +async function startTestServer(options = {}) { + const app = await createApp({ disableJobs: true, disableWebSocket: true, dbPath: ':memory:', ...options }); + const server = app.listen(0); + await once(server, 'listening'); + const { port } = server.address(); + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +async function stopTestServer(server) { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +} + +test('POST /api/v1/campaigns: blocked term returns 422 with categories', async () => { + const blockedSvc = createModerationService({ provider: 'local', terms: ['buy now'] }); + const { server, baseUrl } = await startTestServer({ + apiKey: 'test-key', + moderationService: blockedSvc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ + name: 'Buy Now and Win', + description: 'Great campaign', + rewardPerAction: 5, + }), + }); + assert.equal(res.status, 422); + const body = await res.json(); + assert.equal(body.error, 'Content violates community guidelines'); + assert.ok(Array.isArray(body.categories)); + assert.ok(body.categories.length > 0); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/campaigns: clean content passes moderation and creates campaign', async () => { + const cleanSvc = createModerationService({ provider: 'local', terms: ['buy now'] }); + const { server, baseUrl } = await startTestServer({ + apiKey: 'test-key', + moderationService: cleanSvc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ + name: 'Summer Referral Programme', + description: 'Earn points for every friend you refer', + rewardPerAction: 10, + }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(typeof body.id, 'string'); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/campaigns: override_moderation bypasses check for env-sourced (admin) API key', async () => { + const strictSvc = createModerationService({ provider: 'local', terms: ['buy now'] }); + // 'admin-key' is env-sourced (passed via apiKey option → source: 'env') + const { server, baseUrl } = await startTestServer({ + apiKey: 'admin-key', + moderationService: strictSvc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'admin-key' }, + body: JSON.stringify({ + name: 'Buy Now Admin Test', + description: 'Admin override test', + rewardPerAction: 1, + override_moderation: true, + }), + }); + assert.equal(res.status, 201); + const body = await res.json(); + assert.equal(typeof body.id, 'string'); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/admin/moderation/blocklist: add and remove terms', async () => { + const svc = createModerationService({ provider: 'local', terms: ['spam'] }); + const { server, baseUrl } = await startTestServer({ + masterKey: 'admin-key', + moderationService: svc, + }); + + try { + // Add a term + const addRes = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'admin-key' }, + body: JSON.stringify({ action: 'add', term: 'new-blocked-term' }), + }); + assert.equal(addRes.status, 200); + const addBody = await addRes.json(); + assert.ok(addBody.terms.includes('new-blocked-term')); + + // Remove the term + const removeRes = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'admin-key' }, + body: JSON.stringify({ action: 'remove', term: 'new-blocked-term' }), + }); + assert.equal(removeRes.status, 200); + const removeBody = await removeRes.json(); + assert.ok(!removeBody.terms.includes('new-blocked-term')); + } finally { + await stopTestServer(server); + } +}); + +test('GET /api/v1/admin/moderation/blocklist: returns current terms', async () => { + const svc = createModerationService({ provider: 'local', terms: ['alpha', 'beta'] }); + const { server, baseUrl } = await startTestServer({ + masterKey: 'admin-key', + moderationService: svc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + headers: { 'X-API-Key': 'admin-key' }, + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.terms)); + assert.deepEqual(body.terms.sort(), ['alpha', 'beta']); + } finally { + await stopTestServer(server); + } +}); + +test('POST /api/v1/admin/moderation/blocklist: requires master key', async () => { + const svc = createModerationService({ provider: 'local', terms: [] }); + const { server, baseUrl } = await startTestServer({ + apiKey: 'regular-key', + masterKey: 'admin-key', + moderationService: svc, + }); + + try { + const res = await fetch(`${baseUrl}/api/v1/admin/moderation/blocklist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'regular-key' }, + body: JSON.stringify({ action: 'add', term: 'test' }), + }); + assert.equal(res.status, 401); + } finally { + await stopTestServer(server); + } +}); diff --git a/backend/src/moderation/moderationService.js b/backend/src/moderation/moderationService.js new file mode 100644 index 00000000..3069ae89 --- /dev/null +++ b/backend/src/moderation/moderationService.js @@ -0,0 +1,138 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const DEFAULT_BLOCKLIST_PATH = join( + dirname(fileURLToPath(import.meta.url)), + 'blocklist.json', +); + +function loadBlocklistFile(path) { + try { + const raw = readFileSync(path, 'utf8'); + const data = JSON.parse(raw); + return Array.isArray(data.terms) ? data.terms.map((t) => String(t).toLowerCase().trim()) : []; + } catch { + return []; + } +} + +function saveBlocklistFile(path, terms) { + writeFileSync(path, JSON.stringify({ terms }, null, 2) + '\n', 'utf8'); +} + +async function checkOpenAI(input, fetchImpl, apiKey) { + try { + const response = await fetchImpl('https://api.openai.com/v1/moderations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ input }), + }); + + if (!response.ok) { + return { flagged: false, categories: [] }; + } + + const data = await response.json(); + const result = data.results?.[0]; + if (!result) { + return { flagged: false, categories: [] }; + } + + const categories = Object.entries(result.categories ?? {}) + .filter(([, hit]) => hit) + .map(([cat]) => cat); + + return { flagged: result.flagged === true, categories }; + } catch { + return { flagged: false, categories: [] }; + } +} + +/** + * @param {{ + * provider?: 'local' | 'openai' | 'none', + * terms?: string[] | null, + * blocklistPath?: string, + * fetchImpl?: typeof globalThis.fetch, + * openaiApiKey?: string, + * }} [options] + */ +export function createModerationService({ + provider = process.env.MODERATION_PROVIDER ?? 'local', + terms = null, + blocklistPath = DEFAULT_BLOCKLIST_PATH, + fetchImpl = globalThis.fetch, + openaiApiKey = process.env.OPENAI_API_KEY ?? '', +} = {}) { + // When terms are injected directly (tests / in-process overrides), skip file I/O. + const fileBacked = terms === null; + let currentTerms = fileBacked + ? loadBlocklistFile(blocklistPath) + : terms.map((t) => String(t).toLowerCase().trim()); + + /** + * @param {{ name?: string, description?: string, tags?: string[] }} fields + * @returns {Promise<{ flagged: boolean, categories: string[] }>} + */ + async function check({ name = '', description = '', tags = [] } = {}) { + if (provider === 'none') { + return { flagged: false, categories: [] }; + } + + const combined = [name, description, ...(Array.isArray(tags) ? tags : [])] + .filter(Boolean) + .join(' '); + + if (!combined.trim()) { + return { flagged: false, categories: [] }; + } + + if (provider === 'local') { + const lower = combined.toLowerCase(); + const hit = currentTerms.some((term) => lower.includes(term)); + return { flagged: hit, categories: hit ? ['spam'] : [] }; + } + + if (provider === 'openai') { + if (!openaiApiKey) { + return { flagged: false, categories: [] }; + } + return checkOpenAI(combined, fetchImpl, openaiApiKey); + } + + return { flagged: false, categories: [] }; + } + + /** @param {string} term */ + function addTerm(term) { + const normalized = String(term).toLowerCase().trim(); + if (!normalized || currentTerms.includes(normalized)) { + return; + } + currentTerms.push(normalized); + if (fileBacked) { + saveBlocklistFile(blocklistPath, currentTerms); + } + } + + /** @param {string} term */ + function removeTerm(term) { + const normalized = String(term).toLowerCase().trim(); + const before = currentTerms.length; + currentTerms = currentTerms.filter((t) => t !== normalized); + if (fileBacked && currentTerms.length !== before) { + saveBlocklistFile(blocklistPath, currentTerms); + } + } + + /** @returns {string[]} */ + function getTerms() { + return [...currentTerms]; + } + + return { check, addTerm, removeTerm, getTerms }; +} diff --git a/backend/src/routes/translations.test.js b/backend/src/routes/translations.test.js new file mode 100644 index 00000000..f931860d --- /dev/null +++ b/backend/src/routes/translations.test.js @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../db/migrate.js'; +import { createSqliteCampaignRepository } from '../dal/sqliteCampaignRepository.js'; +import { createApp, isValidLocale } from '../index.js'; + +// ── BCP-47 validation ───────────────────────────────────────────────────────── + +test('isValidLocale accepts common BCP-47 tags', () => { + assert.ok(isValidLocale('es')); + assert.ok(isValidLocale('fr')); + assert.ok(isValidLocale('zh')); + assert.ok(isValidLocale('zh-CN')); + assert.ok(isValidLocale('pt-BR')); + assert.ok(isValidLocale('en-US')); + assert.ok(isValidLocale('zh-Hant')); +}); + +test('isValidLocale rejects invalid locales', () => { + assert.ok(!isValidLocale('')); + assert.ok(!isValidLocale('e')); + assert.ok(!isValidLocale('english')); + assert.ok(!isValidLocale('en_US')); + assert.ok(!isValidLocale('EN')); + assert.ok(!isValidLocale('123')); +}); + +// ── Repository: translations CRUD ──────────────────────────────────────────── + +async function setupRepo() { + const db = new Database(':memory:'); + await runMigrations(db); + return createSqliteCampaignRepository({ db, seed: [] }); +} + +test('repository: getTranslations returns empty object for new campaign', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + assert.deepEqual(repo.getTranslations(campaign.id), {}); +}); + +test('repository: upsertTranslation stores and retrieves a translation', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + + repo.upsertTranslation(campaign.id, 'es', { name: 'Prueba', description: 'Descripción' }); + const translations = repo.getTranslations(campaign.id); + + assert.deepEqual(translations.es, { name: 'Prueba', description: 'Descripción' }); +}); + +test('repository: upsertTranslation overwrites existing locale', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + + repo.upsertTranslation(campaign.id, 'es', { name: 'Primera' }); + repo.upsertTranslation(campaign.id, 'es', { name: 'Segunda' }); + + const translations = repo.getTranslations(campaign.id); + assert.equal(translations.es.name, 'Segunda'); +}); + +test('repository: available_locales reflects stored translations', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + assert.deepEqual(campaign.available_locales, []); + + repo.upsertTranslation(campaign.id, 'fr', { name: 'Essai' }); + repo.upsertTranslation(campaign.id, 'es', { name: 'Prueba' }); + + const refreshed = repo.getById(campaign.id); + assert.ok(refreshed.available_locales.includes('fr')); + assert.ok(refreshed.available_locales.includes('es')); + assert.equal(refreshed.available_locales.length, 2); +}); + +test('repository: deleteTranslation removes a locale', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + + repo.upsertTranslation(campaign.id, 'es', { name: 'Prueba' }); + const removed = repo.deleteTranslation(campaign.id, 'es'); + assert.ok(removed); + assert.deepEqual(repo.getTranslations(campaign.id), {}); +}); + +test('repository: deleteTranslation returns false for missing locale', async () => { + const repo = await setupRepo(); + const campaign = repo.create({ name: 'Test', rewardPerAction: 0 }); + assert.equal(repo.deleteTranslation(campaign.id, 'es'), false); +}); + +// ── HTTP integration tests ──────────────────────────────────────────────────── + +async function startTestServer(options = {}) { + const app = await createApp({ + disableJobs: true, + disableWebSocket: true, + dbPath: ':memory:', + apiKey: 'test-key', + ...options, + }); + const server = app.listen(0); + await once(server, 'listening'); + const { port } = server.address(); + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +async function stop(server) { + await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +} + +async function createTestCampaign(baseUrl) { + const res = await fetch(`${baseUrl}/api/v1/campaigns`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'My Campaign', description: 'English desc', rewardPerAction: 5 }), + }); + return res.json(); +} + +test('PUT /campaigns/:id/translations/:locale stores a translation', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña', description: 'Descripción en español' }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.locale, 'es'); + assert.equal(body.translation.name, 'Mi Campaña'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id/translations returns all translations', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/fr`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Ma Campagne' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations`, { + headers: { 'X-API-Key': 'test-key' }, + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.translations.es); + assert.ok(body.translations.fr); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id returns available_locales', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}`); + const body = await res.json(); + assert.ok(Array.isArray(body.available_locales)); + assert.ok(body.available_locales.includes('es')); + assert.ok(!('_rawTranslations' in body)); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id?locale=es applies locale negotiation', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña', description: 'En español' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}?locale=es`); + const body = await res.json(); + assert.equal(body.name, 'Mi Campaña'); + assert.equal(body.description, 'En español'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id falls back to English for missing locale', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}?locale=de`); + const body = await res.json(); + assert.equal(body.name, 'My Campaign'); + assert.equal(body.description, 'English desc'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns/:id uses Accept-Language header for locale negotiation', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/fr`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Ma Campagne', description: 'En français' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}`, { + headers: { 'Accept-Language': 'fr,en;q=0.9' }, + }); + const body = await res.json(); + assert.equal(body.name, 'Ma Campagne'); + } finally { + await stop(server); + } +}); + +test('GET /campaigns list includes locale-negotiated names', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + + // publish so it appears in public list + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ status: 'published', contractId: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', active: true }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns?locale=es`); + const body = await res.json(); + const found = body.data.find((c) => c.id === campaign.id); + assert.ok(found); + assert.equal(found.name, 'Mi Campaña'); + assert.ok(!('_rawTranslations' in found)); + } finally { + await stop(server); + } +}); + +test('PUT /campaigns/:id/translations/:locale rejects invalid locale', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/not_valid`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'test' }), + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.code, 'INVALID_LOCALE'); + } finally { + await stop(server); + } +}); + +test('PUT /campaigns/:id/translations/:locale enforces 10-locale limit', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const locales = ['es', 'fr', 'de', 'it', 'pt', 'ja', 'ko', 'zh', 'ar', 'ru']; + + for (const locale of locales) { + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/${locale}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: `name in ${locale}` }), + }); + } + + // 11th locale should fail + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/nl`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Nederlands' }), + }); + assert.equal(res.status, 422); + const body = await res.json(); + assert.equal(body.code, 'LOCALE_LIMIT_EXCEEDED'); + } finally { + await stop(server); + } +}); + +test('PUT /campaigns/:id/translations/:locale enforces 2KB size limit', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'test', description: 'x'.repeat(2100) }), + }); + assert.equal(res.status, 413); + const body = await res.json(); + assert.equal(body.code, 'TRANSLATION_TOO_LARGE'); + } finally { + await stop(server); + } +}); + +test('?locale=es query param takes precedence over Accept-Language header', async () => { + const { server, baseUrl } = await startTestServer(); + try { + const campaign = await createTestCampaign(baseUrl); + + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/es`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Mi Campaña' }), + }); + await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}/translations/fr`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' }, + body: JSON.stringify({ name: 'Ma Campagne' }), + }); + + const res = await fetch(`${baseUrl}/api/v1/campaigns/${campaign.id}?locale=es`, { + headers: { 'Accept-Language': 'fr,en;q=0.9' }, + }); + const body = await res.json(); + assert.equal(body.name, 'Mi Campaña'); + } finally { + await stop(server); + } +}); diff --git a/backend/trivela.db b/backend/trivela.db index 405756f0..7c4786a9 100644 Binary files a/backend/trivela.db and b/backend/trivela.db differ diff --git a/package-lock.json b/package-lock.json index 5bcc39f6..fcb7836c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "zod": "^3.23.0" }, "devDependencies": { + "@eslint/js": "^9.0.0", "@readme/openapi-parser": "^2.6.0", "@redocly/cli": "^1.34.5", "@types/cors": "^2.8.0", @@ -56,12 +57,27 @@ "@types/supertest": "^6.0.0", "ajv": "^8.17.0", "ajv-formats": "^3.0.1", + "eslint": "^9.0.0", + "globals": "^15.0.0", "js-yaml": "^4.1.0", "nodemon": "^3.1.0", "redoc-cli": "^0.13.21", "typescript": "^5.0.0" } }, + "backend/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "frontend": { "version": "0.1.0", "license": "Apache-2.0", @@ -70,6 +86,7 @@ "@tanstack/react-virtual": "^3.14.3", "dompurify": "^3.1.0", "driver.js": "^1.3.1", + "qrcode.react": "^3.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-helmet-async": "^2.0.5", @@ -13742,6 +13759,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.2.0.tgz", + "integrity": "sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", diff --git a/trivela.db b/trivela.db new file mode 100644 index 00000000..08076951 Binary files /dev/null and b/trivela.db differ