Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1044afe
fix(security): remove unsafe-eval from Content-Security-Policy script…
hman38705 Jun 17, 2026
fb2374a
fix(security): remove unsafe-inline from CSP script-src and add stric…
hman38705 Jun 17, 2026
c8c1bce
feat(security): add upgrade-insecure-requests directive to CSP
hman38705 Jun 17, 2026
9a445b9
feat(security): add Next.js middleware for per-request nonce-based CSP
hman38705 Jun 17, 2026
0571008
feat(security): wire nonce from middleware into layout inline script
hman38705 Jun 17, 2026
492dc86
feat(typescript): enable strict mode to enforce compile-time type safety
hman38705 Jun 17, 2026
4085262
feat(typescript): add noImplicitReturns and noFallthroughCasesInSwitc…
hman38705 Jun 17, 2026
9ff2cba
fix(typescript): use unknown instead of any for caught API error body
hman38705 Jun 17, 2026
23d6cee
fix(typescript): replace unsafe Error cast with type guard in useAsyn…
hman38705 Jun 17, 2026
1a9c742
fix(typescript): replace as any locale cast with typed Locale assertion
hman38705 Jun 17, 2026
21f1d23
fix(api): log Redis error when newsletter rate limiter fails open
hman38705 Jun 17, 2026
3787246
fix(api): replace bare unwrap() with expect() in TokenStore::confirm
hman38705 Jun 17, 2026
e28bf46
fix(api): log SendGrid response body read error instead of silently d…
hman38705 Jun 17, 2026
844bd8f
fix(api): log serialization error in body_redact fallback path
hman38705 Jun 17, 2026
c02e540
fix(db): add LIMIT clause to newsletter cleanup to prevent unbounded …
hman38705 Jun 17, 2026
b8ac70d
feat(config): add NEWSLETTER_CLEANUP_BATCH_SIZE environment variable
hman38705 Jun 17, 2026
65c70e0
fix(api): pass cleanup batch size to newsletter_delete_expired_pending
hman38705 Jun 17, 2026
6a811b3
fix(config): add newsletter_cleanup_batch_size to Config test fixtures
hman38705 Jun 17, 2026
d9f0071
perf(db): add composite index on email_jobs(status, priority, schedul…
hman38705 Jun 17, 2026
f35eab2
perf(db): add composite index on email_events(email_job_id, timestamp…
hman38705 Jun 17, 2026
f64e7db
perf(db): add partial index on newsletter_subscribers(created_at) for…
hman38705 Jun 17, 2026
ba0665d
perf(db): add partial index on markets(ends_at) for active deadline q…
hman38705 Jun 17, 2026
288929a
docs(db): update performance_indexes.sql reference with all new indexes
hman38705 Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const nextConfig = {
},
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
value: "default-src 'self'; script-src 'self' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests",
},
],
},
Expand Down
7 changes: 5 additions & 2 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import type { ReactNode } from 'react';
import { headers } from 'next/headers';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { darkModeInitScript } from '../lib/darkMode';
import '../styles/accessibility.css';

export const metadata = { title: 'PredictIQ' };

export default function RootLayout({ children }: { children: ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
const nonce = (await headers()).get('x-nonce') ?? '';

return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: darkModeInitScript }} />
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: darkModeInitScript }} />
</head>
<body>
<ErrorBoundary section="main">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react';
import { useI18n } from '../lib/hooks/useI18n';
import { useDarkMode } from '../lib/hooks/useDarkMode';
import { type Locale } from '../lib/i18n';
import { Statistics } from './Statistics';
import { ErrorBoundary } from './ErrorBoundary';

Expand Down Expand Up @@ -92,7 +93,7 @@ export const LandingPage: React.FC<LandingPageProps> = ({ className }) => {
<select
id="locale-select"
value={locale}
onChange={(e) => setLocale(e.target.value as any)}
onChange={(e) => setLocale(e.target.value as Locale)}
aria-label="Language selection"
>
{availableLocales.map((loc) => (
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,15 +213,16 @@ async function request<T>(
}
}

let err: any;
let err: unknown;
try {
err = await res.json();
} catch {
err = {};
}
const message = err?.message ?? res.statusText ?? `HTTP ${res.status}`;
const code = err?.code ?? "UNKNOWN_ERROR";
const details = err?.details ?? undefined;
const errObj = (typeof err === 'object' && err !== null) ? err as Record<string, unknown> : {};
const message = (errObj['message'] as string | undefined) ?? res.statusText ?? `HTTP ${res.status}`;
const code = (errObj['code'] as string | undefined) ?? "UNKNOWN_ERROR";
const details = errObj['details'] as Record<string, unknown> | undefined;
throw new ApiError(message, res.status, code, details);
}

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/hooks/useAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export function useAsync<T>(
}
} catch (error) {
if (isMountedRef.current && !(error instanceof DOMException && error.name === 'AbortError')) {
setState({ data: null, loading: false, error: error as Error });
const normalized = error instanceof Error ? error : new Error(String(error));
setState({ data: null, loading: false, error: normalized });
}
}
}, [asyncFunction]);
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

const cspHeader = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self' https:",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests",
].join('; ');

const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('Content-Security-Policy', cspHeader);

const response = NextResponse.next({
request: { headers: requestHeaders },
});
response.headers.set('Content-Security-Policy', cspHeader);

return response;
}

export const config = {
matcher: [
{
source: '/((?!_next/static|_next/image|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};
4 changes: 3 additions & 1 deletion frontend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noEmit": true,
"incremental": true,
"module": "esnext",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Composite index for priority-ordered queue worker scans.
--
-- The email queue worker queries: WHERE status = 'pending' ORDER BY priority DESC, scheduled_at ASC
-- The existing separate idx_email_jobs_status and idx_email_jobs_scheduled_at indexes force the
-- planner to choose one and filter on the other. This composite index covers both columns so the
-- planner can satisfy the full predicate and sort in a single index scan.

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_jobs_status_priority_scheduled
ON email_jobs (status, priority DESC, scheduled_at ASC);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Composite index for fetching ordered events for a specific email job.
--
-- Analytics queries join email_events to a specific job and order results by
-- timestamp. The existing idx_email_events_job_id covers the equality predicate
-- but the planner must then sort the results. This composite index covers both
-- columns so ordered event lists for a single job are resolved in one scan.

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_events_job_timestamp
ON email_events (email_job_id, timestamp DESC);
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Partial composite index for the newsletter token cleanup query.
--
-- The hourly cleanup deletes rows WHERE confirmed = FALSE AND created_at <= threshold.
-- The existing idx_newsletter_confirmed covers the boolean predicate but leaves the
-- planner to filter on created_at afterward. A partial index scoped to unconfirmed
-- rows means the index is small (only pending subscribers) and covers both the
-- equality predicate and the range filter in a single scan.

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_newsletter_subscribers_cleanup
ON newsletter_subscribers (created_at ASC)
WHERE confirmed = FALSE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Partial index for deadline-based active market queries.
--
-- Queries that look up markets expiring before a given timestamp —
-- e.g. background jobs that auto-close overdue markets — scan the full
-- markets table today because the existing composite index leads with status
-- and total_volume. A partial index scoped to active markets and ordered by
-- ends_at lets those scans skip the resolved and cancelled majority of rows.

CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_markets_active_ends_at
ON markets (ends_at ASC)
WHERE status = 'active';
22 changes: 22 additions & 0 deletions services/api/sql/performance_indexes.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
-- Recommended indexes for query performance.
-- Migrations 012–016 promote these into the versioned migration system;
-- this file is kept as a human-readable reference.

-- markets: featured-list query (status filter + volume sort + deadline sort)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_markets_status_volume_ends_at
ON markets (status, total_volume DESC, ends_at ASC);

-- markets: deadline-based active-market scans (e.g. auto-close jobs)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_markets_active_ends_at
ON markets (ends_at ASC)
WHERE status = 'active';

-- content: published content ordered by date
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_content_published_at
ON content (is_published, published_at DESC);

-- email_jobs: priority-ordered queue worker scan
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_jobs_status_priority_scheduled
ON email_jobs (status, priority DESC, scheduled_at ASC);

-- email_events: ordered event timeline for a single job
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_events_job_timestamp
ON email_events (email_job_id, timestamp DESC);

-- newsletter_subscribers: token expiry cleanup (partial — unconfirmed rows only)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_newsletter_subscribers_cleanup
ON newsletter_subscribers (created_at ASC)
WHERE confirmed = FALSE;
6 changes: 5 additions & 1 deletion services/api/src/body_redact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! for failed request/response logging.

use serde_json::{Map, Value};
use tracing;

/// Maximum bytes captured from request/response body before truncation.
pub const MAX_BODY_BYTES: usize = 4 * 1024; // 4 KB
Expand Down Expand Up @@ -44,7 +45,10 @@ pub fn redact_sensitive(body: &str) -> String {
match serde_json::from_str::<Value>(body) {
Ok(Value::Object(map)) => {
let redacted = redact_map(map);
serde_json::to_string(&Value::Object(redacted)).unwrap_or_else(|_| body.to_owned())
serde_json::to_string(&Value::Object(redacted)).unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to serialize redacted body; logging original");
body.to_owned()
})
}
_ => body.to_owned(),
}
Expand Down
13 changes: 13 additions & 0 deletions services/api/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ pub struct Config {
/// are considered orphaned and will be re-queued on worker startup.
/// Default: 3600 (1 hour). Set via `EMAIL_STALE_JOB_THRESHOLD_SECS`.
pub email_stale_job_threshold_secs: u64,
/// Max rows deleted per newsletter cleanup run. Default: 500.
/// Keep this low enough that the DELETE does not hold a table lock long
/// enough to noticeably delay concurrent subscriber inserts.
/// Set via `NEWSLETTER_CLEANUP_BATCH_SIZE`.
pub newsletter_cleanup_batch_size: u64,
/// HMAC secret for signing unsubscribe tokens.
pub unsubscribe_signing_secret: Option<String>,
/// CORS policy. See [`CorsConfig`] for per-field documentation.
Expand Down Expand Up @@ -444,6 +449,10 @@ impl Config {
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3600),
newsletter_cleanup_batch_size: env::var("NEWSLETTER_CLEANUP_BATCH_SIZE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(500),
unsubscribe_signing_secret: env::var("UNSUBSCRIBE_SIGNING_SECRET").ok(),
cors: CorsConfig::from_env(),
contract_key_schema: ContractKeySchema::from_env(),
Expand Down Expand Up @@ -710,6 +719,7 @@ mod tests {
newsletter_rate_limit_max: 5,
newsletter_rate_limit_window_secs: 3600,
email_stale_job_threshold_secs: 3600,
newsletter_cleanup_batch_size: 500,
unsubscribe_signing_secret: None,
cors: CorsConfig {
dev_mode: false,
Expand Down Expand Up @@ -780,6 +790,7 @@ mod tests {
newsletter_rate_limit_max: 5,
newsletter_rate_limit_window_secs: 3600,
email_stale_job_threshold_secs: 3600,
newsletter_cleanup_batch_size: 500,
unsubscribe_signing_secret: None,
cors: CorsConfig {
dev_mode: false,
Expand Down Expand Up @@ -850,6 +861,7 @@ mod tests {
newsletter_rate_limit_max: 5,
newsletter_rate_limit_window_secs: 3600,
email_stale_job_threshold_secs: 3600,
newsletter_cleanup_batch_size: 500,
unsubscribe_signing_secret: None,
cors: CorsConfig {
dev_mode: false,
Expand Down Expand Up @@ -920,6 +932,7 @@ mod tests {
newsletter_rate_limit_max: 5,
newsletter_rate_limit_window_secs: 3600,
email_stale_job_threshold_secs: 3600,
newsletter_cleanup_batch_size: 500,
unsubscribe_signing_secret: None,
cors: CorsConfig {
dev_mode: false,
Expand Down
18 changes: 15 additions & 3 deletions services/api/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,13 +355,25 @@ impl Database {
}

/// Remove pending (unconfirmed) subscriptions whose token has expired.
pub async fn newsletter_delete_expired_pending(&self, token_ttl_secs: u64) -> anyhow::Result<u64> {
/// `batch_size` caps the number of rows deleted per call to prevent long
/// table locks on large datasets; callers should loop until 0 rows are
/// returned if they need to drain the full backlog.
pub async fn newsletter_delete_expired_pending(
&self,
token_ttl_secs: u64,
batch_size: u64,
) -> anyhow::Result<u64> {
let result = self.with_timeout("newsletter_delete_expired_pending", sqlx::query(
"DELETE FROM newsletter_subscribers
WHERE confirmed = FALSE
AND created_at <= NOW() - ($1 || ' seconds')::INTERVAL",
WHERE id IN (
SELECT id FROM newsletter_subscribers
WHERE confirmed = FALSE
AND created_at <= NOW() - ($1 || ' seconds')::INTERVAL
LIMIT $2
)",
)
.bind(token_ttl_secs as i64)
.bind(batch_size as i64)
.execute(&self.pool)).await.map_err(anyhow::Error::from)?;

Ok(result.rows_affected())
Expand Down
3 changes: 2 additions & 1 deletion services/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ async fn main() -> anyhow::Result<()> {
loop {
interval.tick().await;
let ttl = db_cleanup.config.newsletter_token_ttl_secs;
match db_cleanup.db.newsletter_delete_expired_pending(ttl).await {
let batch = db_cleanup.config.newsletter_cleanup_batch_size;
match db_cleanup.db.newsletter_delete_expired_pending(ttl, batch).await {
Ok(n) if n > 0 => tracing::info!("[newsletter] cleaned up {n} expired pending subscriptions"),
Err(e) => tracing::warn!("[newsletter] cleanup error: {e}"),
_ => {}
Expand Down
18 changes: 14 additions & 4 deletions services/api/src/newsletter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,17 @@ impl IpRateLimiter {
/// Uses an atomic Redis Lua script so the counter is consistent across
/// all instances. Fails open (returns `true`) if Redis is unavailable.
pub async fn allow(&self, key: &str, max_requests: usize, window: Duration) -> bool {
let redis_key = format!("newsletter:ratelimit:{key}");
let redis_key = format!("newsletter:ratelimit:v1:{key}");
match self.cache.incr_with_ttl(&redis_key, window).await {
Ok(count) => count as usize <= max_requests,
Err(_) => true, // fail open if Redis is unavailable
Err(e) => {
tracing::warn!(
error = %e,
key,
"newsletter rate limiter Redis error; failing open to avoid blocking subscribers"
);
true
}
}
}
}
Expand Down Expand Up @@ -121,7 +128,7 @@ impl TokenStore {
return ConfirmResult::InvalidOrExpired;
};

let entry = self.pending.remove(&email).unwrap();
let entry = self.pending.remove(&email).expect("email was found in pending map immediately above");

if Instant::now() > entry.expires_at {
return ConfirmResult::InvalidOrExpired;
Expand Down Expand Up @@ -174,7 +181,10 @@ pub async fn send_confirmation_email(

if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body = response.text().await.unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to read SendGrid error response body");
String::new()
});
anyhow::bail!("sendgrid returned {status}: {body}");
}

Expand Down
Loading