Files
investor-flow/app/server/src/trpc/router.ts
T

215 lines
13 KiB
TypeScript
Raw Normal View History

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import { randomUUID } from 'node:crypto';
import type { Context } from './context.ts';
import { hashPassword, verifyPassword, createSession, clearCookie, oauthStateCookie, verifyOAuthState, OAUTH_STATE_COOKIE } from './context.ts';
import { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts';
import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts';
import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } from '../auth/oauth.ts';
import { STARTER_WATCHLIST, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity } from '../onboarding/starter.ts';
import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts';
import { emaFromCandles, rsi as rsiFn, relativeVolume } from '../analysis/indicators.ts';
const t = initTRPC.context<Context>().create();
const router = t.router;
const publicProcedure = t.procedure;
const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required.' });
return next({ ctx });
});
function oauthCreds(provider: OAuthProvider): { clientId?: string; clientSecret?: string } {
if (provider === 'github') return { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET };
return { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET };
}
const authRouter = router({
signup: publicProcedure
.input(z.object({ email: z.string().email(), password: z.string().min(8) }))
.mutation(async ({ ctx, input }) => {
const email = input.email.toLowerCase();
const existing = ctx.db.prepare('SELECT id FROM users WHERE email=?').get(email);
if (existing) throw new TRPCError({ code: 'CONFLICT', message: 'That email is already registered.' });
const userId = randomUUID();
ctx.db.prepare('INSERT INTO users (id,email,pw_hash,created_at) VALUES (?,?,?,?)').run(userId, email, hashPassword(input.password), new Date().toISOString());
const { cookie } = createSession(ctx.db, userId);
ctx.resHeaders.append('Set-Cookie', cookie);
return { userId };
}),
login: publicProcedure
.input(z.object({ email: z.string().email(), password: z.string(), totp: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
const email = input.email.toLowerCase();
const row = ctx.db.prepare('SELECT id, pw_hash, is_2fa_enabled, totp_secret FROM users WHERE email=?').get(email) as { id: string; pw_hash: string; is_2fa_enabled: number; totp_secret: string | null } | undefined;
if (!row || !verifyPassword(input.password, row.pw_hash)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid email or password.' });
if (row.is_2fa_enabled === 1) {
if (!input.totp || !row.totp_secret || !verifyTotp(input.totp, row.totp_secret)) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Two-factor code required or invalid.' });
}
}
const { cookie } = createSession(ctx.db, row.id);
ctx.resHeaders.append('Set-Cookie', cookie);
return { userId: row.id };
}),
logout: publicProcedure.mutation(({ ctx }) => {
ctx.resHeaders.append('Set-Cookie', clearCookie());
return { ok: true };
}),
me: publicProcedure.query(({ ctx }) => {
if (!ctx.userId) return null;
const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string } | undefined;
const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId);
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl } : null;
}),
enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => {
const userId = ctx.userId as string;
const u = ctx.db.prepare('SELECT email FROM users WHERE id=?').get(userId) as { email: string } | undefined;
const secret = generateBase32Secret();
const codes = generateBackupCodes(10);
const hashes = codes.map((c) => hashBackupCode(c));
ctx.db.prepare('UPDATE users SET totp_secret=?, backup_codes_hashed=? WHERE id=?').run(secret, JSON.stringify(hashes), userId);
return { totpSecret: secret, qrUrl: otpauthUrl(secret, 'Investor Flow', u?.email ?? 'user'), backupCodes: codes };
}),
confirm2fa: protectedProcedure.input(z.object({ totp: z.string() })).mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const row = ctx.db.prepare('SELECT totp_secret FROM users WHERE id=?').get(userId) as { totp_secret: string | null } | undefined;
if (!row?.totp_secret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Enable two-factor first.' });
if (!verifyTotp(input.totp, row.totp_secret)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid two-factor code.' });
ctx.db.prepare('UPDATE users SET is_2fa_enabled=1 WHERE id=?').run(userId);
return { ok: true };
}),
// Slice 2b — OAuth start: return the provider authorize URL + set a CSRF state cookie.
oauthStart: publicProcedure
.input(z.object({ provider: z.enum(['github', 'google']), redirectUri: z.string().url() }))
.mutation(({ ctx, input }) => {
const creds = oauthCreds(input.provider);
if (!creds.clientId || !creds.clientSecret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth provider not configured.' });
const state = generateState();
ctx.resHeaders.append('Set-Cookie', oauthStateCookie(input.provider, state, input.redirectUri));
return { redirectUrl: buildAuthorizeUrl(input.provider, { clientId: creds.clientId, redirectUri: input.redirectUri, state }), state };
}),
// Slice 2b — OAuth callback: verify CSRF state, exchange code, find/link/create user, start a session.
oauthCallback: publicProcedure
.input(z.object({ provider: z.enum(['github', 'google']), code: z.string(), state: z.string(), redirectUri: z.string().url() }))
.mutation(async ({ ctx, input }) => {
if (!verifyOAuthState(ctx.cookies[OAUTH_STATE_COOKIE], input.provider, input.state, input.redirectUri)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid OAuth state.' });
}
const creds = oauthCreds(input.provider);
if (!creds.clientId || !creds.clientSecret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth provider not configured.' });
const info = await exchangeCode(input.provider, input.code, { clientId: creds.clientId, clientSecret: creds.clientSecret, redirectUri: input.redirectUri });
const subject = info.providerSubject;
const email = info.email.toLowerCase();
if (!subject || !email) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Provider did not return a usable identity.' });
// existing link?
let row = ctx.db.prepare('SELECT id FROM users WHERE oauth_subject=? AND oauth_provider=?').get(subject, input.provider) as { id: string } | undefined;
if (!row) {
// link existing account by email, else create an OAuth-only account (sentinel pw_hash)
const byEmail = ctx.db.prepare('SELECT id FROM users WHERE email=?').get(email) as { id: string } | undefined;
const userId = byEmail?.id ?? randomUUID();
if (byEmail) {
ctx.db.prepare('UPDATE users SET oauth_subject=?, oauth_provider=? WHERE id=?').run(subject, input.provider, userId);
} else {
ctx.db.prepare('INSERT INTO users (id,email,pw_hash,oauth_subject,oauth_provider,created_at) VALUES (?,?,?,?,?,?)').run(userId, email, 'oauth', subject, input.provider, new Date().toISOString());
}
row = { id: userId };
}
const { cookie } = createSession(ctx.db, row.id);
ctx.resHeaders.append('Set-Cookie', cookie);
return { userId: row.id };
}),
});
const onboardingRouter = router({
// Public: the starter watchlist + disclaimer shown in the wizard before completing.
starter: publicProcedure.query(() => ({ watchlist: STARTER_WATCHLIST, disclaimer: ONBOARDING_DISCLAIMER })),
// Protected: write complexity/risk/drawdown + first watchlist + optional portfolio; subscribe symbols to demand.
complete: protectedProcedure
.input(z.object({
complexity: z.enum(['beginner', 'intermediate', 'advanced']),
riskTolerance: z.enum(['conservative', 'moderate', 'aggressive']).optional(),
drawdownTolerancePct: z.number().optional(),
firstWatchlistSymbols: z.array(z.string()).optional(),
portfolio: z.array(z.object({ symbol: z.string(), qty: z.number(), avgCost: z.number(), acquiredAt: z.string() })).optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const complexity = input.complexity as Complexity;
const riskTolerance = input.riskTolerance ?? defaultRiskTolerance(complexity);
const drawdown = input.drawdownTolerancePct ?? defaultDrawdownTolerancePct(complexity);
ctx.db.prepare('UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=? WHERE id=?').run(complexity, riskTolerance, drawdown, userId);
const symbols = input.firstWatchlistSymbols ?? STARTER_WATCHLIST.map((s) => s.symbol);
const wlId = randomUUID();
ctx.db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)').run(wlId, userId, 'Starter', JSON.stringify(symbols), new Date().toISOString(), 0);
for (const sym of symbols) {
const kind = (STARTER_WATCHLIST.find((s) => s.symbol === sym)?.tickerKind ?? 'equity') as 'equity' | 'crypto' | 'etf' | 'index';
await ctx.cache.subscribe(sym, kind);
}
if (input.portfolio) {
const ins = ctx.db.prepare('INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?,?,?,?,?,?,?)');
for (const h of input.portfolio) ins.run(randomUUID(), userId, h.symbol.toUpperCase(), h.qty, h.avgCost, h.acquiredAt, 'open');
}
return { ok: true, watchlistId: wlId };
}),
});
const marketRouter = router({
snapshot: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const k = { quote: `yfinance:quote:${symbol}`, candles: `yfinance:candles:${symbol}:1d`, sector: `yfinance:symbol:${symbol}` };
const entries = await ctx.cache.getMany<unknown>([k.quote, k.candles, k.sector]);
const byKey = new Map(entries.map((e) => [e.key, e]));
const val = <T>(key: string): T | null => (byKey.get(key)?.value ?? null) as T | null;
const stale = (key: string): boolean => byKey.get(key)?.isStale ?? true;
return {
symbol,
quote: val<Quote>(k.quote),
candles: val<PriceCandle[]>(k.candles),
sector: val<SymbolMeta>(k.sector),
stale: { quote: stale(k.quote), candles: stale(k.candles), sector: stale(k.sector) },
};
}),
candles: publicProcedure
.input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1d', '1wk']).default('1d') }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const key = `yfinance:candles:${symbol}:${input.timeframe}`;
const entry = await ctx.cache.get<PriceCandle[]>(key);
return { symbol, timeframe: input.timeframe, candles: (entry.value ?? []), isStale: entry.isStale };
}),
indicators: publicProcedure
.input(z.object({
symbol: z.string().min(1),
timeframe: z.enum(['1d', '1wk']).default('1d'),
periods: z.object({
ema: z.array(z.number().int()).default([9, 21, 50, 200]),
rsi: z.number().int().default(14),
relvol: z.number().int().default(20),
}).default(() => ({ ema: [9, 21, 50, 200], rsi: 14, relvol: 20 } as const)),
}))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const key = `yfinance:candles:${symbol}:${input.timeframe}`;
const entry = await ctx.cache.get<PriceCandle[]>(key);
const candles = entry.value ?? [];
const periods = input.periods ?? { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 };
const closes = candles.map((c) => c.c);
const volumes = candles.map((c) => c.v);
const emaObj: Record<string, (number | undefined)[]> = {};
for (const p of periods.ema) {
emaObj[String(p)] = emaFromCandles(candles, 'adjClose', p);
}
return {
ema: emaObj,
rsi: rsiFn(closes, periods.rsi),
relativeVolume: relativeVolume(volumes, periods.relvol),
};
}),
});
export const appRouter = router({ auth: authRouter, onboarding: onboardingRouter, market: marketRouter });
export type AppRouter = typeof appRouter;