Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
Expand All @@ -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")
Expand Down
47 changes: 38 additions & 9 deletions backend/src/config/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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.
Expand Down
54 changes: 54 additions & 0 deletions backend/src/routes/oauth.ts
Original file line number Diff line number Diff line change
@@ -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' });
}
});
12 changes: 8 additions & 4 deletions backend/src/services/2fa-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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);
});
}

/**
Expand Down
Loading
Loading