148 lines
No EOL
3.7 KiB
TypeScript
148 lines
No EOL
3.7 KiB
TypeScript
// Session validation and family resolver for API routes
|
|
// All data routes must use this to validate sessions and get familyId
|
|
|
|
import { sql } from "@/db";
|
|
import { cookies } from "next/headers";
|
|
|
|
interface SessionData {
|
|
userId: string;
|
|
familyId: string | null;
|
|
familyName: string | null;
|
|
tier: string | null;
|
|
}
|
|
|
|
interface AuthResult {
|
|
success: boolean;
|
|
error?: string;
|
|
status?: number;
|
|
session?: SessionData;
|
|
}
|
|
|
|
/**
|
|
* Validate session cookie and resolve family information
|
|
* Use this at the start of every data API route
|
|
*/
|
|
export async function validateSession(): Promise<AuthResult> {
|
|
try {
|
|
const cookieStore = await cookies();
|
|
const sessionToken = cookieStore.get("tia_session")?.value;
|
|
|
|
if (!sessionToken) {
|
|
return { success: false, error: "Not authenticated", status: 401 };
|
|
}
|
|
|
|
// Verify session and get family info
|
|
const sessions = await sql`
|
|
SELECT s.user_id, s.expires, fm.family_id as family_id, f.name as family_name, f.tier
|
|
FROM sessions s
|
|
JOIN users u ON u.id::text = s.user_id::text
|
|
LEFT JOIN family_members fm ON fm.user_id::text = u.id::text
|
|
LEFT JOIN families f ON f.id = fm.family_id
|
|
WHERE s.session_token = ${sessionToken}
|
|
AND s.expires > NOW()
|
|
LIMIT 1
|
|
`;
|
|
|
|
if (!sessions || sessions.length === 0) {
|
|
return { success: false, error: "Invalid or expired session", status: 401 };
|
|
}
|
|
|
|
const session = sessions[0];
|
|
|
|
return {
|
|
success: true,
|
|
session: {
|
|
userId: session.user_id,
|
|
familyId: session.family_id,
|
|
familyName: session.family_name,
|
|
tier: session.tier,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error("Session validation error:", error);
|
|
return { success: false, error: "Session validation failed", status: 500 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require authenticated user with a family
|
|
* Use for routes that require family data
|
|
*/
|
|
export async function requireFamily(): Promise<AuthResult> {
|
|
const auth = await validateSession();
|
|
|
|
if (!auth.success) {
|
|
return auth;
|
|
}
|
|
|
|
if (!auth.session?.familyId) {
|
|
return { success: false, error: "No family associated with session", status: 403 };
|
|
}
|
|
|
|
return auth;
|
|
}
|
|
|
|
/**
|
|
* Require authenticated user who owns a specific resource
|
|
* Use to verify user has access to a resource (e.g., a specific child's data)
|
|
*
|
|
* @param resourceId - The ID of the resource to check
|
|
* @param table - The table name to check ownership in
|
|
* @param resourceLabel - Human-readable label for error messages
|
|
*/
|
|
export async function requireOwnership(
|
|
resourceId: string,
|
|
table: string,
|
|
resourceLabel: string
|
|
): Promise<AuthResult> {
|
|
const auth = await requireFamily();
|
|
|
|
if (!auth.success) {
|
|
return auth;
|
|
}
|
|
|
|
const familyId = auth.session!.familyId!;
|
|
|
|
// Check if resource belongs to user's family
|
|
const result = await sql.unsafe(
|
|
`SELECT id FROM ${table} WHERE id = $1 AND family_id = $2 LIMIT 1`,
|
|
[resourceId, familyId]
|
|
);
|
|
|
|
if (!result || result.length === 0) {
|
|
return {
|
|
success: false,
|
|
error: `${resourceLabel} not found or access denied`,
|
|
status: 404,
|
|
};
|
|
}
|
|
|
|
return auth;
|
|
}
|
|
|
|
/**
|
|
* Require a specific tier level
|
|
* Use for premium features
|
|
*/
|
|
export async function requireTier(minTier: "free" | "pro"): Promise<AuthResult> {
|
|
const auth = await requireFamily();
|
|
|
|
if (!auth.success) {
|
|
return auth;
|
|
}
|
|
|
|
const tier = auth.session?.tier || "free";
|
|
const tierLevels = { free: 0, pro: 1 };
|
|
const userLevel = tierLevels[tier as keyof typeof tierLevels] || 0;
|
|
const requiredLevel = tierLevels[minTier];
|
|
|
|
if (userLevel < requiredLevel) {
|
|
return {
|
|
success: false,
|
|
error: `Requires ${minTier} tier or higher`,
|
|
status: 403,
|
|
};
|
|
}
|
|
|
|
return auth;
|
|
} |