diff --git a/backend/prisma/migrations/20260926000100_query_optimization_indexes/migration.sql b/backend/prisma/migrations/20260926000100_query_optimization_indexes/migration.sql new file mode 100644 index 00000000..08b13aa6 --- /dev/null +++ b/backend/prisma/migrations/20260926000100_query_optimization_indexes/migration.sql @@ -0,0 +1,30 @@ +-- Issue #797: critical query-path indexes for dashboard, milestone, and audit workloads. +-- These indexes mirror the query patterns exposed by backend/src/config/database.ts. + +CREATE INDEX IF NOT EXISTS "idx_projects_tenant_status_created" + ON "projects" ("tenant_id", "status", "created_at" DESC) + WHERE "deleted_at" IS NULL; + +CREATE INDEX IF NOT EXISTS "idx_milestones_project_status_order" + ON "milestones" ("project_id", "status", "order") + WHERE "deleted_at" IS NULL; + +CREATE INDEX IF NOT EXISTS "idx_invoices_project_created" + ON "invoices" ("project_id", "created_at" DESC) + WHERE "deleted_at" IS NULL; + +CREATE INDEX IF NOT EXISTS "idx_invoices_tenant_status_due" + ON "invoices" ("tenant_id", "status", "due_at" ASC) + WHERE "deleted_at" IS NULL; + +CREATE INDEX IF NOT EXISTS "idx_payments_tenant_status_created" + ON "payments" ("tenant_id", "status", "created_at" DESC) + WHERE "deleted_at" IS NULL; + +CREATE INDEX IF NOT EXISTS "idx_payments_project_status_created" + ON "payments" ("project_id", "status", "created_at" DESC) + WHERE "deleted_at" IS NULL AND "project_id" IS NOT NULL; + +CREATE INDEX IF NOT EXISTS "idx_audit_logs_entity_created" + ON "audit_logs" ("entity_id", "created_at" DESC) + WHERE "entity_id" IS NOT NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cab7f6a5..6d808c33 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -175,6 +175,8 @@ model Payment { disputes Dispute[] @@index([tenantId, createdAt]) + @@index([tenantId, status, createdAt], map: "idx_payments_tenant_status_created") + @@index([projectId, status, createdAt], map: "idx_payments_project_status_created") @@index([status], map: "payments_active_status_idx") @@index([txHash]) @@index([projectId]) @@ -273,6 +275,7 @@ model Project { payments Payment[] @@index([tenantId, createdAt]) + @@index([tenantId, status, createdAt], map: "idx_projects_tenant_status_created") @@index([status]) @@index([deletedAt], map: "projects_deleted_at_idx") @@map("projects") @@ -295,6 +298,7 @@ model Milestone { payments Payment[] @@index([projectId]) + @@index([projectId, status, order], map: "idx_milestones_project_status_order") @@index([deletedAt], map: "milestones_deleted_at_idx") @@map("milestones") } @@ -318,6 +322,8 @@ model Invoice { milestone Milestone? @relation(fields: [milestoneId], references: [id]) @@index([tenantId, generatedAt]) + @@index([projectId, createdAt], map: "idx_invoices_project_created") + @@index([tenantId, status, dueAt], map: "idx_invoices_tenant_status_due") @@index([projectId]) @@index([status]) @@index([deletedAt], map: "invoices_deleted_at_idx") diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts index f73f8199..0aeccff2 100644 --- a/backend/src/config/database.ts +++ b/backend/src/config/database.ts @@ -542,6 +542,30 @@ export const RECOMMENDED_INDEXES: CompositeIndex[] = [ targetQuery: "SELECT * FROM invoices WHERE project_id = ? ORDER BY created_at DESC", }, + { + name: "idx_invoices_tenant_status_due", + table: "invoices", + columns: ["tenant_id", "status", "due_at"], + description: "Optimizes invoice dashboards filtered by tenant, status, and due date", + targetQuery: + "SELECT * FROM invoices WHERE tenant_id = ? AND status = ? ORDER BY due_at ASC", + }, + { + name: "idx_milestones_project_status_order", + table: "milestones", + columns: ["project_id", "status", "order"], + description: "Optimizes project milestone lists and status progress checks", + targetQuery: + "SELECT * FROM milestones WHERE project_id = ? AND status = ? ORDER BY \"order\" ASC", + }, + { + name: "idx_projects_tenant_status_created", + table: "projects", + columns: ["tenant_id", "status", "created_at"], + description: "Optimizes project dashboards filtered by tenant and status", + targetQuery: + "SELECT * FROM projects WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC", + }, { name: "idx_verifications_status_type", table: "verifications", @@ -560,11 +584,11 @@ export const RECOMMENDED_INDEXES: CompositeIndex[] = [ "SELECT * FROM transactions WHERE account_id = ? ORDER BY ledger_seq DESC", }, { - name: "idx_payments_recipient_status", + name: "idx_payments_tenant_status_created", table: "payments", - columns: ["recipient", "status"], - description: "Finds pending payments for a recipient", - targetQuery: "SELECT * FROM payments WHERE recipient = ? AND status = ?", + columns: ["tenant_id", "status", "created_at"], + description: "Finds payments by tenant and status ordered by creation time", + targetQuery: "SELECT * FROM payments WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC", }, { name: "idx_payments_created_status", @@ -671,15 +695,16 @@ class IndexRecommendationEngine { i.relname AS "indexName", t.relname AS "table", pg_get_indexdef(i.oid) AS columns, - i.indisunique AS unique, + ix.indisunique AS unique, COALESCE(s.idx_scan, 0) AS "idxScan", COALESCE(s.idx_tup_read, 0) AS "idxTupRead", COALESCE(s.idx_tup_fetch, 0) AS "idxTupFetch", pg_relation_size(i.oid) AS "sizeBytes", - (SELECT MAX(statime) FROM pg_stat_all_indexes WHERE indexrelid = i.oid) AS "lastUsed" - FROM pg_index i - JOIN pg_class t ON i.indrelid = t.oid - LEFT JOIN pg_stat_user_indexes s ON i.indexrelid = s.indexrelid + NULL::text AS "lastUsed" + FROM pg_index ix + JOIN pg_class i ON ix.indexrelid = i.oid + JOIN pg_class t ON ix.indrelid = t.oid + LEFT JOIN pg_stat_user_indexes s ON i.oid = s.indexrelid WHERE t.relkind = 'r' AND t.relnamespace NOT IN ('pg_catalog'::regnamespace, 'information_schema'::regnamespace) ORDER BY s.idx_scan ASC NULLS FIRST @@ -774,6 +799,10 @@ class IndexRecommendationEngine { const plans: Array<{ query: string; plan: unknown; durationMs: number }> = []; for (const q of slowQueries) { + if (!/^\s*(select|with)\b/i.test(q.query)) { + plans.push({ query: q.query, plan: { skipped: "Only SELECT/CTE queries are eligible for EXPLAIN ANALYZE" }, durationMs: q.durationMs }); + continue; + } try { const { PrismaClient } = await import("@prisma/client"); const prisma = new PrismaClient(); diff --git a/backend/src/index.ts b/backend/src/index.ts index 203caf3c..63ddf202 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -97,6 +97,8 @@ import { cacheRouter } from './routes/cache.js'; import { rateLimitQuotasRouter } from './routes/rate-limit-quotas.js'; // Request/response compression — Issue #821 import { compressionRouter } from './routes/compression.js'; +import { oauthRouter } from './routes/oauth.js'; +import { twoFactorAuthRouter } from './routes/2fa.js'; dotenv.config(); @@ -379,6 +381,8 @@ apiV1Router.use('/fiat-payments', authMiddleware, fiatPaymentsRouter); apiV1Router.use('/rate-limit', rateLimitQuotasRouter); // Compression metrics and per-endpoint config — Issue #821 apiV1Router.use('/compression', compressionRouter); +apiV1Router.use('/auth/oauth', oauthRouter); +apiV1Router.use('/auth/2fa', twoFactorAuthRouter); // Explicit URL-based mounting // Per-key usage metrics for the analytics dashboard — Issue #826. diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts new file mode 100644 index 00000000..965a5ff6 --- /dev/null +++ b/backend/src/routes/oauth.ts @@ -0,0 +1,54 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { + OAuthProvider, + consumeOAuthState, + createOAuthAuthorizationUrl, + createOAuthSessionToken, + exchangeOAuthCode, +} from '../services/oauth-service.js'; + +export const oauthRouter = Router(); + +const providerSchema = z.enum(['google', 'github']); + +function callbackUrl(req: Request, provider: OAuthProvider): string { + const configured = process.env.OAUTH_CALLBACK_BASE_URL; + if (configured) return `${configured.replace(/\/$/, '')}/api/v1/auth/oauth/${provider}/callback`; + return `${req.protocol}://${req.get('host')}/api/v1/auth/oauth/${provider}/callback`; +} + +oauthRouter.get('/:provider', (req: Request, res: Response) => { + try { + const provider = providerSchema.parse(req.params.provider); + const redirectTo = typeof req.query.redirectTo === 'string' ? req.query.redirectTo : undefined; + res.redirect(createOAuthAuthorizationUrl(provider, callbackUrl(req, provider), redirectTo)); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : 'Invalid OAuth request' }); + } +}); + +oauthRouter.get('/:provider/callback', async (req: Request, res: Response) => { + try { + const provider = providerSchema.parse(req.params.provider); + const code = z.string().min(1).parse(req.query.code); + const state = z.string().min(1).parse(req.query.state); + const { redirectTo } = consumeOAuthState(provider, state); + const profile = await exchangeOAuthCode(provider, code, callbackUrl(req, provider)); + const token = createOAuthSessionToken(profile); + + const frontendCallback = new URL( + redirectTo || process.env.OAUTH_FRONTEND_CALLBACK_URL || '/auth/oauth/callback', + process.env.FRONTEND_URL || `${req.protocol}://${req.get('host')}`, + ); + frontendCallback.searchParams.set('provider', profile.provider); + frontendCallback.searchParams.set('token', token); + if (profile.email) frontendCallback.searchParams.set('email', profile.email); + if (profile.name) frontendCallback.searchParams.set('name', profile.name); + if (profile.avatarUrl) frontendCallback.searchParams.set('avatarUrl', profile.avatarUrl); + + res.redirect(frontendCallback.toString()); + } catch (error) { + res.status(400).json({ error: error instanceof Error ? error.message : 'OAuth callback failed' }); + } +}); diff --git a/backend/src/services/2fa-service.ts b/backend/src/services/2fa-service.ts index 51129ff3..94be3813 100644 --- a/backend/src/services/2fa-service.ts +++ b/backend/src/services/2fa-service.ts @@ -5,7 +5,7 @@ import speakeasy from 'speakeasy'; import QRCode from 'qrcode'; -import { randomUUID } from 'node:crypto'; +import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { TOTPSecret, TwoFactorSetup, @@ -91,10 +91,10 @@ export function generateBackupCodes(): string[] { * Generate a random alphanumeric code */ function generateRandomCode(length: number): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ23456789'; let result = ''; for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); + result += chars.charAt(randomBytes(1)[0] % chars.length); } return result; } @@ -112,7 +112,11 @@ export function hashBackupCode(code: string): string { */ export function validateBackupCode(backupCodes: string[], code: string): boolean { const hashedCode = hashBackupCode(code); - return backupCodes.some((stored) => hashBackupCode(stored) === hashedCode); + const candidate = Buffer.from(hashedCode); + return backupCodes.some((stored) => { + const storedHash = Buffer.from(stored); + return storedHash.length === candidate.length && timingSafeEqual(storedHash, candidate); + }); } /** diff --git a/backend/src/services/oauth-service.ts b/backend/src/services/oauth-service.ts new file mode 100644 index 00000000..b28ec8f1 --- /dev/null +++ b/backend/src/services/oauth-service.ts @@ -0,0 +1,171 @@ +import { createHash, randomBytes } from 'node:crypto'; + +export type OAuthProvider = 'google' | 'github'; + +export interface OAuthUserProfile { + provider: OAuthProvider; + providerUserId: string; + email: string | null; + name: string | null; + avatarUrl: string | null; +} + +interface OAuthProviderConfig { + authorizationUrl: string; + tokenUrl: string; + profileUrl: string; + emailUrl?: string; + clientId?: string; + clientSecret?: string; + scopes: string[]; +} + +const stateStore = new Map(); +const STATE_TTL_MS = 10 * 60 * 1000; + +function getProviderConfig(provider: OAuthProvider): OAuthProviderConfig { + if (provider === 'google') { + return { + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + profileUrl: 'https://www.googleapis.com/oauth2/v2/userinfo', + clientId: process.env.GOOGLE_OAUTH_CLIENT_ID, + clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET, + scopes: ['openid', 'email', 'profile'], + }; + } + + return { + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + profileUrl: 'https://api.github.com/user', + emailUrl: 'https://api.github.com/user/emails', + clientId: process.env.GITHUB_OAUTH_CLIENT_ID, + clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET, + scopes: ['read:user', 'user:email'], + }; +} + +function requireConfig(provider: OAuthProvider): OAuthProviderConfig { + const config = getProviderConfig(provider); + if (!config.clientId || !config.clientSecret) { + throw new Error(`${provider} OAuth is not configured`); + } + return config; +} + +export function createOAuthAuthorizationUrl(provider: OAuthProvider, callbackUrl: string, redirectTo?: string): string { + const config = requireConfig(provider); + const state = randomBytes(24).toString('base64url'); + stateStore.set(state, { provider, expiresAt: Date.now() + STATE_TTL_MS, redirectTo }); + + const url = new URL(config.authorizationUrl); + url.searchParams.set('client_id', config.clientId!); + url.searchParams.set('redirect_uri', callbackUrl); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', config.scopes.join(' ')); + url.searchParams.set('state', state); + if (provider === 'google') { + url.searchParams.set('access_type', 'offline'); + url.searchParams.set('prompt', 'select_account'); + } + return url.toString(); +} + +export function consumeOAuthState(provider: OAuthProvider, state: string): { redirectTo?: string } { + const stored = stateStore.get(state); + stateStore.delete(state); + + if (!stored || stored.provider !== provider || stored.expiresAt < Date.now()) { + throw new Error('Invalid or expired OAuth state'); + } + + return { redirectTo: stored.redirectTo }; +} + +export async function exchangeOAuthCode( + provider: OAuthProvider, + code: string, + callbackUrl: string, +): Promise { + const config = requireConfig(provider); + const tokenResponse = await fetch(config.tokenUrl, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: config.clientId!, + client_secret: config.clientSecret!, + code, + redirect_uri: callbackUrl, + grant_type: 'authorization_code', + }), + }); + + if (!tokenResponse.ok) { + throw new Error(`OAuth token exchange failed with ${tokenResponse.status}`); + } + + const tokenBody = await tokenResponse.json() as { access_token?: string }; + if (!tokenBody.access_token) { + throw new Error('OAuth provider did not return an access token'); + } + + return provider === 'google' + ? fetchGoogleProfile(config.profileUrl, tokenBody.access_token) + : fetchGitHubProfile(config.profileUrl, config.emailUrl!, tokenBody.access_token); +} + +async function fetchGoogleProfile(profileUrl: string, accessToken: string): Promise { + const response = await fetch(profileUrl, { headers: { authorization: `Bearer ${accessToken}` } }); + if (!response.ok) throw new Error(`Google profile request failed with ${response.status}`); + const profile = await response.json() as { id: string; email?: string; name?: string; picture?: string }; + return { + provider: 'google', + providerUserId: profile.id, + email: profile.email ?? null, + name: profile.name ?? null, + avatarUrl: profile.picture ?? null, + }; +} + +async function fetchGitHubProfile( + profileUrl: string, + emailUrl: string, + accessToken: string, +): Promise { + const headers = { authorization: `Bearer ${accessToken}`, accept: 'application/vnd.github+json' }; + const [profileResponse, emailResponse] = await Promise.all([ + fetch(profileUrl, { headers }), + fetch(emailUrl, { headers }), + ]); + + if (!profileResponse.ok) throw new Error(`GitHub profile request failed with ${profileResponse.status}`); + const profile = await profileResponse.json() as { id: number; email?: string | null; name?: string | null; avatar_url?: string }; + let email = profile.email ?? null; + + if (emailResponse.ok) { + const emails = await emailResponse.json() as Array<{ email: string; primary: boolean; verified: boolean }>; + email = emails.find((item) => item.primary && item.verified)?.email ?? email; + } + + return { + provider: 'github', + providerUserId: String(profile.id), + email, + name: profile.name ?? null, + avatarUrl: profile.avatar_url ?? null, + }; +} + +export function createOAuthSessionToken(profile: OAuthUserProfile): string { + const payload = JSON.stringify({ + provider: profile.provider, + providerUserId: profile.providerUserId, + email: profile.email, + issuedAt: Date.now(), + }); + return createHash('sha256').update(payload).digest('hex'); +} diff --git a/frontend/app/[locale]/dashboard/projects/[id]/page.tsx b/frontend/app/[locale]/dashboard/projects/[id]/page.tsx index d90157ff..beec73eb 100644 --- a/frontend/app/[locale]/dashboard/projects/[id]/page.tsx +++ b/frontend/app/[locale]/dashboard/projects/[id]/page.tsx @@ -121,10 +121,17 @@ export default function ProjectDetailPage() { const events = project.milestones .filter((m) => m.dueDate) .map((m) => ({ - uid: `milestone-${m.id}@agenticpay`, + uid: `agenticpay-project-${project.id}-milestone-${m.id}@agenticpay`, summary: `${project.title} — ${m.title}`, - description: m.description ?? undefined, + description: [ + m.description, + `Project: ${project.title}`, + `Amount: ${m.amount} ${project.currency}`, + `Status: ${m.status}`, + project.githubRepo ? `Repository: ${project.githubRepo}` : undefined, + ].filter(Boolean).join('\n'), start: new Date(m.dueDate!), + allDay: true, })); if (events.length === 0) { @@ -132,7 +139,9 @@ export default function ProjectDetailPage() { return; } - downloadICS(`${project.title.replace(/\s+/g, '-')}.ics`, generateICS(events)); + const filename = `${project.title.trim().replace(/\s+/g, '-') || `project-${project.id}`}-milestones.ics`; + downloadICS(filename, generateICS(events)); + toast.success('Calendar file downloaded.'); }; return ( diff --git a/frontend/app/auth/oauth/callback/page.tsx b/frontend/app/auth/oauth/callback/page.tsx new file mode 100644 index 00000000..023dcb4a --- /dev/null +++ b/frontend/app/auth/oauth/callback/page.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { useAuthStore } from '@/store/useAuthStore'; + +export default function OAuthCallbackPage() { + const router = useRouter(); + const params = useSearchParams(); + const setAuth = useAuthStore((state) => state.setAuth); + + useEffect(() => { + const token = params.get('token'); + const provider = params.get('provider'); + + if (!token || !provider) { + toast.error('OAuth login failed.'); + router.replace('/auth'); + return; + } + + localStorage.setItem('auth_token', token); + setAuth({ + address: '', + email: params.get('email') ?? undefined, + name: params.get('name') ?? undefined, + profileImage: params.get('avatarUrl') ?? undefined, + loginType: 'social', + }); + toast.success(`Signed in with ${provider}.`); + router.replace('/dashboard'); + }, [params, router, setAuth]); + + return ( +
+
+ + Completing sign in... +
+
+ ); +} diff --git a/frontend/components/auth/SocialLogin.tsx b/frontend/components/auth/SocialLogin.tsx index 56620205..e6447cf3 100644 --- a/frontend/components/auth/SocialLogin.tsx +++ b/frontend/components/auth/SocialLogin.tsx @@ -4,7 +4,7 @@ import { useState } from 'react'; import { web3auth } from '@/lib/web3auth'; import { Button } from '@/components/ui/button'; -import { Mail, Chrome, Twitter } from 'lucide-react'; +import { Mail, Chrome, Github, Twitter } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useAuthStore } from '@/store/useAuthStore'; import { toast } from 'sonner'; @@ -14,6 +14,12 @@ export function SocialLogin() { const router = useRouter(); const setAuth = useAuthStore((state) => state.setAuth); + const handleOAuthLogin = (provider: 'google' | 'github') => { + const apiBase = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; + const redirectTo = `${window.location.origin}/auth/oauth/callback`; + window.location.href = `${apiBase}/auth/oauth/${provider}?redirectTo=${encodeURIComponent(redirectTo)}`; + }; + const handleLogin = async (loginProvider: string) => { if (!web3auth) { toast.error( @@ -64,12 +70,21 @@ export function SocialLogin() { +