Skip to content

Commit 709e385

Browse files
committed
chore: CSP hardening, streaming endpoint, observability ingestion, load-test script
- Tighten CSP connect-src to specific origins (Vercel, Gemini, Anthropic, Resend, Google Fonts) - Sync CSP in both next.config.mjs and middleware for dual-layer coverage - Add /api/generate/stream SSE endpoint wired to ai.stream() - Add /api/log observability ingestion endpoint - Add scripts/load-test.sh for rate-limit load testing - Add /api/user route for client-side usage tracking - Update env.example with GOOGLE_GENERATIVE_AI_API_KEY and UPSTASH_ vars
1 parent 9bfef24 commit 709e385

6 files changed

Lines changed: 340 additions & 198 deletions

File tree

app/api/generate/stream/route.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getServerSession } from "next-auth";
3+
import { authOptions } from "@/lib/auth";
4+
import { ai } from "@/lib/ai";
5+
import { canUserPost, ensureUserExists } from "@/lib/db";
6+
import { generateRequestId, trace } from "@/lib/trace";
7+
import { withRateLimit } from "@/lib/rate-limit";
8+
import { z } from "zod";
9+
10+
const streamSchema = z.object({
11+
prompt: z.string().trim().min(10).max(500),
12+
platform: z.enum(["x", "instagram", "linkedin"]).optional(),
13+
});
14+
15+
export async function POST(req: NextRequest) {
16+
return withRateLimit(req, "medium", async (): Promise<NextResponse> => {
17+
const requestId = generateRequestId();
18+
const start = Date.now();
19+
20+
const session = await getServerSession(authOptions);
21+
if (!session?.user?.email) {
22+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
23+
}
24+
25+
const parsed = streamSchema.safeParse(await req.json());
26+
if (!parsed.success) {
27+
return NextResponse.json({ error: "Invalid request", details: parsed.error.flatten() }, { status: 400 });
28+
}
29+
30+
const { prompt } = parsed.data;
31+
32+
const user = await ensureUserExists(session.user.email, session.user.name || undefined);
33+
const check = await canUserPost(user.id, user.plan);
34+
if (!check.allowed) {
35+
return NextResponse.json({ error: check.reason, limitReached: true }, { status: 429 });
36+
}
37+
38+
trace({
39+
requestId,
40+
event: "generate.stream.start",
41+
durationMs: Date.now() - start,
42+
status: "success",
43+
});
44+
45+
const stream = ai.stream({
46+
prompt: `Write a social media post about: ${prompt}. Keep it sharp and useful.`,
47+
trace: { requestId, event: "generate.stream" },
48+
});
49+
50+
const encoder = new TextEncoder();
51+
const readable = new ReadableStream({
52+
async start(controller) {
53+
try {
54+
for await (const chunk of stream) {
55+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text: chunk })}\n\n`));
56+
}
57+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
58+
} catch (err: any) {
59+
trace({
60+
requestId,
61+
event: "generate.stream.error",
62+
durationMs: Date.now() - start,
63+
status: "error",
64+
error: err.message,
65+
});
66+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ error: "Stream failed" })}\n\n`));
67+
} finally {
68+
controller.close();
69+
}
70+
},
71+
});
72+
73+
return new NextResponse(readable, {
74+
headers: {
75+
"Content-Type": "text/event-stream",
76+
"Cache-Control": "no-cache",
77+
"Connection": "keep-alive",
78+
"X-Request-ID": requestId,
79+
},
80+
});
81+
});
82+
}

app/api/log/route.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { z } from "zod";
3+
import { generateRequestId } from "@/lib/trace";
4+
5+
const logSchema = z.object({
6+
level: z.enum(["debug", "info", "warn", "error"]).default("info"),
7+
event: z.string().min(1),
8+
message: z.string().optional(),
9+
data: z.record(z.unknown()).optional(),
10+
});
11+
12+
export async function POST(req: NextRequest) {
13+
const requestId = generateRequestId();
14+
15+
try {
16+
const parsed = logSchema.safeParse(await req.json());
17+
if (!parsed.success) {
18+
return NextResponse.json({ error: "Invalid log format", requestId }, { status: 400 });
19+
}
20+
21+
const { level, event, message, data } = parsed.data;
22+
23+
const entry = {
24+
timestamp: new Date().toISOString(),
25+
requestId,
26+
level,
27+
event,
28+
message: message || event,
29+
...(data || {}),
30+
};
31+
32+
if (process.env.LOG_WEBHOOK_URL) {
33+
fetch(process.env.LOG_WEBHOOK_URL, {
34+
method: "POST",
35+
headers: { "Content-Type": "application/json" },
36+
body: JSON.stringify(entry),
37+
}).catch(() => {});
38+
}
39+
40+
if (process.env.NODE_ENV === "production") {
41+
console.log(JSON.stringify(entry));
42+
}
43+
44+
return NextResponse.json({ success: true, requestId });
45+
} catch {
46+
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
47+
}
48+
}

lib/security.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,24 @@ export const SECURITY_HEADERS: Record<string, string> = {
1111

1212
export function getCSPHeader(): string {
1313
const self = "'self'";
14+
const connectSrc = [
15+
self,
16+
"https://*.vercel-analytics.com",
17+
"https://vitals.vercel-insights.com",
18+
"https://generativelanguage.googleapis.com",
19+
"https://api.anthropic.com",
20+
"https://api.resend.io",
21+
"https://fonts.googleapis.com",
22+
"https://fonts.gstatic.com",
23+
].join(" ");
24+
1425
return [
1526
`default-src ${self}`,
1627
`script-src ${self} 'unsafe-eval' 'unsafe-inline'`,
1728
`style-src ${self} 'unsafe-inline' https://fonts.googleapis.com`,
1829
`img-src ${self} data: blob: https:`,
1930
`font-src ${self} https://fonts.gstatic.com`,
20-
`connect-src ${self} https:`,
31+
`connect-src ${connectSrc}`,
2132
`frame-ancestors 'none'`,
2233
`base-uri ${self}`,
2334
`form-action ${self}`,

next.config.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,20 @@ const nextConfig = {
1919
key: "Permissions-Policy",
2020
value: "camera=(), microphone=(), geolocation=()",
2121
},
22+
{
23+
key: "Content-Security-Policy",
24+
value: [
25+
"default-src 'self'",
26+
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
27+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
28+
"img-src 'self' data: blob: https:",
29+
"font-src 'self' https://fonts.gstatic.com",
30+
"connect-src 'self' https://*.vercel-analytics.com https://vitals.vercel-insights.com https://generativelanguage.googleapis.com https://api.anthropic.com https://api.resend.io https://fonts.googleapis.com https://fonts.gstatic.com",
31+
"frame-ancestors 'none'",
32+
"base-uri 'self'",
33+
"form-action 'self'",
34+
].join("; "),
35+
},
2236
],
2337
},
2438
{

0 commit comments

Comments
 (0)