Harden deploy gates, prod AUTH_SECRET, and Sentry error reporting.
Local Build and Deploy / deploy (push) Successful in 1m42s
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:
1 parent
b9c6001f08
commit
6b884ad25a
12 files changed
+126
-48
No files matched your search
+4
-1
@@ -14,7 +14,8 @@ APP_URL=http://localhost:3000
|
|||||||
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
# NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||||
AUTH_URL=http://localhost:3000
|
AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# NextAuth (>=32 chars) + Laravel APP_KEY (base64:...) for existing 2FA secrets
|
# NextAuth — required in production (>=32 chars). Optional in development.
|
||||||
|
# Laravel APP_KEY (base64:...) for existing 2FA secrets.
|
||||||
AUTH_SECRET=
|
AUTH_SECRET=
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
CONVERT_PASSWORDS=false
|
CONVERT_PASSWORDS=false
|
||||||
@@ -88,4 +89,6 @@ SENTRY_ORG=
|
|||||||
SENTRY_PROJECT=
|
SENTRY_PROJECT=
|
||||||
SENTRY_AUTH_TOKEN=
|
SENTRY_AUTH_TOKEN=
|
||||||
# Optional release tag shown in Sentry (e.g. git sha).
|
# Optional release tag shown in Sentry (e.g. git sha).
|
||||||
|
# Deploy sets APP_VERSION + NEXT_PUBLIC_APP_VERSION from git sha.
|
||||||
APP_VERSION=
|
APP_VERSION=
|
||||||
|
NEXT_PUBLIC_APP_VERSION=
|
||||||
@@ -48,21 +48,30 @@ jobs:
|
|||||||
# Preserve .next/cache so Next.js can reuse its incremental build cache.
|
# Preserve .next/cache so Next.js can reuse its incremental build cache.
|
||||||
rm -rf .output dist
|
rm -rf .output dist
|
||||||
|
|
||||||
# 4. Configure trusted dependencies globally and install cleanly
|
# Release tag for Sentry / logs (short git sha)
|
||||||
export PNPM_CONFIG_ONLY_BUILT_DEPENDENCIES="@parcel/watcher,@swc/core,sharp"
|
export APP_VERSION="$(git rev-parse --short HEAD)"
|
||||||
|
export NEXT_PUBLIC_APP_VERSION="${APP_VERSION}"
|
||||||
|
echo "APP_VERSION=${APP_VERSION}"
|
||||||
|
|
||||||
|
# 4. Install — onlyBuiltDependencies comes from pnpm-workspace.yaml
|
||||||
|
# (do not set a PNPM only-built-deps env override here).
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# 5. Apply versioned CMS migrations and generate the Prisma client safely
|
# 5. Apply versioned CMS migrations and generate the Prisma client safely
|
||||||
pnpm db:migrate
|
pnpm db:migrate
|
||||||
pnpm prisma:generate
|
pnpm prisma:generate
|
||||||
|
|
||||||
# 6. Next.js Build
|
# 6. Pre-deploy quality gates (fail before build if broken)
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# 7. Next.js Build
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# 7. Fix ownership: Build first, THEN set permissions for the web server
|
# 8. Fix ownership: Build first, THEN set permissions for the web server
|
||||||
sudo chown -R www-data:www-data /var/www/atom-nexst/
|
sudo chown -R www-data:www-data /var/www/atom-nexst/
|
||||||
|
|
||||||
# 8. Hard restart of the Systemd service to clear memory cache
|
# 9. Hard restart of the Systemd service to clear memory cache
|
||||||
echo "Hard resetting systemd service..."
|
echo "Hard resetting systemd service..."
|
||||||
sudo systemctl stop atom-nexst.service || true
|
sudo systemctl stop atom-nexst.service || true
|
||||||
|
|
||||||
|
|||||||
+31
-26
@@ -1,8 +1,32 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { Cron } from "croner";
|
import { Cron } from "croner";
|
||||||
import { env } from "../src/env";
|
import { env } from "../src/env";
|
||||||
import { logger } from "../src/lib/logger";
|
import { logger } from "../src/lib/logger";
|
||||||
import { prisma } from "../src/lib/prisma";
|
import { prisma } from "../src/lib/prisma";
|
||||||
|
|
||||||
|
function initWorkerSentry(): void {
|
||||||
|
const dsn = process.env.SENTRY_DSN;
|
||||||
|
if (!dsn || process.env.NODE_ENV !== "production") return;
|
||||||
|
|
||||||
|
Sentry.init({
|
||||||
|
dsn,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
release: process.env.APP_VERSION,
|
||||||
|
tracesSampleRate: 0.05,
|
||||||
|
});
|
||||||
|
logger.info("Sentry initialized for jobs worker", { module: "jobs" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureWorkerError(err: unknown, context: string): void {
|
||||||
|
logger.error(context, {
|
||||||
|
module: "jobs",
|
||||||
|
err: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
if (process.env.SENTRY_DSN) {
|
||||||
|
Sentry.captureException(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function backupEmulatorJar(): Promise<void> {
|
async function backupEmulatorJar(): Promise<void> {
|
||||||
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
if (!env.EMULATOR_JAR_PATH || !env.EMULATOR_BACKUP_DIR) return;
|
||||||
|
|
||||||
@@ -41,10 +65,7 @@ async function backupEmulatorJar(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("JAR backup failed", {
|
captureWorkerError(err, "JAR backup failed");
|
||||||
module: "jobs",
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,10 +77,7 @@ async function cleanupOldLogs(): Promise<void> {
|
|||||||
});
|
});
|
||||||
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
logger.info("Cleaned up login logs older than 30 days", { module: "jobs" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Log cleanup failed", {
|
captureWorkerError(err, "Log cleanup failed");
|
||||||
module: "jobs",
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,24 +91,17 @@ async function cleanupOldSessions(): Promise<void> {
|
|||||||
module: "jobs",
|
module: "jobs",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error("Session cleanup failed", {
|
captureWorkerError(err, "Session cleanup failed");
|
||||||
module: "jobs",
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
initWorkerSentry();
|
||||||
logger.info("Worker started", { module: "jobs" });
|
logger.info("Worker started", { module: "jobs" });
|
||||||
|
|
||||||
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
if (env.EMULATOR_JAR_PATH && env.EMULATOR_BACKUP_DIR) {
|
||||||
new Cron("0 3 * * *", () => {
|
new Cron("0 3 * * *", () => {
|
||||||
backupEmulatorJar().catch((e) =>
|
backupEmulatorJar().catch((e) => captureWorkerError(e, "Backup error"));
|
||||||
logger.error("Backup error", {
|
|
||||||
module: "jobs",
|
|
||||||
err: e instanceof Error ? e.message : String(e),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
logger.info("Scheduled: emulator JAR backup (daily 03:00)", {
|
logger.info("Scheduled: emulator JAR backup (daily 03:00)", {
|
||||||
module: "jobs",
|
module: "jobs",
|
||||||
@@ -99,10 +110,7 @@ async function main() {
|
|||||||
|
|
||||||
new Cron("0 4 * * *", () => {
|
new Cron("0 4 * * *", () => {
|
||||||
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
Promise.all([cleanupOldLogs(), cleanupOldSessions()]).catch((e) =>
|
||||||
logger.error("Cleanup error", {
|
captureWorkerError(e, "Cleanup error"),
|
||||||
module: "jobs",
|
|
||||||
err: e instanceof Error ? e.message : String(e),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
logger.info("Scheduled: old data cleanup (daily 04:00)", { module: "jobs" });
|
||||||
@@ -115,9 +123,6 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
logger.error("Fatal", {
|
captureWorkerError(err, "Fatal");
|
||||||
module: "jobs",
|
|
||||||
err: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
@@ -102,7 +102,9 @@ export function ConfirmDialog({
|
|||||||
variant={variant === "danger" ? "destructive" : "default"}
|
variant={variant === "danger" ? "destructive" : "default"}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
className={cn(variant === "danger" && "bg-destructive text-destructive-foreground")}
|
className={cn(
|
||||||
|
variant === "danger" && "bg-destructive text-destructive-foreground",
|
||||||
|
)}
|
||||||
autoFocus
|
autoFocus
|
||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export function AdminMediaGrid() {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="w-full h-[120px] object-cover block"
|
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)}
|
{extOf(f.name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
@@ -80,6 +80,17 @@ const schema = z.object({
|
|||||||
SENTRY_PROJECT: z.string().optional(),
|
SENTRY_PROJECT: z.string().optional(),
|
||||||
SENTRY_AUTH_TOKEN: z.string().optional(),
|
SENTRY_AUTH_TOKEN: z.string().optional(),
|
||||||
APP_VERSION: 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>;
|
type Env = z.infer<typeof schema>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { redactSentryEvent } from "@/lib/sentry-redact";
|
||||||
|
|
||||||
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ if (dsn) {
|
|||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn,
|
dsn,
|
||||||
environment: process.env.NODE_ENV,
|
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,
|
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1.0,
|
||||||
replaysSessionSampleRate: 0,
|
replaysSessionSampleRate: 0,
|
||||||
replaysOnErrorSampleRate: 1.0,
|
replaysOnErrorSampleRate: 1.0,
|
||||||
@@ -16,6 +18,7 @@ if (dsn) {
|
|||||||
blockAllMedia: true,
|
blockAllMedia: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
beforeSend: redactSentryEvent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { ZodError, type z } from "zod";
|
import { ZodError, type z } from "zod";
|
||||||
|
import { reportError } from "@/lib/report-error";
|
||||||
|
|
||||||
/** Throwable API error with HTTP status code */
|
/** Throwable API error with HTTP status code */
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -46,11 +47,6 @@ export function handleApiError(error: unknown): Response {
|
|||||||
) {
|
) {
|
||||||
return apiError("Not found", 404);
|
return apiError("Not found", 404);
|
||||||
}
|
}
|
||||||
console.error(
|
reportError(error, "API error");
|
||||||
"[API error]",
|
|
||||||
error instanceof Error
|
|
||||||
? { message: error.message, name: error.name }
|
|
||||||
: error,
|
|
||||||
);
|
|
||||||
return apiError("Internal server error", 500);
|
return apiError("Internal server error", 500);
|
||||||
}
|
}
|
||||||
@@ -12,4 +12,26 @@ describe("production deploy workflow", () => {
|
|||||||
expect(workflow).not.toMatch(/rm\s+-rf[^\n]*\.next/);
|
expect(workflow).not.toMatch(/rm\s+-rf[^\n]*\.next/);
|
||||||
expect(workflow).toContain("pnpm install --frozen-lockfile");
|
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}"',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
|
import { reportError } from "@/lib/report-error";
|
||||||
import {
|
import {
|
||||||
DatabaseError,
|
DatabaseError,
|
||||||
ForbiddenError,
|
ForbiddenError,
|
||||||
@@ -247,11 +248,6 @@ export function handleActionError(error: unknown): ActionFailure {
|
|||||||
return fail("Not found");
|
return fail("Not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(
|
reportError(error, "Action error");
|
||||||
"[Action error]",
|
|
||||||
error instanceof Error
|
|
||||||
? { message: error.message, name: error.name }
|
|
||||||
: error,
|
|
||||||
);
|
|
||||||
return fail("Internal server error");
|
return fail("Internal server error");
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
* Delete a page with cascade or reparent mode.
|
||||||
*/
|
*/
|
||||||
@@ -223,7 +236,7 @@ export async function deletePage(
|
|||||||
data: { parentId: page.parentId },
|
data: { parentId: page.parentId },
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.catalogItems.deleteMany({ where: { pageId } });
|
await deleteCatalogItemsByPageIds([pageId]);
|
||||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||||
|
|
||||||
return { deletedPages: 1, movedChildren: result.count };
|
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--) {
|
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||||
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user