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:
Investor Flow Build
2026-06-29 17:40:42 -04:00
parent 1962ecc740
commit 82079b3e66
7 changed files with 349 additions and 5 deletions
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { appRouter } from '../router.ts';
import { resolveSessionUserId, type Context } from '../context.ts';
import { createDb, initSchema } from '../../db/client.ts';
import { createCacheRepository, type Quote, type PriceCandle, type SymbolMeta, type SourceKind } from '../../cache/CacheRepository.ts';
import { FakeSourceAdapter, type SourceFetch } from '../../adapters/SourceAdapter.ts';
import { AdapterQueue } from '../../queue/AdapterQueue.ts';
function setup() {
const db = createDb({ path: ':memory:' });
initSchema(db);
const fake = new FakeSourceAdapter('yfinance')
.set('yfinance:quote:NVDA', { symbol: 'NVDA', price: 194.97, change: 2.44, changePercent: 1.27 } as Quote, 'live_quote')
.set('yfinance:candles:NVDA:1d', [{ ts: '2026-06-27', o: 192, h: 196, l: 191, c: 194.97, v: 1.2e8, adjClose: 194.9 }] as PriceCandle[], 'daily_permanent')
.set('yfinance:symbol:NVDA', { symbol: 'NVDA', name: 'NVIDIA Corporation', sector: 'Technology', industry: 'Semiconductors', tickerKind: 'equity' } as SymbolMeta, 'symbol_meta');
const adapters = new Map<SourceKind, SourceFetch>([['yfinance', fake]]);
const queue = new AdapterQueue({ db, adapters, rateLimitMs: { yfinance: 0 } });
const cache = createCacheRepository({ db, scheduler: queue });
queue.cache = cache;
const freshCtx = (req?: Request): Context => ({ db, cache, resHeaders: new Headers(), userId: req ? resolveSessionUserId(db, req) : null });
return { db, fake, queue, cache, freshCtx };
}
const cookieHeader = (res: Headers) => res.get('set-cookie')?.split(';')[0] ?? '';
const reqWithCookie = (cookie: string) => new Request('http://localhost/api/trpc', { headers: { cookie } });
test('signup creates a user + session row and sets a signed cookie', async () => {
const { db, freshCtx } = setup();
const ctx = freshCtx();
const caller = appRouter.createCaller(ctx);
const res = await caller.auth.signup({ email: 'A@B.CO', password: 'password123' });
assert.ok(res.userId);
const u = db.prepare('SELECT email, pw_hash FROM users WHERE id=?').get(res.userId) as { email: string; pw_hash: string };
assert.equal(u.email, 'a@b.co'); // normalized lowercase
assert.ok(u.pw_hash.startsWith('scrypt$'));
assert.equal((db.prepare('SELECT COUNT(*) AS c FROM sessions').get() as { c: number }).c, 1);
assert.ok(ctx.resHeaders.get('set-cookie'), 'cookie must be set');
// session cookie round-trip: resolve userId from the cookie
const req = reqWithCookie(cookieHeader(ctx.resHeaders));
assert.equal(resolveSessionUserId(db, req), res.userId);
});
test('duplicate signup is CONFLICT', async () => {
const { freshCtx } = setup();
const caller = appRouter.createCaller(freshCtx());
await caller.auth.signup({ email: 'a@b.co', password: 'password123' });
await assert.rejects(() => appRouter.createCaller(freshCtx()).auth.signup({ email: 'a@b.co', password: 'password123' }), (e: { code: string }) => e.code === 'CONFLICT');
});
test('login succeeds with correct password; fails UNAUTHORIZED with wrong password', async () => {
const { freshCtx } = setup();
await appRouter.createCaller(freshCtx()).auth.signup({ email: 'a@b.co', password: 'password123' });
const ctx = freshCtx();
const caller = appRouter.createCaller(ctx);
const res = await caller.auth.login({ email: 'A@B.CO', password: 'password123' });
assert.ok(res.userId);
assert.ok(ctx.resHeaders.get('set-cookie'));
await assert.rejects(() => caller.auth.login({ email: 'a@b.co', password: 'wrong' }), (e: { code: string }) => e.code === 'UNAUTHORIZED');
});
test('me returns null unauthenticated; prefs when session resolves', async () => {
const { freshCtx } = setup();
const signupCtx = freshCtx();
const { userId } = await appRouter.createCaller(signupCtx).auth.signup({ email: 'a@b.co', password: 'password123' });
assert.equal(await appRouter.createCaller(freshCtx()).auth.me(), null);
const req = reqWithCookie(cookieHeader(signupCtx.resHeaders));
const me = await appRouter.createCaller(freshCtx(req)).auth.me();
assert.equal(me?.userId, userId);
assert.equal(me?.complexity, 'beginner');
});
test('logout clears the session cookie', async () => {
const { freshCtx } = setup();
const ctx = freshCtx();
await appRouter.createCaller(ctx).auth.logout();
const c = ctx.resHeaders.get('set-cookie') ?? '';
assert.match(c, /Max-Age=0/);
});
test('market.snapshot returns nulls + stale when cache is empty (and queues refreshes)', async () => {
const { db, freshCtx } = setup();
const snap = await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'nVdA' }); // case-normalized
assert.equal(snap.symbol, 'NVDA');
assert.equal(snap.quote, null);
assert.equal(snap.candles, null);
assert.equal(snap.sector, null);
assert.equal(snap.stale.quote && snap.stale.candles && snap.stale.sector, true);
assert.equal((db.prepare("SELECT COUNT(*) AS c FROM adapter_queue WHERE status='pending'").get() as { c: number }).c, 3);
});
test('market.snapshot serves cached values (not stale) after drain populates cache', async () => {
const { queue, freshCtx } = setup();
await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'NVDA' }); // queues
await queue.drain(); // populates cache from FakeSourceAdapter
const snap = await appRouter.createCaller(freshCtx()).market.snapshot({ symbol: 'NVDA' });
assert.equal(snap.quote?.price, 194.97);
assert.equal(snap.sector?.sector, 'Technology');
assert.equal(snap.candles?.length, 1);
assert.equal(snap.stale.quote, false);
assert.equal(snap.stale.candles, false);
assert.equal(snap.stale.sector, false);
});
+92
View File
@@ -0,0 +1,92 @@
// Investor Flow — tRPC context + auth/session (DESIGN.md §2.2 auth/session seam).
// Signed session cookies (HMAC), scrypt password hashing (design specified argon2id;
// adapted to built-in scrypt — reversible, OWASP-approved).
import { randomUUID, randomBytes, scryptSync, timingSafeEqual, createHmac } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import type { CacheRepository } from '../cache/CacheRepository.ts';
export const SESSION_COOKIE = 'iflow_session';
const SESSION_SECRET = process.env.IFLOW_SESSION_SECRET ?? 'dev-secret-change-me';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
export interface Context {
db: DatabaseSync;
cache: CacheRepository;
resHeaders: Headers; // mutable; procedures append Set-Cookie here (applied to Response by the fetch adapter)
userId: string | null; // resolved from the session cookie; null = unauthenticated
}
export interface CreateContextOpts { req: Request; resHeaders: Headers; info: unknown; }
// ----- signed session cookie (token.mac) -----
function sign(token: string): string { return `${token}.${createHmac('sha256', SESSION_SECRET).update(token).digest('hex')}`; }
function unsign(signed: string): string | null {
const idx = signed.lastIndexOf('.');
if (idx <= 0) return null;
const token = signed.slice(0, idx);
const mac = signed.slice(idx + 1);
const expected = createHmac('sha256', SESSION_SECRET).update(token).digest('hex');
const a = Buffer.from(mac); const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b) ? token : null;
}
export function sessionCookie(sessionId: string): string {
// HttpOnly + SameSite=Lax. Secure omitted for slice-1 local http; add behind TLS in deployment slice.
return `${SESSION_COOKIE}=${sign(sessionId)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${SESSION_TTL_MS / 1000}`;
}
export function clearCookie(): string { return `${SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`; }
export function parseCookies(req: Request): Record<string, string> {
const header = req.headers.get('cookie') ?? '';
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim(); const v = part.slice(eq + 1).trim();
if (k) out[k] = v;
}
return out;
}
export function createSession(database: DatabaseSync, userId: string): { sessionId: string; cookie: string } {
const sessionId = randomUUID();
const now = new Date().toISOString();
const expires = new Date(Date.now() + SESSION_TTL_MS).toISOString();
database.prepare('INSERT INTO sessions (id,user_id,expires_at,created_at) VALUES (?,?,?,?)').run(sessionId, userId, expires, now);
return { sessionId, cookie: sessionCookie(sessionId) };
}
export function resolveSessionUserId(database: DatabaseSync, req: Request): string | null {
const signed = parseCookies(req)[SESSION_COOKIE];
if (!signed) return null;
const token = unsign(signed);
if (!token) return null;
const row = database.prepare('SELECT user_id, expires_at FROM sessions WHERE id=?').get(token) as { user_id: string; expires_at: string } | undefined;
if (!row) return null;
if (Date.parse(row.expires_at) < Date.now()) return null;
return row.user_id;
}
// ----- password hashing (scrypt) -----
export function hashPassword(pw: string): string {
const salt = randomBytes(16);
const hash = scryptSync(pw, salt, 64);
return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`;
}
export function verifyPassword(pw: string, stored: string): boolean {
const parts = stored.split('$');
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
const salt = Buffer.from(parts[1], 'hex');
const hash = Buffer.from(parts[2], 'hex');
const test = scryptSync(pw, salt, 64);
return hash.length === test.length && timingSafeEqual(hash, test);
}
// ----- context factory: 1f wires real db+cache; tests inject in-memory -----
export function makeCreateContext(opts: { db: DatabaseSync; cache: CacheRepository }) {
return ({ req, resHeaders }: CreateContextOpts): Context => ({
db: opts.db,
cache: opts.cache,
resHeaders,
userId: resolveSessionUserId(opts.db, req),
});
}
+73
View File
@@ -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;