slice 1e-1f: tRPC router (auth + market.snapshot) + node:http server
auth.signup/login/logout/me with signed HMAC session cookies + scrypt hashing; market.snapshot mega-endpoint (quote+candles+sector). node:http server mounts tRPC at /api/trpc + /health + background drain loop. Live-verified: real NVDA 194.97/65 candles/Technology served after stale-while-revalidate drain. 36 tests green.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
// Investor Flow — tRPC router (DESIGN.md §2.3). Slice 1: auth.signup/login/logout/me + market.snapshot.
|
||||
// market.snapshot is the M1 mega-endpoint (quote + daily candles + sector overlay in one round trip).
|
||||
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 } from './context.ts';
|
||||
import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts';
|
||||
|
||||
const t = initTRPC.context<Context>().create();
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
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() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const email = input.email.toLowerCase();
|
||||
const row = ctx.db.prepare('SELECT id, pw_hash FROM users WHERE email=?').get(email) as { id: string; pw_hash: string } | undefined;
|
||||
if (!row || !verifyPassword(input.password, row.pw_hash)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid email or password.' });
|
||||
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;
|
||||
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture } : null;
|
||||
}),
|
||||
});
|
||||
|
||||
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) },
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({ auth: authRouter, market: marketRouter });
|
||||
export type AppRouter = typeof appRouter;
|
||||
Reference in New Issue
Block a user