Harden deploy gates, prod AUTH_SECRET, and Sentry error reporting.
Local Build and Deploy / deploy (push) Successful in 1m42s

Align onlyBuiltDependencies with the workspace, fail fast without AUTH_SECRET in production, and delete catalog_items via VARCHAR-safe SQL so page deletes do not leave orphans.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-18 19:32:15 +02:00
1 parent b9c6001f08
commit 6b884ad25a
12 files changed
+128 -50

No files matched your search

+3 -1
View File
@@ -102,7 +102,9 @@ export function ConfirmDialog({
variant={variant === "danger" ? "destructive" : "default"}
disabled={isLoading}
onClick={handleConfirm}
className={cn(variant === "danger" && "bg-destructive text-destructive-foreground")}
className={cn(
variant === "danger" && "bg-destructive text-destructive-foreground",
)}
autoFocus
>
{confirmLabel}
+1 -1
View File
@@ -236,7 +236,7 @@ export function AdminMediaGrid() {
loading="lazy"
className="w-full h-[120px] object-cover block"
/>
<span className="absolute top-1.5 right-1.5 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-black/55 text-white uppercase">
<span className="absolute top-1.5 right-1.5 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-black/55 text-background uppercase">
{extOf(f.name)}
</span>
</div>
+11
View File
@@ -80,6 +80,17 @@ const schema = z.object({
SENTRY_PROJECT: z.string().optional(),
SENTRY_AUTH_TOKEN: z.string().optional(),
APP_VERSION: z.string().optional(),
NEXT_PUBLIC_APP_VERSION: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.NODE_ENV !== "production") return;
if (!data.AUTH_SECRET || data.AUTH_SECRET.length < 32) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"AUTH_SECRET (>=32 characters) is required when NODE_ENV=production",
path: ["AUTH_SECRET"],
});
}
});
type Env = z.infer<typeof schema>;
+3
View File
@@ -1,4 +1,5 @@
import * as Sentry from "@sentry/nextjs";
import { redactSentryEvent } from "@/lib/sentry-redact";
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
@@ -6,6 +7,7 @@ if (dsn) {
Sentry.init({
dsn,
environment: process.env.NODE_ENV,
release: process.env.NEXT_PUBLIC_APP_VERSION || process.env.APP_VERSION,
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 1.0,
@@ -16,6 +18,7 @@ if (dsn) {
blockAllMedia: true,
}),
],
beforeSend: redactSentryEvent,
});
}
+2 -6
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { ZodError, type z } from "zod";
import { reportError } from "@/lib/report-error";
/** Throwable API error with HTTP status code */
export class ApiError extends Error {
@@ -46,11 +47,6 @@ export function handleApiError(error: unknown): Response {
) {
return apiError("Not found", 404);
}
console.error(
"[API error]",
error instanceof Error
? { message: error.message, name: error.name }
: error,
);
reportError(error, "API error");
return apiError("Internal server error", 500);
}
+22
View File
@@ -12,4 +12,26 @@ describe("production deploy workflow", () => {
expect(workflow).not.toMatch(/rm\s+-rf[^\n]*\.next/);
expect(workflow).toContain("pnpm install --frozen-lockfile");
});
it("does not override onlyBuiltDependencies (uses pnpm-workspace.yaml)", () => {
expect(workflow).not.toContain("PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES");
});
it("runs typecheck and tests before build", () => {
expect(workflow).toContain("pnpm typecheck");
expect(workflow).toContain("pnpm test");
const typecheckAt = workflow.indexOf("pnpm typecheck");
const testAt = workflow.indexOf("pnpm test");
const buildAt = workflow.indexOf("pnpm build");
expect(typecheckAt).toBeGreaterThan(-1);
expect(testAt).toBeGreaterThan(typecheckAt);
expect(buildAt).toBeGreaterThan(testAt);
});
it("exports APP_VERSION from git for Sentry releases", () => {
expect(workflow).toContain('export APP_VERSION="$(git rev-parse --short HEAD)"');
expect(workflow).toContain(
'export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"',
);
});
});
+2 -6
View File
@@ -3,6 +3,7 @@ import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
import { auth } from "@/lib/auth";
import { canAccess, getApiAdminContext } from "@/lib/permissions";
import { rateLimit } from "@/lib/rate-limit";
import { reportError } from "@/lib/report-error";
import {
DatabaseError,
ForbiddenError,
@@ -247,11 +248,6 @@ export function handleActionError(error: unknown): ActionFailure {
return fail("Not found");
}
console.error(
"[Action error]",
error instanceof Error
? { message: error.message, name: error.name }
: error,
);
reportError(error, "Action error");
return fail("Internal server error");
}
+18
View File
@@ -0,0 +1,18 @@
import * as Sentry from "@sentry/nextjs";
import { logger } from "@/lib/logger";
/**
* Log unexpected errors and forward them to Sentry when a DSN is configured.
* Domain errors (validation, auth, etc.) should NOT go through here.
*/
export function reportError(error: unknown, context = "Unhandled error"): void {
const message = error instanceof Error ? error.message : String(error);
logger.error(context, {
err: message,
name: error instanceof Error ? error.name : undefined,
});
if (process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN) {
Sentry.captureException(error);
}
}
+15 -2
View File
@@ -204,6 +204,19 @@ export async function movePage(
});
}
/**
* Delete catalog_items for the given page ids.
* Habbo DBs often store page_id as VARCHAR; Prisma Int deleteMany misses rows.
*/
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
if (pageIds.length === 0) return;
const idStrs = pageIds.map(String);
await prisma.$executeRaw`
DELETE FROM catalog_items
WHERE CAST(page_id AS CHAR) IN (${Prisma.join(idStrs)})
`;
}
/**
* Delete a page with cascade or reparent mode.
*/
@@ -223,7 +236,7 @@ export async function deletePage(
data: { parentId: page.parentId },
});
await prisma.catalogItems.deleteMany({ where: { pageId } });
await deleteCatalogItemsByPageIds([pageId]);
await prisma.catalogPages.delete({ where: { id: pageId } });
return { deletedPages: 1, movedChildren: result.count };
@@ -249,7 +262,7 @@ async function cascadeDelete(pageId: number): Promise<number> {
}
}
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } });
await deleteCatalogItemsByPageIds(toDelete);
for (let i = toDelete.length - 1; i >= 0; i--) {
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
}