Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sentry.edge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
enableLogs: true,
traceLifecycle: 'stream',
environment: process.env.NODE_ENV,
spotlight: process.env.NODE_ENV === 'development',
Expand Down
1 change: 1 addition & 0 deletions sentry.server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
tracesSampleRate: 1,
enableLogs: true,
traceLifecycle: 'stream',
environment: process.env.NODE_ENV,
integrations: [Sentry.nodeRuntimeMetricsIntegration()],
Expand Down
14 changes: 14 additions & 0 deletions src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Sentry from "@sentry/nextjs";
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";

Expand All @@ -14,12 +15,25 @@ export async function POST(request: Request) {
const provided = request.headers.get("x-revalidate-secret");

if (!secret || provided !== secret) {
// Security-relevant: the only legitimate caller is the out-of-band sync
// job. Distinguish a misconfigured server from a bad/forged secret without
// ever logging the provided value.
Sentry.logger.warn("Revalidation request rejected", {
"api.endpoint": "revalidate",
"api.reject_reason": secret ? "invalid_secret" : "secret_not_configured",
});
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

for (const tag of TAGS) {
revalidateTag(tag, "max");
}

// Operational: confirms the sync job's post-commit cache bust reached the app.
Sentry.logger.info("Site cache revalidated", {
"api.endpoint": "revalidate",
"revalidate.tags": TAGS.join(","),
});

return NextResponse.json({ revalidated: true, tags: TAGS });
}
1 change: 1 addition & 0 deletions src/instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 1,
enableLogs: true,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
integrations: [
Expand Down
68 changes: 64 additions & 4 deletions src/server/actions/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use server";

import * as Sentry from "@sentry/nextjs";
import { eq, inArray } from "drizzle-orm";
import { revalidatePath, revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
Expand Down Expand Up @@ -71,6 +72,7 @@ export async function unpublishChangelog(
if (!session) {
return unauthorizedPayload;
}
Sentry.setUser({ id: session.user?.id });
const id = formData.get("id") as string;

try {
Expand All @@ -79,10 +81,20 @@ export async function unpublishChangelog(
.set({ published: false, adminManaged: true })
.where(eq(Changelog.id, id));
} catch (error) {
console.error("DELETE ACTION ERROR:", error);
Sentry.logger.error("Changelog unpublish failed", {
"changelog.id": id,
"changelog.action": "unpublish",
"error.message": error instanceof Error ? error.message : String(error),
});
return { message: "Unable to unpublish changelog", success: false };
}

// Audit: a previously public entry was hidden from the changelog.
Sentry.logger.info("Changelog unpublished", {
"changelog.id": id,
"changelog.action": "unpublish",
});

revalidateTag("changelogs", "max");
revalidateTag("changelog-detail", "max");
revalidatePath("/changelog/_admin");
Expand All @@ -98,6 +110,7 @@ export async function publishChangelog(
if (!session) {
return unauthorizedPayload;
}
Sentry.setUser({ id: session.user?.id });
const id = formData.get("id") as string;

try {
Expand All @@ -120,10 +133,20 @@ export async function publishChangelog(
})
.where(eq(Changelog.id, id));
} catch (error) {
console.error("DELETE ACTION ERROR:", error);
Sentry.logger.error("Changelog publish failed", {
"changelog.id": id,
"changelog.action": "publish",
"error.message": error instanceof Error ? error.message : String(error),
});
return { message: "Unable to publish changelog", success: false };
}

// Audit: an entry became publicly visible on the changelog.
Sentry.logger.info("Changelog published", {
"changelog.id": id,
"changelog.action": "publish",
});

revalidateTag("changelogs", "max");
revalidateTag("changelog-detail", "max");
revalidatePath("/changelog/_admin");
Expand All @@ -138,6 +161,7 @@ export async function createChangelog(
if (!session) {
return unauthorizedPayload;
}
Sentry.setUser({ id: session.user?.id });
const categoryNames = uniqueCategoryNames(getFormCategoryNames(formData));

if (categoryNames.length > 0) {
Expand Down Expand Up @@ -184,6 +208,16 @@ export async function createChangelog(

await syncChangelogCategories(changelog.id, categoryNames);

// Audit: a new entry was authored through the admin UI (always a draft;
// platform/category counts show how the entry was targeted at creation).
Sentry.logger.info("Changelog created", {
"changelog.id": changelog.id,
"changelog.slug": formData.get("slug") as string,
"changelog.action": "create",
"changelog.platform_count": parsePlatforms(formData).length,
"changelog.category_count": categoryNames.length,
});

return redirect("/changelog/_admin");
}

Expand All @@ -195,6 +229,7 @@ export async function editChangelog(
if (!session) {
return unauthorizedPayload;
}
Sentry.setUser({ id: session.user?.id });
const id = formData.get("id") as string;
const categoryNames = uniqueCategoryNames(getFormCategoryNames(formData));

Expand Down Expand Up @@ -222,10 +257,24 @@ export async function editChangelog(

await syncChangelogCategories(id, categoryNames);
} catch (error: any) {
console.error("EDIT ACTION ERROR:", error);
Sentry.logger.error("Changelog edit failed", {
"changelog.id": id,
"changelog.action": "edit",
"error.message": error instanceof Error ? error.message : String(error),
});
return { message: (error as Error).message, success: false };
}

// Audit: an existing entry's content or targeting was changed via the admin
// UI, which also marks it admin-managed so the file sync no longer touches it.
Sentry.logger.info("Changelog updated", {
"changelog.id": id,
"changelog.slug": formData.get("slug") as string,
"changelog.action": "edit",
"changelog.platform_count": parsePlatforms(formData).length,
"changelog.category_count": categoryNames.length,
});

revalidateTag("changelogs", "max");
revalidateTag("changelog-detail", "max");
return redirect("/changelog/_admin");
Expand All @@ -239,6 +288,7 @@ export async function deleteChangelog(
if (!session) {
return unauthorizedPayload;
}
Sentry.setUser({ id: session.user?.id });
const id = formData.get("id") as string;
try {
// Soft delete: mark deleted + admin-managed instead of removing the row,
Expand All @@ -254,10 +304,20 @@ export async function deleteChangelog(
})
.where(eq(Changelog.id, id));
} catch (error) {
console.error("DELETE ACTION ERROR:", error);
Sentry.logger.error("Changelog delete failed", {
"changelog.id": id,
"changelog.action": "delete",
"error.message": error instanceof Error ? error.message : String(error),
});
return { message: "Unable to delete changelog", success: false };
}

// Audit: an entry was soft-deleted (hidden, but the row is retained).
Sentry.logger.info("Changelog deleted", {
"changelog.id": id,
"changelog.action": "delete",
});

revalidateTag("changelogs", "max");
revalidateTag("changelog-detail", "max");
revalidatePath("/changelog/_admin");
Expand Down
10 changes: 10 additions & 0 deletions src/server/authOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,14 @@ export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
callbacks: {
// Surface the opaque user id (the JWT subject) on the session so server
// code can attach it to Sentry via setUser without logging the email (PII).
session({ session, token }) {
if (session.user && token.sub) {
session.user.id = token.sub;
}
return session;
},
},
};
11 changes: 11 additions & 0 deletions src/types/next-auth.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { DefaultSession } from "next-auth";

declare module "next-auth" {
interface Session {
user?: {
// Opaque user id (JWT subject), populated in the session callback so it
// can be passed to Sentry.setUser without exposing the user's email.
id?: string;
} & DefaultSession["user"];
}
}
Loading