feat(admin): add production error center and refresh CMS dependencies
This commit is contained in:
1 parent
c6b919c01d
commit
816e3875c2
47 files changed
+1545
-547
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
.husky/* text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
@@ -31,6 +31,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Dependency security audit
|
||||||
|
run: pnpm deps:audit
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm biome:lint
|
run: pnpm biome:lint
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,5 @@ test-results/
|
|||||||
playwright-report/
|
playwright-report/
|
||||||
blob-report/
|
blob-report/
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
|
/public/vendor/tinymce/
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# CMS error center and dependency maintenance
|
||||||
|
|
||||||
|
Open **HK > DevOps > CMS error center** (`/admin/devops/cms-errors`).
|
||||||
|
The previous `/admin/devops/errors` page still displays emulator errors.
|
||||||
|
|
||||||
|
## Access and workflow
|
||||||
|
|
||||||
|
- Read: existing `admin.devops.view` permission, checked on the server.
|
||||||
|
- Mark resolved: `admin.devops.edit`, checked again in the server action; recorded in the staff audit log.
|
||||||
|
- Search by event reference, Next.js digest, route, message or deployment version.
|
||||||
|
- Expand a group for its latest stack, sanitized context and occurrence references.
|
||||||
|
- Marking a group resolved does not delete evidence. A later occurrence reopens it.
|
||||||
|
- Refresh is manual so inspecting an expanded error is not interrupted by polling.
|
||||||
|
|
||||||
|
## What is collected
|
||||||
|
|
||||||
|
Pino `logger.error`, `logServerError`, unexpected action errors, Next.js request
|
||||||
|
failures, React error boundaries, uncaught browser errors and unhandled browser
|
||||||
|
promise rejections. API wrapper failures return an `errorId`; Next.js boundary
|
||||||
|
errors can be correlated by their digest. Release identifiers come from the build.
|
||||||
|
Browser reports retain their own client release separately from the receiving server.
|
||||||
|
|
||||||
|
Reports are stored in `storage/cms-errors`, using the existing persistent storage
|
||||||
|
mount. No third-party service, token or new database table is required.
|
||||||
|
Storage does not depend on database availability; viewing the HK and its permission
|
||||||
|
checks still require authentication/database availability. Server console logs
|
||||||
|
remain the fallback when the CMS itself cannot serve requests.
|
||||||
|
|
||||||
|
Retention: seven days; approximately 10 MB/day, 100 queued server writes, and the
|
||||||
|
latest 1,000 events in the viewer. Limits are per CMS process/storage volume;
|
||||||
|
this is intended for the existing single-instance deployment. Daily expiry runs
|
||||||
|
on the next write. The browser endpoint is same-origin, body-size bounded and
|
||||||
|
rate-limited. Browser reports are untrusted observations and cannot grant access.
|
||||||
|
Known credential patterns and URL parameters are redacted; arbitrary object
|
||||||
|
metadata and request bodies are excluded. Avoid putting personal data in error
|
||||||
|
messages: this is pattern-based redaction, not a universal data-loss filter.
|
||||||
|
|
||||||
|
This does not collect historical console logs, process crashes before framework
|
||||||
|
startup, nginx failures, all `console.error` calls, or every handled business
|
||||||
|
validation error. A browser stack may point at minified chunks; private source-map
|
||||||
|
symbolication and distributed traces are not part of this local viewer. A recorded
|
||||||
|
stack is diagnostic evidence, not an automatic root-cause determination.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
`pnpm install --frozen-lockfile`, `pnpm deps:audit`, `pnpm typecheck`, `pnpm test`,
|
||||||
|
`pnpm knip`, `pnpm biome:lint`, and `pnpm build` are the verification sequence.
|
||||||
|
`pnpm analyze --output` writes a Next.js bundle analysis (not an application build).
|
||||||
|
|
||||||
|
TinyMCE 8.9 is pinned in pnpm. `pnpm assets:editor` copies its runtime files and
|
||||||
|
license notices to ignored `public/vendor/tinymce`; dev/build run this first.
|
||||||
|
The Docker build includes the generated assets. The editor retains its existing
|
||||||
|
HTML fields and toolbar; validate saved content and preview when upgrading it.
|
||||||
|
|
||||||
|
Lenis has been removed; the public site now uses native scrolling. Other used
|
||||||
|
runtime libraries remain. Vitest and its coverage provider are upgraded together;
|
||||||
|
`clearMocks: false` preserves initialization-time permission contract assertions.
|
||||||
|
|
||||||
|
Renovate uses separate development/UI/Vitest groups with manual merge and a
|
||||||
|
three-day release age. The existing external Gitea bot configuration is retained.
|
||||||
|
Node/pnpm upgrades remain coordinated with Docker and the runner toolchain.
|
||||||
|
Obsolete global overrides were removed; a scoped esbuild override remains because
|
||||||
|
Drizzle Kit's loader still resolves a vulnerable legacy development-server build.
|
||||||
|
The two deprecated esbuild-kit packages remain upstream dependencies of Drizzle
|
||||||
|
Kit; replacing the ORM is not warranted for this tooling issue.
|
||||||
@@ -12,6 +12,6 @@
|
|||||||
"scripts/furni-diagnose-now.ts"
|
"scripts/furni-diagnose-now.ts"
|
||||||
],
|
],
|
||||||
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
|
"project": ["src/**/*.{ts,tsx,css}", "scripts/**/*.{ts,js}"],
|
||||||
"ignoreDependencies": ["@sentry/nextjs", "pino-pretty", "husky"],
|
"ignoreDependencies": ["pino-pretty"],
|
||||||
"ignoreBinaries": ["sendmail"]
|
"ignoreBinaries": ["sendmail"]
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,10 @@ const securityHeaders = [
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
env: {
|
||||||
|
NEXT_PUBLIC_CMS_RELEASE:
|
||||||
|
process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit() || "unknown",
|
||||||
|
},
|
||||||
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
deploymentId: process.env.NEXT_DEPLOYMENT_ID?.trim() || getGitCommit(),
|
||||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
|||||||
+22
-18
@@ -7,8 +7,8 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "[email protected]+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
"packageManager": "[email protected]+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "pnpm assets:editor && next dev",
|
||||||
"build": "next build",
|
"build": "pnpm assets:editor && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
"toolchain:check": "node scripts/check-node-toolchain.mjs",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
@@ -25,14 +25,18 @@
|
|||||||
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
"db:migrate:status": "tsx scripts/apply-migrations.ts --status",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
"hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts",
|
||||||
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts"
|
"test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts",
|
||||||
|
"prepare": "node scripts/prepare-hooks.mjs",
|
||||||
|
"assets:editor": "node scripts/copy-editor-assets.mjs",
|
||||||
|
"deps:audit": "pnpm audit --audit-level=high",
|
||||||
|
"analyze": "next experimental-analyze"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
"*.{js,ts,jsx,tsx,json}": "biome check --write --no-errors-on-unmatched",
|
||||||
"*.{ts,tsx}": "node scripts/check-admin-colors.mjs"
|
"*.{ts,tsx}": "node scripts/check-admin-colors.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.7.0",
|
"@base-ui/react": "1.8.0",
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
"@dnd-kit/sortable": "10.0.0",
|
"@dnd-kit/sortable": "10.0.0",
|
||||||
"@dnd-kit/utilities": "3.2.2",
|
"@dnd-kit/utilities": "3.2.2",
|
||||||
@@ -49,46 +53,46 @@
|
|||||||
"jpeg-js": "0.4.4",
|
"jpeg-js": "0.4.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
"lenis": "1.3.26",
|
"lucide-react": "1.41.0",
|
||||||
"lucide-react": "1.38.0",
|
|
||||||
"lzma-wasm": "1.0.7",
|
"lzma-wasm": "1.0.7",
|
||||||
"motion": "13.1.1",
|
"motion": "13.2.0",
|
||||||
"music-metadata": "11.15.0",
|
"music-metadata": "11.15.0",
|
||||||
"mysql2": "3.24.2",
|
"mysql2": "3.24.3",
|
||||||
"next": "16.3.4",
|
"next": "16.3.4",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "4.14.1",
|
"next-intl": "4.14.2",
|
||||||
"otplib": "13.5.0",
|
"otplib": "13.5.0",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "7.87.0",
|
"react-hook-form": "7.87.0",
|
||||||
"resend": "6.25.0",
|
"resend": "6.26.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"sonner": "2.0.8",
|
"sonner": "2.0.8",
|
||||||
"tailwind-merge": "3.6.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"zod": "4.5.4"
|
"zod": "4.5.4",
|
||||||
|
"tinymce": "8.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.11",
|
"@biomejs/biome": "2.5.12",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@tailwindcss/typography": "0.5.20",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@types/node": "26.4.0",
|
"@types/node": "26.4.1",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.5",
|
"@types/react-dom": "19.2.7",
|
||||||
"@vitest/coverage-v8": "4.1.11",
|
"@vitest/coverage-v8": "5.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"knip": "6.33.0",
|
"knip": "6.34.0",
|
||||||
"lint-staged": "17.4.1",
|
"lint-staged": "17.4.1",
|
||||||
"pino-pretty": "13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
"postcss": "^8.5.26",
|
"postcss": "8.5.28",
|
||||||
"tailwindcss": "4.3.3",
|
"tailwindcss": "4.3.3",
|
||||||
"tsx": "4.23.13",
|
"tsx": "4.23.13",
|
||||||
"typescript": "7.0.2",
|
"typescript": "7.0.2",
|
||||||
"vitest": "4.1.11"
|
"vitest": "5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+536
-356
File diff suppressed because it is too large.
Load diff
+4
-71
@@ -1,80 +1,13 @@
|
|||||||
# pnpm-workspace.yaml
|
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
sharp: true
|
sharp: true
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
"@swc/core": true
|
"@swc/core": true
|
||||||
bcrypt: true
|
|
||||||
|
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAge: 1440
|
||||||
- "@base-ui/[email protected]"
|
|
||||||
- "@base-ui/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@biomejs/[email protected]"
|
|
||||||
- "@hookform/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "@next/[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
- "[email protected]"
|
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
glob: "^11.0.3"
|
# drizzle-kit still uses an obsolete development-server dependency.
|
||||||
rimraf: "^6.0.1"
|
"@esbuild-kit/core-utils>esbuild": "^0.25.9"
|
||||||
uuid: "^11.1.0"
|
|
||||||
fast-uri: "^3.1.3"
|
|
||||||
"@hono/node-server": "^1.19.13"
|
|
||||||
postcss: "^8.5.26"
|
|
||||||
tmp: "^0.2.6"
|
|
||||||
sharp: "^0.35.4"
|
|
||||||
brace-expansion: "^5.0.8"
|
|
||||||
"@types/react": "19.2.18"
|
"@types/react": "19.2.18"
|
||||||
"@types/react-dom": "19.2.5"
|
"@types/react-dom": "19.2.7"
|
||||||
# Tijdelijke mitigatie voor drizzle-kit audit
|
|
||||||
esbuild: "^0.25.9"
|
|
||||||
|
|
||||||
allowedDeprecatedVersions:
|
|
||||||
"@esbuild-kit/esm-loader": "*"
|
|
||||||
"@esbuild-kit/core-utils": "*"
|
|
||||||
|
|
||||||
ignoredBuiltDependencies:
|
|
||||||
- "@prisma/engines"
|
|
||||||
- "prisma"
|
|
||||||
|
|
||||||
peerDependencyRules:
|
|
||||||
allowedVersions:
|
|
||||||
nodemailer: "9.0.3"
|
|
||||||
ignoreMissing:
|
|
||||||
- nodemailer
|
|
||||||
+24
-17
@@ -14,29 +14,36 @@
|
|||||||
"prConcurrentLimit": 5,
|
"prConcurrentLimit": 5,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"description": "Group all dependency updates into a single PR",
|
"matchDepTypes": ["devDependencies"],
|
||||||
"matchPackagePatterns": ["*"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
"groupName": "All dependencies",
|
"groupName": "Development tools",
|
||||||
"groupSlug": "all",
|
"automerge": false
|
||||||
"automerge": true
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": [
|
||||||
|
"@base-ui/react",
|
||||||
|
"lucide-react",
|
||||||
|
"motion",
|
||||||
|
"@dnd-kit/**"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "Interface libraries",
|
||||||
|
"automerge": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["vitest", "@vitest/coverage-v8"],
|
||||||
|
"groupName": "Vitest",
|
||||||
|
"automerge": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Major updates need manual review",
|
|
||||||
"matchUpdateTypes": ["major"],
|
"matchUpdateTypes": ["major"],
|
||||||
"labels": ["dependencies", "major"],
|
"automerge": false
|
||||||
"automerge": false,
|
|
||||||
"assignees": [],
|
|
||||||
"reviewers": []
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Disable updates for engine pins",
|
|
||||||
"matchPackageNames": ["node", "pnpm"],
|
"matchPackageNames": ["node", "pnpm"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Disable server-only (abandoned, pinned at 0.0.1)",
|
|
||||||
"matchPackageNames": ["server-only"],
|
|
||||||
"enabled": false
|
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"minimumReleaseAge": "3 days",
|
||||||
|
"automerge": false
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { cp, mkdir } from "node:fs/promises";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const source = path.dirname(require.resolve("tinymce/package.json"));
|
||||||
|
const target = path.resolve("public/vendor/tinymce");
|
||||||
|
await mkdir(target, { recursive: true });
|
||||||
|
for (const name of [
|
||||||
|
"tinymce.min.js",
|
||||||
|
"icons",
|
||||||
|
"models",
|
||||||
|
"plugins",
|
||||||
|
"skins",
|
||||||
|
"themes",
|
||||||
|
"license.md",
|
||||||
|
"notices.txt",
|
||||||
|
]) {
|
||||||
|
await cp(path.join(source, name), path.join(target, name), {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Production builds and CI do not need Git hooks or dev-only executables.
|
||||||
|
if (process.env.NODE_ENV !== "production" && !process.env.CI) {
|
||||||
|
const { default: husky } = await import("husky");
|
||||||
|
husky();
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { Home, RefreshCw } from "lucide-react";
|
import { Home, RefreshCw } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ErrorScreen } from "@/components/error-screen";
|
import { ErrorScreen } from "@/components/error-screen";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
import { reportBrowserError } from "@/lib/browser-errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Site route-segment error boundary. Renders inside the site shell layout.
|
* Site route-segment error boundary. Renders inside the site shell layout.
|
||||||
@@ -19,8 +20,10 @@ export default function SiteErrorPage({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.error");
|
const t = useTranslations("pages.error");
|
||||||
|
|
||||||
|
const [reference, setReference] = useState(error.digest);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||||
|
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +49,9 @@ export default function SiteErrorPage({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
error.digest ? (
|
reference ? (
|
||||||
<p className="error-screen-digest">
|
<p className="error-screen-digest">
|
||||||
{t("reference", { digest: error.digest })}
|
{t("reference", { digest: reference })}
|
||||||
</p>
|
</p>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
guard: vi.fn(),
|
||||||
|
resolve: vi.fn(),
|
||||||
|
audit: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard }));
|
||||||
|
vi.mock("@/lib/error-monitor", () => ({
|
||||||
|
ErrorStore: class {
|
||||||
|
resolve = mocks.resolve;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
PERMS: { DEVOPS_EDIT: "admin.devops.edit" },
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||||
|
|
||||||
|
import { resolveCmsError } from "./actions";
|
||||||
|
|
||||||
|
it("requires edit permission before resolving or auditing", async () => {
|
||||||
|
mocks.guard.mockRejectedValue(new Error("denied"));
|
||||||
|
await expect(resolveCmsError(new FormData())).rejects.toThrow("denied");
|
||||||
|
expect(mocks.resolve).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.audit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("records who resolved the problem", async () => {
|
||||||
|
mocks.guard.mockResolvedValue({ id: 42 });
|
||||||
|
mocks.resolve.mockResolvedValue(undefined);
|
||||||
|
mocks.audit.mockResolvedValue(undefined);
|
||||||
|
const data = new FormData();
|
||||||
|
data.set("fingerprint", "aabbccddeeff0011");
|
||||||
|
await resolveCmsError(data);
|
||||||
|
expect(mocks.guard).toHaveBeenCalledWith("admin.devops.edit");
|
||||||
|
expect(mocks.audit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ userId: 42, action: "cms.error.resolve" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requirePermission } from "@/lib/admin/guard";
|
||||||
|
import { ErrorStore } from "@/lib/error-monitor";
|
||||||
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
import { logAudit } from "@/lib/services/audit";
|
||||||
|
export async function resolveCmsError(data: FormData) {
|
||||||
|
const staff = await requirePermission(PERMS.DEVOPS_EDIT);
|
||||||
|
await new ErrorStore().resolve(String(data.get("fingerprint") ?? ""));
|
||||||
|
await logAudit({
|
||||||
|
userId: staff.id,
|
||||||
|
action: "cms.error.resolve",
|
||||||
|
target: "cms-error",
|
||||||
|
after: { fingerprint: String(data.get("fingerprint")) },
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/devops/cms-errors");
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ read: vi.fn(), access: vi.fn() }));
|
||||||
|
vi.mock("@/lib/permissions", () => ({
|
||||||
|
getAdminContext: async () => ({
|
||||||
|
session: { user: { rank: 1 } },
|
||||||
|
permissions: {},
|
||||||
|
}),
|
||||||
|
canAccess: mocks.access,
|
||||||
|
PERMS: { DEVOPS_VIEW: "devops.view" },
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: () => {
|
||||||
|
throw new Error("redirect");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/error-monitor", () => ({
|
||||||
|
ErrorStore: class {
|
||||||
|
read = mocks.read;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("@/components/link", () => ({ default: () => null }));
|
||||||
|
|
||||||
|
import Page from "./page";
|
||||||
|
|
||||||
|
it("rejects access before reading diagnostic data", async () => {
|
||||||
|
mocks.access.mockReturnValue(false);
|
||||||
|
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
|
||||||
|
"redirect",
|
||||||
|
);
|
||||||
|
expect(mocks.read).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "@/components/link";
|
||||||
|
import { ErrorStore } from "@/lib/error-monitor";
|
||||||
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||||
|
import { resolveCmsError } from "./actions";
|
||||||
|
export default async function CmsErrorsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) {
|
||||||
|
const { session, permissions } = await getAdminContext();
|
||||||
|
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
|
||||||
|
redirect("/admin");
|
||||||
|
const params = await searchParams;
|
||||||
|
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
|
||||||
|
let result: Awaited<ReturnType<ErrorStore["read"]>>;
|
||||||
|
try {
|
||||||
|
result = await new ErrorStore().read();
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
<div className="admin-card" role="alert">
|
||||||
|
Error storage could not be read. Check the CMS storage volume and
|
||||||
|
permissions.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const filtered = result.records.filter(
|
||||||
|
(r) =>
|
||||||
|
(!params.source || r.source === params.source) &&
|
||||||
|
(!query || JSON.stringify(r).toLowerCase().includes(query)),
|
||||||
|
);
|
||||||
|
const groups = new Map<string, typeof filtered>();
|
||||||
|
for (const r of filtered) {
|
||||||
|
const list = groups.get(r.fingerprint) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
groups.set(r.fingerprint, list);
|
||||||
|
}
|
||||||
|
const page = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
Number(params.page) || 1,
|
||||||
|
Math.max(1, Math.ceil(groups.size / 25)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const pageHref = (next: number) =>
|
||||||
|
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`;
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">CMS error center</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Server and browser errors from the last 7 days. Browser reports are
|
||||||
|
unverified client observations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Link href="/admin/devops" className="btn btn-outline">
|
||||||
|
DevOps
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/devops/errors" className="btn btn-outline">
|
||||||
|
Emulator errors
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||||
|
Refresh / clear filters
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<form className="admin-card flex flex-wrap gap-3">
|
||||||
|
<input
|
||||||
|
aria-label="Search errors"
|
||||||
|
name="q"
|
||||||
|
defaultValue={params.q}
|
||||||
|
placeholder="Reference, digest, message, route or release"
|
||||||
|
className="input min-w-0 flex-1"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name="source"
|
||||||
|
aria-label="Error source"
|
||||||
|
defaultValue={params.source ?? ""}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">All sources</option>
|
||||||
|
<option value="server">Server</option>
|
||||||
|
<option value="browser">Browser</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{filtered.length} occurrences · {groups.size} groups.{" "}
|
||||||
|
{result.truncated
|
||||||
|
? "Showing the latest 1,000 events. Narrowing filters searches this retained window."
|
||||||
|
: ""}{" "}
|
||||||
|
Storage is limited to 10 MB per day; excess events remain in server logs
|
||||||
|
where available.
|
||||||
|
</p>
|
||||||
|
{groups.size === 0 ? (
|
||||||
|
<div className="admin-card">
|
||||||
|
No captured errors match these filters. Monitoring starts with this
|
||||||
|
deployment; older console logs are not imported.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{[...groups.entries()]
|
||||||
|
.slice((page - 1) * 25, page * 25)
|
||||||
|
.map(([fingerprint, events]) => {
|
||||||
|
const r = events[0];
|
||||||
|
return (
|
||||||
|
<details key={fingerprint} className="admin-card overflow-hidden">
|
||||||
|
<summary className="cursor-pointer">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "}
|
||||||
|
{events.length} occurrences · {r.at}
|
||||||
|
</span>
|
||||||
|
<p className="font-semibold break-words">{r.event}</p>
|
||||||
|
<p className="text-sm break-words">{r.message}</p>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4 space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
Reference: <code className="break-all">{r.id}</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Release: <code className="break-all">{r.release}</code>
|
||||||
|
</p>
|
||||||
|
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||||
|
{JSON.stringify(r.context, null, 2)}
|
||||||
|
</pre>
|
||||||
|
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
|
||||||
|
{r.stack ||
|
||||||
|
"No stack supplied. Use the reference or digest to correlate server logs."}
|
||||||
|
</pre>
|
||||||
|
{!r.resolved &&
|
||||||
|
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
|
||||||
|
<form action={resolveCmsError}>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="fingerprint"
|
||||||
|
value={r.fingerprint}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-outline">
|
||||||
|
Mark resolved
|
||||||
|
</button>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
A new occurrence reopens the group automatically.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<details>
|
||||||
|
<summary className="cursor-pointer">
|
||||||
|
Occurrence references
|
||||||
|
</summary>
|
||||||
|
<ul>
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e.id} className="break-all">
|
||||||
|
{e.at} · {e.id}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Browser stack traces may reference compiled chunks. This view
|
||||||
|
does not infer a root cause or include request bodies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<nav aria-label="Error pages" className="flex gap-3">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link href={pageHref(page - 1)} className="btn btn-outline">
|
||||||
|
Previous
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{page * 25 < groups.size ? (
|
||||||
|
<Link href={pageHref(page + 1)} className="btn btn-outline">
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,9 @@ export default async function EmulatorErrorsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||||
|
CMS error center
|
||||||
|
</a>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{total} total errors — showing page {page} of {lastPage}
|
{total} total errors — showing page {page} of {lastPage}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ export default async function DevOpsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<a href="/admin/devops/cms-errors" className="btn btn-outline">
|
||||||
|
CMS error center
|
||||||
|
</a>
|
||||||
{/* Status cards */}
|
{/* Status cards */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { LayoutDashboard, RefreshCw } from "lucide-react";
|
import { LayoutDashboard, RefreshCw } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { reportBrowserError } from "@/lib/browser-errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin route-segment error boundary. Renders inside the admin layout shell.
|
* Admin route-segment error boundary. Renders inside the admin layout shell.
|
||||||
@@ -19,8 +20,10 @@ export default function AdminErrorPage({
|
|||||||
const t = useTranslations("pages.error");
|
const t = useTranslations("pages.error");
|
||||||
const tNav = useTranslations("admin.nav");
|
const tNav = useTranslations("admin.nav");
|
||||||
|
|
||||||
|
const [reference, setReference] = useState(error.digest);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||||
|
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,12 +66,12 @@ export default function AdminErrorPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{error.digest ? (
|
{reference ? (
|
||||||
<p
|
<p
|
||||||
className="mt-6 text-xs font-mono"
|
className="mt-6 text-xs font-mono"
|
||||||
style={{ color: "var(--admin-muted, #9ca3af)" }}
|
style={{ color: "var(--admin-muted, #9ca3af)" }}
|
||||||
>
|
>
|
||||||
{t("reference", { digest: error.digest })}
|
{t("reference", { digest: reference })}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { reportBrowserError } from "@/lib/browser-errors";
|
||||||
|
|
||||||
export default function PermissionRankError({
|
export default function PermissionRankError({
|
||||||
error,
|
error,
|
||||||
@@ -11,8 +12,10 @@ export default function PermissionRankError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [reference, setReference] = useState(error.digest);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||||
|
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,9 +31,9 @@ export default function PermissionRankError({
|
|||||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 text-xs">
|
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 text-xs">
|
||||||
{error.stack ?? error.message}
|
{error.stack ?? error.message}
|
||||||
</pre>
|
</pre>
|
||||||
{error.digest ? (
|
{reference ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Reference: {error.digest}
|
Reference: {reference}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="button" onClick={reset}>
|
<Button type="button" onClick={reset}>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ append: vi.fn(), limit: vi.fn() }));
|
||||||
|
vi.mock("@/lib/rate-limit", () => ({
|
||||||
|
clientIp: async () => "test",
|
||||||
|
rateLimit: mocks.limit,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/error-monitor", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/error-monitor")>(
|
||||||
|
"@/lib/error-monitor",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
ErrorStore: class {
|
||||||
|
append = mocks.append;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
afterEach(() => vi.resetAllMocks());
|
||||||
|
const req = (body: string, origin = "https://cms.test") =>
|
||||||
|
new Request("https://cms.test/api/diagnostics/errors", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { origin, "Content-Type": "application/json" },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
it("rejects cross-origin reports before touching storage", async () => {
|
||||||
|
expect((await POST(req("{}", "https://other.test"))).status).toBe(403);
|
||||||
|
expect(mocks.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("limits reports before reading their body", async () => {
|
||||||
|
mocks.limit.mockResolvedValue({ ok: false });
|
||||||
|
expect((await POST(req("{}"))).status).toBe(429);
|
||||||
|
expect(mocks.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("rejects oversized reports without trusting content-length", async () => {
|
||||||
|
mocks.limit.mockResolvedValue({ ok: true });
|
||||||
|
expect((await POST(req("a".repeat(17000)))).status).toBe(413);
|
||||||
|
expect(mocks.append).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("stores only sanitized diagnostic fields and identifies browser claims", async () => {
|
||||||
|
mocks.limit.mockResolvedValue({ ok: true });
|
||||||
|
mocks.append.mockResolvedValue(undefined);
|
||||||
|
const r = await POST(
|
||||||
|
req(
|
||||||
|
JSON.stringify({
|
||||||
|
event: "browser.exception",
|
||||||
|
message: "token=secret",
|
||||||
|
path: "/news?token=hidden",
|
||||||
|
body: "never store",
|
||||||
|
release: "old-client",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(r.status).toBe(202);
|
||||||
|
const saved = mocks.append.mock.calls[0][0];
|
||||||
|
expect(saved.source).toBe("browser");
|
||||||
|
expect(saved.context.clientRelease).toBe("old-client");
|
||||||
|
expect(JSON.stringify(saved)).not.toMatch(/hidden|never store|token=secret/);
|
||||||
|
});
|
||||||
|
it("reports storage failure rather than claiming receipt", async () => {
|
||||||
|
mocks.limit.mockResolvedValue({ ok: true });
|
||||||
|
mocks.append.mockRejectedValue(new Error("disk"));
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await POST(
|
||||||
|
req(
|
||||||
|
JSON.stringify({
|
||||||
|
event: "browser.exception",
|
||||||
|
message: "failure",
|
||||||
|
path: "/",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).status,
|
||||||
|
).toBe(503);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { createErrorRecord, ErrorStore } from "@/lib/error-monitor";
|
||||||
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
event: z.enum([
|
||||||
|
"browser.exception",
|
||||||
|
"browser.boundary",
|
||||||
|
"browser.unhandled_rejection",
|
||||||
|
]),
|
||||||
|
clientReference: z.string().uuid().optional(),
|
||||||
|
message: z.string().max(1000),
|
||||||
|
stack: z.string().max(6000).optional(),
|
||||||
|
path: z.string().startsWith("/").max(1000),
|
||||||
|
digest: z.string().max(200).optional(),
|
||||||
|
release: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (
|
||||||
|
request.headers.get("origin") !==
|
||||||
|
new URL(
|
||||||
|
process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || request.url,
|
||||||
|
).origin ||
|
||||||
|
!request.headers.get("content-type")?.startsWith("application/json")
|
||||||
|
)
|
||||||
|
return new Response(null, { status: 403 });
|
||||||
|
const limit = await rateLimit(`cms-error:${await clientIp()}`, 20, 60000);
|
||||||
|
if (!limit.ok) return new Response(null, { status: 429 });
|
||||||
|
const reader = request.body?.getReader();
|
||||||
|
if (!reader) return new Response(null, { status: 400 });
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let length = 0;
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
length += value.byteLength;
|
||||||
|
if (length > 16000) {
|
||||||
|
await reader.cancel();
|
||||||
|
return new Response(null, { status: 413 });
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
const parsed = schema.safeParse(
|
||||||
|
JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
||||||
|
);
|
||||||
|
if (!parsed.success) return new Response(null, { status: 400 });
|
||||||
|
const data = parsed.data;
|
||||||
|
const error = new Error(data.message);
|
||||||
|
error.stack = data.stack ?? "";
|
||||||
|
const record = createErrorRecord("browser", data.event, error, {
|
||||||
|
clientReference: data.clientReference ?? null,
|
||||||
|
path: data.path.split("?")[0],
|
||||||
|
digest: data.digest ?? null,
|
||||||
|
clientRelease: data.release ?? "unknown",
|
||||||
|
});
|
||||||
|
await new ErrorStore().append(record);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ id: record.id },
|
||||||
|
{ status: 202, headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return new Response(null, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-4
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { Home, RefreshCw } from "lucide-react";
|
import { Home, RefreshCw } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ErrorScreen } from "@/components/error-screen";
|
import { ErrorScreen } from "@/components/error-screen";
|
||||||
import Link from "@/components/link";
|
import Link from "@/components/link";
|
||||||
|
import { reportBrowserError } from "@/lib/browser-errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Route-segment error boundary. Renders inside the root layout (so the shell
|
* Route-segment error boundary. Renders inside the root layout (so the shell
|
||||||
@@ -19,8 +20,10 @@ export default function ErrorPage({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.error");
|
const t = useTranslations("pages.error");
|
||||||
|
|
||||||
|
const [reference, setReference] = useState(error.digest);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||||
|
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -46,9 +49,9 @@ export default function ErrorPage({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
error.digest ? (
|
reference ? (
|
||||||
<p className="error-screen-digest">
|
<p className="error-screen-digest">
|
||||||
{t("reference", { digest: error.digest })}
|
{t("reference", { digest: reference })}
|
||||||
</p>
|
</p>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { reportBrowserError } from "@/lib/browser-errors";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last-resort boundary for errors thrown in the root layout itself. It replaces
|
* Last-resort boundary for errors thrown in the root layout itself. It replaces
|
||||||
@@ -14,8 +15,10 @@ export default function GlobalError({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [reference, setReference] = useState(error.digest);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
|
||||||
|
if (error.digest) reportBrowserError(error, "browser.boundary");
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -107,8 +110,8 @@ export default function GlobalError({
|
|||||||
<button type="button" className="ge-btn" onClick={() => reset()}>
|
<button type="button" className="ge-btn" onClick={() => reset()}>
|
||||||
Try again
|
Try again
|
||||||
</button>
|
</button>
|
||||||
{error.digest ? (
|
{reference ? (
|
||||||
<p className="ge-digest">Reference: {error.digest}</p>
|
<p className="ge-digest">Reference: {reference}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { ReactNode } from "react";
|
|||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
import { ScopedThemeVars } from "@/components/scoped-theme-vars";
|
import { ScopedThemeVars } from "@/components/scoped-theme-vars";
|
||||||
import { SmoothScroll } from "@/components/smooth-scroll";
|
|
||||||
import { ThemeScopeDetector } from "@/components/theme-scope-detector";
|
import { ThemeScopeDetector } from "@/components/theme-scope-detector";
|
||||||
import { ThemeVars } from "@/components/theme-vars";
|
import { ThemeVars } from "@/components/theme-vars";
|
||||||
import { enforceSiteAccess } from "@/lib/access-guard";
|
import { enforceSiteAccess } from "@/lib/access-guard";
|
||||||
@@ -102,7 +101,6 @@ export default async function RootLayout({
|
|||||||
<ThemeScopeDetector />
|
<ThemeScopeDetector />
|
||||||
{children}
|
{children}
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
<SmoothScroll />
|
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
richColors
|
richColors
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
|
||||||
// TinyMCE community build, loaded from the public jsDelivr CDN. `no-api-key`
|
// Editor assets are pinned in the lockfile and copied during build.
|
||||||
// is the documented sentinel for the free self-hosted/CDN build and silences
|
const TINYMCE_SRC = "/vendor/tinymce/tinymce.min.js";
|
||||||
// the "register for an API key" notification.
|
|
||||||
const TINYMCE_SRC = "https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
|
|
||||||
|
|
||||||
type TinyMCEGlobal = {
|
type TinyMCEGlobal = {
|
||||||
init: (opts: Record<string, unknown>) => Promise<unknown>;
|
init: (opts: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Lenis } from "lenis/react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export function SmoothScroll() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const isAdmin = pathname === "/admin" || pathname?.startsWith("/admin/");
|
|
||||||
const [prefersReduced, setPrefersReduced] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
||||||
setPrefersReduced(mq.matches);
|
|
||||||
const onChange = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
|
|
||||||
mq.addEventListener("change", onChange);
|
|
||||||
return () => mq.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Admin panels own their scroll containers; root wheel smoothing consumes their events.
|
|
||||||
if (prefersReduced || isAdmin) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Lenis
|
|
||||||
root
|
|
||||||
options={{
|
|
||||||
duration: 1.2,
|
|
||||||
lerp: 0.1,
|
|
||||||
smoothWheel: true,
|
|
||||||
wheelMultiplier: 1,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ export const systemManifest = {
|
|||||||
PERMS.LOGS_VIEW,
|
PERMS.LOGS_VIEW,
|
||||||
PERMS.ANALYTICS_VIEW,
|
PERMS.ANALYTICS_VIEW,
|
||||||
PERMS.DEVOPS_VIEW,
|
PERMS.DEVOPS_VIEW,
|
||||||
|
PERMS.DEVOPS_EDIT,
|
||||||
PERMS.NOTIFICATIONS_VIEW,
|
PERMS.NOTIFICATIONS_VIEW,
|
||||||
PERMS.PERMISSIONS_MANAGE,
|
PERMS.PERMISSIONS_MANAGE,
|
||||||
PERMS.RCON_EXECUTE,
|
PERMS.RCON_EXECUTE,
|
||||||
|
|||||||
@@ -612,8 +612,8 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||||
expect(discovered).toHaveLength(138);
|
expect(discovered).toHaveLength(139);
|
||||||
expect(issues).toEqual([]);
|
expect(issues).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -173,6 +173,7 @@ const expectedManifests = [
|
|||||||
PERMS.LOGS_VIEW,
|
PERMS.LOGS_VIEW,
|
||||||
PERMS.ANALYTICS_VIEW,
|
PERMS.ANALYTICS_VIEW,
|
||||||
PERMS.DEVOPS_VIEW,
|
PERMS.DEVOPS_VIEW,
|
||||||
|
PERMS.DEVOPS_EDIT,
|
||||||
PERMS.NOTIFICATIONS_VIEW,
|
PERMS.NOTIFICATIONS_VIEW,
|
||||||
PERMS.PERMISSIONS_MANAGE,
|
PERMS.PERMISSIONS_MANAGE,
|
||||||
PERMS.RCON_EXECUTE,
|
PERMS.RCON_EXECUTE,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
|
|||||||
it("discovers the exact legacy administration inventory", () => {
|
it("discovers the exact legacy administration inventory", () => {
|
||||||
const pages = discoverLegacyPages();
|
const pages = discoverLegacyPages();
|
||||||
|
|
||||||
expect(pages).toHaveLength(138);
|
expect(pages).toHaveLength(139);
|
||||||
expect(pages).toContainEqual({
|
expect(pages).toContainEqual({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/users/:id/edit",
|
legacyPath: "/admin/users/:id/edit",
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
|||||||
import { validateMigrationEntries } from "./validate-matrix";
|
import { validateMigrationEntries } from "./validate-matrix";
|
||||||
|
|
||||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||||
it("covers all 138 legacy pages exactly once", () => {
|
it("covers all 139 legacy pages exactly once", () => {
|
||||||
const discovered = discoverLegacyPages();
|
const discovered = discoverLegacyPages();
|
||||||
|
|
||||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
|
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ const SYSTEM_PREFIXES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
describe("systemMigrationEntries", () => {
|
describe("systemMigrationEntries", () => {
|
||||||
it("covers all 19 System pages exactly once", () => {
|
it("covers all 20 System pages exactly once", () => {
|
||||||
expect(systemMigrationEntries).toHaveLength(19);
|
expect(systemMigrationEntries).toHaveLength(20);
|
||||||
expect(
|
expect(
|
||||||
validateMigrationEntries(
|
validateMigrationEntries(
|
||||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||||
|
|||||||
@@ -193,6 +193,18 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [
|
|||||||
localization: "PARTIAL",
|
localization: "PARTIAL",
|
||||||
accessibility: "PARTIAL",
|
accessibility: "PARTIAL",
|
||||||
}),
|
}),
|
||||||
|
plannedSystemEntry({
|
||||||
|
surface: "admin",
|
||||||
|
legacyPath: "/admin/devops/cms-errors",
|
||||||
|
sourceFile: "src/app/admin/devops/cms-errors/page.tsx",
|
||||||
|
targetPath: "/admin/system/observability/devops/cms-errors",
|
||||||
|
decision: "REBUILD",
|
||||||
|
capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [PERMS.DEVOPS_EDIT] },
|
||||||
|
dependencies: { queries: ["ErrorStore"], mutations: ["resolveCmsError"] },
|
||||||
|
auditRequirement: "MUTATION",
|
||||||
|
localization: "PARTIAL",
|
||||||
|
accessibility: "PARTIAL",
|
||||||
|
}),
|
||||||
plannedSystemEntry({
|
plannedSystemEntry({
|
||||||
surface: "admin",
|
surface: "admin",
|
||||||
legacyPath: "/admin/emulator",
|
legacyPath: "/admin/emulator",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { reportBrowserError } from "@/lib/browser-errors";
|
||||||
|
|
||||||
|
window.addEventListener("error", (event) => {
|
||||||
|
if (event.error) reportBrowserError(event.error);
|
||||||
|
});
|
||||||
|
window.addEventListener("unhandledrejection", (event) =>
|
||||||
|
reportBrowserError(event.reason, "browser.unhandled_rejection"),
|
||||||
|
);
|
||||||
@@ -122,12 +122,12 @@ export function withAdmin(
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await finishExport?.().catch(() => undefined);
|
await finishExport?.().catch(() => undefined);
|
||||||
logServerError("admin.api_failed", error, {
|
const errorId = logServerError("admin.api_failed", error, {
|
||||||
path: request.nextUrl.pathname,
|
path: request.nextUrl.pathname,
|
||||||
userId: context.session.user.id,
|
userId: context.session.user.id,
|
||||||
});
|
});
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ ok: false, error: "Internal server error" },
|
{ ok: false, error: "Internal server error", errorId },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { reportBrowserError } from "./browser-errors";
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
it("returns a reusable reference, deduplicates reports and omits URL parameters", () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
vi.stubGlobal("window", {});
|
||||||
|
vi.stubGlobal("location", {
|
||||||
|
pathname: "/admin/studio",
|
||||||
|
search: "?token=secret",
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const error = new Error("browser-test-unique");
|
||||||
|
const id = reportBrowserError(error);
|
||||||
|
expect(reportBrowserError(error)).toBe(id);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(payload.clientReference).toBe(id);
|
||||||
|
expect(payload.path).toBe("/admin/studio");
|
||||||
|
expect(JSON.stringify(payload)).not.toContain("token=secret");
|
||||||
|
});
|
||||||
|
it("never throws again when reporting fails on the network", async () => {
|
||||||
|
vi.stubGlobal("window", {});
|
||||||
|
vi.stubGlobal("location", { pathname: "/" });
|
||||||
|
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
||||||
|
expect(reportBrowserError(new Error("offline-test"))).toBeTruthy();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const seen = new Map<string, { at: number; id: string }>();
|
||||||
|
export function reportBrowserError(
|
||||||
|
error: unknown,
|
||||||
|
event = "browser.exception",
|
||||||
|
) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const candidate =
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(typeof error === "string" ? error : "Unknown browser error");
|
||||||
|
const key = `${event}:${location.pathname}:${"digest" in candidate ? candidate.digest : ""}:${candidate.message}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const prior = seen.get(key);
|
||||||
|
if (prior && now - prior.at < 60000) return prior.id;
|
||||||
|
const reference = crypto.randomUUID();
|
||||||
|
if (seen.size >= 100) seen.clear();
|
||||||
|
seen.set(key, { at: now, id: reference });
|
||||||
|
const digest = "digest" in candidate ? String(candidate.digest) : undefined;
|
||||||
|
void fetch("/api/diagnostics/errors", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
keepalive: true,
|
||||||
|
body: JSON.stringify({
|
||||||
|
event,
|
||||||
|
clientReference: reference,
|
||||||
|
message: candidate.message.slice(0, 1000),
|
||||||
|
stack: candidate.stack?.slice(0, 6000),
|
||||||
|
path: location.pathname,
|
||||||
|
digest,
|
||||||
|
release: process.env.NEXT_PUBLIC_CMS_RELEASE,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
return reference;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { createErrorRecord, ErrorStore } from "./error-monitor";
|
||||||
|
|
||||||
|
it("redacts secrets in messages, stack and context while retaining a reference", () => {
|
||||||
|
const r = createErrorRecord(
|
||||||
|
"server",
|
||||||
|
"payment.failed",
|
||||||
|
new Error("https://user:password@host/a?token=abc password=hello"),
|
||||||
|
{
|
||||||
|
token: "secret",
|
||||||
|
path: "/a?session=hidden",
|
||||||
|
nested: { password: "never" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const text = JSON.stringify(r);
|
||||||
|
for (const secret of ["password@", "token=abc", "hello", "hidden", "never"])
|
||||||
|
expect(text).not.toContain(secret);
|
||||||
|
expect(r.id).toBeTruthy();
|
||||||
|
expect(r.stack).toContain("Error");
|
||||||
|
});
|
||||||
|
it("persists across instances and groups repeated errors without losing references", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||||
|
try {
|
||||||
|
const store = new ErrorStore(dir);
|
||||||
|
const a = createErrorRecord("server", "test", new Error("failed"));
|
||||||
|
await store.append(a);
|
||||||
|
await store.append({ ...a, id: "second" });
|
||||||
|
const result = await new ErrorStore(dir).read();
|
||||||
|
expect(result.records).toHaveLength(2);
|
||||||
|
expect(result.records[0].fingerprint).toBe(result.records[1].fingerprint);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("resolves a group and reopens it when a later occurrence arrives", async () => {
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
|
||||||
|
try {
|
||||||
|
const store = new ErrorStore(dir);
|
||||||
|
const a = createErrorRecord("server", "test", new Error("failed"));
|
||||||
|
await store.append(a);
|
||||||
|
await store.resolve(a.fingerprint);
|
||||||
|
expect((await store.read()).records[0].resolved).toBe(true);
|
||||||
|
await store.append({
|
||||||
|
...a,
|
||||||
|
id: "later",
|
||||||
|
at: new Date(Date.now() + 1000).toISOString(),
|
||||||
|
});
|
||||||
|
expect((await store.read()).records[0].resolved).toBe(false);
|
||||||
|
await expect(store.resolve("../escape")).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
appendFile,
|
||||||
|
mkdir,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
stat,
|
||||||
|
unlink,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function redactErrorText(value: string): string {
|
||||||
|
return value
|
||||||
|
.slice(0, 12000)
|
||||||
|
.replace(
|
||||||
|
/(https?:\/\/|mysql:\/\/|redis:\/\/)[^\s/@]+:[^\s/@]+@/gi,
|
||||||
|
"$1[REDACTED]@",
|
||||||
|
)
|
||||||
|
.replace(/([?&])[^\s#)]+/g, "$1[REDACTED]")
|
||||||
|
.replace(
|
||||||
|
/((?:password|secret|token|authorization|cookie|api[_-]?key)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
|
||||||
|
"$1[REDACTED]",
|
||||||
|
)
|
||||||
|
.replace(/Bearer\s+[^\s,;]+/gi, "Bearer [REDACTED]");
|
||||||
|
}
|
||||||
|
export interface CmsErrorRecord {
|
||||||
|
id: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
at: string;
|
||||||
|
source: "server" | "browser";
|
||||||
|
event: string;
|
||||||
|
message: string;
|
||||||
|
stack: string;
|
||||||
|
release: string;
|
||||||
|
fingerprint: string;
|
||||||
|
context: Record<string, string | number | boolean | null>;
|
||||||
|
}
|
||||||
|
export function createErrorRecord(
|
||||||
|
source: CmsErrorRecord["source"],
|
||||||
|
event: string,
|
||||||
|
error: unknown,
|
||||||
|
context: Record<string, unknown> = {},
|
||||||
|
): CmsErrorRecord {
|
||||||
|
const message = redactErrorText(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === "string"
|
||||||
|
? error
|
||||||
|
: "Unknown error",
|
||||||
|
);
|
||||||
|
const stack =
|
||||||
|
error instanceof Error ? redactErrorText(error.stack ?? "") : "";
|
||||||
|
const safeContext = Object.fromEntries(
|
||||||
|
Object.entries(context)
|
||||||
|
.slice(0, 24)
|
||||||
|
.map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
/password|secret|token|cookie|authorization|body|query|email|api[_-]?key/i.test(
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
? "[REDACTED]"
|
||||||
|
: typeof value === "string"
|
||||||
|
? redactErrorText(value).slice(0, 1000)
|
||||||
|
: typeof value === "number" ||
|
||||||
|
typeof value === "boolean" ||
|
||||||
|
value === null
|
||||||
|
? value
|
||||||
|
: "[NON_SCALAR]",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
source,
|
||||||
|
event: redactErrorText(event).slice(0, 160),
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
|
||||||
|
fingerprint: createHash("sha256")
|
||||||
|
.update(`${source}:${event}:${message}:${stack.split("\n")[1] ?? ""}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16),
|
||||||
|
context: safeContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const DAY_LIMIT = 10 * 1024 * 1024;
|
||||||
|
const RETENTION_DAYS = 7;
|
||||||
|
export class ErrorStore {
|
||||||
|
private static queue: Promise<void> = Promise.resolve();
|
||||||
|
private static pending = 0;
|
||||||
|
constructor(
|
||||||
|
private directory = path.join(process.cwd(), "storage", "cms-errors"),
|
||||||
|
) {}
|
||||||
|
async append(record: CmsErrorRecord) {
|
||||||
|
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
|
||||||
|
ErrorStore.pending++;
|
||||||
|
const write = ErrorStore.queue.then(() => this.writeRecord(record));
|
||||||
|
ErrorStore.queue = write.catch(() => {});
|
||||||
|
try {
|
||||||
|
await write;
|
||||||
|
} finally {
|
||||||
|
ErrorStore.pending--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async writeRecord(record: CmsErrorRecord) {
|
||||||
|
await mkdir(this.directory, { recursive: true });
|
||||||
|
const files = await readdir(this.directory);
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
for (const name of files)
|
||||||
|
if (
|
||||||
|
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) &&
|
||||||
|
name.slice(0, 10) < cutoff
|
||||||
|
)
|
||||||
|
await unlink(path.join(this.directory, name)).catch(() => {});
|
||||||
|
const file = path.join(this.directory, record.at.slice(0, 10) + ".jsonl");
|
||||||
|
const size = await stat(file)
|
||||||
|
.then((s) => s.size)
|
||||||
|
.catch(() => 0);
|
||||||
|
if (size >= DAY_LIMIT) throw new Error("Daily error storage limit reached");
|
||||||
|
await appendFile(file, JSON.stringify(record) + "\n", { mode: 0o600 });
|
||||||
|
}
|
||||||
|
async resolve(fingerprint: string) {
|
||||||
|
if (!/^[a-f0-9]{16}$/.test(fingerprint))
|
||||||
|
throw new Error("Invalid fingerprint");
|
||||||
|
const { records } = await this.read();
|
||||||
|
if (!records.some((r) => r.fingerprint === fingerprint))
|
||||||
|
throw new Error("Error group no longer available");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await writeFile(
|
||||||
|
path.join(this.directory, `${now.slice(0, 10)}.${fingerprint}.resolved`),
|
||||||
|
now,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
|
||||||
|
const files = await readdir(this.directory).catch(
|
||||||
|
(e: NodeJS.ErrnoException) => {
|
||||||
|
if (e.code === "ENOENT") return [];
|
||||||
|
throw e;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const resolved = new Map<string, string>();
|
||||||
|
for (const name of files
|
||||||
|
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f))
|
||||||
|
.sort()) {
|
||||||
|
const at = await readFile(path.join(this.directory, name), "utf8");
|
||||||
|
resolved.set(name.split(".")[1], at);
|
||||||
|
}
|
||||||
|
const records: CmsErrorRecord[] = [];
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
for (const name of files
|
||||||
|
.filter(
|
||||||
|
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.reverse()) {
|
||||||
|
const file = path.join(this.directory, name);
|
||||||
|
if ((await stat(file)).size > DAY_LIMIT + 100000) continue;
|
||||||
|
const lines = (await readFile(file, "utf8")).trim().split("\n").reverse();
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const r = JSON.parse(line);
|
||||||
|
if (r.id && r.fingerprint)
|
||||||
|
records.push({
|
||||||
|
...r,
|
||||||
|
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* Ignore an interrupted final write. */
|
||||||
|
}
|
||||||
|
if (records.length >= 1000) return { records, truncated: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { records, truncated: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const store = new ErrorStore();
|
||||||
|
let pending = 0;
|
||||||
|
let lastFailure = 0;
|
||||||
|
export function captureCmsError(
|
||||||
|
source: CmsErrorRecord["source"],
|
||||||
|
event: string,
|
||||||
|
error: unknown,
|
||||||
|
context: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const record = createErrorRecord(source, event, error, context);
|
||||||
|
if (process.env.NODE_ENV === "test" || process.env.VITEST || pending >= 100)
|
||||||
|
return record.id;
|
||||||
|
pending++;
|
||||||
|
void store
|
||||||
|
.append(record)
|
||||||
|
.catch(() => {
|
||||||
|
if (Date.now() - lastFailure > 60000) {
|
||||||
|
lastFailure = Date.now();
|
||||||
|
process.stderr.write(
|
||||||
|
"CMS error monitor could not persist an event; check storage permissions or daily quota.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
pending--;
|
||||||
|
});
|
||||||
|
return record.id;
|
||||||
|
}
|
||||||
+17
-2
@@ -1,5 +1,6 @@
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
import { captureCmsError } from "./error-monitor";
|
||||||
|
|
||||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
@@ -41,7 +42,21 @@ export const logger = {
|
|||||||
warn(message: string, meta: Record<string, unknown> = {}): void {
|
warn(message: string, meta: Record<string, unknown> = {}): void {
|
||||||
pinoLogger.warn(meta, message);
|
pinoLogger.warn(meta, message);
|
||||||
},
|
},
|
||||||
error(message: string, meta: Record<string, unknown> = {}): void {
|
error(message: string, meta: Record<string, unknown> = {}): string {
|
||||||
pinoLogger.error(meta, message);
|
const error =
|
||||||
|
meta.err instanceof Error
|
||||||
|
? meta.err
|
||||||
|
: meta.error instanceof Error
|
||||||
|
? meta.error
|
||||||
|
: new Error(
|
||||||
|
typeof meta.err === "string"
|
||||||
|
? meta.err
|
||||||
|
: typeof meta.error === "string"
|
||||||
|
? meta.error
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
const errorId = captureCmsError("server", message, error, meta);
|
||||||
|
pinoLogger.error({ ...meta, errorId }, message);
|
||||||
|
return errorId;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createRequire } from "node:module";
|
import { expect, it } from "vitest";
|
||||||
import { describe, expect, it } from "vitest";
|
import { createErrorRecord } from "./error-monitor";
|
||||||
|
|
||||||
describe("observability dependency contract", () => {
|
it("captures diagnostic data without serializing arbitrary request bodies", () => {
|
||||||
it("does not ship the Sentry SDK", () => {
|
const record = createErrorRecord(
|
||||||
const require = createRequire(import.meta.url);
|
"server",
|
||||||
|
"request.failed",
|
||||||
expect(() => require.resolve("@sentry/nextjs")).toThrow();
|
new Error("failure"),
|
||||||
});
|
{ body: { password: "secret" }, path: "/admin/studio" },
|
||||||
|
);
|
||||||
|
expect(record.context.body).toBe("[REDACTED]");
|
||||||
|
expect(record.context.path).toBe("/admin/studio");
|
||||||
|
expect(record.stack).toContain("failure");
|
||||||
});
|
});
|
||||||
@@ -5,9 +5,8 @@ import { logger } from "@/lib/logger";
|
|||||||
* Domain errors (validation, auth, etc.) should NOT go through here.
|
* Domain errors (validation, auth, etc.) should NOT go through here.
|
||||||
*/
|
*/
|
||||||
export function reportError(error: unknown, context = "Unhandled error"): void {
|
export function reportError(error: unknown, context = "Unhandled error"): void {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(context, {
|
logger.error(context, {
|
||||||
err: message,
|
err: error,
|
||||||
name: error instanceof Error ? error.name : undefined,
|
name: error instanceof Error ? error.name : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,8 +38,11 @@ export function logServerError(
|
|||||||
event: string,
|
event: string,
|
||||||
error: unknown,
|
error: unknown,
|
||||||
context: LogContext = {},
|
context: LogContext = {},
|
||||||
): void {
|
): string {
|
||||||
logger.error(JSON.stringify(serverErrorRecord(event, error, context)));
|
return logger.error(event, {
|
||||||
|
...serverErrorRecord(event, error, context).context,
|
||||||
|
err: error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function logScalar(value: unknown): LogScalar {
|
function logScalar(value: unknown): LogScalar {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ let running: Promise<void> | undefined;
|
|||||||
export function drainFurnitureImports(): Promise<void> {
|
export function drainFurnitureImports(): Promise<void> {
|
||||||
if (running) return running;
|
if (running) return running;
|
||||||
running = drain()
|
running = drain()
|
||||||
.catch((error) => logServerError("furni.worker_failed", error))
|
.catch((error) => {
|
||||||
|
logServerError("furni.worker_failed", error);
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
running = undefined;
|
running = undefined;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
|
// Preserve module-initialization calls used by route permission contract tests.
|
||||||
|
clearMocks: false,
|
||||||
// Modules under test transitively import @/env; skip its strict parse so
|
// Modules under test transitively import @/env; skip its strict parse so
|
||||||
// unit tests run without a populated .env.
|
// unit tests run without a populated .env.
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
Reference in new issue
Block a user