Task 4 — POST /api/subscriptions/create:
- family_id from session (requireFamily) — IDOR-safe, never from body
- rejects if a live sub exists (also enforced by partial unique index)
- creates RZP sub via fetch Basic auth, total_count 120, notes carry family_id
- inserts family_subscriptions row 'created'; returns subscriptionId + keyId only
- key_secret never sent to client
Task 5 — POST /api/webhooks/razorpay (source of truth):
- RAW body, timing-safe HMAC over webhook secret
- idempotency: unique insert on x-razorpay-event-id; duplicate -> 200 bail
- routes events -> family_subscriptions status + syncs families.tier:
authenticated/activated/charged/resumed/pending -> grantPremium (pending=grace)
halted/cancelled/completed/expired/paused -> revokeToFree
- 400 bad sig, 200 success/duplicate/unknown, 500 processing error (retry)
middleware: /api/subscriptions protected; /api/webhooks/razorpay intentionally
public (authenticates via HMAC, not cookie).
Verified locally: HMAC valid/tampered, unix->date, event routing maps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
2.8 KiB
TypeScript
113 lines
2.8 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
// PWA static assets — must bypass auth entirely.
|
|
// If the SW or manifest gets a 302→login, the browser registers the login
|
|
// page as the service worker, which breaks auth for the entire PWA session.
|
|
const pwaAssets = [
|
|
"/sw.js",
|
|
"/sw.js.map",
|
|
"/manifest.webmanifest",
|
|
"/~offline",
|
|
"/icons/",
|
|
"/serwist-",
|
|
];
|
|
|
|
// Public routes that don't require authentication
|
|
const publicRoutes = [
|
|
"/",
|
|
"/pricing",
|
|
"/privacy",
|
|
"/terms",
|
|
"/about",
|
|
"/blog",
|
|
"/partners",
|
|
"/login",
|
|
"/admin-login",
|
|
"/m",
|
|
"/invite",
|
|
"/verify",
|
|
"/api/auth/signin",
|
|
"/api/admin/auth",
|
|
"/api/onboarding",
|
|
"/api/profile",
|
|
];
|
|
|
|
// 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/storage-usage",
|
|
"/api/chat",
|
|
"/api/history",
|
|
"/api/family/members",
|
|
"/api/subscriptions",
|
|
// NOTE: /api/webhooks/razorpay is intentionally NOT here — Razorpay calls it
|
|
// with no session cookie; it authenticates via HMAC signature instead.
|
|
];
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const { pathname } = request.nextUrl;
|
|
|
|
// Always pass PWA assets through — never redirect to login
|
|
for (const asset of pwaAssets) {
|
|
if (pathname === asset || pathname.startsWith(asset)) {
|
|
return NextResponse.next();
|
|
}
|
|
}
|
|
|
|
// Logged-in users hitting the marketing root (or the old PWA start_url /?source=pwa)
|
|
// should land on the app home, not the marketing page.
|
|
if (pathname === "/") {
|
|
const session = request.cookies.get("tia_session")?.value;
|
|
if (session) {
|
|
return NextResponse.redirect(new URL("/home", request.url));
|
|
}
|
|
}
|
|
|
|
// Always allow public routes
|
|
for (const route of publicRoutes) {
|
|
if (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
|
|
for (const route of protectedApiRoutes) {
|
|
if (pathname.startsWith(route)) {
|
|
if (!sessionToken && !adminSessionToken) {
|
|
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
"/api/:path*",
|
|
"/((?!_next/static|_next/image|favicon.ico).*)",
|
|
],
|
|
};
|