Skip to content

Commit 089f8aa

Browse files
Your Namecursoragent
andcommitted
Add admin observability dashboard and harden open-beta security.
Server-side analytics pipeline with admin-only metrics at /admin/observability, HSTS and share-link RLS fixes, service-role admin writes, and auth redirect hardening. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 81c3136 commit 089f8aa

30 files changed

Lines changed: 1613 additions & 30 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ SUPABASE_SERVICE_ROLE_KEY=
8383
# Admin promotion on login (real email — no .local domains)
8484
OWNER_EMAIL=kubee3302@gmail.com
8585

86+
# --- Observability / analytics (admin dashboard) ---
87+
# Service role is required for server-side event storage and admin reads.
88+
# Run docs/ANALYTICS_SCHEMA.sql once in Supabase SQL editor.
89+
# Shared secret for middleware → collect route (generate a random string).
90+
ANALYTICS_INTERNAL_SECRET=
91+
# Optional salt for IP/session hashing (defaults to a built-in value).
92+
ANALYTICS_IP_SALT=
93+
8694
# Comma-separated origins for CORS if splitting API later
8795
ALLOWED_ORIGINS=http://localhost:3000,https://medcore-research-builder.vercel.app
8896

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ out
44
.env
55
.env.local
66
.env.*.local
7+
.env.vercel
8+
.env.vercel
79
.DS_Store
810
*.log
911
.vercel

SECURITY.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
| Version | Supported |
66
|---------|-----------|
7-
| 2.0.x | Yes |
7+
| 3.7.x | Yes |
88

99
## Reporting a vulnerability
1010

@@ -23,6 +23,8 @@ We aim to respond within 7 days.
2323
In scope:
2424

2525
- Server-side API routes (`/api/*`) — injection, SSRF, rate-limit bypass, secret leakage
26+
- Admin routes (`/admin/*`, `/api/admin/*`) — auth bypass, data exposure
27+
- Supabase RLS misconfigurations (share links, profiles, analytics)
2628
- Client-side XSS in manuscript fields or export flows
2729
- Insecure defaults that expose user drafts or API keys
2830

@@ -32,11 +34,25 @@ Out of scope:
3234
- Social engineering
3335
- Denial of service against upstream scholarly APIs (PubMed, Crossref, etc.)
3436

37+
## Open-beta security posture
38+
39+
| Area | Posture |
40+
|------|---------|
41+
| Auth | Supabase email/password at `/auth`; optional guest mode |
42+
| Admin | `OWNER_EMAIL` or `profiles.role = admin`; API routes check `getAppUser()` |
43+
| API keys | Server env only — never `NEXT_PUBLIC_*` except Supabase anon key |
44+
| Service role | `SUPABASE_SERVICE_ROLE_KEY` server-only (analytics, owner promotion, share reads, announcements) |
45+
| Rate limits | Per-IP tiers on LLM, search, verify, and default routes; Upstash Redis recommended on Vercel |
46+
| Share links | Unguessable tokens; reads via service role (no anon table scan) |
47+
| Analytics | `analytics_events` has RLS with no policies; middleware beacons require `ANALYTICS_INTERNAL_SECRET` |
48+
| Headers | CSP, HSTS (HTTPS), X-Frame-Options DENY, nosniff, Referrer-Policy |
49+
| Profiles RLS | Users cannot self-promote to admin (`role` column locked) |
50+
3551
## Architecture notes
3652

37-
- Drafts are stored in **browser localStorage only** — not on MedCore servers
53+
- Guest drafts live in **browser localStorage** — not on MedCore servers
54+
- Cloud sync requires authenticated users (`manuscript_projects` RLS per user)
3855
- API keys live in **server environment variables** only
39-
- Rate limits apply per IP on LLM and verification routes (self-hosted; use Redis/Upstash at scale)
4056

4157
## Safe disclosure
4258

app/admin/observability/page.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Metadata } from "next";
2+
import Link from "next/link";
3+
import { redirect } from "next/navigation";
4+
import { getAppUser } from "@/lib/auth";
5+
import { isSupabaseConfigured } from "@/lib/supabase/server";
6+
import { LogoWordmark } from "@/components/ui/Logo";
7+
import { ObservabilityDashboard } from "@/components/admin/ObservabilityDashboard";
8+
9+
export const metadata: Metadata = {
10+
title: "Observability — MedCore Admin",
11+
description: "Admin observability dashboard for visits, usage, abuse signals, and alerts.",
12+
robots: { index: false, follow: false },
13+
};
14+
15+
export default async function AdminObservabilityPage() {
16+
if (!isSupabaseConfigured()) {
17+
return (
18+
<div className="min-h-screen bg-[var(--mc-canvas)] flex items-center justify-center p-6">
19+
<div className="card-elevated max-w-md p-8 text-center">
20+
<p className="text-med-ink font-medium">Supabase not configured</p>
21+
<p className="text-sm text-med-sub mt-2">Admin observability requires Supabase auth.</p>
22+
<Link href="/" className="btn-primary inline-block mt-4">
23+
Home
24+
</Link>
25+
</div>
26+
</div>
27+
);
28+
}
29+
30+
const user = await getAppUser();
31+
if (!user || user.role !== "admin") {
32+
redirect("/auth?next=/admin/observability");
33+
}
34+
35+
return (
36+
<div className="min-h-screen bg-[var(--mc-canvas)]">
37+
<header className="sticky top-0 z-20 bg-white/85 backdrop-blur-md border-b border-med-line">
38+
<div className="max-w-[1100px] mx-auto px-5 py-3 flex items-center justify-between gap-4">
39+
<Link href="/">
40+
<LogoWordmark />
41+
</Link>
42+
<div className="text-right">
43+
<p className="text-[11px] uppercase tracking-wide text-med-sub font-medium">Admin</p>
44+
<p className="text-sm text-med-ink truncate max-w-[200px]">{user.email}</p>
45+
</div>
46+
</div>
47+
</header>
48+
<main className="max-w-[1100px] mx-auto px-5 py-8">
49+
<div className="mb-6">
50+
<h1 className="display-title text-2xl">Observability</h1>
51+
<p className="muted text-sm mt-1 max-w-2xl">
52+
Visits, auth activity, feature usage, abuse signals, and alerts for open-beta safety and
53+
marketing insights. No raw IPs or user emails are shown in aggregates.
54+
</p>
55+
</div>
56+
<ObservabilityDashboard />
57+
</main>
58+
</div>
59+
);
60+
}

app/api/_utils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from "next/server";
22
import { BODY_LIMITS, RATE_LIMITS } from "@/lib/constants";
33
import { checkRateLimit, clientKey } from "@/lib/rateLimit";
4+
import { trackFromRequest } from "@/lib/analytics/track";
45

56
export function ok<T>(data: T, init?: ResponseInit) {
67
return NextResponse.json(data, init);
@@ -18,6 +19,14 @@ export async function enforceRateLimit(req: Request, tier: RateTier = "default")
1819
const key = `${tier}:${clientKey(req)}`;
1920
const result = await checkRateLimit(key, limit, windowMs);
2021
if (!result.ok) {
22+
trackFromRequest(req, {
23+
eventType: "rate_limit",
24+
category: "abuse",
25+
path: new URL(req.url).pathname,
26+
method: req.method,
27+
metadata: { tier, retryAfterSec: result.retryAfterSec },
28+
severity: "warn",
29+
});
2130
return bad("Rate limit exceeded — please wait and try again.", 429, {
2231
retryAfterSec: result.retryAfterSec,
2332
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { ok, bad, handleError, enforceRateLimit } from "../../_utils";
2+
import { getAppUser } from "@/lib/auth";
3+
import { buildObservabilityPayload } from "@/lib/analytics/aggregate";
4+
import { isAnalyticsConfigured } from "@/lib/supabase/admin";
5+
6+
export const runtime = "nodejs";
7+
export const dynamic = "force-dynamic";
8+
9+
/** Admin-only observability metrics and retrospective report. */
10+
export async function GET(req: Request) {
11+
try {
12+
const limited = await enforceRateLimit(req, "default");
13+
if (limited) return limited;
14+
15+
const user = await getAppUser();
16+
if (!user || user.role !== "admin") return bad("Admin access required", 403);
17+
18+
const days = Math.min(90, Math.max(7, Number(new URL(req.url).searchParams.get("days") || 30)));
19+
const payload = await buildObservabilityPayload(days);
20+
21+
return ok({
22+
...payload,
23+
admin: { email: user.email },
24+
schemaReady: isAnalyticsConfigured(),
25+
});
26+
} catch (e) {
27+
return handleError(e);
28+
}
29+
}

app/api/analytics/collect/route.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { ok, bad, safeJson, handleError, enforceRateLimit } from "../../_utils";
2+
import { getAppUser } from "@/lib/auth";
3+
import { isInternalAnalyticsRequest } from "@/lib/analytics/parse-edge";
4+
import { trackFromRequest } from "@/lib/analytics/track";
5+
import type { AnalyticsCategory, AnalyticsEventType } from "@/lib/analytics/types";
6+
import { createClient, isSupabaseConfigured } from "@/lib/supabase/server";
7+
import { isAnalyticsConfigured } from "@/lib/supabase/admin";
8+
9+
export const runtime = "nodejs";
10+
export const dynamic = "force-dynamic";
11+
12+
type Body = {
13+
eventType?: AnalyticsEventType;
14+
category?: AnalyticsCategory;
15+
path?: string;
16+
method?: string;
17+
feature?: string;
18+
mode?: string;
19+
metadata?: Record<string, unknown>;
20+
};
21+
22+
const CLIENT_ALLOWED: AnalyticsEventType[] = [
23+
"auth_failed",
24+
"feature_use",
25+
"signup",
26+
"login",
27+
];
28+
29+
/** Ingest analytics events from middleware (internal) or client (auth/feature). */
30+
export async function POST(req: Request) {
31+
try {
32+
if (!isAnalyticsConfigured()) return ok({ tracked: false, reason: "not_configured" });
33+
34+
const internal = isInternalAnalyticsRequest(req);
35+
if (!internal) {
36+
const limited = await enforceRateLimit(req, "default");
37+
if (limited) return limited;
38+
}
39+
40+
const b = await safeJson<Body>(req, "default");
41+
if (!b?.eventType) return bad("eventType is required");
42+
43+
if (!internal && !CLIENT_ALLOWED.includes(b.eventType)) {
44+
return bad("Event type not allowed from client", 403);
45+
}
46+
47+
let userId: string | null = null;
48+
if (isSupabaseConfigured() && (b.eventType === "signup" || b.eventType === "login")) {
49+
const supabase = createClient();
50+
const {
51+
data: { user },
52+
} = await supabase.auth.getUser();
53+
userId = user?.id ?? null;
54+
}
55+
56+
const category =
57+
b.category ||
58+
(b.eventType === "page_view"
59+
? "visit"
60+
: b.eventType === "auth_failed" || b.eventType === "signup" || b.eventType === "login"
61+
? "auth"
62+
: b.eventType === "rate_limit"
63+
? "abuse"
64+
: "usage");
65+
66+
const metadata = { ...(b.metadata || {}) };
67+
if (b.feature) metadata.feature = b.feature;
68+
if (b.mode) metadata.mode = b.mode;
69+
70+
trackFromRequest(req, {
71+
eventType: b.eventType,
72+
category,
73+
path: b.path ?? null,
74+
method: b.method ?? null,
75+
userId,
76+
metadata,
77+
severity:
78+
b.eventType === "rate_limit" || b.eventType === "auth_failed" ? "warn" : "info",
79+
});
80+
81+
return ok({ tracked: true });
82+
} catch (e) {
83+
return handleError(e);
84+
}
85+
}

app/api/projects/route.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { bad, handleError, ok, safeJson } from "../_utils";
1+
import { bad, enforceRateLimit, handleError, ok, safeJson } from "../_utils";
22
import { createClient, isSupabaseConfigured } from "@/lib/supabase/server";
33
import { getAppUser } from "@/lib/auth";
4+
import { trackFromRequest } from "@/lib/analytics/track";
45
import type { ProjectState } from "@/lib/types";
56

67
export const runtime = "nodejs";
@@ -9,8 +10,10 @@ export const runtime = "nodejs";
910
export const dynamic = "force-dynamic";
1011

1112
/** Cloud sync: load/save manuscript project for authenticated users. */
12-
export async function GET() {
13+
export async function GET(req: Request) {
1314
try {
15+
const limited = await enforceRateLimit(req, "default");
16+
if (limited) return limited;
1417
if (!isSupabaseConfigured()) return ok({ configured: false, project: null });
1518
const user = await getAppUser();
1619
if (!user) return bad("Sign in required for cloud sync", 401);
@@ -37,6 +40,8 @@ export async function GET() {
3740

3841
export async function PUT(req: Request) {
3942
try {
43+
const limited = await enforceRateLimit(req, "default");
44+
if (limited) return limited;
4045
if (!isSupabaseConfigured()) return bad("Supabase not configured", 503);
4146
const user = await getAppUser();
4247
if (!user) return bad("Sign in required for cloud sync", 401);
@@ -73,6 +78,15 @@ export async function PUT(req: Request) {
7378
.single();
7479

7580
if (error) throw error;
81+
82+
trackFromRequest(req, {
83+
eventType: "project_sync",
84+
category: "usage",
85+
path: "/api/projects",
86+
method: "PUT",
87+
userId: user.id,
88+
});
89+
7690
return ok({ saved: true, updatedAt: data.updated_at });
7791
} catch (e) {
7892
return handleError(e);

app/api/share/route.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { bad, enforceRateLimit, handleError, ok, safeJson } from "../_utils";
2-
import { createClient, isSupabaseConfigured } from "@/lib/supabase/server";
2+
import { createClient, createServiceClient, isSupabaseConfigured } from "@/lib/supabase/server";
33
import { getAppUser } from "@/lib/auth";
4+
import { trackFromRequest } from "@/lib/analytics/track";
45
import type { ProjectState } from "@/lib/types";
56

67
export const runtime = "nodejs";
@@ -56,6 +57,14 @@ export async function POST(req: Request) {
5657
});
5758
if (error) throw error;
5859

60+
trackFromRequest(req, {
61+
eventType: "share_created",
62+
category: "usage",
63+
path: "/api/share",
64+
userId: user?.id ?? null,
65+
metadata: { anonymous: !user },
66+
});
67+
5968
const origin = originFromRequest(req);
6069
const url = `${origin}/?share=${token}`;
6170
return ok({ token, url, expiresAt });
@@ -74,9 +83,13 @@ export async function GET(req: Request) {
7483
if (limited) return limited;
7584
const token = new URL(req.url).searchParams.get("token");
7685
if (!token) return bad("token is required");
86+
if (!/^[a-f0-9]{32,64}$/i.test(token)) return bad("invalid token", 400);
7787

78-
const supabase = createClient();
79-
const { data, error } = await supabase
88+
// Service-role read by token only — anon RLS must not allow table scans.
89+
const admin = createServiceClient();
90+
if (!admin) return bad("Server-stored sharing is unavailable.", 503);
91+
92+
const { data, error } = await admin
8093
.from("shared_projects")
8194
.select("state, expires_at")
8295
.eq("token", token)

app/auth/callback/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from "next/server";
22
import { createClient } from "@/lib/supabase/server";
33
import { promoteOwnerIfNeeded } from "@/lib/auth";
4+
import { trackFromRequest } from "@/lib/analytics/track";
45

56
export async function GET(request: Request) {
67
const { searchParams, origin } = new URL(request.url);
@@ -23,6 +24,12 @@ export async function GET(request: Request) {
2324
} = await supabase.auth.getUser();
2425
if (user?.email) {
2526
await promoteOwnerIfNeeded(user.id, user.email);
27+
trackFromRequest(request, {
28+
eventType: "login",
29+
category: "auth",
30+
path: "/auth/callback",
31+
userId: user.id,
32+
});
2633
}
2734
return NextResponse.redirect(`${origin}${next}`);
2835
}

0 commit comments

Comments
 (0)