diff --git a/backend/src/config/routeRateLimits.js b/backend/src/config/routeRateLimits.js new file mode 100644 index 00000000..c53c9cf4 --- /dev/null +++ b/backend/src/config/routeRateLimits.js @@ -0,0 +1,83 @@ +// @ts-check + +/** + * Per-route rate limits (#465). The global limiter applies one budget to every + * route; write-heavy endpoints need a tighter one than reads, and admin routes + * may need their own. A rule is `{ method, path, maxRequests, windowMs? }` + * where `path` is an Express-style template relative to the API version prefix + * (`/campaigns/:id/image`). A matching rule gets its own counter bucket and can + * only tighten the effective limit, never loosen it (so a per-API-key tier + * that is already stricter still wins). + */ + +/** @typedef {{ method: string, path: string, maxRequests: number, windowMs?: number }} RouteRateLimitRule */ + +/** @type {RouteRateLimitRule[]} */ +export const DEFAULT_ROUTE_RATE_LIMITS = [ + { method: 'POST', path: '/campaigns', maxRequests: 30 }, + { method: 'POST', path: '/campaigns/:id/clone', maxRequests: 20 }, + { method: 'POST', path: '/campaigns/:id/image', maxRequests: 10 }, + { method: 'PUT', path: '/campaigns/:id', maxRequests: 30 }, + { method: 'DELETE', path: '/campaigns/:id', maxRequests: 20 }, +]; + +/** @param {string} template */ +function templateToRegExp(template) { + const source = template + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/:[A-Za-z0-9_]+/g, '[^/]+'); + return new RegExp(`^${source}/?$`); +} + +/** Strips the `/api` or `/api/v1` prefix so rules are version-independent. */ +function stripApiPrefix(pathname) { + return pathname.replace(/^\/api(?:\/v\d+)?(?=\/|$)/, '') || '/'; +} + +/** + * Parses `RATE_LIMIT_ROUTES`, a JSON array of rules that replaces the defaults. + * Invalid entries are dropped rather than failing startup. + * @param {string | undefined} raw + * @returns {RouteRateLimitRule[] | null} + */ +export function parseRouteRateLimits(raw) { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return parsed.filter( + (rule) => + rule && + typeof rule.method === 'string' && + typeof rule.path === 'string' && + Number.isInteger(rule.maxRequests) && + rule.maxRequests > 0 && + (rule.windowMs === undefined || (Number.isInteger(rule.windowMs) && rule.windowMs > 0)), + ); + } catch { + return null; + } +} + +/** + * Builds a resolver `(req) => { id, maxRequests, windowMs? } | null`. + * @param {RouteRateLimitRule[]} [rules] + */ +export function createRouteRateLimitResolver(rules = DEFAULT_ROUTE_RATE_LIMITS) { + const compiled = rules.map((rule) => ({ + method: rule.method.toUpperCase(), + matcher: templateToRegExp(rule.path), + id: `${rule.method.toUpperCase()} ${rule.path}`, + maxRequests: rule.maxRequests, + windowMs: rule.windowMs, + })); + + return function resolveRouteLimit(req) { + const pathname = stripApiPrefix((req.originalUrl || req.url || '').split('?')[0]); + const method = String(req.method || '').toUpperCase(); + const match = compiled.find((rule) => rule.method === method && rule.matcher.test(pathname)); + return match + ? { id: match.id, maxRequests: match.maxRequests, windowMs: match.windowMs } + : null; + }; +} diff --git a/backend/src/config/routeRateLimits.test.js b/backend/src/config/routeRateLimits.test.js new file mode 100644 index 00000000..9e517a57 --- /dev/null +++ b/backend/src/config/routeRateLimits.test.js @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createRouteRateLimitResolver, + DEFAULT_ROUTE_RATE_LIMITS, + parseRouteRateLimits, +} from './routeRateLimits.js'; +import { createRateLimiter } from '../middleware/rateLimit.js'; + +const req = (method, url, extra = {}) => ({ + method, + originalUrl: url, + headers: {}, + query: {}, + ip: '10.0.0.1', + ...extra, +}); + +function run(limiter, request) { + return new Promise((resolve) => { + const headers = {}; + const res = { + setHeader: (k, v) => { + headers[k] = v; + }, + status(code) { + this.code = code; + return this; + }, + json(body) { + resolve({ status: this.code, body, headers }); + }, + }; + limiter(request, res, () => resolve({ status: 200, headers })); + }); +} + +test('resolver matches method + templated path, ignoring version prefix and query', () => { + const resolve = createRouteRateLimitResolver(DEFAULT_ROUTE_RATE_LIMITS); + assert.equal(resolve(req('POST', '/api/v1/campaigns/42/image?x=1'))?.maxRequests, 10); + assert.equal(resolve(req('POST', '/api/campaigns'))?.maxRequests, 30); + assert.equal(resolve(req('GET', '/api/v1/campaigns')), null); + assert.equal(resolve(req('POST', '/api/v1/campaigns/42/other')), null); +}); + +test('parseRouteRateLimits drops invalid entries and rejects non-arrays', () => { + const rules = parseRouteRateLimits( + JSON.stringify([ + { method: 'POST', path: '/x', maxRequests: 5 }, + { method: 'POST', path: '/y', maxRequests: 0 }, + { path: '/z', maxRequests: 3 }, + ]), + ); + assert.deepEqual(rules, [{ method: 'POST', path: '/x', maxRequests: 5 }]); + assert.equal(parseRouteRateLimits('{"a":1}'), null); + assert.equal(parseRouteRateLimits('not json'), null); + assert.equal(parseRouteRateLimits(undefined), null); +}); + +test('a write route is throttled harder than reads, from its own bucket', async () => { + const limiter = createRateLimiter({ + maxRequests: 100, + windowMs: 60_000, + timeProvider: () => 1_000, + resolveRouteLimit: createRouteRateLimitResolver([ + { method: 'POST', path: '/campaigns/:id/image', maxRequests: 2 }, + ]), + }); + + const upload = () => run(limiter, req('POST', '/api/v1/campaigns/1/image')); + assert.equal((await upload()).status, 200); + assert.equal((await upload()).status, 200); + const blocked = await upload(); + assert.equal(blocked.status, 429); + assert.equal(blocked.body.limit, 2); + assert.equal(blocked.headers['X-RateLimit-Limit'], '2'); + + // Reads from the same client are unaffected and still use the global budget. + const read = await run(limiter, req('GET', '/api/v1/campaigns')); + assert.equal(read.status, 200); + assert.equal(read.headers['X-RateLimit-Limit'], '100'); +}); + +test('a route limit never loosens a stricter global/tier limit', async () => { + const limiter = createRateLimiter({ + maxRequests: 1, + windowMs: 60_000, + timeProvider: () => 1_000, + resolveRouteLimit: createRouteRateLimitResolver([ + { method: 'POST', path: '/campaigns', maxRequests: 50 }, + ]), + }); + + assert.equal((await run(limiter, req('POST', '/api/v1/campaigns'))).status, 200); + assert.equal((await run(limiter, req('POST', '/api/v1/campaigns'))).status, 429); +}); diff --git a/backend/src/index.js b/backend/src/index.js index ff484bba..67491eae 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -31,6 +31,11 @@ import { createRpcPool } from './rpcPool.js'; import { resolveStellarNetworkConfig } from './config/stellarNetwork.js'; import { validateBackendEnv } from './config/envValidation.js'; import { getRateTierLimits, DEFAULT_RATE_TIER } from './config/rateTiers.js'; +import { + createRouteRateLimitResolver, + DEFAULT_ROUTE_RATE_LIMITS, + parseRouteRateLimits, +} from './config/routeRateLimits.js'; import { createDal } from './dal/index.js'; import { createJobRunner } from './jobs/jobRunner.js'; import { WebhookService, WEBHOOK_EVENTS } from './services/webhookService.js'; @@ -718,6 +723,11 @@ export async function createApp(options = {}) { timeProvider: /** @type {any} */ (options.rateLimit)?.timeProvider, store: rateLimitStore, resolveLimits: resolveRateLimitForRequest, + resolveRouteLimit: createRouteRateLimitResolver( + /** @type {any} */ (options.rateLimit)?.routes ?? + parseRouteRateLimits(process.env.RATE_LIMIT_ROUTES) ?? + DEFAULT_ROUTE_RATE_LIMITS, + ), resolveMonthlyQuota: resolveMonthlyQuotaForRequest, getMonthlyUsage: getMonthlyUsageForRequest, incrementMonthlyUsage: incrementMonthlyUsageForRequest, @@ -3357,6 +3367,8 @@ export async function createApp(options = {}) { prefix, rateLimiter, createIpfsPinRoutes({ getService: () => ipfsPinService, campaignRepository, guard }), + ); + // #1258 — fraud flag review (master key) app.use( `${prefix}/admin/fraud`, diff --git a/backend/src/lib/logger.js b/backend/src/lib/logger.js new file mode 100644 index 00000000..9df606ca --- /dev/null +++ b/backend/src/lib/logger.js @@ -0,0 +1 @@ +export { log as logger } from '../middleware/logger.js'; diff --git a/backend/src/middleware/rateLimit.js b/backend/src/middleware/rateLimit.js index dda45e22..e75289d6 100644 --- a/backend/src/middleware/rateLimit.js +++ b/backend/src/middleware/rateLimit.js @@ -110,6 +110,12 @@ export function createRateLimiter({ // request, returns `{ maxRequests, windowMs }` to use instead of the static // defaults above, or a falsy value to fall back to them. Sync or async. resolveLimits = null, + // Optional per-route limits (#465). Given the request, returns + // `{ id, maxRequests, windowMs? }` for a matching route rule, or a falsy + // value. A match gets its own counter bucket (so a hot write endpoint does + // not consume the client's read budget) and can only tighten the limit that + // would otherwise apply — never loosen it. + resolveRouteLimit = null, // Optional monthly quota hooks (#759), independent of the windowed // maxRequests/windowMs check above: the window check limits burst/sustained // rate, this limits total volume over a calendar month regardless of how @@ -132,10 +138,14 @@ export function createRateLimiter({ return async function rateLimit(req, res, next) { try { const now = timeProvider(); - const key = keyGenerator(req); + const routeLimit = resolveRouteLimit ? resolveRouteLimit(req) : null; + const key = routeLimit ? `${keyGenerator(req)}|route:${routeLimit.id}` : keyGenerator(req); const override = resolveLimits ? await resolveLimits(req) : null; - const effectiveMaxRequests = override?.maxRequests ?? maxRequests; - const effectiveWindowMs = override?.windowMs ?? windowMs; + const tierMaxRequests = override?.maxRequests ?? maxRequests; + const effectiveMaxRequests = routeLimit + ? Math.min(tierMaxRequests, routeLimit.maxRequests) + : tierMaxRequests; + const effectiveWindowMs = routeLimit?.windowMs ?? override?.windowMs ?? windowMs; const { count, resetAt } = await rateLimitStore.increment(key, effectiveWindowMs, now); diff --git a/backend/src/moderation/moderation.test.js b/backend/src/moderation/moderation.test.js index 4006dd17..75cbec13 100644 --- a/backend/src/moderation/moderation.test.js +++ b/backend/src/moderation/moderation.test.js @@ -230,3 +230,56 @@ test('POST /api/v1/admin/moderation/blocklist: requires master key', async () => await stopTestServer(server); } }); + +// ── Unit tests: built-in spam/phishing heuristics (#469) ───────────────────── + +test('moderationService (local): flags link-stuffed and shortener content as spam', async () => { + const svc = createModerationService({ provider: 'local', terms: [] }); + const stuffed = await svc.check({ + name: 'Rewards', + description: 'see http://a.example http://b.example http://c.example http://d.example', + }); + assert.equal(stuffed.flagged, true); + assert.deepEqual(stuffed.categories, ['spam']); + + const short = await svc.check({ name: 'Win big', description: 'claim at bit.ly/xyz123' }); + assert.equal(short.flagged, true); +}); + +test('moderationService (local): flags repeated-character runs and shouting', async () => { + const svc = createModerationService({ provider: 'local', terms: [] }); + assert.equal((await svc.check({ name: 'Heyyyyyyyyyyyy', description: 'x' })).flagged, true); + assert.equal( + (await svc.check({ name: 'EARN FREE POINTS RIGHT NOW', description: 'LIMITED TIME ONLY' })) + .flagged, + true, + ); +}); + +test('moderationService (local): flags wallet phishing lures', async () => { + const svc = createModerationService({ provider: 'local', terms: [] }); + const result = await svc.check({ + name: 'Airdrop', + description: 'Enter your seed phrase to claim your reward', + }); + assert.equal(result.flagged, true); + assert.deepEqual(result.categories, ['phishing']); +}); + +test('moderationService (local): ordinary campaign copy is not flagged by heuristics', async () => { + const svc = createModerationService({ provider: 'local', terms: [] }); + const result = await svc.check({ + name: 'Summer Referral Campaign', + description: 'Invite friends and earn points. Details at https://example.com/rules', + tags: ['referral', 'summer'], + }); + assert.equal(result.flagged, false); +}); + +test('moderationService (none): heuristics are skipped', async () => { + const svc = createModerationService({ provider: 'none', terms: [] }); + assert.equal( + (await svc.check({ name: 'seed phrase', description: 'private key' })).flagged, + false, + ); +}); diff --git a/backend/src/moderation/moderationService.js b/backend/src/moderation/moderationService.js index 28f7f6b0..5ac577c7 100644 --- a/backend/src/moderation/moderationService.js +++ b/backend/src/moderation/moderationService.js @@ -18,6 +18,53 @@ function saveBlocklistFile(path, terms) { writeFileSync(path, JSON.stringify({ terms }, null, 2) + '\n', 'utf8'); } +const URL_PATTERN = /\b(?:https?:\/\/|www\.)\S+/gi; +const SHORTENER_PATTERN = /\b(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl|is\.gd|cutt\.ly|rb\.gy)\//i; +const PHISHING_PATTERNS = [ + /\b(?:seed|recovery|secret)\s+(?:phrase|words)\b/i, + /\bprivate\s+key\b/i, + /\b(?:send|share|enter|give)\b[^.]{0,40}\b(?:secret key|seed phrase|private key|password)\b/i, + /\bverify\s+your\s+wallet\b/i, + /\bwallet\s+(?:has been\s+)?(?:suspended|compromised|locked)\b/i, +]; +const MAX_LINKS = 3; +const REPEATED_CHAR_RUN = /(.)\1{9,}/u; +const MIN_SHOUTING_LETTERS = 20; + +/** + * Built-in spam/phishing heuristics (#469) that run on top of the blocklist + * for the `local` provider, so obvious abuse is caught with no external + * service. Intentionally conservative: each rule needs a strong signal. + * @param {string} text + * @returns {string[]} categories hit ('spam' | 'phishing') + */ +export function detectSpamSignals(text) { + const categories = new Set(); + + const links = text.match(URL_PATTERN) ?? []; + if (links.length > MAX_LINKS || SHORTENER_PATTERN.test(text)) { + categories.add('spam'); + } + + if (REPEATED_CHAR_RUN.test(text)) { + categories.add('spam'); + } + + const letters = text.replace(/[^A-Za-z]/g, ''); + if (letters.length >= MIN_SHOUTING_LETTERS) { + const upper = letters.replace(/[^A-Z]/g, '').length; + if (upper / letters.length > 0.8) { + categories.add('spam'); + } + } + + if (PHISHING_PATTERNS.some((pattern) => pattern.test(text))) { + categories.add('phishing'); + } + + return [...categories]; +} + async function checkOpenAI(input, fetchImpl, apiKey) { try { const response = await fetchImpl('https://api.openai.com/v1/moderations', { @@ -90,8 +137,10 @@ export function createModerationService({ if (provider === 'local') { const lower = combined.toLowerCase(); - const hit = currentTerms.some((term) => lower.includes(term)); - return { flagged: hit, categories: hit ? ['spam'] : [] }; + const blocklistHit = currentTerms.some((term) => lower.includes(term)); + const categories = new Set(detectSpamSignals(combined)); + if (blocklistHit) categories.add('spam'); + return { flagged: categories.size > 0, categories: [...categories] }; } if (provider === 'openai') { diff --git a/backend/src/services/sorobanTransactionBuilder.js b/backend/src/services/sorobanTransactionBuilder.js new file mode 100644 index 00000000..6f29d3fb --- /dev/null +++ b/backend/src/services/sorobanTransactionBuilder.js @@ -0,0 +1,244 @@ +import { Account, BASE_FEE, Contract, TransactionBuilder, rpc } from '@stellar/stellar-sdk'; + +const DEFAULT_TIMEOUT_SECONDS = 60; +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 500; +const DEFAULT_POLL_INTERVAL_MS = 1_000; +const DEFAULT_POLL_ATTEMPTS = 30; + +/** Thrown when any stage of the build → simulate → assemble → sign → submit lifecycle fails. */ +export class SorobanTransactionError extends Error { + /** + * @param {string} message + * @param {{ stage: string, retryable?: boolean, cause?: unknown, hash?: string }} details + */ + constructor(message, { stage, retryable = false, cause, hash }) { + super(message); + this.name = 'SorobanTransactionError'; + this.stage = stage; + this.retryable = retryable; + this.hash = hash; + if (cause) this.cause = cause; + } +} + +const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Shared Soroban transaction construction (#460): one place for the + * simulate → assemble → sign → submit lifecycle and its retry rules, so + * backend routes and admin scripts stop re-implementing it. + * + * @param {{ + * server: { + * getAccount(id: string): Promise, + * simulateTransaction(tx: any): Promise, + * sendTransaction(tx: any): Promise, + * getTransaction(hash: string): Promise, + * }, + * networkPassphrase: string, + * maxAttempts?: number, + * retryDelayMs?: number, + * pollIntervalMs?: number, + * pollAttempts?: number, + * sleep?: (ms: number) => Promise, + * assemble?: (tx: any, simulation: any) => { build(): any }, + * }} options + */ +export function createSorobanTransactionBuilder({ + server, + networkPassphrase, + maxAttempts = DEFAULT_MAX_ATTEMPTS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + pollAttempts = DEFAULT_POLL_ATTEMPTS, + sleep = sleepFor, + assemble = (tx, simulation) => rpc.assembleTransaction(tx, simulation), +}) { + if (!server) throw new TypeError('server is required'); + if (!networkPassphrase) throw new TypeError('networkPassphrase is required'); + + /** + * Builds an unsigned contract-call transaction for `sourcePublicKey`. + * @param {{ + * sourcePublicKey: string, + * contractId: string, + * method: string, + * args?: any[], + * fee?: string, + * timeoutSeconds?: number, + * }} params + */ + async function build({ + sourcePublicKey, + contractId, + method, + args = [], + fee = BASE_FEE, + timeoutSeconds = DEFAULT_TIMEOUT_SECONDS, + }) { + if (!sourcePublicKey || !contractId || !method) { + throw new SorobanTransactionError('sourcePublicKey, contractId and method are required', { + stage: 'build', + }); + } + + let account; + try { + const loaded = await server.getAccount(sourcePublicKey); + account = new Account( + sourcePublicKey, + loaded.sequenceNumber ? loaded.sequenceNumber() : loaded.sequence, + ); + } catch (cause) { + throw new SorobanTransactionError( + `Could not load source account: ${cause?.message ?? cause}`, + { + stage: 'build', + retryable: true, + cause, + }, + ); + } + + try { + return new TransactionBuilder(account, { fee, networkPassphrase }) + .addOperation(new Contract(contractId).call(method, ...args)) + .setTimeout(timeoutSeconds) + .build(); + } catch (cause) { + throw new SorobanTransactionError(`Could not build transaction: ${cause?.message ?? cause}`, { + stage: 'build', + cause, + }); + } + } + + /** Simulates `tx` and returns the simulation, throwing on a simulation error. */ + async function simulate(tx) { + let simulation; + try { + simulation = await server.simulateTransaction(tx); + } catch (cause) { + throw new SorobanTransactionError(`Simulation request failed: ${cause?.message ?? cause}`, { + stage: 'simulate', + retryable: true, + cause, + }); + } + if (simulation?.error) { + throw new SorobanTransactionError(`Simulation failed: ${simulation.error}`, { + stage: 'simulate', + retryable: false, + }); + } + return simulation; + } + + /** Applies the simulation's footprint/resource fee to `tx`. */ + function assembleTx(tx, simulation) { + try { + return assemble(tx, simulation).build(); + } catch (cause) { + throw new SorobanTransactionError( + `Could not assemble transaction: ${cause?.message ?? cause}`, + { + stage: 'assemble', + cause, + }, + ); + } + } + + /** Signs `tx` with a Keypair-like signer (`sign(tx)`), or an async `(tx) => tx` function. */ + async function sign(tx, signer) { + try { + if (typeof signer === 'function') { + return (await signer(tx)) ?? tx; + } + tx.sign(signer); + return tx; + } catch (cause) { + throw new SorobanTransactionError(`Could not sign transaction: ${cause?.message ?? cause}`, { + stage: 'sign', + cause, + }); + } + } + + /** Submits `tx` and polls until it leaves NOT_FOUND. Returns the final transaction result. */ + async function submit(tx) { + let sent; + try { + sent = await server.sendTransaction(tx); + } catch (cause) { + throw new SorobanTransactionError(`Submission failed: ${cause?.message ?? cause}`, { + stage: 'submit', + retryable: true, + cause, + }); + } + + if (sent.status === 'ERROR') { + throw new SorobanTransactionError('Transaction rejected by the network', { + stage: 'submit', + hash: sent.hash, + }); + } + if (sent.status === 'TRY_AGAIN_LATER') { + throw new SorobanTransactionError('Network asked to try again later', { + stage: 'submit', + retryable: true, + hash: sent.hash, + }); + } + + for (let attempt = 0; attempt < pollAttempts; attempt += 1) { + const result = await server.getTransaction(sent.hash); + if (result.status === 'SUCCESS') { + return { hash: sent.hash, ...result }; + } + if (result.status === 'FAILED') { + throw new SorobanTransactionError('Transaction failed on-chain', { + stage: 'submit', + hash: sent.hash, + }); + } + await sleep(pollIntervalMs); + } + + throw new SorobanTransactionError('Timed out waiting for transaction confirmation', { + stage: 'submit', + retryable: false, + hash: sent.hash, + }); + } + + /** + * Full lifecycle for one contract call. Retryable failures (RPC hiccups, + * TRY_AGAIN_LATER) rebuild the transaction from a fresh sequence number up + * to `maxAttempts` times with linear backoff; non-retryable ones fail fast. + * @param {Parameters[0] & { signer: any }} params + */ + async function invoke({ signer, ...params }) { + let lastError; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const built = await build(params); + const simulation = await simulate(built); + const assembled = assembleTx(built, simulation); + const signed = await sign(assembled, signer); + const result = await submit(signed); + return { ...result, attempts: attempt, simulation }; + } catch (error) { + lastError = error; + const retryable = error instanceof SorobanTransactionError && error.retryable; + if (!retryable || attempt === maxAttempts) break; + await sleep(retryDelayMs * attempt); + } + } + throw lastError; + } + + return { build, simulate, assemble: assembleTx, sign, submit, invoke }; +} diff --git a/backend/src/services/sorobanTransactionBuilder.test.js b/backend/src/services/sorobanTransactionBuilder.test.js new file mode 100644 index 00000000..e7b2fda2 --- /dev/null +++ b/backend/src/services/sorobanTransactionBuilder.test.js @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Keypair, Networks, StrKey } from '@stellar/stellar-sdk'; +import { + createSorobanTransactionBuilder, + SorobanTransactionError, +} from './sorobanTransactionBuilder.js'; + +const CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 7)); +const source = Keypair.random(); + +function makeServer(overrides = {}) { + const calls = { getAccount: 0, simulate: 0, send: 0, get: 0 }; + const server = { + calls, + async getAccount() { + calls.getAccount += 1; + return { sequenceNumber: () => '100' }; + }, + async simulateTransaction() { + calls.simulate += 1; + return {}; + }, + async sendTransaction() { + calls.send += 1; + return { status: 'PENDING', hash: 'abc123' }; + }, + async getTransaction() { + calls.get += 1; + return { status: 'SUCCESS', ledger: 7 }; + }, + ...overrides, + }; + return server; +} + +const builderFor = (server, extra = {}) => + createSorobanTransactionBuilder({ + server, + networkPassphrase: Networks.TESTNET, + sleep: async () => {}, + assemble: (tx) => ({ build: () => tx }), + ...extra, + }); + +const params = () => ({ + sourcePublicKey: source.publicKey(), + contractId: CONTRACT_ID, + method: 'register', + args: [], + signer: source, +}); + +test('invoke runs build → simulate → assemble → sign → submit once on success', async () => { + const server = makeServer(); + const result = await builderFor(server).invoke(params()); + + assert.equal(result.hash, 'abc123'); + assert.equal(result.status, 'SUCCESS'); + assert.equal(result.attempts, 1); + assert.deepEqual([server.calls.getAccount, server.calls.simulate, server.calls.send], [1, 1, 1]); +}); + +test('build produces a signed-able contract call for the source account', async () => { + const tx = await builderFor(makeServer()).build(params()); + assert.equal(tx.source, source.publicKey()); + assert.equal(tx.operations.length, 1); + assert.equal(tx.operations[0].type, 'invokeHostFunction'); +}); + +test('build rejects missing parameters', async () => { + await assert.rejects( + builderFor(makeServer()).build({ sourcePublicKey: source.publicKey() }), + (err) => err instanceof SorobanTransactionError && err.stage === 'build', + ); +}); + +test('a simulation error fails fast without retrying or submitting', async () => { + const server = makeServer({ + async simulateTransaction() { + server.calls.simulate += 1; + return { error: 'HostError: contract trapped' }; + }, + }); + + await assert.rejects( + builderFor(server).invoke(params()), + (err) => err.stage === 'simulate' && err.retryable === false, + ); + assert.equal(server.calls.simulate, 1); + assert.equal(server.calls.send, 0); +}); + +test('retryable submission errors are retried with a fresh build, then succeed', async () => { + let sends = 0; + const server = makeServer({ + async sendTransaction() { + sends += 1; + server.calls.send += 1; + return sends < 3 + ? { status: 'TRY_AGAIN_LATER', hash: 'h' } + : { status: 'PENDING', hash: 'ok' }; + }, + }); + + const result = await builderFor(server).invoke(params()); + assert.equal(result.hash, 'ok'); + assert.equal(result.attempts, 3); + assert.equal(server.calls.getAccount, 3); +}); + +test('gives up after maxAttempts retryable failures', async () => { + const server = makeServer({ + async sendTransaction() { + server.calls.send += 1; + return { status: 'TRY_AGAIN_LATER', hash: 'h' }; + }, + }); + + await assert.rejects( + builderFor(server, { maxAttempts: 2 }).invoke(params()), + (err) => err.retryable, + ); + assert.equal(server.calls.send, 2); +}); + +test('an on-chain FAILED result is not retried and carries the hash', async () => { + const server = makeServer({ + async getTransaction() { + return { status: 'FAILED' }; + }, + }); + + await assert.rejects( + builderFor(server).invoke(params()), + (err) => err.stage === 'submit' && err.hash === 'abc123' && err.retryable === false, + ); + assert.equal(server.calls.send, 1); +}); + +test('polls past NOT_FOUND until the transaction is confirmed', async () => { + let polls = 0; + const server = makeServer({ + async getTransaction() { + polls += 1; + return polls < 3 ? { status: 'NOT_FOUND' } : { status: 'SUCCESS' }; + }, + }); + + const result = await builderFor(server).invoke(params()); + assert.equal(result.status, 'SUCCESS'); + assert.equal(polls, 3); +}); + +test('sign accepts an async signer function', async () => { + const tx = await builderFor(makeServer()).build(params()); + let seen = null; + const signed = await builderFor(makeServer()).sign(tx, async (t) => { + seen = t; + return t; + }); + assert.equal(seen, tx); + assert.equal(signed, tx); +}); diff --git a/frontend/index.html b/frontend/index.html index 4276fef4..22eb7d03 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,6 +15,22 @@
+ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5d0ee5ab..c7d233d0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,7 +25,13 @@ const EmbedCampaign = lazy(() => import('./pages/EmbedCampaign')); const PublicProfile = lazy(() => import('./pages/PublicProfile')); const UserProfile = lazy(() => import('./pages/UserProfile')); const WebhookManagement = lazy(() => import('./pages/WebhookManagement')); -import { applyTheme, getPreferredTheme, THEME_STORAGE_KEY } from './theme'; +import { + applyTheme, + getPreferredTheme, + getStoredTheme, + persistTheme, + subscribeToSystemTheme, +} from './theme'; import { getRuntimeConfig, initializeRuntimeConfig, setRuntimeStellarNetwork } from './config'; import { connectWallet as connectWalletProvider, @@ -54,12 +60,20 @@ export default function App() { useEffect(() => { applyTheme(theme); - - if (typeof window !== 'undefined') { - window.localStorage.setItem(THEME_STORAGE_KEY, theme); - } }, [theme]); + // Follow the OS preference until the user picks a theme themselves; an + // explicit choice (saved by toggleTheme) always wins. + useEffect( + () => + subscribeToSystemTheme((systemTheme) => { + if (!getStoredTheme()) { + setTheme(systemTheme); + } + }), + [], + ); + useEffect(() => { let cancelled = false; @@ -81,7 +95,9 @@ export default function App() { }, []); const toggleTheme = () => { - setTheme((currentTheme) => (currentTheme === 'dark' ? 'light' : 'dark')); + const nextTheme = theme === 'dark' ? 'light' : 'dark'; + persistTheme(nextTheme); + setTheme(nextTheme); }; const loadWalletBalance = async (address) => { diff --git a/frontend/src/theme.js b/frontend/src/theme.js index d1056a52..4b6580aa 100644 --- a/frontend/src/theme.js +++ b/frontend/src/theme.js @@ -1,18 +1,69 @@ export const THEME_STORAGE_KEY = 'trivela-theme'; -export function getPreferredTheme() { +const isTheme = (value) => value === 'light' || value === 'dark'; + +/** Returns the theme the user explicitly chose, or null if they never did (or storage is unavailable). */ +export function getStoredTheme() { if (typeof window === 'undefined') { - return 'dark'; + return null; } - const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); - if (storedTheme === 'light' || storedTheme === 'dark') { - return storedTheme; + try { + const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); + return isTheme(storedTheme) ? storedTheme : null; + } catch { + return null; + } +} + +/** Returns the operating-system preference (`prefers-color-scheme`), defaulting to dark. */ +export function getSystemTheme() { + if (typeof window === 'undefined') { + return 'dark'; } return window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; } +/** Stored choice first, then the system preference. */ +export function getPreferredTheme() { + return getStoredTheme() ?? getSystemTheme(); +} + +/** Saves an explicit user choice. Storage failures (private mode, quota) are ignored. */ +export function persistTheme(theme) { + if (typeof window === 'undefined' || !isTheme(theme)) { + return; + } + + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // The theme still applies for this session. + } +} + +/** + * Calls `onChange('light' | 'dark')` whenever the OS preference changes. + * Returns an unsubscribe function. + */ +export function subscribeToSystemTheme(onChange) { + if (typeof window === 'undefined' || !window.matchMedia) { + return () => {}; + } + + const query = window.matchMedia('(prefers-color-scheme: light)'); + const handler = (event) => onChange(event.matches ? 'light' : 'dark'); + + if (query.addEventListener) { + query.addEventListener('change', handler); + return () => query.removeEventListener('change', handler); + } + + query.addListener?.(handler); + return () => query.removeListener?.(handler); +} + export function applyTheme(theme) { if (typeof document === 'undefined') { return; diff --git a/frontend/src/theme.test.js b/frontend/src/theme.test.js new file mode 100644 index 00000000..c2485200 --- /dev/null +++ b/frontend/src/theme.test.js @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + THEME_STORAGE_KEY, + applyTheme, + getPreferredTheme, + getStoredTheme, + persistTheme, + subscribeToSystemTheme, +} from './theme'; + +function mockMatchMedia(prefersLight) { + const listeners = new Set(); + const query = { + matches: prefersLight, + addEventListener: (_event, fn) => listeners.add(fn), + removeEventListener: (_event, fn) => listeners.delete(fn), + }; + window.matchMedia = vi.fn(() => query); + return { + emit: (matches) => { + query.matches = matches; + listeners.forEach((fn) => fn({ matches })); + }, + listeners, + }; +} + +describe('theme persistence and system detection', () => { + beforeEach(() => { + window.localStorage.clear(); + delete document.documentElement.dataset.theme; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses the OS preference when nothing is stored', () => { + mockMatchMedia(true); + expect(getStoredTheme()).toBeNull(); + expect(getPreferredTheme()).toBe('light'); + + mockMatchMedia(false); + expect(getPreferredTheme()).toBe('dark'); + }); + + it('prefers a stored choice over the OS preference', () => { + mockMatchMedia(true); + persistTheme('dark'); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark'); + expect(getPreferredTheme()).toBe('dark'); + }); + + it('ignores invalid stored values', () => { + mockMatchMedia(false); + window.localStorage.setItem(THEME_STORAGE_KEY, 'purple'); + expect(getStoredTheme()).toBeNull(); + persistTheme('purple'); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('purple'); + }); + + it('survives unavailable storage', () => { + mockMatchMedia(true); + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('denied'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('denied'); + }); + expect(getPreferredTheme()).toBe('light'); + expect(() => persistTheme('dark')).not.toThrow(); + }); + + it('notifies on OS preference changes and stops after unsubscribe', () => { + const media = mockMatchMedia(false); + const onChange = vi.fn(); + const unsubscribe = subscribeToSystemTheme(onChange); + + media.emit(true); + expect(onChange).toHaveBeenLastCalledWith('light'); + media.emit(false); + expect(onChange).toHaveBeenLastCalledWith('dark'); + + unsubscribe(); + expect(media.listeners.size).toBe(0); + }); + + it('applyTheme sets the data attribute and color scheme', () => { + applyTheme('light'); + expect(document.documentElement.dataset.theme).toBe('light'); + expect(document.documentElement.style.colorScheme).toBe('light'); + }); +});