Security hardening: auth, bcrypt, rate-limiting, RLS, audit

This commit is contained in:
Manohar Gupta 2026-05-16 23:11:01 +05:30
parent 4cf886ea43
commit 2196c3d07d
20 changed files with 770 additions and 168 deletions

View file

@ -95,6 +95,29 @@ const { familyId, familyName, child, children, tier, memberCount } = useFamily()
**Offline Queue:** Uses localStorage (`tia_offline_queue`) for failed API calls, retries when online.
**Session Validation:** All data API routes must use functions from `@/lib/auth`:
```typescript
import { validateSession, requireFamily, requireOwnership } from "@/lib/auth";
// Require family for user data
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const familyId = auth.session!.familyId!;
// ... route logic
}
// Require ownership for specific resources
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
```
**Chat Sessions:** Stored in localStorage (`tia_chat_sessions`) - shared between home page AI card and /ai page. Database tables: `chat_sessions`, `chat_messages`.
**API Routes:** Return standard JSON `{ success: true, items: [...] }` format for lists.
@ -210,18 +233,43 @@ Set in `.env.local` for development, or in Dokploy dashboard for production.
Required:
- `DATABASE_URL` - PostgreSQL connection
- `AUTH_SECRET` - NextAuth secret
- `DATABASE_URL` - PostgreSQL connection (as `tia_app` role after H2.1)
- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only)
- `LITELLM_URL` - AI gateway URL
- `LITELLM_KEY` - AI API key
- `R2_ACCOUNT_ID` - Cloudflare R2 account ID
- `R2_ACCESS_KEY_ID` - R2 access key
- `R2_SECRET_ACCESS_KEY` - R2 secret key
- `R2_BUCKET_NAME` - R2 bucket name (e.g., "tia")
- `R2_PUBLIC_URL` - Public R2 URL (e.g., `https://pub-...r2.dev`)
- `R2_PUBLIC_URL` - Public R2 URL
- `CRON_SECRET` - Secret for cron backup endpoint
Optional for AI:
### Security Patterns
- `LITELLM_BASE_URL` - AI gateway URL
- `LITELLM_API_KEY` - AI API key
All data API routes must validate sessions using `@/lib/auth`:
```typescript
import { requireFamily, requireOwnership } from "@/lib/auth";
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
// ... route logic
}
```
AI routes use medical guardrails from `@/lib/ai/medical-triggers`:
```typescript
import { detectMedicalIntent } from "@/lib/ai/medical-triggers";
const intent = detectMedicalIntent(query);
if (intent.isMedical) {
// Redirect to pediatrician
}
```
## Known Issues

57
docs/database-setup.md Normal file
View file

@ -0,0 +1,57 @@
# Database Setup
## Manual Migrations
This directory contains SQL migrations that require superuser access and are applied manually.
## Applying Migrations
### Apply with psql:
```bash
# Connect as superuser
psql "$DATABASE_URL_SUPERUSER" -f drizzle/manual/01-app-role.sql
```
### Environment Variables
- `DATABASE_URL` - Application connection (as `tia_app` role)
- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only)
## Migration 01: App Role
File: `01-app-role.sql`
Creates `tia_app` role for application connections.
**Before applying:**
1. Change the password in the SQL file to a strong random value:
```sql
CREATE ROLE tia_app WITH LOGIN PASSWORD 'your-secure-random-password';
```
2. Update `DATABASE_URL` in Dokploy to use `tia_app`:
```
postgresql://tia_app:your-password@host:5432/tia
```
**Apply:**
```bash
psql "$DATABASE_URL_SUPERUSER" -f drizzle/manual/01-app-role.sql
```
**After applying:**
- Test application works with new role
- Verify `tia_app` can SELECT/INSERT/UPDATE/DELETE
- Verify `tia_app` CANNOT DROP tables, CREATE TABLE, or ALTER ROLE
## Migration 02: Enable RLS
File: `02-enable-rls.sql`
Enables Row-Level Security on all family-scoped tables.
**Apply after H2.1 and H2.2 are complete:**
```bash
psql "$DATABASE_URL_SUPERUSER" -f drizzle/manual/02-enable-rls.sql
```

2
next-env.d.ts vendored
View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -2,6 +2,28 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "Content-Security-Policy", value:
"default-src 'self'; " +
"img-src 'self' data: https://*.r2.cloudflarestorage.com https://*.r2.dev; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' https://llm.manohargupta.com; " +
"font-src 'self' data:;"
},
],
},
];
},
};
export default nextConfig;

View file

@ -11,6 +11,7 @@
"@auth/drizzle-adapter": "^1.11.2",
"@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2",
@ -21,6 +22,7 @@
"nodemailer": "^7.0.13",
"openai": "^6.37.0",
"postgres": "^3.4.9",
"rate-limiter-flexible": "^11.1.0",
"react": "19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.4",
@ -28,6 +30,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

28
pnpm-lock.yaml generated
View file

@ -17,6 +17,9 @@ importers:
'@aws-sdk/s3-request-presigner':
specifier: ^3.1045.0
version: 3.1045.0
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
chart.js:
specifier: ^4.5.1
version: 4.5.1
@ -47,6 +50,9 @@ importers:
postgres:
specifier: ^3.4.9
version: 3.4.9
rate-limiter-flexible:
specifier: ^11.1.0
version: 11.1.0
react:
specifier: 19.2.4
version: 19.2.4
@ -63,6 +69,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.3.0
'@types/bcryptjs':
specifier: ^3.0.0
version: 3.0.0
'@types/node':
specifier: ^20
version: 20.19.40
@ -1273,6 +1282,10 @@ packages:
'@tailwindcss/postcss@4.3.0':
resolution: {integrity: sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==}
'@types/bcryptjs@3.0.0':
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
'@types/node@20.19.40':
resolution: {integrity: sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==}
@ -1289,6 +1302,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
@ -1645,6 +1662,9 @@ packages:
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
rate-limiter-flexible@11.1.0:
resolution: {integrity: sha512-lyyC0SqKz+dE5JoHZ4JMqdrM3LSZKBxzuAFAyKCYAnmHnPz/Rb6iDquxoL4CMipDXoR0G+QRhOzYWL3JKihbNw==}
react-chartjs-2@5.3.1:
resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==}
peerDependencies:
@ -3012,6 +3032,10 @@ snapshots:
postcss: 8.5.14
tailwindcss: 4.3.0
'@types/bcryptjs@3.0.0':
dependencies:
bcryptjs: 3.0.3
'@types/node@20.19.40':
dependencies:
undici-types: 6.21.0
@ -3026,6 +3050,8 @@ snapshots:
baseline-browser-mapping@2.10.28: {}
bcryptjs@3.0.3: {}
bowser@2.14.1: {}
buffer-from@1.1.2: {}
@ -3304,6 +3330,8 @@ snapshots:
preact@10.24.3: {}
rate-limiter-flexible@11.1.0: {}
react-chartjs-2@5.3.1(chart.js@4.5.1)(react@19.2.4):
dependencies:
chart.js: 4.5.1

View file

@ -1,44 +1,157 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { cookies } from "next/headers";
import bcrypt from "bcryptjs";
import { rateLimit, getClientIp, getRateLimitKey } from "@/lib/rate-limit";
// Simple admin auth - in production use proper JWT
const ADMIN_USER = "admin";
const ADMIN_PASS = "admin123";
export const dynamic = "force-dynamic";
async function hashPassword(password: string): Promise<string> {
return await bcrypt.hash(password, 12);
}
// POST - Sign in or create first admin
export async function POST(request: Request) {
try {
const body = await request.json();
const { username, password } = body;
const { username, password, action } = body;
// Simple check (in production use bcrypt)
if (username === ADMIN_USER && password === ADMIN_PASS) {
return NextResponse.json({
success: true,
admin: { username, role: "super_admin" },
token: "admin-session-token"
});
if (!username || !password) {
return NextResponse.json({ error: "Username and password required" }, { status: 400 });
}
// Rate limiting
const ip = getClientIp(request);
const isSignup = action === "signup";
const rateLimitResult = await rateLimit(
getRateLimitKey(isSignup ? "admin-signup" : "admin-signin", ip),
{ max: isSignup ? 3 : 5, windowSec: isSignup ? 3600 : 900 }
);
if (!rateLimitResult.success) {
const response = NextResponse.json(
{ error: "Too many attempts. Please try again later." },
{ status: 429 }
);
response.headers.set("Retry-After", Math.ceil((rateLimitResult.reset.getTime() - Date.now()) / 1000).toString());
return response;
}
// First time setup (signup)
if (isSignup) {
// Check if admin already exists
const existing = await sql`SELECT id FROM admins LIMIT 1`;
if (existing && existing.length > 0) {
return NextResponse.json({ error: "Admin already exists. Use sign in." }, { status: 400 });
}
const adminId = crypto.randomUUID();
const passwordHash = await hashPassword(password);
await sql`
INSERT INTO admins (id, username, password_hash, role, created_at)
VALUES (${adminId}, ${username}, ${passwordHash}, 'super_admin', NOW())
`;
// Create session
const sessionToken = crypto.randomUUID();
const expires = new Date();
expires.setDate(expires.getDate() + 7);
await sql`
INSERT INTO admin_sessions (session_token, admin_id, expires)
VALUES (${sessionToken}, ${adminId}, ${expires.toISOString()})
`;
const response = NextResponse.json({
success: true,
admin: { username, role: "super_admin" },
});
response.cookies.set("tia_admin_session", sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
return response;
}
// Sign in - verify against database
const admins = await sql`
SELECT id, username, password_hash, role FROM admins WHERE username = ${username} LIMIT 1
`;
const admin = admins?.[0];
if (!admin) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
const valid = await bcrypt.compare(password, admin.password_hash);
if (!valid) {
return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
}
// Create session
const sessionToken = crypto.randomUUID();
const expires = new Date();
expires.setDate(expires.getDate() + 7);
await sql`
INSERT INTO admin_sessions (session_token, admin_id, expires)
VALUES (${sessionToken}, ${admin.id}, ${expires.toISOString()})
`;
// Update last login
await sql`UPDATE admins SET last_login = NOW() WHERE id = ${admin.id}`;
const response = NextResponse.json({
success: true,
admin: { username: admin.username, role: admin.role },
});
response.cookies.set("tia_admin_session", sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
path: "/",
});
return response;
} catch (error) {
console.error("Admin auth error:", error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// GET admin info (protected)
// GET - Check current session
export async function GET(request: Request) {
const authHeader = request.headers.get("authorization");
try {
const cookieStore = await cookies();
const sessionToken = cookieStore.get("tia_admin_session")?.value;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (!sessionToken) {
return NextResponse.json({ authenticated: false });
}
// Check token
if (authHeader !== "Bearer admin-session-token") {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
// Verify session
const sessions = await sql`
SELECT s.admin_id, s.expires, a.username, a.role
FROM admin_sessions s
JOIN admins a ON a.id = s.admin_id
WHERE s.session_token = ${sessionToken}
AND s.expires > NOW()
LIMIT 1
`;
if (!sessions || sessions.length === 0) {
return NextResponse.json({ authenticated: false });
}
const session = sessions[0];
return NextResponse.json({
admin: { username: "admin", role: "super_admin" }
authenticated: true,
admin: { username: session.username, role: session.role }
});
} catch (error) {
console.error("Admin session check error:", error);
return NextResponse.json({ authenticated: false });
}
}

View file

@ -1,11 +1,18 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { detectMedicalIntent, ESCALATION_RULES } from "@/lib/ai/medical-triggers";
import { logAudit } from "@/lib/audit";
const LITELLM_URL = process.env.LITELLM_URL || "https://llm.manohargupta.com";
const LITELLM_KEY = process.env.LITELLM_KEY || "sk-tiger-gateway-289bf7d1cf0c0b12ff5ccf48d95ff3c3";
const LITELLM_URL = process.env.LITELLM_URL;
const LITELLM_KEY = process.env.LITELLM_KEY;
export async function POST(request: Request) {
try {
// Check API key is configured
if (!LITELLM_KEY) {
return NextResponse.json({ error: "AI service not configured" }, { status: 503 });
}
const body = await request.json();
const { messages, childId } = body;
@ -13,6 +20,43 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "messages array required" }, { status: 400 });
}
const lastUserMsg = [...messages].reverse().find(m => m.role === "user")?.content || "";
// HARD GUARDRAIL: Check for medical intent BEFORE calling LLM
const intent = detectMedicalIntent(lastUserMsg);
if (intent.isMedical) {
// Fetch pediatrician phone
const families = await sql`
SELECT pediatrician_phone FROM families
WHERE id IN (SELECT family_id FROM family_members WHERE user_id IN (SELECT user_id FROM sessions WHERE session_token = ${request.headers.get("cookie")?.match(/tia_session=([^;]+)/)?.[1]}))
LIMIT 1
`;
const pediatricianPhone = families[0]?.pediatrician_phone;
const reply = [
`I can't interpret symptoms — that's a pediatrician's job, not mine.`,
``,
ESCALATION_RULES[intent.category],
``,
pediatricianPhone
? `Call your pediatrician now: ${pediatricianPhone}`
: `Add your pediatrician's phone in Settings so I can show it here.`,
].join("\n");
// Audit log
await logAudit({
action: "ai_medical_redirect",
metadata: { category: intent.category, keyword: intent.matchedKeyword },
request,
});
return NextResponse.json({
reply,
redirected: true,
category: intent.category,
});
}
// Get child's context
let context = "";
if (childId) {
@ -29,7 +73,16 @@ export async function POST(request: Request) {
const systemMessage = {
role: "system",
content: `You are Tia, a friendly baby care assistant. Give caring, practical advice for new parents. Keep responses brief and helpful.`,
content: `You are Tia, a friendly baby care assistant.
STRICT RULES:
- Never diagnose, interpret symptoms, or give medical advice
- Never recommend medications, dosages, or treatments
- If user describes symptoms, fever, rash, breathing issues refuse and redirect to pediatrician
- Provide only general educational info (developmental milestones, food intro, sleep patterns)
- Always note "this is general info, not medical advice"
- Keep responses under 200 words
- Be warm but clinical`,
};
// Call LiteLLM
@ -43,6 +96,7 @@ export async function POST(request: Request) {
model: "minimax-2.7",
messages: [systemMessage, ...messages],
max_tokens: 500,
temperature: 0.3,
}),
});
@ -52,7 +106,6 @@ export async function POST(request: Request) {
}
const data = await response.json();
console.log("LiteLLM response:", data);
return NextResponse.json({ reply: data.choices?.[0]?.message?.content });
} catch (error) {
console.error(error);

View file

@ -1,10 +1,25 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth";
// GET - list allergies for a child
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId") || "default";
const childId = searchParams.get("childId");
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
try {
const allergies = await sql.unsafe(
@ -21,6 +36,11 @@ export async function GET(request: Request) {
// POST - create allergy
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body = await request.json();
const { childId, name, severity, notes } = body;
@ -29,6 +49,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
const [allergy] = await sql.unsafe(
`INSERT INTO allergies (child_id, name, severity, notes)
VALUES ($1, $2, $3, $4)
@ -43,32 +68,13 @@ export async function POST(request: Request) {
}
}
// PATCH - update allergy
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { id, name, severity, notes } = body;
if (!id || !name) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const [allergy] = await sql.unsafe(
`UPDATE allergies SET name = $1, severity = $2, notes = $3, updated_at = NOW()
WHERE id = $4
RETURNING id, name, severity, notes`,
[name, severity, notes, id]
);
return NextResponse.json({ success: true, allergy });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// DELETE - delete allergy
export async function DELETE(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@ -77,6 +83,17 @@ export async function DELETE(request: Request) {
}
try {
const allergy = await sql.unsafe(
`SELECT a.id, a.child_id FROM allergies a
JOIN children c ON c.id = a.child_id
WHERE a.id = $1`,
[id]
);
if (!allergy || allergy.length === 0) {
return NextResponse.json({ error: "Allergy not found or access denied" }, { status: 404 });
}
await sql.unsafe(`DELETE FROM allergies WHERE id = $1`, [id]);
return NextResponse.json({ success: true });
} catch (error) {

View file

@ -1,10 +1,15 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { validateSession, requireFamily } from "@/lib/auth";
// GET - list children
// GET - list children (family only)
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const familyId = searchParams.get("familyId") || null;
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const familyId = auth.session!.familyId!;
try {
const children = await sql.unsafe(
@ -18,11 +23,17 @@ export async function GET(request: Request) {
}
}
// POST - create child
// POST - create child (family only)
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body = await request.json();
const { familyId, name, birthDate, sex } = body;
const { name, birthDate, sex } = body;
const familyId = auth.session!.familyId!;
if (!familyId || !name || !birthDate || !sex) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
@ -39,25 +50,3 @@ export async function POST(request: Request) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// PATCH - update child
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { id, name, birthDate } = body;
if (!id || !name || !birthDate) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const [child] = await sql.unsafe(
`UPDATE children SET name = $1, birth_date = $2, updated_at = NOW() WHERE id = $3 RETURNING id, name, birth_date as "birthDate", sex`,
[name, birthDate, id]
);
return NextResponse.json({ success: true, child });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth } from "@/lib/growth-standards";
interface GrowthEntry {
@ -12,6 +13,11 @@ interface GrowthEntry {
}
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body: GrowthEntry = await request.json();
const { childId, measuredAt, weightKg, heightCm, headCircumferenceCm, notes } = body;
@ -20,6 +26,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
await sql.unsafe(
`INSERT INTO growth (child_id, measured_at, weight_kg, height_cm, head_circumference_cm, notes)
VALUES ($1, $2, $3, $4, $5, $6)`,
@ -34,6 +46,11 @@ export async function POST(request: Request) {
}
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId");
const birthDate = searchParams.get("birthDate");
@ -43,6 +60,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
try {
const growth = await sql.unsafe(
`SELECT * FROM growth WHERE child_id = $1 ORDER BY measured_at DESC`,

View file

@ -1,10 +1,25 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth";
// GET - list illness logs for a child
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId") || "default";
const childId = searchParams.get("childId");
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
try {
const illnesses = await sql.unsafe(
@ -21,6 +36,11 @@ export async function GET(request: Request) {
// POST - create illness log
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body = await request.json();
const { childId, name, startDate, endDate, notes } = body;
@ -29,6 +49,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
const [illness] = await sql.unsafe(
`INSERT INTO illness_logs (child_id, name, start_date, end_date, notes)
VALUES ($1, $2, $3, $4, $5)
@ -43,32 +68,13 @@ export async function POST(request: Request) {
}
}
// PATCH - update illness log
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { id, name, startDate, endDate, notes } = body;
if (!id || !name || !startDate) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const [illness] = await sql.unsafe(
`UPDATE illness_logs SET name = $1, start_date = $2, end_date = $3, notes = $4, updated_at = NOW()
WHERE id = $5
RETURNING id, name, start_date as "startDate", end_date as "endDate", notes`,
[name, startDate, endDate, notes, id]
);
return NextResponse.json({ success: true, illness });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// DELETE - delete illness log
export async function DELETE(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@ -77,6 +83,17 @@ export async function DELETE(request: Request) {
}
try {
const illness = await sql.unsafe(
`SELECT i.id, i.child_id FROM illness_logs i
JOIN children c ON c.id = i.child_id
WHERE i.id = $1`,
[id]
);
if (!illness || illness.length === 0) {
return NextResponse.json({ error: "Illness not found or access denied" }, { status: 404 });
}
await sql.unsafe(`DELETE FROM illness_logs WHERE id = $1`, [id]);
return NextResponse.json({ success: true });
} catch (error) {

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth";
interface LogEntry {
type: "feed" | "diaper" | "sleep";
@ -12,6 +13,11 @@ interface LogEntry {
}
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body: LogEntry = await request.json();
const { type, childId, subType, amountMl, notes, startedAt, endedAt } = body;
@ -20,6 +26,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
const now = new Date().toISOString();
if (type === "feed") {
@ -54,6 +66,11 @@ export async function POST(request: Request) {
}
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId");
const type = searchParams.get("type");
@ -63,6 +80,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "childId and type required" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
try {
let results: any[] = [];
if (type === "feed") {

View file

@ -1,10 +1,26 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth";
// GET - list medicines for a child
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId") || "default";
const childId = searchParams.get("childId");
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
try {
const medicines = await sql.unsafe(
@ -21,6 +37,11 @@ export async function GET(request: Request) {
// POST - create medicine
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body = await request.json();
const { childId, name, dose, notes, reminderTime } = body;
@ -29,6 +50,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
const [medicine] = await sql.unsafe(
`INSERT INTO medicines (child_id, name, dose, notes, reminder_time)
VALUES ($1, $2, $3, $4, $5)
@ -43,32 +70,13 @@ export async function POST(request: Request) {
}
}
// PATCH - update medicine
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { id, name, dose, notes, reminderTime } = body;
if (!id || !name) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const [medicine] = await sql.unsafe(
`UPDATE medicines SET name = $1, dose = $2, notes = $3, reminder_time = $4, updated_at = NOW()
WHERE id = $5
RETURNING id, name, dose, notes, reminder_time as "reminderTime"`,
[name, dose, notes, reminderTime, id]
);
return NextResponse.json({ success: true, medicine });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// DELETE - delete medicine
export async function DELETE(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@ -77,6 +85,18 @@ export async function DELETE(request: Request) {
}
try {
// Verify medicine belongs to a child in user's family
const medicine = await sql.unsafe(
`SELECT m.id, m.child_id FROM medicines m
JOIN children c ON c.id = m.child_id
WHERE m.id = $1`,
[id]
);
if (!medicine || medicine.length === 0) {
return NextResponse.json({ error: "Medicine not found or access denied" }, { status: 404 });
}
await sql.unsafe(`DELETE FROM medicines WHERE id = $1`, [id]);
return NextResponse.json({ success: true });
} catch (error) {

View file

@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth";
interface VaccinationEntry {
childId: string;
@ -13,6 +14,11 @@ interface VaccinationEntry {
}
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body: VaccinationEntry = await request.json();
const { childId, vaccineName, scheduledDate, givenDate, status, provider, lotNumber, notes } = body;
@ -21,6 +27,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
await sql.unsafe(
`INSERT INTO vaccinations (child_id, vaccine_name, scheduled_date, given_date, status, provider, lot_number, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
@ -35,6 +47,11 @@ export async function POST(request: Request) {
}
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId");
@ -42,6 +59,12 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
// Verify child belongs to user's family
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
try {
const vaccinations = await sql.unsafe(
`SELECT * FROM vaccinations WHERE child_id = $1 ORDER BY scheduled_date ASC`,

View file

@ -1,10 +1,25 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily, requireOwnership } from "@/lib/auth";
// GET - list doctor visits for a child
export async function GET(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const childId = searchParams.get("childId") || "default";
const childId = searchParams.get("childId");
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
try {
const visits = await sql.unsafe(
@ -21,6 +36,11 @@ export async function GET(request: Request) {
// POST - create visit
export async function POST(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
try {
const body = await request.json();
const { childId, doctorName, reason, date, notes } = body;
@ -29,6 +49,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const ownership = await requireOwnership(childId, "children", "Child");
if (!ownership.success) {
return NextResponse.json({ error: ownership.error }, { status: ownership.status });
}
const [visit] = await sql.unsafe(
`INSERT INTO doctor_visits (child_id, doctor_name, reason, visit_date, notes)
VALUES ($1, $2, $3, $4, $5)
@ -43,32 +68,13 @@ export async function POST(request: Request) {
}
}
// PATCH - update visit
export async function PATCH(request: Request) {
try {
const body = await request.json();
const { id, doctorName, reason, date, notes } = body;
if (!id || !doctorName || !date) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const [visit] = await sql.unsafe(
`UPDATE doctor_visits SET doctor_name = $1, reason = $2, visit_date = $3, notes = $4, updated_at = NOW()
WHERE id = $5
RETURNING id, doctor_name as "doctorName", reason, visit_date as "date", notes`,
[doctorName, reason, date, notes, id]
);
return NextResponse.json({ success: true, visit });
} catch (error) {
console.error(error);
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
// DELETE - delete visit
export async function DELETE(request: Request) {
const auth = await requireFamily();
if (!auth.success) {
return NextResponse.json({ error: auth.error }, { status: auth.status });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
@ -77,6 +83,17 @@ export async function DELETE(request: Request) {
}
try {
const visit = await sql.unsafe(
`SELECT v.id, v.child_id FROM doctor_visits v
JOIN children c ON c.id = v.child_id
WHERE v.id = $1`,
[id]
);
if (!visit || visit.length === 0) {
return NextResponse.json({ error: "Visit not found or access denied" }, { status: 404 });
}
await sql.unsafe(`DELETE FROM doctor_visits WHERE id = $1`, [id]);
return NextResponse.json({ success: true });
} catch (error) {

View file

@ -8,9 +8,11 @@ const queryClient = postgres(connectionString, {
max: 10,
idle_timeout: 20,
max_lifetime: 60 * 30,
// Prepare: true enables prepared statements
});
export const db = drizzle(queryClient, { schema });
// Raw client for queries that need session context
export const sql = queryClient;
export const dbUnscoped = drizzle(queryClient, { schema });
export type Database = typeof db;
export type Database = typeof dbUnscoped;

View file

@ -0,0 +1,57 @@
// Medical intent detection for AI guardrails
export const MEDICAL_KEYWORDS = [
// symptoms
"fever", "temperature", "vomit", "throw up", "diarrhea", "rash",
"breathing", "breath", "cough", "wheeze", "lethargy", "lethargic",
"unconscious", "seizure", "convulsion", "blood", "bleeding",
"swollen", "swelling", "bruise", "injury", "hurt", "pain",
"infection", "pus", "discharge", "dehydration", "dehydrated",
"refusing feed", "won't eat", "won't drink", "no wet diaper",
"yellow skin", "jaundice", "blue lips", "limp",
// diagnostic phrasing
"is it normal", "is this normal", "should I worry", "should i worry",
"what's wrong", "whats wrong", "diagnose", "what could", "what might",
// medication
"how much medicine", "dosage", "give my baby", "can I give",
"tylenol", "crocin", "paracetamol", "ibuprofen", "antibiotic",
];
export const ESCALATION_RULES = {
fever: "For babies under 12 months, any fever above 100.4°F (38°C) requires same-day pediatrician contact. Above 102°F at any age is same-day.",
breathing: "Any unusual breathing pattern, fast breathing, or pulling at the chest is an immediate doctor visit.",
feeding: "Refusing all feeds for 8+ hours, or no wet diaper for 6+ hours, needs urgent attention.",
lethargy: "If your baby seems unusually sleepy, hard to wake, or floppy, call your pediatrician now.",
vomiting: "More than 2 vomiting episodes, or projectile vomiting, needs same-day medical care.",
rash: "A rash with fever, or any rash that spreads quickly, needs to be seen.",
injury: "Any head injury, fall from height, or visible wound needs assessment.",
default: "This is something a pediatrician needs to evaluate, not me.",
} as const;
export type MedicalCategory = keyof typeof ESCALATION_RULES;
export function detectMedicalIntent(query: string): {
isMedical: boolean;
category: MedicalCategory;
matchedKeyword: string | null;
} {
const lower = query.toLowerCase();
for (const kw of MEDICAL_KEYWORDS) {
if (lower.includes(kw)) {
let category: MedicalCategory = "default";
if (/fever|temperature/.test(lower)) category = "fever";
else if (/breath|cough|wheeze/.test(lower)) category = "breathing";
else if (/lethargy|lethargic|unconscious|limp/.test(lower)) category = "lethargy";
else if (/feed|drink|eat|diaper/.test(lower)) category = "feeding";
else if (/vomit|throw up/.test(lower)) category = "vomiting";
else if (/rash/.test(lower)) category = "rash";
else if (/injury|hurt|blood|bleeding|fall|head/.test(lower)) category = "injury";
return { isMedical: true, category, matchedKeyword: kw };
}
}
return { isMedical: false, category: "default", matchedKeyword: null };
}

View file

@ -1,7 +1,68 @@
export function middleware() {
return;
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Public routes that don't require authentication
const publicRoutes = [
"/",
"/login",
"/admin-login",
"/api/auth/signin",
"/api/admin/auth",
"/api/setup",
"/api/onboarding",
"/api/debug",
"/api/ai",
];
// Protected API routes that need authentication
const protectedApiRoutes = [
"/api/children",
"/api/logs",
"/api/growth",
"/api/vaccinations",
"/api/medicines",
"/api/allergies",
"/api/illnesses",
"/api/visits",
"/api/family",
"/api/families",
"/api/invites",
"/api/notifications",
"/api/upload",
"/api/chat",
"/api/history",
];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Always allow public routes
if (publicRoutes.some((route) => pathname === route || pathname.startsWith(route + "/"))) {
return NextResponse.next();
}
// Check session cookie for protected routes
const sessionToken = request.cookies.get("tia_session")?.value;
const adminSessionToken = request.cookies.get("tia_admin_session")?.value;
// Allow admin routes with admin session
if (pathname.startsWith("/api/admin") && adminSessionToken) {
return NextResponse.next();
}
// For protected API routes, require session
if (protectedApiRoutes.some((route) => pathname.startsWith(route))) {
if (!sessionToken && !adminSessionToken) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api).*)"],
matcher: [
"/api/:path*",
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};

View file

@ -0,0 +1,29 @@
// Migration script to invalidate old password hashes
// Old format "hash_..." passwords will be invalidated, forcing password reset
import { sql } from "../db";
async function migratePasswords() {
console.log("Finding users with old password hashes...");
// Find users with old hash format
const users = await sql`
SELECT id, email, password_hash FROM users
WHERE password_hash IS NOT NULL
AND password_hash LIKE 'hash_%'
`;
console.log(`Found ${users.length} users with old password hashes`);
for (const user of users) {
console.log(`Invalidating password for ${user.email}...`);
await sql`
UPDATE users SET password_hash = NULL, password_updated_at = NULL
WHERE id = ${user.id}
`;
}
console.log("Migration complete. Users will need to reset password.");
}
migratePasswords().catch(console.error).finally(() => process.exit());