Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59d3e97ef0 | |||
| 0c0d0caae6 | |||
| 2c9f12623e | |||
| a55649c5f2 | |||
| 0d7c588536 | |||
| b6debcbb59 | |||
| 5847bc5795 | |||
| e662415137 | |||
| 580b087e8a | |||
| ac3c405cb2 | |||
| a594affdfa | |||
| 61e78ea672 | |||
| 6501eac38a | |||
| 7f9206ae77 | |||
| 6229f8e886 |
@@ -28,6 +28,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -68,6 +69,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -79,12 +81,28 @@ jobs:
|
||||
pnpm release:tag
|
||||
|
||||
build-images:
|
||||
name: 🐳 Build & Push Images
|
||||
name: 🐳 Build ${{ matrix.name }}
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- image: nextjs
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
name: Build-Base
|
||||
- image: runtime
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
name: Production Runtime
|
||||
- image: gatekeeper
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
name: Gatekeeper (Product)
|
||||
- image: directus
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
name: Directus (Base)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -99,58 +117,19 @@ jobs:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/nextjs:latest
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
- name: 🏗️ Build & Push Production Runtime
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/runtime:latest
|
||||
|
||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||
|
||||
- name: 🏗️ Build & Push Directus (Base)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/directus:latest
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@sentry/nextjs": "^8.55.0",
|
||||
"next": "15.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
|
||||
9
apps/sample-website/sentry.client.config.ts
Normal file
9
apps/sample-website/sentry.client.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
|
||||
initSentry({
|
||||
// Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel
|
||||
// Or just use the real DSN if you don't care about ad-blockers for errors.
|
||||
// The Mintel standard is to use the relay.
|
||||
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay
|
||||
tunnel: "/errors/api/relay",
|
||||
});
|
||||
8
apps/sample-website/sentry.edge.config.ts
Normal file
8
apps/sample-website/sentry.edge.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
8
apps/sample-website/sentry.server.config.ts
Normal file
8
apps/sample-website/sentry.server.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSentryRelayHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createSentryRelayHandler({
|
||||
dsn: validateMintelEnv().SENTRY_DSN,
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import "./globals.css";
|
||||
import {
|
||||
AnalyticsContextProvider,
|
||||
AnalyticsAutoTracker,
|
||||
} from "@mintel/next-observability/client";
|
||||
import { getAnalyticsConfig } from "@/lib/observability";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sample Website",
|
||||
@@ -11,9 +17,18 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const analyticsConfig = getAnalyticsConfig();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<AnalyticsContextProvider config={analyticsConfig}>
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsAutoTracker />
|
||||
</Suspense>
|
||||
{children}
|
||||
</AnalyticsContextProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createUmamiProxyHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createUmamiProxyHandler({
|
||||
websiteId: validateMintelEnv().UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT,
|
||||
});
|
||||
13
apps/sample-website/src/instrumentation.ts
Normal file
13
apps/sample-website/src/instrumentation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("../sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("../sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
54
apps/sample-website/src/lib/observability.ts
Normal file
54
apps/sample-website/src/lib/observability.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
UmamiAnalyticsService,
|
||||
GotifyNotificationService,
|
||||
NoopNotificationService,
|
||||
} from "@mintel/observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
let analyticsService: any = null;
|
||||
let notificationService: any = null;
|
||||
|
||||
export function getAnalyticsConfig() {
|
||||
const isClient = typeof window !== "undefined";
|
||||
|
||||
if (isClient) {
|
||||
return {
|
||||
enabled: true,
|
||||
apiEndpoint: "/stats",
|
||||
};
|
||||
}
|
||||
|
||||
const env = validateMintelEnv();
|
||||
return {
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAnalyticsService() {
|
||||
if (analyticsService) return analyticsService;
|
||||
|
||||
const config = getAnalyticsConfig();
|
||||
analyticsService = new UmamiAnalyticsService(config);
|
||||
|
||||
return analyticsService;
|
||||
}
|
||||
|
||||
export function getNotificationService() {
|
||||
if (notificationService) return notificationService;
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
const env = validateMintelEnv();
|
||||
notificationService = new GotifyNotificationService({
|
||||
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||
url: env.GOTIFY_URL || "",
|
||||
token: env.GOTIFY_TOKEN || "",
|
||||
});
|
||||
} else {
|
||||
// Notifications are typically server-side only to protect tokens
|
||||
notificationService = new NoopNotificationService();
|
||||
}
|
||||
|
||||
return notificationService;
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
"@mintel/husky-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"mintel": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --target es2020",
|
||||
"build": "tsup",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
||||
"test": "vitest run"
|
||||
@@ -28,4 +28,4 @@
|
||||
"@types/prompts": "^2.4.4",
|
||||
"@mintel/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ program
|
||||
react: "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@directus/sdk": "^21.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
@@ -263,11 +264,15 @@ export default createMintelI18nRequestConfig(
|
||||
// Create instrumentation.ts
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "src/instrumentation.ts"),
|
||||
`import * as Sentry from '@sentry/nextjs';
|
||||
`import { Sentry } from '@mintel/next-observability';
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
// Server-side initialization
|
||||
await import('./sentry.server.config');
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,8 +402,12 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||
SENTRY_DSN=
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Notifications (Gotify)
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=
|
||||
`;
|
||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||
|
||||
|
||||
11
packages/cli/tsup.config.ts
Normal file
11
packages/cli/tsup.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'es2020',
|
||||
clean: true,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
@@ -1,40 +1,41 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import nextPlugin from "@next/eslint-plugin-next";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import hooksPlugin from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
import js from "@eslint/js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
export const nextConfig = [
|
||||
{
|
||||
ignores: [
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/out/**",
|
||||
"**/coverage/**",
|
||||
"**/.next/**",
|
||||
"**/node_modules/**",
|
||||
"**/.gitea/**",
|
||||
"**/.changeset/**",
|
||||
"**/.vercel/**",
|
||||
],
|
||||
},
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
/**
|
||||
* Mintel Next.js ESLint Configuration (Flat Config)
|
||||
*
|
||||
* This configuration replaces the legacy 'eslint-config-next' which
|
||||
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
|
||||
*/
|
||||
export const nextConfig = tseslint.config(
|
||||
{
|
||||
plugins: {
|
||||
"react": reactPlugin,
|
||||
"react-hooks": hooksPlugin,
|
||||
"@next/next": nextPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
// Add common browser/node globals if needed,
|
||||
// though usually handled by base configs
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-const": "warn",
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...hooksPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...nextPlugin.configs["core-web-vitals"].rules,
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
"@next/next/no-img-element": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -20,7 +20,10 @@
|
||||
"dependencies": {
|
||||
"@eslint/eslintrc": "^3.0.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@next/eslint-plugin-next": "15.1.6",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
|
||||
import { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Gatekeeper specific overrides
|
||||
basePath: '/gatekeeper',
|
||||
};
|
||||
|
||||
export default mintelNextConfig(nextConfig);
|
||||
|
||||
@@ -9,17 +9,79 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (session?.value === password) {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||
|
||||
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
|
||||
try {
|
||||
const url = new URL(originalUrl, `${proto}://${host}`);
|
||||
if (url.searchParams.get("gk_bypass") === password) {
|
||||
// Remove the bypass parameter from the redirect URL
|
||||
url.searchParams.delete("gk_bypass");
|
||||
const cleanUrl = url.pathname + url.search;
|
||||
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
|
||||
|
||||
const response = NextResponse.redirect(absoluteCleanUrl);
|
||||
|
||||
// Set the session cookie so the bypass is persistent
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: "Bypass",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
response.cookies.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (e) {
|
||||
// URL parsing failed, proceed with normal logic
|
||||
}
|
||||
|
||||
let isAuthenticated = false;
|
||||
let identity = "Guest";
|
||||
|
||||
if (session?.value) {
|
||||
if (session.value === password) {
|
||||
isAuthenticated = true;
|
||||
} else {
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
if (payload.identity) {
|
||||
isAuthenticated = true;
|
||||
identity = payload.identity;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback or old format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return new NextResponse("OK", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"X-Auth-User": identity,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
const gatekeeperUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
||||
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
||||
|
||||
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
26
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
26
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (!session?.value) {
|
||||
return NextResponse.json({ authenticated: false }, { status: 401 });
|
||||
}
|
||||
|
||||
let identity = "Guest";
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
identity = payload.identity || "Guest";
|
||||
} catch (e) {
|
||||
// Old format probably just the password
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
identity: identity,
|
||||
});
|
||||
}
|
||||
@@ -17,26 +17,81 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const password = formData.get("password");
|
||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
if (password === expectedPassword) {
|
||||
let userIdentity = "";
|
||||
|
||||
// 1. Check Global Admin (from ENV)
|
||||
if (
|
||||
adminEmail &&
|
||||
adminPassword &&
|
||||
email === adminEmail &&
|
||||
password === adminPassword
|
||||
) {
|
||||
userIdentity = "Admin";
|
||||
}
|
||||
// 2. Check Generic Code (Guest)
|
||||
else if (!email && password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 3. Check Directus if email is provided
|
||||
if (email && password && process.env.DIRECTUS_URL) {
|
||||
try {
|
||||
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
const { data } = await loginRes.json();
|
||||
const accessToken = data.access_token;
|
||||
|
||||
// Fetch user info to get a nice display name
|
||||
const userRes = await fetch(`${process.env.DIRECTUS_URL}/users/me`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (userRes.ok) {
|
||||
const { data: user } = await userRes.json();
|
||||
userIdentity = user.first_name || user.email;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Directus Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, expectedPassword, {
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: userIdentity,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(
|
||||
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
|
||||
);
|
||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,24 +140,35 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-6">
|
||||
<form action={login} className="space-y-4">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="GATEKEEPER CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="EMAIL (OPTIONAL)"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="ACCESS CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/gatekeeper/login");
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -3,18 +3,37 @@ FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm
|
||||
ENV CI=true
|
||||
|
||||
# Copy source (honoring .dockerignore)
|
||||
COPY . .
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/package.json
|
||||
COPY packages/next-utils/package.json ./packages/next-utils/package.json
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
|
||||
COPY packages/next-config/package.json ./packages/next-config/package.json
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
|
||||
COPY packages/infra/package.json ./packages/infra/package.json
|
||||
COPY packages/cms-infra/package.json ./packages/cms-infra/package.json
|
||||
COPY packages/mail/package.json ./packages/mail/package.json
|
||||
COPY packages/cli/package.json ./packages/cli/package.json
|
||||
COPY packages/observability/package.json ./packages/observability/package.json
|
||||
COPY packages/next-observability/package.json ./packages/next-observability/package.json
|
||||
COPY packages/husky-config/package.json ./packages/husky-config/package.json
|
||||
COPY packages/ui/package.json ./packages/ui/package.json
|
||||
|
||||
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
# Use a secret for NPM_TOKEN and a cache mount for the pnpm store
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Copy the rest of the source
|
||||
COPY . .
|
||||
|
||||
# Build Gatekeeper and its dependencies
|
||||
RUN pnpm --filter @mintel/gatekeeper... build
|
||||
RUN --mount=type=cache,target=/app/packages/gatekeeper/.next/cache \
|
||||
pnpm --filter @mintel/gatekeeper... build
|
||||
RUN mkdir -p packages/gatekeeper/public
|
||||
|
||||
# Step 2: Runner stage
|
||||
@@ -25,9 +44,12 @@ ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||
COPY --from=builder /app/packages/gatekeeper/.next/standalone ./
|
||||
COPY --from=builder /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
49
packages/next-observability/package.json
Normal file
49
packages/next-observability/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.0.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client.d.ts",
|
||||
"import": "./dist/client.js",
|
||||
"require": "./dist/client.cjs"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts src/client.ts --format cjs,esm --dts --splitting",
|
||||
"dev": "tsup src/index.ts src/client.ts --format cjs,esm --watch --dts --splitting",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@sentry/nextjs": "^8.55.0",
|
||||
"next": "15.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal file
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useAnalytics } from "./context";
|
||||
|
||||
/**
|
||||
* Automatically tracks pageviews on client-side route changes in Next.js.
|
||||
*/
|
||||
export function AnalyticsAutoTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
|
||||
analytics.trackPageview(url);
|
||||
}, [pathname, searchParams, analytics]);
|
||||
|
||||
return null;
|
||||
}
|
||||
50
packages/next-observability/src/analytics/context.tsx
Normal file
50
packages/next-observability/src/analytics/context.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, ReactNode, useMemo } from "react";
|
||||
import type { AnalyticsService } from "@mintel/observability";
|
||||
import {
|
||||
NoopAnalyticsService,
|
||||
UmamiAnalyticsService,
|
||||
} from "@mintel/observability";
|
||||
|
||||
const AnalyticsContext = createContext<AnalyticsService>(
|
||||
new NoopAnalyticsService(),
|
||||
);
|
||||
|
||||
export interface AnalyticsContextProviderProps {
|
||||
service?: AnalyticsService;
|
||||
config?: {
|
||||
enabled: boolean;
|
||||
websiteId?: string;
|
||||
apiEndpoint: string;
|
||||
};
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnalyticsContextProvider({
|
||||
service,
|
||||
config,
|
||||
children,
|
||||
}: AnalyticsContextProviderProps) {
|
||||
const activeService = useMemo(() => {
|
||||
if (service) return service;
|
||||
if (config) return new UmamiAnalyticsService(config);
|
||||
return new NoopAnalyticsService();
|
||||
}, [service, config]);
|
||||
|
||||
return (
|
||||
<AnalyticsContext.Provider value={activeService}>
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const context = useContext(AnalyticsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAnalytics must be used within an AnalyticsContextProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
4
packages/next-observability/src/client.ts
Normal file
4
packages/next-observability/src/client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
|
||||
export * from "./analytics/context";
|
||||
export * from "./analytics/auto-tracker";
|
||||
26
packages/next-observability/src/errors/sentry.ts
Normal file
26
packages/next-observability/src/errors/sentry.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export interface SentryConfig {
|
||||
dsn?: string;
|
||||
enabled?: boolean;
|
||||
tracesSampleRate?: number;
|
||||
tunnel?: string;
|
||||
replaysOnErrorSampleRate?: number;
|
||||
replaysSessionSampleRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized Sentry initialization for Mintel projects.
|
||||
*/
|
||||
export function initSentry(config: SentryConfig) {
|
||||
Sentry.init({
|
||||
dsn: config.dsn,
|
||||
enabled: config.enabled ?? Boolean(config.dsn || config.tunnel),
|
||||
tracesSampleRate: config.tracesSampleRate ?? 0,
|
||||
tunnel: config.tunnel,
|
||||
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate ?? 1.0,
|
||||
replaysSessionSampleRate: config.replaysSessionSampleRate ?? 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
export { Sentry };
|
||||
90
packages/next-observability/src/handlers/index.ts
Normal file
90
packages/next-observability/src/handlers/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Logic for Umami Smart Proxy Route Handler.
|
||||
*/
|
||||
export function createUmamiProxyHandler(config: {
|
||||
websiteId?: string;
|
||||
apiEndpoint: string;
|
||||
}) {
|
||||
return async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
if (!config.websiteId) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
website: config.websiteId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${config.apiEndpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": request.headers.get("user-agent") || "Mintel-Proxy",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
||||
},
|
||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic for Sentry/GlitchTip Relay Route Handler.
|
||||
*/
|
||||
export function createSentryRelayHandler(config: { dsn?: string }) {
|
||||
return async function POST(request: NextRequest) {
|
||||
try {
|
||||
const envelope = await request.text();
|
||||
const lines = envelope.split("\n");
|
||||
if (lines.length < 1) {
|
||||
return NextResponse.json({ error: "Empty envelope" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!config.dsn) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
const dsnUrl = new URL(config.dsn);
|
||||
const projectId = dsnUrl.pathname.replace("/", "");
|
||||
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||
|
||||
const response = await fetch(relayUrl, {
|
||||
method: "POST",
|
||||
body: envelope,
|
||||
headers: {
|
||||
"Content-Type": "application/x-sentry-envelope",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
packages/next-observability/src/index.ts
Normal file
2
packages/next-observability/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers/index";
|
||||
export * from "./errors/sentry";
|
||||
10
packages/next-observability/tsconfig.json
Normal file
10
packages/next-observability/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,20 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
export const mintelEnvSchema = {
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
||||
|
||||
// Analytics (Proxy Pattern)
|
||||
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
UMAMI_API_ENDPOINT: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://analytics.infra.mintel.me"),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// Notifications
|
||||
GOTIFY_URL: z.string().url().optional(),
|
||||
GOTIFY_TOKEN: z.string().optional(),
|
||||
|
||||
LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
.default("info"),
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: z.coerce.number().default(587),
|
||||
MAIL_USERNAME: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||
z.array(z.string()).default([])
|
||||
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||
z.array(z.string()).default([]),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -24,11 +40,26 @@ export function validateMintelEnv(schemaExtension = {}) {
|
||||
...schemaExtension,
|
||||
});
|
||||
|
||||
const isBuildTime =
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.SKIP_ENV_VALIDATION === "true";
|
||||
|
||||
const result = fullSchema.safeParse(process.env);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment variables');
|
||||
if (isBuildTime) {
|
||||
console.warn(
|
||||
"⚠️ Some environment variables are missing during build, but skipping strict validation.",
|
||||
);
|
||||
// Return partial data to allow build to continue
|
||||
return process.env as unknown as z.infer<typeof fullSchema>;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"❌ Invalid environment variables:",
|
||||
result.error.flatten().fieldErrors,
|
||||
);
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
|
||||
123
packages/observability/README.md
Normal file
123
packages/observability/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# @mintel/observability
|
||||
|
||||
Standardized observability package for the Mintel ecosystem, providing Umami analytics and Sentry/GlitchTip error tracking with a focus on privacy and ad-blocker resilience.
|
||||
|
||||
## Features
|
||||
|
||||
- **Umami Smart Proxy**: Track analytics without external scripts and hide your Website ID.
|
||||
- **Sentry Relay**: Bypass ad-blockers for error tracking by relaying envelopes through your own server.
|
||||
- **Unified API**: consistent interface for tracking across multiple projects.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @mintel/observability @sentry/nextjs
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Unified Environment (via @mintel/next-utils)
|
||||
|
||||
Define the following environment variables:
|
||||
|
||||
```bash
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
```
|
||||
|
||||
Note: No `NEXT_PUBLIC_` prefix is required for these anymore, as they are handled by server-side proxies.
|
||||
|
||||
### 2. Analytics Setup
|
||||
|
||||
In your root layout:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AnalyticsContextProvider,
|
||||
AnalyticsAutoTracker,
|
||||
UmamiAnalyticsService,
|
||||
} from "@mintel/observability";
|
||||
|
||||
const analytics = new UmamiAnalyticsService({
|
||||
enabled: true,
|
||||
websiteId: process.env.UMAMI_WEBSITE_ID, // Server-side
|
||||
apiEndpoint:
|
||||
typeof window === "undefined" ? process.env.UMAMI_API_ENDPOINT : "/stats",
|
||||
});
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<AnalyticsContextProvider service={analytics}>
|
||||
<AnalyticsAutoTracker />
|
||||
{children}
|
||||
</AnalyticsContextProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Route Handlers
|
||||
|
||||
Create a proxy for Umami:
|
||||
`app/stats/api/send/route.ts`
|
||||
|
||||
```ts
|
||||
import { createUmamiProxyHandler } from "@mintel/observability";
|
||||
export const POST = await createUmamiProxyHandler({
|
||||
websiteId: process.env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: process.env.UMAMI_API_ENDPOINT,
|
||||
});
|
||||
```
|
||||
|
||||
Create a relay for Sentry:
|
||||
`app/errors/api/relay/route.ts`
|
||||
|
||||
```ts
|
||||
import { createSentryRelayHandler } from "@mintel/observability";
|
||||
export const POST = await createSentryRelayHandler({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Notification Setup (Server-side)
|
||||
|
||||
```ts
|
||||
import { GotifyNotificationService } from "@mintel/observability";
|
||||
|
||||
const notifications = new GotifyNotificationService({
|
||||
enabled: true,
|
||||
url: process.env.GOTIFY_URL,
|
||||
token: process.env.GOTIFY_TOKEN,
|
||||
});
|
||||
|
||||
await notifications.notify({
|
||||
title: "Lead Capture",
|
||||
message: "New contact form submission",
|
||||
priority: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Sentry Configuration
|
||||
|
||||
Use `initSentry` in your `sentry.server.config.ts` and `sentry.client.config.ts`.
|
||||
|
||||
On the client, use the tunnel:
|
||||
|
||||
```ts
|
||||
initSentry({
|
||||
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder
|
||||
tunnel: "/errors/api/relay",
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This package implements the **Smart Proxy** pattern:
|
||||
|
||||
- The client NEVER knows the real `UMAMI_WEBSITE_ID`.
|
||||
- Tracking events are sent to your own domain (`/stats/api/send`).
|
||||
- Your server injects the secret ID and forwards to Umami.
|
||||
- This bypasses ad-blockers and keeps your configuration secure.
|
||||
34
packages/observability/package.json
Normal file
34
packages/observability/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@mintel/observability",
|
||||
"version": "1.0.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
14
packages/observability/src/analytics/noop.test.ts
Normal file
14
packages/observability/src/analytics/noop.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NoopAnalyticsService } from "./noop";
|
||||
|
||||
describe("NoopAnalyticsService", () => {
|
||||
it("should not throw on track", () => {
|
||||
const service = new NoopAnalyticsService();
|
||||
expect(() => service.track("test")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not throw on trackPageview", () => {
|
||||
const service = new NoopAnalyticsService();
|
||||
expect(() => service.trackPageview()).not.toThrow();
|
||||
});
|
||||
});
|
||||
15
packages/observability/src/analytics/noop.ts
Normal file
15
packages/observability/src/analytics/noop.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
||||
|
||||
/**
|
||||
* No-operation analytics service.
|
||||
* Used when analytics are disabled or for local development.
|
||||
*/
|
||||
export class NoopAnalyticsService implements AnalyticsService {
|
||||
track(eventName: string, props?: AnalyticsEventProperties): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
trackPageview(url?: string): void {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
31
packages/observability/src/analytics/service.ts
Normal file
31
packages/observability/src/analytics/service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Type definition for analytics event properties.
|
||||
*/
|
||||
export type AnalyticsEventProperties = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* Interface for analytics service implementations.
|
||||
*
|
||||
* This interface defines the contract for all analytics services,
|
||||
* allowing for different implementations (Umami, Google Analytics, etc.)
|
||||
* while maintaining a consistent API.
|
||||
*/
|
||||
export interface AnalyticsService {
|
||||
/**
|
||||
* Track a custom event with optional properties.
|
||||
*
|
||||
* @param eventName - The name of the event to track
|
||||
* @param props - Optional event properties (metadata)
|
||||
*/
|
||||
track(eventName: string, props?: AnalyticsEventProperties): void;
|
||||
|
||||
/**
|
||||
* Track a pageview.
|
||||
*
|
||||
* @param url - The URL to track (defaults to current location)
|
||||
*/
|
||||
trackPageview(url?: string): void;
|
||||
}
|
||||
74
packages/observability/src/analytics/umami.test.ts
Normal file
74
packages/observability/src/analytics/umami.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { UmamiAnalyticsService } from "./umami";
|
||||
|
||||
describe("UmamiAnalyticsService", () => {
|
||||
const mockConfig = {
|
||||
websiteId: "test-website-id",
|
||||
apiEndpoint: "https://analytics.test",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("should not send payload if disabled", async () => {
|
||||
const service = new UmamiAnalyticsService({
|
||||
...mockConfig,
|
||||
enabled: false,
|
||||
});
|
||||
service.track("test-event");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send payload with correct data for track", async () => {
|
||||
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
|
||||
|
||||
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||
|
||||
service.track("test-event", { foo: "bar" });
|
||||
|
||||
// Wait for async sendPayload
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://analytics.test/api/send",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining('"type":"event"'),
|
||||
}),
|
||||
);
|
||||
|
||||
const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
||||
expect(callBody.payload.name).toBe("test-event");
|
||||
expect(callBody.payload.data.foo).toBe("bar");
|
||||
expect(callBody.payload.website).toBe("test-website-id");
|
||||
});
|
||||
|
||||
it("should log warning if send fails", async () => {
|
||||
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
|
||||
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal error"),
|
||||
});
|
||||
|
||||
service.track("test-event");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
"Umami API responded with error",
|
||||
expect.objectContaining({ status: 500 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
115
packages/observability/src/analytics/umami.ts
Normal file
115
packages/observability/src/analytics/umami.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
||||
|
||||
export interface UmamiConfig {
|
||||
websiteId?: string;
|
||||
apiEndpoint: string; // The endpoint to send to (proxied or direct)
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(msg: string, data?: any): void;
|
||||
warn(msg: string, data?: any): void;
|
||||
error(msg: string, data?: any): void;
|
||||
trace(msg: string, data?: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||
*/
|
||||
export class UmamiAnalyticsService implements AnalyticsService {
|
||||
private logger?: Logger;
|
||||
|
||||
constructor(
|
||||
private config: UmamiConfig,
|
||||
logger?: Logger,
|
||||
) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private async sendPayload(type: "event", data: Record<string, any>) {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
const isClient = typeof window !== "undefined";
|
||||
const websiteId = this.config.websiteId;
|
||||
|
||||
if (!isClient && !websiteId) {
|
||||
this.logger?.warn(
|
||||
"Umami tracking called on server but no Website ID configured",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
website: websiteId,
|
||||
hostname: isClient ? window.location.hostname : "server",
|
||||
screen: isClient
|
||||
? `${window.screen.width}x${window.screen.height}`
|
||||
: undefined,
|
||||
language: isClient ? navigator.language : undefined,
|
||||
referrer: isClient ? document.referrer : undefined,
|
||||
...data,
|
||||
};
|
||||
|
||||
this.logger?.trace("Sending analytics payload", { type, url: data.url });
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiEndpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": isClient ? navigator.userAgent : "Mintel-Server",
|
||||
},
|
||||
body: JSON.stringify({ type, payload }),
|
||||
keepalive: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger?.warn("Umami API responded with error", {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
}
|
||||
} catch (fetchError) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((fetchError as Error).name === "AbortError") {
|
||||
this.logger?.error("Umami request timed out");
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error("Failed to send analytics", {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||
this.sendPayload("event", {
|
||||
name: eventName,
|
||||
data: props,
|
||||
url:
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
trackPageview(url?: string) {
|
||||
this.sendPayload("event", {
|
||||
url:
|
||||
url ||
|
||||
(typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined),
|
||||
});
|
||||
}
|
||||
}
|
||||
9
packages/observability/src/index.ts
Normal file
9
packages/observability/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Analytics
|
||||
export * from "./analytics/service";
|
||||
export * from "./analytics/umami";
|
||||
export * from "./analytics/noop";
|
||||
|
||||
// Notifications
|
||||
export * from "./notifications/service";
|
||||
export * from "./notifications/gotify";
|
||||
export * from "./notifications/noop";
|
||||
82
packages/observability/src/notifications/gotify.test.ts
Normal file
82
packages/observability/src/notifications/gotify.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GotifyNotificationService } from "./gotify";
|
||||
|
||||
describe("GotifyNotificationService", () => {
|
||||
const mockConfig = {
|
||||
url: "https://gotify.test",
|
||||
token: "test-token",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("should not notify if disabled", async () => {
|
||||
const service = new GotifyNotificationService({
|
||||
...mockConfig,
|
||||
enabled: false,
|
||||
});
|
||||
await service.notify({ title: "test", message: "test" });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send correct payload to Gotify", async () => {
|
||||
const service = new GotifyNotificationService(mockConfig, mockLogger);
|
||||
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||
|
||||
await service.notify({
|
||||
title: "Alert",
|
||||
message: "Critical issue",
|
||||
priority: 8,
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://gotify.test/message?token=test-token",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: "Alert",
|
||||
message: "Critical issue",
|
||||
priority: 8,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing trailing slash in URL", async () => {
|
||||
const service = new GotifyNotificationService({
|
||||
...mockConfig,
|
||||
url: "https://gotify.test",
|
||||
});
|
||||
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||
|
||||
await service.notify({ title: "test", message: "test" });
|
||||
|
||||
expect((global.fetch as any).mock.calls[0][0]).toBe(
|
||||
"https://gotify.test/message?token=test-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("should log error if notify fails", async () => {
|
||||
const service = new GotifyNotificationService(mockConfig, mockLogger);
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve("Unauthorized"),
|
||||
});
|
||||
|
||||
await service.notify({ title: "test", message: "test" });
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
"Gotify notification failed",
|
||||
expect.objectContaining({ status: 401 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
56
packages/observability/src/notifications/gotify.ts
Normal file
56
packages/observability/src/notifications/gotify.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NotificationOptions, NotificationService } from "./service";
|
||||
|
||||
export interface GotifyConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gotify Notification Service implementation.
|
||||
*/
|
||||
export class GotifyNotificationService implements NotificationService {
|
||||
constructor(
|
||||
private config: GotifyConfig,
|
||||
private logger?: { error(msg: string, data?: any): void },
|
||||
) {}
|
||||
|
||||
async notify(options: NotificationOptions): Promise<void> {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
try {
|
||||
const { title, message, priority = 4 } = options;
|
||||
|
||||
// Ensure we have a trailing slash for base URL, then append 'message'
|
||||
const baseUrl = this.config.url.endsWith("/")
|
||||
? this.config.url
|
||||
: `${this.config.url}/`;
|
||||
const url = new URL("message", baseUrl);
|
||||
url.searchParams.set("token", this.config.token);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger?.error("Gotify notification failed", {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error("Gotify notification error", {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/observability/src/notifications/noop.test.ts
Normal file
11
packages/observability/src/notifications/noop.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NoopNotificationService } from "./noop";
|
||||
|
||||
describe("NoopNotificationService", () => {
|
||||
it("should not throw on notify", async () => {
|
||||
const service = new NoopNotificationService();
|
||||
await expect(
|
||||
service.notify({ title: "test", message: "test" }),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
10
packages/observability/src/notifications/noop.ts
Normal file
10
packages/observability/src/notifications/noop.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NotificationService } from "./service";
|
||||
|
||||
/**
|
||||
* No-operation notification service.
|
||||
*/
|
||||
export class NoopNotificationService implements NotificationService {
|
||||
async notify(): Promise<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
16
packages/observability/src/notifications/service.ts
Normal file
16
packages/observability/src/notifications/service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface NotificationOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for notification service implementations.
|
||||
* Allows for different implementations (Gotify, Slack, Email, etc.)
|
||||
*/
|
||||
export interface NotificationService {
|
||||
/**
|
||||
* Send a notification.
|
||||
*/
|
||||
notify(options: NotificationOptions): Promise<void>;
|
||||
}
|
||||
11
packages/observability/tsconfig.json
Normal file
11
packages/observability/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
614
pnpm-lock.yaml
generated
614
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user