diff --git a/next-env.d.ts b/next-env.d.ts
index c4b7818..9edff1c 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,6 @@
///
///
-import "./.next/dev/types/routes.d.ts";
+import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/src/__tests__/security.test.ts b/src/__tests__/security.test.ts
index 7b1b349..dbcb176 100644
--- a/src/__tests__/security.test.ts
+++ b/src/__tests__/security.test.ts
@@ -1,102 +1,109 @@
-// Security verification tests
+// Security verification tests - live HTTP tests
// Run with: pnpm test -- src/__tests__/security.test.ts
+// Note: These tests require a running server
-import { validateSession, requireFamily, requireOwnership, requireTier } from "@/lib/auth";
+const BASE_URL = process.env.TEST_URL || "http://localhost:3001";
-describe("Security: Session Validation", () => {
- describe("validateSession", () => {
- it("should reject invalid session token", async () => {
- // Note: This requires mocking cookies()
- // In real tests, use next/headers in test environment
+describe("Security: Authentication", () => {
+ describe("POST /api/auth/signin", () => {
+ it("should reject invalid credentials", async () => {
+ const res = await fetch(`${BASE_URL}/api/auth/signin`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: "invalid@test.com", password: "wrong", action: "signin" }),
+ });
+ const data = await res.json();
+ expect(res.status).toBe(401);
+ expect(data.error).toBeDefined();
});
- it("should accept valid session token", async () => {
- // Validates session and returns user/family info
- });
- });
-
- describe("requireFamily", () => {
- it("should reject unauthenticated requests", async () => {
- const result = await requireFamily();
- expect(result.success).toBe(false);
- expect(result.status).toBe(401);
- });
-
- it("should reject authenticated user without family", async () => {
- // User authenticated but familyId is null
+ it("should return success with valid credentials", async () => {
+ const res = await fetch(`${BASE_URL}/api/auth/signin`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: "test@test.com", password: "Test1234", action: "signin" }),
+ });
+ // Either 200 (success) or 401 (invalid password) is acceptable
+ expect([200, 401]).toContain(res.status);
});
});
});
-describe("Security: Ownership Validation", () => {
- describe("requireOwnership", () => {
- it("should reject access to other family's children", async () => {
- // Try to access child that doesn't belong to user's family
- });
+describe("Security: Cross-Family Access", () => {
+ it("should reject access to chat without auth", async () => {
+ const res = await fetch(`${BASE_URL}/api/chat`);
+ expect(res.status).toBe(401);
+ });
- it("should allow access to own family's children", async () => {
- // Try to access child that belongs to user's family
- });
+ it("should reject access to family without auth", async () => {
+ const res = await fetch(`${BASE_URL}/api/family`);
+ expect(res.status).toBe(401);
+ });
+
+ it("should reject access to family/members without auth", async () => {
+ const res = await fetch(`${BASE_URL}/api/family/members`);
+ expect(res.status).toBe(401);
+ });
+
+ it("should reject access to invites without auth", async () => {
+ const res = await fetch(`${BASE_URL}/api/invites`);
+ expect(res.status).toBe(401);
+ });
+
+ it("should reject access to upload without auth", async () => {
+ const res = await fetch(`${BASE_URL}/api/upload`);
+ expect(res.status).toBe(401);
});
});
-describe("Security: Tier Validation", () => {
- describe("requireTier", () => {
- it("should reject free tier for pro features", async () => {
- // User has free tier but accessing pro feature
- });
+describe("Security: Admin Routes", () => {
+ it("should reject /api/admin/families without admin session", async () => {
+ const res = await fetch(`${BASE_URL}/api/admin/families`);
+ expect(res.status).toBe(401);
+ });
- it("should allow pro tier for pro features", async () => {
- // User has pro tier accessing pro feature
+ it("should reject /api/admin/users without admin session", async () => {
+ const res = await fetch(`${BASE_URL}/api/admin/users`);
+ expect(res.status).toBe(401);
+ });
+});
+
+describe("Security: Public Routes", () => {
+ it("should allow /api/auth/signin without auth", async () => {
+ const res = await fetch(`${BASE_URL}/api/auth/signin`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: "x@y.com", password: "x", action: "signin" }),
});
+ // Should not return 401 (but might return 400 or other error)
+ expect(res.status).not.toBe(401);
+ });
+
+ it("should allow /api/onboarding without auth", async () => {
+ const res = await fetch(`${BASE_URL}/api/onboarding`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({}),
+ });
+ expect([400, 401, 500]).toContain(res.status);
});
});
describe("Security: Rate Limiting", () => {
- describe("Auth Rate Limits", () => {
- it("should block after 5 failed signin attempts", async () => {
- // 5 signin failures within 15 minutes
- });
-
- it("should block after 3 failed signup attempts", async () => {
- // 3 signup failures within 1 hour
- });
- });
-});
-
-describe("Security: Password", () => {
- describe("bcrypt", () => {
- it("should hash password with cost 12", async () => {
- const hash = await bcrypt.hash("testpassword", 12);
- expect(hash.startsWith("$2a$12$")).toBe(true);
- });
-
- it("should verify correct password", async () => {
- const hash = await bcrypt.hash("testpassword", 12);
- const valid = await bcrypt.compare("testpassword", hash);
- expect(valid).toBe(true);
- });
-
- it("should reject incorrect password", async () => {
- const hash = await bcrypt.hash("testpassword", 12);
- const valid = await bcrypt.compare("wrongpassword", hash);
- expect(valid).toBe(false);
- });
- });
-});
-
-describe("Security: Session Cookie", () => {
- describe("Cookie Settings", () => {
- it("should be httpOnly", () => {
- // Verify cookie configuration
- });
-
- it("should be secure in production", () => {
- // Verify secure flag in production env
- });
-
- it("should have sameSite: lax", () => {
- // Verify sameSite setting
- });
+ it("should implement rate limiting on auth", async () => {
+ // Try multiple signins rapidly
+ let rateLimited = false;
+ for (let i = 0; i < 10; i++) {
+ const res = await fetch(`${BASE_URL}/api/auth/signin`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: "ratelimit@test.com", password: "wrong", action: "signin" }),
+ });
+ if (res.status === 429) {
+ rateLimited = true;
+ break;
+ }
+ }
+ expect(rateLimited).toBe(true);
});
});
\ No newline at end of file
diff --git a/src/app/admin-login/page.tsx b/src/app/admin-login/page.tsx
index 386b025..af6652c 100644
--- a/src/app/admin-login/page.tsx
+++ b/src/app/admin-login/page.tsx
@@ -85,10 +85,6 @@ export default function AdminLoginPage() {
{loading ? "..." : "Login"}
-
-
- Default: admin / admin123
-
);
diff --git a/src/app/api/admin/analytics/route.ts b/src/app/api/admin/analytics/route.ts
index 8548682..c103ed2 100644
--- a/src/app/api/admin/analytics/route.ts
+++ b/src/app/api/admin/analytics/route.ts
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
export async function GET(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
// Get activity log counts
const logsCount = await sql`SELECT COUNT(*) as count FROM activity_logs`;
diff --git a/src/app/api/admin/children/route.ts b/src/app/api/admin/children/route.ts
index 32534da..8e403bd 100644
--- a/src/app/api/admin/children/route.ts
+++ b/src/app/api/admin/children/route.ts
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
export async function GET(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
// Get all children with family info
const children = await sql`
diff --git a/src/app/api/admin/families/route.ts b/src/app/api/admin/families/route.ts
index 3e4a1bc..7bb93f0 100644
--- a/src/app/api/admin/families/route.ts
+++ b/src/app/api/admin/families/route.ts
@@ -1,15 +1,13 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireAdmin } from "@/lib/admin-auth";
// GET all families with members
export async function GET(request: Request) {
- try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
- // Get all families with user and child counts
+ try {
const families = await sql`
SELECT
f.id,
@@ -27,7 +25,6 @@ export async function GET(request: Request) {
ORDER BY f.created_at DESC
`;
- // Get members for each family
const familyIds = families.map((f: any) => f.id);
let members: any[] = [];
if (familyIds.length > 0) {
@@ -72,12 +69,10 @@ export async function GET(request: Request) {
// Update family tier
export async function PATCH(request: Request) {
- try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+ try {
const body = await request.json();
const { familyId, tier, maxChildren, maxMembers } = body;
@@ -102,16 +97,13 @@ export async function PATCH(request: Request) {
// Add member to family or create family
export async function POST(request: Request) {
- try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+ try {
const body = await request.json();
const { familyId, email, role, displayName, name } = body;
- // Create new family
if (name && !familyId) {
const newFamilyId = crypto.randomUUID();
await sql`
@@ -121,12 +113,10 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, familyId: newFamilyId });
}
- // Add member to existing family
if (!familyId || !email) {
return NextResponse.json({ error: "familyId and email required" }, { status: 400 });
}
- // Find or create user
let users = await sql`SELECT id FROM users WHERE email = ${email}`;
let userId = users?.[0]?.id;
@@ -135,7 +125,6 @@ export async function POST(request: Request) {
await sql`INSERT INTO users (id, email, created_at, updated_at) VALUES (${userId}, ${email}, NOW(), NOW())`;
}
- // Add to family_members
await sql`
INSERT INTO family_members (id, family_id, user_id, role, display_name, created_at)
VALUES (${crypto.randomUUID()}, ${familyId}, ${userId}, ${role || 'caregiver'}, ${displayName || email}, NOW())
@@ -151,12 +140,10 @@ export async function POST(request: Request) {
// Remove member from family
export async function DELETE(request: Request) {
- try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+ try {
const { searchParams } = new URL(request.url);
const memberId = searchParams.get("memberId");
diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts
index 1d7b1fd..f94de74 100644
--- a/src/app/api/admin/stats/route.ts
+++ b/src/app/api/admin/stats/route.ts
@@ -1,8 +1,12 @@
import { NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// Get stats from database
export async function GET(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
const { searchParams } = new URL(request.url);
const period = searchParams.get("period") || "30"; // days
diff --git a/src/app/api/admin/support/route.ts b/src/app/api/admin/support/route.ts
index 8585085..363cada 100644
--- a/src/app/api/admin/support/route.ts
+++ b/src/app/api/admin/support/route.ts
@@ -1,12 +1,12 @@
import { NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
export async function GET(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
const { searchParams } = new URL(request.url);
const status = searchParams.get("status") || "all";
@@ -49,11 +49,10 @@ export async function GET(request: Request) {
// Update ticket status
export async function PATCH(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
const body = await request.json();
const { ticketId, status } = body;
@@ -76,6 +75,9 @@ export async function PATCH(request: Request) {
// Create ticket (for users)
export async function POST(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
const body = await request.json();
const { familyId, userId, email, subject, description, priority } = body;
diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts
index 80b3307..109f9d9 100644
--- a/src/app/api/admin/users/route.ts
+++ b/src/app/api/admin/users/route.ts
@@ -1,13 +1,13 @@
import { NextResponse } from "next/server";
+import { requireAdmin } from "@/lib/admin-auth";
import { sql } from "@/db";
// GET all users
export async function GET(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
// Get all users
const users = await sql`
@@ -46,11 +46,10 @@ export async function GET(request: Request) {
// Create user or add to family
export async function POST(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
const body = await request.json();
const { email, familyId, role, name } = body;
@@ -89,11 +88,10 @@ export async function POST(request: Request) {
// Update user password or other fields
export async function PATCH(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
const body = await request.json();
const { userId, password } = body;
@@ -130,11 +128,10 @@ export async function PATCH(request: Request) {
// Remove user from family
export async function DELETE(request: Request) {
+ const auth = await requireAdmin(request);
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
try {
- const authHeader = request.headers.get("authorization");
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
- }
const { searchParams } = new URL(request.url);
const memberId = searchParams.get("memberId");
diff --git a/src/app/api/auth/migrate/route.ts b/src/app/api/auth/migrate/route.ts
deleted file mode 100644
index c718c11..0000000
--- a/src/app/api/auth/migrate/route.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { NextResponse } from "next/server";
-import { sql } from "@/db";
-
-// Run user password migration
-export async function POST() {
- try {
- await sql`
- ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255)
- `;
- await sql`
- ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMP WITH TIME ZONE
- `;
- return NextResponse.json({ success: true });
- } catch (error) {
- return NextResponse.json({ error: String(error) }, { status: 500 });
- }
-}
\ No newline at end of file
diff --git a/src/app/api/auth/reset-confirm/route.ts b/src/app/api/auth/reset-confirm/route.ts
new file mode 100644
index 0000000..4fc70bc
--- /dev/null
+++ b/src/app/api/auth/reset-confirm/route.ts
@@ -0,0 +1,52 @@
+import { NextResponse } from "next/server";
+import { sql } from "@/db";
+import { rateLimit, getClientIp, getRateLimitKey } from "@/lib/rate-limit";
+import bcrypt from "bcryptjs";
+
+export async function POST(request: Request) {
+ const ip = getClientIp(request);
+ const rateLimitResult = await rateLimit(getRateLimitKey("reset-confirm", ip), { max: 5, windowSec: 900 });
+
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
+ }
+
+ try {
+ const body = await request.json();
+ const { token, password } = body;
+
+ if (!token || !password) {
+ return NextResponse.json({ error: "Token and password required" }, { status: 400 });
+ }
+
+ // Clean token prefix
+ const cleanToken = token.startsWith("reset_") ? token.slice(6) : token;
+
+ const resets = await sql.unsafe(
+ `SELECT * FROM password_resets WHERE token = $1 AND expires_at > NOW() AND used_at IS NULL`,
+ [cleanToken]
+ );
+
+ if (!resets || resets.length === 0) {
+ return NextResponse.json({ error: "Invalid or expired token" }, { status: 400 });
+ }
+
+ const reset = resets[0];
+ const passwordHash = await bcrypt.hash(password, 12);
+
+ await sql.unsafe(
+ `UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
+ [passwordHash, reset.user_id]
+ );
+
+ await sql.unsafe(
+ `UPDATE password_resets SET used_at = NOW() WHERE id = $1`,
+ [reset.id]
+ );
+
+ return NextResponse.json({ success: true, message: "Password updated" });
+ } catch (error) {
+ console.error("Reset confirm error:", error);
+ return NextResponse.json({ error: "Reset failed" }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/auth/reset-request/route.ts b/src/app/api/auth/reset-request/route.ts
new file mode 100644
index 0000000..7d14a20
--- /dev/null
+++ b/src/app/api/auth/reset-request/route.ts
@@ -0,0 +1,46 @@
+import { NextResponse } from "next/server";
+import { sql } from "@/db";
+import { rateLimit, getClientIp, getRateLimitKey } from "@/lib/rate-limit";
+import { randomBytes } from "crypto";
+
+export async function POST(request: Request) {
+ const ip = getClientIp(request);
+ const rateLimitResult = await rateLimit(getRateLimitKey("reset-request", ip), { max: 3, windowSec: 3600 });
+
+ if (!rateLimitResult.success) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ try {
+ const body = await request.json();
+ const { email } = body;
+
+ if (!email) {
+ return NextResponse.json({ error: "Email required" }, { status: 400 });
+ }
+
+ const users = await sql.unsafe(`SELECT id FROM users WHERE email = $1`, [email]);
+
+ // Always return success to prevent email enumeration
+ if (!users || users.length === 0) {
+ return NextResponse.json({ success: true, message: "If email exists, reset link sent" });
+ }
+
+ const user = users[0];
+ const token = randomBytes(32).toString("hex");
+ const expiresAt = new Date();
+ expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour
+
+ await sql.unsafe(
+ `INSERT INTO password_resets (user_id, token, expires_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
+ [user.id, token, expiresAt.toISOString()]
+ );
+
+ // In production, send email with reset link
+ // For now, return token for testing
+ return NextResponse.json({ success: true, token: `reset_${token}`, message: "Reset link sent" });
+ } catch (error) {
+ console.error("Reset request error:", error);
+ return NextResponse.json({ success: true, message: "If email exists, reset link sent" });
+ }
+}
\ No newline at end of file
diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts
index 165303f..eadeb21 100644
--- a/src/app/api/chat/route.ts
+++ b/src/app/api/chat/route.ts
@@ -1,8 +1,12 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireFamily, requireOwnership } from "@/lib/auth";
// GET - list chat sessions 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";
@@ -43,6 +47,9 @@ export async function GET(request: Request) {
// POST - create new chat session
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, title } = body;
@@ -51,6 +58,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "childId required" }, { status: 400 });
}
+ // Verify ownership
+ const ownership = await requireOwnership(childId, "children", "Child");
+ if (!ownership.success) {
+ return NextResponse.json({ error: ownership.error }, { status: ownership.status });
+ }
+
const [session] = await sql`
INSERT INTO chat_sessions (child_id, title)
VALUES (${childId}, ${title || "New conversation"})
@@ -66,6 +79,9 @@ export async function POST(request: Request) {
// PATCH - update session title or add message
export async function PATCH(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 { sessionId, title, role, content } = body;
@@ -92,6 +108,9 @@ export async function PATCH(request: Request) {
// DELETE - delete chat session
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");
@@ -99,6 +118,16 @@ export async function DELETE(request: Request) {
return NextResponse.json({ error: "ID required" }, { status: 400 });
}
+ // Verify ownership via child_id
+ const sessions = await sql`SELECT child_id FROM chat_sessions WHERE id = ${id}`;
+ if (!sessions.length) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+ const ownership = await requireOwnership(sessions[0].child_id, "children", "Child");
+ if (!ownership.success) {
+ return NextResponse.json({ error: ownership.error }, { status: ownership.status });
+ }
+
try {
await sql`DELETE FROM chat_sessions WHERE id = ${id}`;
return NextResponse.json({ success: true });
diff --git a/src/app/api/debug/route.ts b/src/app/api/debug/route.ts
deleted file mode 100644
index b6f08c2..0000000
--- a/src/app/api/debug/route.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { NextResponse } from "next/server";
-
-export async function GET() {
- const results: Record = {};
-
- try {
- const connectionString = process.env.DATABASE_URL || "";
- results["env_exists"] = connectionString ? "yes" : "no";
-
- if (connectionString) {
- const { default: postgres } = await import("postgres");
- const client = postgres(connectionString);
- const result = await client`SELECT version()`;
- results["connect"] = "success";
- results["version"] = result[0]?.version?.slice(0, 50) || "ok";
- await client.end();
- }
- } catch (err: any) {
- results["error"] = err.message || String(err);
- }
-
- return NextResponse.json(results);
-}
\ No newline at end of file
diff --git a/src/app/api/families/route.ts b/src/app/api/families/route.ts
index cbcc84c..6e26917 100644
--- a/src/app/api/families/route.ts
+++ b/src/app/api/families/route.ts
@@ -1,7 +1,17 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireFamily } from "@/lib/auth";
+import { cookies } from "next/headers";
+// POST - create a new family (for onboarding)
export async function POST(request: Request) {
+ // This needs authentication but not family yet
+ const sessionToken = (await cookies()).get("tia_session")?.value;
+
+ if (!sessionToken) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
+ }
+
try {
const body = await request.json();
const { name } = body;
@@ -18,20 +28,17 @@ export async function POST(request: Request) {
}
}
+// GET - list families for current user
export async function GET(request: Request) {
- const { searchParams } = new URL(request.url);
- const userId = searchParams.get("userId");
-
- if (!userId) {
- return NextResponse.json({ error: "userId required" }, { status: 400 });
- }
+ const auth = await requireFamily();
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const families = await sql.unsafe(
`SELECT f.* FROM families f
JOIN family_members fm ON f.id = fm.family_id
WHERE fm.user_id = $1`,
- [userId]
+ [auth.session!.userId]
);
return NextResponse.json({ families });
diff --git a/src/app/api/family/members/route.ts b/src/app/api/family/members/route.ts
index f1465be..be72857 100644
--- a/src/app/api/family/members/route.ts
+++ b/src/app/api/family/members/route.ts
@@ -1,22 +1,18 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireFamily } from "@/lib/auth";
// GET - list family members
export async function GET(request: Request) {
- const { searchParams } = new URL(request.url);
- const familyId = searchParams.get("familyId") || null;
-
- if (!familyId) {
- return NextResponse.json({ error: "Family ID required" }, { status: 400 });
- }
+ const auth = await requireFamily();
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
- // Simple query - no aliases
const members = await sql`
SELECT fm.id, fm.user_id, fm.role, fm.created_at, u.name, u.email
FROM family_members fm
LEFT JOIN users u ON u.id = fm.user_id
- WHERE fm.family_id = ${familyId}
+ WHERE fm.family_id = ${auth.session!.familyId}
ORDER BY fm.created_at
`;
@@ -29,6 +25,9 @@ export async function GET(request: Request) {
// DELETE - remove member
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 memberId = searchParams.get("id");
@@ -37,7 +36,8 @@ export async function DELETE(request: Request) {
}
try {
- await sql`DELETE FROM family_members WHERE id = ${memberId}`;
+ // Only delete if member belongs to this family
+ await sql`DELETE FROM family_members WHERE id = ${memberId} AND family_id = ${auth.session!.familyId}`;
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
@@ -47,6 +47,9 @@ export async function DELETE(request: Request) {
// PATCH - update member role
export async function PATCH(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 { memberId, role } = body;
@@ -56,7 +59,7 @@ export async function PATCH(request: Request) {
}
await sql`
- UPDATE family_members SET role = ${role || "caregiver"} WHERE id = ${memberId}
+ UPDATE family_members SET role = ${role || "caregiver"} WHERE id = ${memberId} AND family_id = ${auth.session!.familyId}
`;
return NextResponse.json({ success: true });
diff --git a/src/app/api/family/route.ts b/src/app/api/family/route.ts
index 7c1221b..3b295ea 100644
--- a/src/app/api/family/route.ts
+++ b/src/app/api/family/route.ts
@@ -1,15 +1,16 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireFamily } from "@/lib/auth";
// GET - get family details
export async function GET(request: Request) {
- const { searchParams } = new URL(request.url);
- const familyId = searchParams.get("familyId");
+ const auth = await requireFamily();
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
try {
const family = await sql.unsafe(
`SELECT id, name, tier, max_children, max_members FROM families WHERE id = $1`,
- [familyId]
+ [auth.session!.familyId]
);
if (!family || family.length === 0) {
@@ -25,17 +26,16 @@ export async function GET(request: Request) {
// PATCH - update family
export async function PATCH(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, pediatricianPhone, tier } = body;
-
- if (!familyId) {
- return NextResponse.json({ error: "Family ID required" }, { status: 400 });
- }
+ const { name, pediatricianPhone, tier } = body;
await sql.unsafe(
- `UPDATE families SET name = COALESCE($1, name), tier = COALESCE($2, tier), updated_at = NOW() WHERE id = $3`,
- [name, tier, familyId]
+ `UPDATE families SET name = COALESCE($1, name), pediatrician_phone = COALESCE($2, pediatrician_phone), tier = COALESCE($3, tier), updated_at = NOW() WHERE id = $4`,
+ [name, pediatricianPhone, tier, auth.session!.familyId]
);
return NextResponse.json({ success: true });
diff --git a/src/app/api/history/route.ts b/src/app/api/history/route.ts
index 0d591cc..e115890 100644
--- a/src/app/api/history/route.ts
+++ b/src/app/api/history/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireFamily, requireOwnership } from "@/lib/auth";
import { getAgeInMonths, guidelines } from "@/lib/guidelines";
const LITELLM_URL = process.env.LITELLM_BASE_URL || "https://llm.manohargupta.com";
@@ -59,6 +60,9 @@ Just return the JSON, no explanation.`;
}
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, birthDate } = body;
@@ -67,6 +71,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "childId and birthDate required" }, { status: 400 });
}
+ const ownership = await requireOwnership(childId, "children", "Child");
+ if (!ownership.success) {
+ return NextResponse.json({ error: ownership.error }, { status: ownership.status });
+ }
+
const content = await generateHistory(childId, birthDate);
// Parse and insert logs
diff --git a/src/app/api/invites/accept/route.ts b/src/app/api/invites/accept/route.ts
index 813a9ff..b8b74b1 100644
--- a/src/app/api/invites/accept/route.ts
+++ b/src/app/api/invites/accept/route.ts
@@ -1,8 +1,17 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireFamily } from "@/lib/auth";
+import { cookies } from "next/headers";
// POST /api/invites/accept - accept an invite with token
export async function POST(request: Request) {
+ // Require authentication (but not family - that's being created)
+ const sessionToken = (await cookies()).get("tia_session")?.value;
+
+ if (!sessionToken) {
+ return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
+ }
+
try {
const body = await request.json();
const { token, userId } = body;
diff --git a/src/app/api/invites/route.ts b/src/app/api/invites/route.ts
index c982b8b..d7bcc44 100644
--- a/src/app/api/invites/route.ts
+++ b/src/app/api/invites/route.ts
@@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
+import { requireFamily } from "@/lib/auth";
import { randomBytes } from "crypto";
// GET - list invites for a family
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 });
try {
const invites = await sql.unsafe(
@@ -13,7 +14,7 @@ export async function GET(request: Request) {
FROM family_invites
WHERE family_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC`,
- [familyId]
+ [auth.session!.familyId]
);
return NextResponse.json({ invites: invites || [] });
} catch (error) {
@@ -24,12 +25,15 @@ export async function GET(request: Request) {
// POST - create invite
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, email, role, displayName } = body;
+ const { email, role, displayName } = body;
- if (!familyId || !email) {
- return NextResponse.json({ error: "familyId and email required" }, { status: 400 });
+ if (!email) {
+ return NextResponse.json({ error: "email required" }, { status: 400 });
}
// Check member count limit
@@ -39,7 +43,7 @@ export async function POST(request: Request) {
LEFT JOIN family_members fm ON fm.family_id = f.id
WHERE f.id = $1
GROUP BY f.id`,
- [familyId]
+ [auth.session!.familyId]
);
const family = memberCheck[0];
@@ -57,11 +61,9 @@ export async function POST(request: Request) {
`INSERT INTO family_invites (family_id, email, role, display_name, token, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, email, role, display_name as "displayName", expires_at as "expiresAt"`,
- [familyId, email, role || "caregiver", displayName, token, expiresAt.toISOString()]
+ [auth.session!.familyId, email, role || "caregiver", displayName, token, expiresAt.toISOString()]
);
- // TODO: Send invite email with magic link
-
return NextResponse.json({ success: true, invite, inviteUrl: `/invite/${token}` });
} catch (error) {
console.error(error);
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index b408441..44b6569 100644
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -1,6 +1,7 @@
-import { S3Client, PutObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
+import { S3Client, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { NextRequest, NextResponse } from "next/server";
+import { requireFamily, requireOwnership } from "@/lib/auth";
// Get config at runtime (not build time)
function getR2Config() {
@@ -14,6 +15,9 @@ function getR2Config() {
}
export async function GET(req: NextRequest) {
+ const auth = await requireFamily();
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
const { searchParams } = new URL(req.url);
const childId = searchParams.get("childId");
@@ -52,6 +56,9 @@ export async function GET(req: NextRequest) {
}
export async function POST(req: NextRequest) {
+ const auth = await requireFamily();
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
const R2 = getR2Config();
if (!R2.accountId || !R2.accessKeyId || !R2.secretKey || !R2.bucket) {
return NextResponse.json({ error: "R2 not configured" }, { status: 500 });
@@ -69,6 +76,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Missing filename or contentType" }, { status: 400 });
}
+ // Verify ownership if childId provided
+ if (childId) {
+ const ownership = await requireOwnership(childId, "children", "Child");
+ if (!ownership.success) {
+ return NextResponse.json({ error: ownership.error }, { status: ownership.status });
+ }
+ }
+
const client = new S3Client({
region: "auto",
endpoint: `https://${R2.accountId}.r2.cloudflarestorage.com`,
@@ -97,4 +112,36 @@ export async function POST(req: NextRequest) {
const err = e as Error;
return NextResponse.json({ error: err.message }, { status: 500 });
}
+}
+
+export async function DELETE(req: NextRequest) {
+ const auth = await requireFamily();
+ if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
+
+ const { searchParams } = new URL(req.url);
+ const key = searchParams.get("key");
+
+ if (!key) {
+ return NextResponse.json({ error: "key required" }, { status: 400 });
+ }
+
+ const R2 = getR2Config();
+ if (!R2.accountId || !R2.accessKeyId || !R2.secretKey || !R2.bucket) {
+ return NextResponse.json({ error: "R2 not configured" }, { status: 500 });
+ }
+
+ const client = new S3Client({
+ region: "auto",
+ endpoint: `https://${R2.accountId}.r2.cloudflarestorage.com`,
+ credentials: { accessKeyId: R2.accessKeyId, secretAccessKey: R2.secretKey },
+ });
+
+ try {
+ const command = new DeleteObjectCommand({ Bucket: R2.bucket, Key: key });
+ await client.send(command);
+ return NextResponse.json({ success: true });
+ } catch (e: unknown) {
+ const err = e as Error;
+ return NextResponse.json({ error: err.message }, { status: 500 });
+ }
}
\ No newline at end of file
diff --git a/src/db/schema/audit.ts b/src/db/schema/audit.ts
new file mode 100644
index 0000000..5927bb2
--- /dev/null
+++ b/src/db/schema/audit.ts
@@ -0,0 +1,13 @@
+import { pgTable } from "drizzle-orm/pg-core";
+import { text, timestamp, uuid } from "drizzle-orm/pg-core";
+
+export const auditLog = pgTable("audit_log", {
+ id: uuid("id").defaultRandom().primaryKey(),
+ userId: uuid("user_id"),
+ familyId: uuid("family_id"),
+ action: text("action").notNull(),
+ metadata: text("metadata"), // JSON string
+ ipAddress: text("ip_address"),
+ userAgent: text("user_agent"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+});
\ No newline at end of file
diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts
index 946a15b..a0eac5e 100644
--- a/src/db/schema/index.ts
+++ b/src/db/schema/index.ts
@@ -1,2 +1,3 @@
export * from "./auth";
-export * from "./family";
\ No newline at end of file
+export * from "./family";
+export * from "./audit";
\ No newline at end of file
diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts
new file mode 100644
index 0000000..d089c1c
--- /dev/null
+++ b/src/lib/admin-auth.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from "next/server";
+import { sql } from "@/db";
+
+/**
+ * Validate admin session from tia_admin_session cookie
+ */
+export async function requireAdmin(request: Request): Promise<{
+ success: boolean;
+ error?: string;
+ status?: number;
+ admin?: any;
+}> {
+ const cookieHeader = request.headers.get("cookie");
+ const sessionToken = cookieHeader?.match(/tia_admin_session=([^;]+)/)?.[1];
+
+ if (!sessionToken) {
+ return { success: false, error: "Not authenticated", status: 401 };
+ }
+
+ try {
+ const sessions = await sql.unsafe(
+ `SELECT id, username, role, expires_at FROM admin_sessions
+ WHERE session_token = $1 AND expires_at > NOW()`,
+ [sessionToken]
+ );
+
+ if (!sessions || sessions.length === 0) {
+ return { success: false, error: "Invalid or expired session", status: 401 };
+ }
+
+ return { success: true, admin: sessions[0] };
+ } catch (error) {
+ console.error("Admin auth error:", error);
+ return { success: false, error: "Internal error", status: 500 };
+ }
+}
\ No newline at end of file
diff --git a/src/middleware.ts b/src/middleware.ts
index 39a5dce..cc50a44 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -8,9 +8,7 @@ const publicRoutes = [
"/admin-login",
"/api/auth/signin",
"/api/admin/auth",
- "/api/setup",
"/api/onboarding",
- "/api/debug",
"/api/ai",
];
@@ -31,14 +29,17 @@ const protectedApiRoutes = [
"/api/upload",
"/api/chat",
"/api/history",
+ "/api/family/members",
];
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();
+ for (const route of publicRoutes) {
+ if (pathname === route || pathname.startsWith(route + "/")) {
+ return NextResponse.next();
+ }
}
// Check session cookie for protected routes
@@ -51,9 +52,12 @@ export function middleware(request: NextRequest) {
}
// For protected API routes, require session
- if (protectedApiRoutes.some((route) => pathname.startsWith(route))) {
- if (!sessionToken && !adminSessionToken) {
- return NextResponse.json({ error: "Authentication required" }, { status: 401 });
+ for (const route of protectedApiRoutes) {
+ if (pathname.startsWith(route)) {
+ if (!sessionToken && !adminSessionToken) {
+ return NextResponse.json({ error: "Authentication required" }, { status: 401 });
+ }
+ break;
}
}