diff --git a/.cursor/hooks.json b/.cursor/hooks.json deleted file mode 100644 index 3bce532..0000000 --- a/.cursor/hooks.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "hooks": { - "afterFileEdit": [ - { - "command": "pnpm run check" - } - ] - } -} diff --git a/.gitignore b/.gitignore index 08bd1b6..0693572 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ yarn-error.log* next-env.d.ts references -.cursor/plans \ No newline at end of file +.cursor/plans +.cursor/hooks/logs/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 62c55fe..0af3390 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -40,7 +40,7 @@ "editor.defaultFormatter": "biomejs.biome" }, "[markdown]": { - "editor.defaultFormatter": "yzhang.markdown-all-in-one" + "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" }, "[mdx]": { "editor.defaultFormatter": "biomejs.biome" diff --git a/AGENTS.md b/AGENTS.md index 884849e..b9c1315 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ import { usePermissions } from "@/hooks/use-permissions"; // Custom hooks ``` **Current Implementation:** + - Auth module uses barrel exports via `@/auth` index file - Database uses barrel exports via `@/db` index file - UI components use direct imports: `@/components/ui/button` @@ -74,33 +75,75 @@ import { usePermissions } from "@/hooks/use-permissions"; // Custom hooks - **Components**: Place reusable components in `@/components` - **UI**: Use shadcn/ui components from `@/ui/*` -## File Organization +## Environment Variables with t3-env + +Portal uses `@t3-oss/env-nextjs` for type-safe, validated environment variable management. Each module that needs environment variables has its own `keys.ts` file. + +### Pattern + +1. **Module-level `keys.ts`**: Each lib module (auth, db, observability, xmpp, etc.) exports a `keys()` function that defines and validates its environment variables using Zod schemas. + +```typescript +// src/lib/xmpp/keys.ts +import { z } from "zod"; +import { createEnv } from "@t3-oss/env-nextjs"; + +export const keys = () => + createEnv({ + server: { + XMPP_DOMAIN: z.string().optional(), + PROSODY_REST_URL: z.url().optional(), + // ... other vars + }, + runtimeEnv: { + XMPP_DOMAIN: process.env.XMPP_DOMAIN, + PROSODY_REST_URL: process.env.PROSODY_REST_URL, + // ... other vars + }, + }); +``` + +1. **Central `env.ts`**: The main `src/env.ts` file extends all module keys and provides a single source of truth. +```typescript +// src/env.ts +import { keys as auth } from "@/lib/auth/keys"; +import { keys as database } from "@/lib/db/keys"; +import { keys as observability } from "@/lib/observability/keys"; +import { keys as xmpp } from "@/lib/xmpp/keys"; + +export const env = createEnv({ + extends: [auth(), database(), observability(), xmpp()], + server: {}, + client: {}, + runtimeEnv: {}, +}); ``` -src/ -├── app/ # Next.js App Router -│ ├── (dashboard)/ # Protected dashboard routes -│ │ └── app/ # Main application routes -│ ├── api/ # API routes -│ ├── auth/ # Authentication pages -├── components/ # Reusable React components -│ ├── ui/ # shadcn/ui -│ └── layout/ # Layout components -├── lib/ # Core business logic -│ ├── auth/ # Authentication module -│ ├── db/ # Database configuration -│ ├── api/ # API client utilities -│ ├── config/ # Application configuration -│ ├── email/ # Email configuration -│ ├── routes/ # Route utilities and i18n routes -│ ├── seo/ # SEO utilities -│ └── utils/ # General utilities -├── hooks/ # Custom React hooks -├── i18n/ # Internationalization setup -├── styles/ # Global styles -└── proxy.ts # Development proxy configuration + +1. **Usage in modules**: Modules import and use their own `keys()` function, not direct `process.env` access. + +```typescript +// src/lib/xmpp/config.ts +import { keys } from "./keys"; + +const env = keys(); +export const xmppConfig = { + domain: env.XMPP_DOMAIN || "xmpp.atl.chat", + prosody: { + restUrl: env.PROSODY_REST_URL, + // ... + }, +}; ``` +### Benefits + +- **Type safety**: Environment variables are typed and validated at runtime +- **Early error detection**: Invalid or missing required vars fail fast with clear error messages +- **Modularity**: Each module manages its own environment variables +- **No direct `process.env`**: All env access goes through validated `keys()` functions +- **Client/Server separation**: t3-env handles Next.js client/server boundary correctly + ## Security Guidelines - Never expose API keys in client code @@ -120,4 +163,4 @@ src/ - Database setup uses Docker Compose (`docker compose up -d portal-db`) - Available MCP tools: shadcn, Better Auth, llms.txt documentation, Next.js Devtools, GitHub, Sentry, Trigger.dev - Use TanStack Query for all server state management -- Internationalization handled via next-intl with locale files in `locale/` directory \ No newline at end of file +- Internationalization handled via next-intl with locale files in `locale/` directory diff --git a/locale/en/routes.json b/locale/en/routes.json index dc494d2..2361e76 100644 --- a/locale/en/routes.json +++ b/locale/en/routes.json @@ -112,6 +112,13 @@ "label": "Settings" } }, + "xmpp": { + "label": "XMPP", + "metadata": { + "title": "XMPP Account", + "description": "Manage your XMPP chat account" + } + }, "admin": { "label": "Admin", "metadata": { diff --git a/package.json b/package.json index 1a64b8b..f0f135a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "preinstall": "npx only-allow pnpm", "deduplicate": "pnpm dedupe", "prepare": "husky", - "dev": "pnpm dlx @react-grab/cursor@latest && next dev", + "dev": "next dev", + "dev:grab": "pnpm dlx @react-grab/cursor@latest && next dev", "dev:turbo": "next dev --turbopack", "dev:https": "next dev --experimental-https", "scan": "NEXT_PUBLIC_REACT_SCAN_ENABLED=true next dev & pnpm dlx react-scan@latest localhost:3000", @@ -17,7 +18,6 @@ "start": "next start", "typegen": "next typegen", "type-check": "tsc --noEmit", - "type-check:full": "next typegen && tsc --noEmit", "check": "ultracite check", "fix": "ultracite fix", "info": "next info", @@ -123,7 +123,6 @@ "husky": "^9.1.7", "lint-staged": "^16.2.7", "next-devtools-mcp": "^0.3.9", - "react-grab": "^0.0.98", "shadcn": "^3.6.3", "tailwindcss": "^4.1.18", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 991fabe..2b20923 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,9 +264,6 @@ importers: next-devtools-mcp: specifier: ^0.3.9 version: 0.3.9 - react-grab: - specifier: ^0.0.98 - version: 0.0.98(@types/react@19.2.7)(react@19.2.3) shadcn: specifier: ^3.6.3 version: 3.6.3(@types/node@25.0.3)(hono@4.11.3)(typescript@5.9.3) diff --git a/scripts/create-prosody-oauth-client.ts b/scripts/create-prosody-oauth-client.ts new file mode 100644 index 0000000..6217f54 --- /dev/null +++ b/scripts/create-prosody-oauth-client.ts @@ -0,0 +1,115 @@ +import "dotenv/config"; + +import { randomBytes, randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; + +import { db } from "@/lib/db"; +import { oauthClient } from "@/lib/db/schema/oauth"; + +// ============================================================================ +// Create Prosody OAuth Client Script +// ============================================================================ +// This script registers Prosody XMPP server as an OAuth client in Better Auth. +// It creates a confidential client with password grant type for legacy XMPP +// client support. +// +// Usage: +// pnpm create-prosody-oauth-client +// +// Environment Variables: +// PROSODY_CLIENT_NAME - Name for the OAuth client (default: "Prosody XMPP Server") +// PROSODY_CLIENT_ID - Custom client ID (optional, auto-generated if not provided) +// +// Output: +// Prints the client_id and client_secret that should be set in Prosody +// configuration as PROSODY_OAUTH_CLIENT_ID and PROSODY_OAUTH_CLIENT_SECRET + +async function createProsodyOAuthClient() { + const clientName = process.env.PROSODY_CLIENT_NAME || "Prosody XMPP Server"; + const customClientId = process.env.PROSODY_CLIENT_ID; + + try { + // Check if Prosody client already exists + const existingClient = await db + .select() + .from(oauthClient) + .where(eq(oauthClient.name, clientName)) + .limit(1); + + if (existingClient.length > 0) { + const client = existingClient[0]; + console.log("ℹ️ Prosody OAuth client already exists:"); + console.log(" Client ID:", client.clientId); + console.log( + " Client Secret:", + client.clientSecret ? "(set - not displayed for security)" : "(not set)" + ); + console.log(" Name:", client.name); + console.log(" Disabled:", client.disabled); + return; + } + + // Generate client ID and secret + // Use randomBytes for secure random generation + const generateRandomString = (length: number) => + randomBytes(length).toString("base64url").slice(0, length); + const clientId = customClientId || `prosody_${generateRandomString(32)}`; + const clientSecret = generateRandomString(64); + + // Create OAuth client in database + const [newClient] = await db + .insert(oauthClient) + .values({ + id: randomUUID(), + clientId, + clientSecret, + name: clientName, + redirectUris: [], // Not needed for server-to-server auth + grantTypes: ["authorization_code", "password"], // Password grant for legacy clients + tokenEndpointAuthMethod: "client_secret_post", // Prosody will use POST for auth + scopes: ["openid", "xmpp"], // Required scopes + skipConsent: true, // Trusted first-party client + public: false, // Confidential client + disabled: false, + }) + .returning(); + + if (!newClient) { + throw new Error("Failed to create OAuth client: No client returned"); + } + + console.log("✅ Prosody OAuth client created successfully!"); + console.log(""); + console.log("📋 Configuration for Prosody:"); + console.log(` PROSODY_OAUTH_CLIENT_ID=${newClient.clientId}`); + console.log(` PROSODY_OAUTH_CLIENT_SECRET=${newClient.clientSecret}`); + console.log(""); + console.log("⚠️ Store these credentials securely!"); + console.log( + " Add them to your Prosody environment variables or .env file." + ); + } catch (error) { + console.error("❌ Failed to create Prosody OAuth client:"); + if (error instanceof Error) { + console.error(" Error:", error.message); + if (error.stack) { + console.error(" Stack:", error.stack); + } + } else { + console.error(" Error:", error); + } + throw error; + } +} + +// Run if called directly +if (require.main === module) { + createProsodyOAuthClient() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); +} + +export { createProsodyOAuthClient }; diff --git a/src/app/(dashboard)/app/xmpp/page.tsx b/src/app/(dashboard)/app/xmpp/page.tsx new file mode 100644 index 0000000..39609da --- /dev/null +++ b/src/app/(dashboard)/app/xmpp/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; + +import { PageHeader } from "@/components/layout/page/page-header"; +import { XmppAccountManagement } from "@/components/xmpp/xmpp-account-management"; +import { getServerQueryClient } from "@/lib/api/hydration"; +import { verifySession } from "@/lib/auth/dal"; +import { getServerRouteResolver, routeConfig } from "@/lib/routes"; +import { getRouteMetadata } from "@/lib/seo"; + +// Metadata is automatically generated from route config +export async function generateMetadata(): Promise { + const resolver = await getServerRouteResolver(); + return getRouteMetadata("/app/xmpp", routeConfig, resolver); +} + +// ============================================================================ +// XMPP Account Management Page +// ============================================================================ +// Page for managing XMPP accounts - create, view, update, and delete + +export default async function XmppPage() { + // Verify user session + await verifySession(); + + // Create QueryClient for this request (isolated per request) + const queryClient = getServerQueryClient(); + + // Note: We don't prefetch XMPP account data here because: + // 1. It requires authentication cookies which aren't available server-side + // 2. Prefetching with null would mark the query as successful and block refetch for 30s + // The client-side hook will fetch the data when the component mounts + + const resolver = await getServerRouteResolver(); + + return ( + +
+
+ + +
+
+
+ ); +} diff --git a/src/app/api/xmpp/accounts/[id]/route.ts b/src/app/api/xmpp/accounts/[id]/route.ts new file mode 100644 index 0000000..3aee755 --- /dev/null +++ b/src/app/api/xmpp/accounts/[id]/route.ts @@ -0,0 +1,264 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; + +import { APIError, handleAPIError, requireAuth } from "@/lib/api/utils"; +import { isAdmin } from "@/lib/auth/check-role"; +import { db } from "@/lib/db"; +import { xmppAccount } from "@/lib/db/schema/xmpp"; +import { + deleteProsodyAccount, + ProsodyAccountNotFoundError, +} from "@/lib/xmpp/client"; + +// Zod schema for update request validation +const updateXmppAccountSchema = z.object({ + username: z.string().optional(), + status: z.enum(["active", "suspended", "deleted"]).optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), +}); + +export const dynamic = "force-dynamic"; + +/** + * GET /api/xmpp/accounts/[id] + * Get specific XMPP account details (admin or owner only) + */ +export async function GET( + request: NextRequest, + ctx: RouteContext<"/api/xmpp/accounts/[id]"> +) { + try { + const { userId } = await requireAuth(request); + const { id } = await ctx.params; + + const [account] = await db + .select() + .from(xmppAccount) + .where(eq(xmppAccount.id, id)) + .limit(1); + + if (!account) { + return Response.json( + { ok: false, error: "XMPP account not found" }, + { status: 404 } + ); + } + + // Check authorization: user owns account or is admin + const isAdminUser = await isAdmin(userId); + + if (account.userId !== userId && !isAdminUser) { + return Response.json( + { ok: false, error: "Forbidden - Access denied" }, + { status: 403 } + ); + } + + return Response.json({ + ok: true, + account: { + id: account.id, + jid: account.jid, + username: account.username, + status: account.status, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + metadata: account.metadata, + }, + }); + } catch (error) { + return handleAPIError(error); + } +} + +/** + * PATCH /api/xmpp/accounts/[id] + * Update XMPP account (username, status, metadata) + * Note: Changing username may require recreating the Prosody account + */ +export async function PATCH( + request: NextRequest, + ctx: RouteContext<"/api/xmpp/accounts/[id]"> +) { + try { + const { userId } = await requireAuth(request); + const { id } = await ctx.params; + + // Validate request body with Zod + const parseResult = updateXmppAccountSchema.safeParse(await request.json()); + if (!parseResult.success) { + return Response.json( + { + ok: false, + error: "Invalid request body", + details: parseResult.error.flatten(), + }, + { status: 400 } + ); + } + const body = parseResult.data; + + const [account] = await db + .select() + .from(xmppAccount) + .where(eq(xmppAccount.id, id)) + .limit(1); + + if (!account) { + return Response.json( + { ok: false, error: "XMPP account not found" }, + { status: 404 } + ); + } + + // Check authorization: user owns account or is admin + const isAdminUser = await isAdmin(userId); + + if (account.userId !== userId && !isAdminUser) { + return Response.json( + { ok: false, error: "Forbidden - Access denied" }, + { status: 403 } + ); + } + + const updates: Partial = {}; + + // Update username - REJECTED: Username changes require Prosody account recreation + // which would cause authentication failures. Users must delete and recreate account. + if (body.username && body.username !== account.username) { + return Response.json( + { + ok: false, + error: + "Username cannot be changed. Please delete your account and create a new one with the desired username.", + }, + { status: 400 } + ); + } + + // Update status + if (body.status && body.status !== account.status) { + updates.status = body.status; + } + + // Update metadata + if (body.metadata !== undefined) { + updates.metadata = body.metadata; + } + + if (Object.keys(updates).length === 0) { + return Response.json( + { ok: false, error: "No valid updates provided" }, + { status: 400 } + ); + } + + // Update account in database + const [updated] = await db + .update(xmppAccount) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(xmppAccount.id, id)) + .returning(); + + if (!updated) { + throw new APIError("Failed to update XMPP account", 500); + } + + return Response.json({ + ok: true, + account: { + id: updated.id, + jid: updated.jid, + username: updated.username, + status: updated.status, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + metadata: updated.metadata, + }, + }); + } catch (error) { + return handleAPIError(error); + } +} + +/** + * DELETE /api/xmpp/accounts/[id] + * Delete/suspend XMPP account (soft delete) + */ +export async function DELETE( + request: NextRequest, + ctx: RouteContext<"/api/xmpp/accounts/[id]"> +) { + try { + const { userId } = await requireAuth(request); + const { id } = await ctx.params; + + const [account] = await db + .select() + .from(xmppAccount) + .where(eq(xmppAccount.id, id)) + .limit(1); + + if (!account) { + return Response.json( + { ok: false, error: "XMPP account not found" }, + { status: 404 } + ); + } + + // Check authorization: user owns account or is admin + const isAdminUser = await isAdmin(userId); + + if (account.userId !== userId && !isAdminUser) { + return Response.json( + { ok: false, error: "Forbidden - Access denied" }, + { status: 403 } + ); + } + + // Delete account from Prosody + try { + await deleteProsodyAccount(account.username); + } catch (error) { + // If account doesn't exist in Prosody, that's okay - continue with soft delete + if (error instanceof ProsodyAccountNotFoundError) { + // Account doesn't exist in Prosody - continue with soft delete + } else if (error instanceof Error) { + throw new APIError( + `Failed to delete XMPP account from Prosody: ${error.message}`, + 500 + ); + } else { + throw new APIError( + "Failed to delete XMPP account from Prosody: Unknown error", + 500 + ); + } + } + + // Soft delete: mark as deleted in database + const [deleted] = await db + .update(xmppAccount) + .set({ + status: "deleted", + updatedAt: new Date(), + }) + .where(eq(xmppAccount.id, id)) + .returning(); + + if (!deleted) { + throw new APIError("Failed to delete XMPP account", 500); + } + + return Response.json({ + ok: true, + message: "XMPP account deleted successfully", + }); + } catch (error) { + return handleAPIError(error); + } +} diff --git a/src/app/api/xmpp/accounts/route.ts b/src/app/api/xmpp/accounts/route.ts new file mode 100644 index 0000000..4707994 --- /dev/null +++ b/src/app/api/xmpp/accounts/route.ts @@ -0,0 +1,266 @@ +import { randomUUID } from "node:crypto"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; + +import { APIError, handleAPIError, requireAuth } from "@/lib/api/utils"; +import { db } from "@/lib/db"; +import { user } from "@/lib/db/schema/auth"; +import { xmppAccount } from "@/lib/db/schema/xmpp"; +import { + checkProsodyAccountExists, + createProsodyAccount, + deleteProsodyAccount, +} from "@/lib/xmpp/client"; +import { xmppConfig } from "@/lib/xmpp/config"; +import { + formatJid, + generateUsernameFromEmail, + isValidXmppUsername, +} from "@/lib/xmpp/utils"; + +// Zod schema for create request validation +const createXmppAccountSchema = z.object({ + username: z.string().optional(), +}); + +export const dynamic = "force-dynamic"; + +/** + * Determine username from request body or user email + */ +function determineUsername( + providedUsername: string | undefined, + userEmail: string +): { username: string } | { error: Response } { + if (providedUsername) { + if (!isValidXmppUsername(providedUsername)) { + return { + error: Response.json( + { + ok: false, + error: + "Invalid username format. Username must be alphanumeric with underscores, hyphens, or dots, and start with a letter or number.", + }, + { status: 400 } + ), + }; + } + return { username: providedUsername.toLowerCase() }; + } + + try { + return { username: generateUsernameFromEmail(userEmail) }; + } catch { + return { + error: Response.json( + { + ok: false, + error: + "Could not generate username from email. Please provide a custom username.", + }, + { status: 400 } + ), + }; + } +} + +/** + * Check if username is available in both database and Prosody + */ +async function checkUsernameAvailability( + username: string +): Promise<{ available: true } | { available: false; error: Response }> { + // Check database + const [existingUsername] = await db + .select() + .from(xmppAccount) + .where(eq(xmppAccount.username, username)) + .limit(1); + + if (existingUsername) { + return { + available: false, + error: Response.json( + { ok: false, error: "Username already taken" }, + { status: 409 } + ), + }; + } + + // Check Prosody + const prosodyAccountExists = await checkProsodyAccountExists(username); + if (prosodyAccountExists) { + return { + available: false, + error: Response.json( + { ok: false, error: "Username already taken in XMPP server" }, + { status: 409 } + ), + }; + } + + return { available: true }; +} + +/** + * POST /api/xmpp/accounts + * Create a new XMPP account for the authenticated user + */ +export async function POST(request: NextRequest) { + try { + const { userId } = await requireAuth(request); + + // Validate request body with Zod + const parseResult = createXmppAccountSchema.safeParse(await request.json()); + if (!parseResult.success) { + return Response.json( + { + ok: false, + error: "Invalid request body", + details: parseResult.error.flatten(), + }, + { status: 400 } + ); + } + const body = parseResult.data; + + // Check if user already has an XMPP account (one per user) + const [existingAccount] = await db + .select() + .from(xmppAccount) + .where(eq(xmppAccount.userId, userId)) + .limit(1); + + if (existingAccount) { + return Response.json( + { ok: false, error: "User already has an XMPP account" }, + { status: 409 } + ); + } + + // Get user email to generate username if not provided + const [userData] = await db + .select({ email: user.email }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + if (!userData) { + return Response.json( + { ok: false, error: "User not found" }, + { status: 404 } + ); + } + + // Determine username + const usernameResult = determineUsername(body.username, userData.email); + if ("error" in usernameResult) { + return usernameResult.error; + } + const { username } = usernameResult; + + // Check username availability + const availabilityResult = await checkUsernameAvailability(username); + if (!availabilityResult.available) { + return availabilityResult.error; + } + + // Create account in Prosody (no password - authentication via OAuth) + try { + await createProsodyAccount(username); + } catch (error) { + if (error instanceof Error) { + // Check if account creation failed due to existing account + if (error.message.includes("already exists")) { + return Response.json( + { ok: false, error: "Username already taken" }, + { status: 409 } + ); + } + throw new APIError( + `Failed to create XMPP account in Prosody: ${error.message}`, + 500 + ); + } + throw new APIError("Failed to create XMPP account in Prosody", 500); + } + + // Create account record in database + const jid = formatJid(username, xmppConfig.domain); + const [newAccount] = await db + .insert(xmppAccount) + .values({ + id: randomUUID(), + userId, + jid, + username, + status: "active", + }) + .returning(); + + if (!newAccount) { + // Rollback: Try to delete Prosody account if database insert fails + try { + await deleteProsodyAccount(username); + } catch { + // Ignore rollback errors + } + throw new APIError("Failed to create XMPP account record", 500); + } + + return Response.json( + { + ok: true, + account: { + id: newAccount.id, + jid: newAccount.jid, + username: newAccount.username, + status: newAccount.status, + createdAt: newAccount.createdAt, + }, + }, + { status: 201 } + ); + } catch (error) { + return handleAPIError(error); + } +} + +/** + * GET /api/xmpp/accounts + * Get current user's XMPP account information + */ +export async function GET(request: NextRequest) { + try { + const { userId } = await requireAuth(request); + + const [account] = await db + .select() + .from(xmppAccount) + .where(eq(xmppAccount.userId, userId)) + .limit(1); + + if (!account) { + return Response.json( + { ok: false, error: "XMPP account not found" }, + { status: 404 } + ); + } + + return Response.json({ + ok: true, + account: { + id: account.id, + jid: account.jid, + username: account.username, + status: account.status, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + metadata: account.metadata, + }, + }); + } catch (error) { + return handleAPIError(error); + } +} diff --git a/src/components/xmpp/xmpp-account-management.tsx b/src/components/xmpp/xmpp-account-management.tsx new file mode 100644 index 0000000..e259905 --- /dev/null +++ b/src/components/xmpp/xmpp-account-management.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useState } from "react"; +import { AlertCircle, CheckCircle2, Loader2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + useCreateXmppAccount, + useDeleteXmppAccount, + useXmppAccount, +} from "@/hooks/use-xmpp-account"; +import type { XmppAccountStatus } from "@/lib/xmpp/types"; + +export function XmppAccountManagement() { + const { data: account, isLoading, error } = useXmppAccount(); + const createMutation = useCreateXmppAccount(); + const deleteMutation = useDeleteXmppAccount(); + const [username, setUsername] = useState(""); + + const handleCreate = async () => { + try { + await createMutation.mutateAsync({ + username: username.trim() || undefined, + }); + toast.success("XMPP account created", { + description: "Your XMPP account has been created successfully.", + }); + setUsername(""); + } catch (error) { + toast.error("Failed to create account", { + description: + error instanceof Error + ? error.message + : "An error occurred while creating your XMPP account.", + }); + } + }; + + const handleDelete = async () => { + if (!account) { + return; + } + + try { + await deleteMutation.mutateAsync(account.id); + toast.success("XMPP account deleted", { + description: "Your XMPP account has been deleted successfully.", + }); + } catch (error) { + toast.error("Failed to delete account", { + description: + error instanceof Error + ? error.message + : "An error occurred while deleting your XMPP account.", + }); + } + }; + + const getStatusBadge = (status: XmppAccountStatus) => { + const variants: Record< + XmppAccountStatus, + "default" | "secondary" | "destructive" + > = { + active: "default", + suspended: "secondary", + deleted: "destructive", + }; + + return ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); + }; + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + +
+ +

Failed to load XMPP account information.

+
+
+
+ ); + } + + // User doesn't have an XMPP account - show create form + if (!account) { + return ( + + + XMPP Account + + Create an XMPP account to use our XMPP chat service. You can connect + using any XMPP-compatible client. + + + +
+ + setUsername(e.target.value)} + placeholder="Leave empty to use your email username" + value={username} + /> +

+ If left empty, your username will be generated from your email + address. Username must be alphanumeric with underscores, hyphens, + or dots. +

+
+
+ + + +
+ ); + } + + // User has an XMPP account - show account info + return ( + + +
+
+ XMPP Account + + Manage your XMPP account settings and information. + +
+ {getStatusBadge(account.status)} +
+
+ +
+ +
+ + {account.jid} + + +
+
+ +
+ +

{account.username}

+
+ +
+ +
+ {account.status === "active" ? ( + <> + + Active + + ) : ( + <> + + {account.status} + + )} +
+
+ +
+ +

+ {new Date(account.createdAt).toLocaleDateString()} +

+
+ +
+

How to Connect

+
    +
  1. + 1. Download an XMPP client (e.g., Gajim, Conversations, Pidgin) +
  2. +
  3. 2. Add a new account with JID: {account.jid}
  4. +
  5. + 3. Use your Portal password for authentication (legacy clients) +
  6. +
  7. + 4. Or use OAuth token authentication (modern clients with + OAUTHBEARER support) +
  8. +
+
+
+ + + + + + + + Delete XMPP Account? + + This will permanently delete your XMPP account ({account.jid}). + This action cannot be undone. You will need to create a new + account if you want to use XMPP again. + + + + Cancel + + Delete Account + + + + + +
+ ); +} diff --git a/src/env.ts b/src/env.ts index 9f2d749..3fe509a 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,9 +3,10 @@ import { createEnv } from "@t3-oss/env-nextjs"; import { keys as auth } from "@/lib/auth/keys"; import { keys as database } from "@/lib/db/keys"; import { keys as observability } from "@/lib/observability/keys"; +import { keys as xmpp } from "@/lib/xmpp/keys"; export const env = createEnv({ - extends: [auth(), database(), observability()], + extends: [auth(), database(), observability(), xmpp()], server: {}, client: {}, runtimeEnv: {}, diff --git a/src/hooks/use-xmpp-account.ts b/src/hooks/use-xmpp-account.ts new file mode 100644 index 0000000..af5cbf1 --- /dev/null +++ b/src/hooks/use-xmpp-account.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/api/query-keys"; +import { + createXmppAccount, + deleteXmppAccount, + fetchXmppAccount, + fetchXmppAccountById, + updateXmppAccount, +} from "@/lib/api/xmpp"; +import type { + CreateXmppAccountRequest, + UpdateXmppAccountRequest, +} from "@/lib/xmpp/types"; + +// ============================================================================ +// XMPP Account Hooks +// ============================================================================ +// TanStack Query hooks for XMPP account management + +/** + * Fetch current user's XMPP account + */ +export function useXmppAccount() { + return useQuery({ + queryKey: queryKeys.xmppAccounts.current(), + queryFn: fetchXmppAccount, + staleTime: 30 * 1000, // 30 seconds + }); +} + +/** + * Fetch a specific XMPP account by ID + */ +export function useXmppAccountById(id: string) { + return useQuery({ + queryKey: queryKeys.xmppAccounts.detail(id), + queryFn: () => fetchXmppAccountById(id), + enabled: !!id, + staleTime: 60 * 1000, // 1 minute + }); +} + +/** + * Create a new XMPP account for the current user + */ +export function useCreateXmppAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: CreateXmppAccountRequest) => createXmppAccount(data), + onSuccess: () => { + // Invalidate current account query to refetch + queryClient.invalidateQueries({ + queryKey: queryKeys.xmppAccounts.current(), + }); + }, + }); +} + +/** + * Update an XMPP account + */ +export function useUpdateXmppAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + id, + data, + }: { + id: string; + data: UpdateXmppAccountRequest; + }) => updateXmppAccount(id, data), + onSuccess: (data, variables) => { + // Update specific account in cache + queryClient.setQueryData( + queryKeys.xmppAccounts.detail(variables.id), + data + ); + // Invalidate current account query + queryClient.invalidateQueries({ + queryKey: queryKeys.xmppAccounts.current(), + }); + }, + }); +} + +/** + * Delete an XMPP account + */ +export function useDeleteXmppAccount() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (id: string) => deleteXmppAccount(id), + onSuccess: () => { + // Invalidate all XMPP account queries + queryClient.invalidateQueries({ + queryKey: queryKeys.xmppAccounts.all, + }); + }, + }); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 1a977d6..27cbfcf 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,7 @@ import "server-only"; +import { captureException, captureRequestError } from "@sentry/nextjs"; + import { keys } from "@/lib/observability/keys"; /** @@ -17,12 +19,16 @@ import { keys } from "@/lib/observability/keys"; */ export function register() { // Runtime-specific instrumentation can be added here - // TODO: Add Node.js specific instrumentation (e.g., OpenTelemetry) + // Note: Sentry's globalHandlersIntegration automatically handles: + // - unhandledRejection (promise rejections) + // - uncaughtException (synchronous errors) + // Manual handlers are not needed and would cause double-reporting + if (process.env.NEXT_RUNTIME === "nodejs") { // Node.js specific instrumentation + // Additional instrumentation can be added here if needed } - // TODO: Add Edge runtime specific instrumentation if (process.env.NEXT_RUNTIME === "edge") { // Edge runtime specific instrumentation } @@ -41,7 +47,17 @@ const getCachedEnv = () => { return cachedEnv; }; -export const onRequestError = async ( +/** + * Called when the Next.js server captures an error during request handling. + * This includes errors from: + * - Route handlers (API routes) + * - Server Components + * - Server Actions + * - Middleware + * + * Uses Sentry's built-in captureRequestError for proper integration with Next.js. + */ +export const onRequestError = ( error: unknown, request: { path: string; @@ -62,16 +78,46 @@ export const onRequestError = async ( return; } - // Use Sentry's built-in captureRequestError for better integration - const { captureRequestError } = await import("@sentry/nextjs"); - - // Create a RequestInfo-compatible object - const requestInfo = { - path: request.path || "/", - method: request.method || "GET", - headers: request.headers, - }; - - // biome-ignore lint/suspicious/noExplicitAny: Sentry API compatibility requires any types - captureRequestError(error, requestInfo as any, context as any); + try { + // Use Sentry's built-in captureRequestError for better integration + // This automatically includes request context, user info, and breadcrumbs + // Ensure headers is always defined to match RequestInfo type + const requestInfo = { + path: request.path || "/", + method: request.method || "GET", + headers: request.headers ?? {}, + }; + // Ensure all required ErrorContext fields are present + const errorContext = { + routerKind: context.routerKind, + routeType: context.routeType, + renderSource: context.renderSource, + routePath: context.routePath ?? request.path ?? "/", + revalidateReason: context.revalidateReason, + }; + captureRequestError(error, requestInfo, errorContext); + } catch (sentryError) { + // Fallback: if captureRequestError fails, try direct captureException + try { + captureException(error, { + tags: { + type: "request_error", + path: request.path || "/", + method: request.method || "GET", + routerKind: context.routerKind, + routeType: context.routeType, + }, + extra: { + request, + context, + }, + }); + } catch { + // Sentry not available or failed to capture + // eslint-disable-next-line no-console + console.error("Failed to capture error to Sentry:", sentryError); + // eslint-disable-next-line no-console + console.error("Original error:", error); + } + } }; diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 8a0add4..b8072d6 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -11,3 +11,4 @@ export * from "./server-queries"; export * from "./types"; export * from "./user"; export * from "./utils"; +export * from "./xmpp"; diff --git a/src/lib/api/query-keys.ts b/src/lib/api/query-keys.ts index 7be0702..96861e4 100644 --- a/src/lib/api/query-keys.ts +++ b/src/lib/api/query-keys.ts @@ -66,4 +66,12 @@ export const queryKeys = { stats: () => [...queryKeys.admin.all, "stats"] as const, dashboard: () => [...queryKeys.admin.all, "dashboard"] as const, }, + + // XMPP Account queries + xmppAccounts: { + all: ["xmppAccounts"] as const, + current: () => [...queryKeys.xmppAccounts.all, "current"] as const, + details: () => [...queryKeys.xmppAccounts.all, "detail"] as const, + detail: (id: string) => [...queryKeys.xmppAccounts.details(), id] as const, + }, } as const; diff --git a/src/lib/api/utils.ts b/src/lib/api/utils.ts index c5d4139..d362350 100644 --- a/src/lib/api/utils.ts +++ b/src/lib/api/utils.ts @@ -8,8 +8,7 @@ import "server-only"; import type { NextRequest } from "next/server"; import { isAdmin, isAdminOrStaff } from "@/lib/auth/check-role"; -import { captureError, parseError } from "@/lib/observability/error"; -import { log } from "@/lib/observability/log"; +import { captureError, log, parseError } from "@/lib/observability"; import { auth } from "@/auth"; export interface AuthResult { diff --git a/src/lib/api/xmpp.ts b/src/lib/api/xmpp.ts new file mode 100644 index 0000000..3e2a40c --- /dev/null +++ b/src/lib/api/xmpp.ts @@ -0,0 +1,139 @@ +// ============================================================================ +// XMPP API Client Functions +// ============================================================================ +// Client-side functions for calling XMPP account API endpoints + +import type { + CreateXmppAccountRequest, + UpdateXmppAccountRequest, + XmppAccount, +} from "@/lib/xmpp/types"; + +/** + * Response type from XMPP API endpoints + */ +interface XmppApiResponse { + ok: boolean; + error?: string; + account?: T; + message?: string; +} + +/** + * Fetch current user's XMPP account + */ +export async function fetchXmppAccount(): Promise { + const response = await fetch("/api/xmpp/accounts"); + + if (!response.ok) { + if (response.status === 404) { + return null; // User doesn't have an XMPP account yet + } + const error = await response + .json() + .catch(() => ({ error: "Unknown error" })); + throw new Error( + error.error || `Failed to fetch XMPP account: ${response.statusText}` + ); + } + + const data = (await response.json()) as XmppApiResponse; + return data.account ?? null; +} + +/** + * Fetch a specific XMPP account by ID + */ +export async function fetchXmppAccountById(id: string): Promise { + const response = await fetch(`/api/xmpp/accounts/${id}`); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ error: "Unknown error" })); + throw new Error( + error.error || `Failed to fetch XMPP account: ${response.statusText}` + ); + } + + const data = (await response.json()) as XmppApiResponse; + if (!data.account) { + throw new Error("XMPP account not found"); + } + return data.account; +} + +/** + * Create a new XMPP account for the current user + */ +export async function createXmppAccount( + data: CreateXmppAccountRequest +): Promise { + const response = await fetch("/api/xmpp/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ error: "Unknown error" })); + throw new Error( + error.error || `Failed to create XMPP account: ${response.statusText}` + ); + } + + const result = (await response.json()) as XmppApiResponse; + if (!result.account) { + throw new Error("Failed to create XMPP account: No account returned"); + } + return result.account; +} + +/** + * Update an XMPP account + */ +export async function updateXmppAccount( + id: string, + data: UpdateXmppAccountRequest +): Promise { + const response = await fetch(`/api/xmpp/accounts/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ error: "Unknown error" })); + throw new Error( + error.error || `Failed to update XMPP account: ${response.statusText}` + ); + } + + const result = (await response.json()) as XmppApiResponse; + if (!result.account) { + throw new Error("Failed to update XMPP account: No account returned"); + } + return result.account; +} + +/** + * Delete an XMPP account + */ +export async function deleteXmppAccount(id: string): Promise { + const response = await fetch(`/api/xmpp/accounts/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ error: "Unknown error" })); + throw new Error( + error.error || `Failed to delete XMPP account: ${response.statusText}` + ); + } +} diff --git a/src/lib/auth/config.ts b/src/lib/auth/config.ts index b025f74..7d7f561 100644 --- a/src/lib/auth/config.ts +++ b/src/lib/auth/config.ts @@ -19,8 +19,11 @@ import { import "server-only"; +import { eq } from "drizzle-orm"; + import { db } from "@/lib/db/client"; import { schema } from "@/lib/db/schema"; +import { xmppAccount } from "@/lib/db/schema/xmpp"; import { sendOTPEmail, sendResetPasswordEmail, @@ -246,7 +249,7 @@ const oauthProviderConfig = { // clientRegistrationDefaultScopes: ["openid", "profile"], // Default scopes for new clients // clientRegistrationAllowedScopes: ["email", "offline_access"], // Additional allowed scopes for new clients // Scopes configuration - scopes: ["openid", "profile", "email", "offline_access"], // Supported scopes + scopes: ["openid", "profile", "email", "offline_access", "xmpp"], // Supported scopes // Valid audiences (resources) for this OAuth server validAudiences: [baseURL, `${baseURL}/api`], // Cached trusted clients (first-party applications) @@ -275,11 +278,42 @@ const oauthProviderConfig = { // "https://example.com/roles": ["editor"], // }; // }, - // customUserInfoClaims: ({ user, scopes, jwt }) => { - // return { - // locale: "en-GB", - // }; - // }, + customUserInfoClaims: async ({ + user, + scopes, + }: { + user: { id: string }; + scopes: string[]; + }) => { + const claims: Record = {}; + + // Add XMPP username when 'xmpp' scope is requested + if (scopes.includes("xmpp")) { + try { + const [xmppAccountRecord] = await db + .select({ username: xmppAccount.username }) + .from(xmppAccount) + .where(eq(xmppAccount.userId, user.id)) + .limit(1); + + if (xmppAccountRecord) { + claims.xmpp_username = xmppAccountRecord.username; + } + } catch (error) { + // Capture error in Sentry but gracefully continue without XMPP claim + const Sentry = await import("@sentry/nextjs"); + Sentry.captureException(error, { + tags: { + function: "customUserInfoClaims", + userId: user.id, + }, + }); + // Continue without XMPP claim on error + } + } + + return claims; + }, // Token expirations // accessTokenExpiresIn: "1h", // Default: 1 hour // m2mAccessTokenExpiresIn: "1h", // Default: 1 hour (machine-to-machine) diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index ad36911..ee30bbf 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -13,6 +13,7 @@ import { oauthConsent, oauthRefreshToken, } from "./oauth"; +import { xmppAccount } from "./xmpp"; export const schema = { user, session, @@ -26,4 +27,5 @@ export const schema = { oauthAccessToken, oauthRefreshToken, jwks, + xmppAccount, }; diff --git a/src/lib/db/schema/xmpp.ts b/src/lib/db/schema/xmpp.ts new file mode 100644 index 0000000..47535d4 --- /dev/null +++ b/src/lib/db/schema/xmpp.ts @@ -0,0 +1,43 @@ +import { + index, + jsonb, + pgEnum, + pgTable, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +import { user } from "./auth"; + +// XMPP account status enum +export const xmppAccountStatusEnum = pgEnum("xmpp_account_status", [ + "active", + "suspended", + "deleted", +]); + +export const xmppAccount = pgTable( + "xmpp_account", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .unique() + .references(() => user.id, { onDelete: "cascade" }), + jid: text("jid").notNull().unique(), // Full JID: username@xmpp.atl.chat + username: text("username").notNull().unique(), // XMPP localpart (username) + status: xmppAccountStatusEnum("status").default("active").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + metadata: jsonb("metadata"), // Optional JSONB for additional data + }, + (table) => [ + index("xmpp_account_userId_idx").on(table.userId), + index("xmpp_account_jid_idx").on(table.jid), + index("xmpp_account_username_idx").on(table.username), + index("xmpp_account_status_idx").on(table.status), + ] +); diff --git a/src/lib/observability/cache.ts b/src/lib/observability/cache.ts deleted file mode 100644 index 25cfdd9..0000000 --- a/src/lib/observability/cache.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Cache instrumentation utilities for monitoring cache performance - */ - -interface CacheOptions { - key: string | string[]; - address?: string; - port?: number; -} - -interface CacheSetOptions extends CacheOptions { - itemSize?: number; -} - -interface CacheGetOptions extends CacheOptions { - hit?: boolean; - itemSize?: number; -} - -/** - * Normalize cache key into primary key and key array - */ -const normalizeKey = (key: string | string[]) => { - const keys = Array.isArray(key) ? key : [key]; - return { primaryKey: keys[0], keys }; -}; - -/** - * Build base cache attributes shared between get and set operations - */ -const baseCacheAttributes = (options: CacheOptions) => { - const { keys } = normalizeKey(options.key); - return { - "cache.key": keys, - ...(options.address && { "network.peer.address": options.address }), - ...(options.port && { "network.peer.port": options.port }), - }; -}; - -/** - * Instrument cache set operations - */ -export const instrumentCacheSet = async ( - options: CacheSetOptions, - setter: () => Promise | T -): Promise => { - try { - const { startSpan } = require("@sentry/nextjs"); - const { primaryKey } = normalizeKey(options.key); - - return await startSpan( - { - name: `cache.set ${primaryKey}`, - op: "cache.put", - attributes: { - ...baseCacheAttributes(options), - ...(options.itemSize && { "cache.item_size": options.itemSize }), - }, - }, - setter - ); - } catch { - // Fallback without instrumentation - return await setter(); - } -}; - -/** - * Instrument cache get operations - */ -export const instrumentCacheGet = async ( - options: CacheGetOptions, - getter: () => Promise | T -): Promise => { - try { - const { startSpan } = require("@sentry/nextjs"); - const { primaryKey } = normalizeKey(options.key); - - return await startSpan( - { - name: `cache.get ${primaryKey}`, - op: "cache.get", - attributes: baseCacheAttributes(options), - }, - async (span: { setAttribute: (key: string, value: unknown) => void }) => { - const result = await getter(); - - // Set cache hit/miss and item size - // Prefer explicit hit parameter; fallback checks if result is not undefined - // This handles falsy values (0, "", false, null) correctly - const hit = options.hit ?? result !== undefined; - span.setAttribute("cache.hit", hit); - - if (hit && options.itemSize) { - span.setAttribute("cache.item_size", options.itemSize); - } - - return result; - } - ); - } catch { - // Fallback without instrumentation - return await getter(); - } -}; - -/** - * Helper to calculate item size for common data types - */ -export const calculateCacheItemSize = (value: unknown): number => { - if (value === null || value === undefined) { - return 0; - } - if (typeof value === "string") { - return value.length; - } - if (typeof value === "object") { - try { - return JSON.stringify(value).length; - } catch { - return 0; - } - } - return String(value).length; -}; - -/** - * Common cache configurations for Portal - * Provides preset configurations for different cache backends - */ -export const cacheConfigs = { - /** - * Redis cache configuration - * @param host - Redis server hostname (default: "localhost") - * @param port - Redis server port (default: 6379) - * @returns Cache configuration with address and port - */ - redis: (host = "localhost", port = 6379) => ({ address: host, port }), - /** - * In-memory cache configuration - * @returns Cache configuration for in-memory storage - */ - memory: () => ({ address: "in-memory" }), - /** - * Next.js cache configuration - * @returns Cache configuration for Next.js built-in cache - */ - nextjs: () => ({ address: "next-cache" }), -}; diff --git a/src/lib/observability/client.ts b/src/lib/observability/client.ts index 2f8dcf0..c4742c0 100644 --- a/src/lib/observability/client.ts +++ b/src/lib/observability/client.ts @@ -6,10 +6,16 @@ import { consoleLoggingIntegration, + extraErrorDataIntegration, + httpClientIntegration, init, + replayIntegration, + reportingObserverIntegration, zodErrorsIntegration, } from "@sentry/nextjs"; +import { keys } from "./keys"; + // Common regex patterns for client filtering const API_ROUTE_REGEX = /^\/api\//; const ATL_DOMAINS_REGEX = /^https:\/\/.*\.atl\.(dev|sh|tools|chat)\//; @@ -29,11 +35,7 @@ const ATL_DEV_REGEX = /^https:\/\/.*\.atl\.dev$/; const ATL_SH_REGEX = /^https:\/\/.*\.atl\.sh$/; const ATL_TOOLS_REGEX = /^https:\/\/.*\.atl\.tools$/; const ATL_CHAT_REGEX = /^https:\/\/.*\.atl\.chat$/; - -import { initializeFingerprinting } from "./fingerprinting"; -import { keys } from "./keys"; -import { portalSampler } from "./sampling"; -import { initializeTransactionSanitization } from "./troubleshooting"; +const TRAILING_SLASH_REGEX = /\/$/; const getReplayIntegration = () => { // replayIntegration is only available client-side @@ -41,20 +43,399 @@ const getReplayIntegration = () => { return null; } + return replayIntegration({ + maskAllText: true, + blockAllMedia: true, + }); +}; + +// ============================================================================ +// Transaction Sanitization +// ============================================================================ + +/** + * Sanitize transaction names by replacing dynamic segments + */ +const sanitizeTransactionName = (transactionName?: string): string => { + if (!transactionName) { + return "unknown"; + } + + return ( + transactionName + // Replace UUIDs with placeholder + .replace( + /\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/gi, + "/" + ) + // Replace hash-like strings + .replace(/\/[0-9a-fA-F]{32,}/gi, "/") + // Replace numeric IDs + .replace(/\/\d+/g, "/") + // Replace email addresses + .replace(/\/[^/]+@[^/]+\.[^/]+/g, "/") + // Replace base64 tokens + .replace(/\/[A-Za-z0-9+/=]{40,}/g, "/") + // Replace hex tokens + .replace(/\/[0-9a-fA-F]{32,}/gi, "/") + // Clean up multiple slashes + .replace(/\/+/g, "/") + // Remove trailing slash + .replace(TRAILING_SLASH_REGEX, "") + ); +}; + +/** + * Initialize transaction name sanitization + */ +const initializeTransactionSanitization = (): void => { try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { replayIntegration } = require("@sentry/nextjs"); - return ( - replayIntegration?.({ - maskAllText: true, - blockAllMedia: true, - }) ?? null - ); + const { addEventProcessor } = require("@sentry/nextjs"); + addEventProcessor((event: { transaction?: string; type?: string }) => { + if (event.type === "transaction" && event.transaction) { + event.transaction = sanitizeTransactionName(event.transaction); + } + return event; + }); } catch { - return null; + // Sentry not available + } +}; + +// ============================================================================ +// Error Fingerprinting +// ============================================================================ + +interface SentryEvent { + fingerprint?: string[]; + transaction?: string; + type?: string; + [key: string]: unknown; +} + +interface SentryHint { + originalException?: { + name?: string; + statusCode?: number; + status?: number; + endpoint?: string; + url?: string; + method?: string; + operation?: string; + table?: string; + model?: string; + provider?: string; + type?: string; + code?: string; + field?: string; + path?: string; + rule?: string; + constraint?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Process API error fingerprinting + */ +const processApiError = ( + event: SentryEvent, + exception: SentryHint["originalException"] +): boolean => { + if (exception?.name === "ApiError" || exception?.statusCode) { + const endpoint = exception.endpoint || exception.url || "unknown"; + const method = exception.method || "unknown"; + const statusCode = exception.statusCode || exception.status || 0; + event.fingerprint = ["api-error", endpoint, method, String(statusCode)]; + return true; + } + return false; +}; + +/** + * Process database error fingerprinting + */ +const processDatabaseError = ( + event: SentryEvent, + exception: SentryHint["originalException"] +): boolean => { + if ( + exception?.name?.includes("Database") || + exception?.code?.startsWith("P") + ) { + const operation = exception.operation || "unknown"; + const table = exception.table || exception.model || "unknown"; + event.fingerprint = ["database-error", operation, table]; + return true; + } + return false; +}; + +/** + * Process auth error fingerprinting + */ +const processAuthError = ( + event: SentryEvent, + exception: SentryHint["originalException"] +): boolean => { + if (exception?.name?.includes("Auth") || exception?.provider) { + const provider = exception.provider || "unknown"; + const errorType = exception.type || exception.code || "unknown"; + event.fingerprint = ["auth-error", provider, errorType]; + return true; + } + return false; +}; + +/** + * Process validation error fingerprinting + */ +const processValidationError = ( + event: SentryEvent, + exception: SentryHint["originalException"] +): boolean => { + if (exception?.name?.includes("Validation") || exception?.field) { + const field = exception.field || exception.path || "unknown"; + const rule = exception.rule || exception.constraint || "unknown"; + event.fingerprint = ["validation-error", field, rule]; + return true; + } + return false; +}; + +/** + * Initialize fingerprinting in beforeSend hook + */ +const initializeFingerprinting = (): void => { + try { + const { addEventProcessor } = require("@sentry/nextjs"); + addEventProcessor((event: SentryEvent, hint: SentryHint) => { + const exception = hint?.originalException; + + if (!(exception && event)) { + return event; + } + + // Try each error type processor + if (processApiError(event, exception)) { + return event; + } + if (processDatabaseError(event, exception)) { + return event; + } + if (processAuthError(event, exception)) { + return event; + } + if (processValidationError(event, exception)) { + return event; + } + + return event; + }); + } catch { + // Sentry not available } }; +// ============================================================================ +// Sampling +// ============================================================================ + +interface SamplingContext { + name: string; + attributes?: Record; + parentSampled?: boolean; + parentSampleRate?: number; + inheritOrSampleWith: (fallbackRate: number) => number; +} + +/** + * Check if transaction should be skipped + */ +const shouldSkipTransaction = (name: string): boolean => { + return name.includes("health") || name.includes("metrics"); +}; + +/** + * Check if transaction is critical auth flow + */ +const isCriticalAuthFlow = (name: string): boolean => { + return ( + name.includes("auth") || name.includes("login") || name.includes("signup") + ); +}; + +/** + * Get API route sampling rate + */ +const getApiRouteSamplingRate = ( + name: string, + isProduction: boolean +): number | null => { + if (name.includes("/api/")) { + return isProduction ? 0.3 : 1; + } + return null; +}; + +/** + * Get static asset sampling rate + */ +const getStaticAssetSamplingRate = ( + name: string, + isProduction: boolean +): number | null => { + if (name.includes("/_next/") || name.includes("/favicon")) { + return isProduction ? 0.01 : 0.1; + } + return null; +}; + +/** + * Get user tier sampling rate + */ +const getUserTierSamplingRate = ( + attributes: Record | undefined, + isProduction: boolean +): number | null => { + if (attributes?.userTier === "premium") { + return isProduction ? 0.5 : 1; + } + return null; +}; + +/** + * Portal's intelligent sampling function + */ +const portalSampler = (isProduction: boolean) => { + return (samplingContext: SamplingContext): number => { + const { name, attributes, inheritOrSampleWith } = samplingContext; + + // Skip health checks and monitoring endpoints + if (shouldSkipTransaction(name)) { + return 0; + } + + // Always sample auth flows (critical user experience) + if (isCriticalAuthFlow(name)) { + return 1; + } + + // High sampling for API routes + const apiRate = getApiRouteSamplingRate(name, isProduction); + if (apiRate !== null) { + return apiRate; + } + + // Lower sampling for static assets + const staticRate = getStaticAssetSamplingRate(name, isProduction); + if (staticRate !== null) { + return staticRate; + } + + // Sample based on user tier if available + const tierRate = getUserTierSamplingRate(attributes, isProduction); + if (tierRate !== null) { + return tierRate; + } + + // Default rates based on environment + return inheritOrSampleWith(isProduction ? 0.1 : 1); + }; +}; + +/** + * Create beforeSend callback for filtering sensitive data + */ +const createBeforeSend = (isProduction: boolean) => { + // biome-ignore lint/suspicious/noExplicitAny: Sentry callback types require any + return (event: any, hint?: any) => { + // Remove sensitive user data + if (event?.user) { + event.user.email = undefined; + event.user.ip_address = undefined; + } + + // Filter development-only errors in production + if ( + isProduction && + hint?.originalException && + typeof hint.originalException === "object" && + hint.originalException !== null && + "message" in hint.originalException && + typeof hint.originalException.message === "string" && + hint.originalException.message.includes("HMR") + ) { + return null; + } + + return event; + }; +}; + +/** + * Filter transaction data + */ +// biome-ignore lint/suspicious/noExplicitAny: Sentry callback types require any +const beforeSendTransaction = (event: any) => { + // Remove query parameters that might contain sensitive data + if (event?.transaction) { + event.transaction = event.transaction.split("?")[0]; + } + return event; +}; + +/** + * Create beforeBreadcrumb callback for filtering breadcrumbs + */ +const createBeforeBreadcrumb = (isProduction: boolean) => { + // biome-ignore lint/suspicious/noExplicitAny: Sentry callback types require any + return (breadcrumb: any): any => { + // Skip console breadcrumbs in production + if (isProduction && breadcrumb?.category === "console") { + return null; + } + + // Skip UI clicks on non-interactive elements + if ( + breadcrumb?.category === "ui.click" && + breadcrumb?.message?.includes("div") + ) { + return null; + } + + return breadcrumb; + }; +}; + +/** + * Create tracesSampler callback + */ +const createTracesSampler = (isProduction: boolean) => { + // biome-ignore lint/suspicious/noExplicitAny: Sentry callback types require any + return (samplingContext: any) => { + // Adapt Sentry's TracesSamplerContext to portalSampler's SamplingContext + const adaptedContext = { + name: + samplingContext?.transactionContext?.name || + samplingContext?.name || + "", + attributes: samplingContext?.transactionContext?.data, + parentSampled: samplingContext?.parentSampled, + parentSampleRate: samplingContext?.parentSampleRate, + inheritOrSampleWith: (fallbackRate: number) => { + if (samplingContext?.parentSampled !== undefined) { + return samplingContext.parentSampled ? 1 : 0; + } + return Math.random() < fallbackRate ? 1 : 0; + }, + }; + + return portalSampler(isProduction)(adaptedContext); + }; +}; + export const initializeSentry = (): ReturnType => { const env = keys(); @@ -74,76 +455,70 @@ export const initializeSentry = (): ReturnType => { } // HTTP client integration for fetch/XHR error tracking (client-side only) - try { - const { httpClientIntegration } = require("@sentry/nextjs"); - if (httpClientIntegration) { - integrations.push( - httpClientIntegration({ - failedRequestStatusCodes: [[400, 599]], // Track 4xx and 5xx errors - failedRequestTargets: [ - API_ROUTE_REGEX, // Internal API routes - ATL_DOMAINS_REGEX, // ATL domains - ], - }) - ); - } - } catch { - // httpClientIntegration not available - } + integrations.push( + httpClientIntegration({ + failedRequestStatusCodes: [[400, 599]], // Track 4xx and 5xx errors + failedRequestTargets: [ + API_ROUTE_REGEX, // Internal API routes + ATL_DOMAINS_REGEX, // ATL domains + ], + }) + ); // Reporting Observer for browser deprecations and crashes (client-side only) - try { - const { reportingObserverIntegration } = require("@sentry/nextjs"); - if (reportingObserverIntegration) { - integrations.push( - reportingObserverIntegration({ - types: ["crash", "deprecation", "intervention"], - }) - ); - } - } catch { - // reportingObserverIntegration not available - } + integrations.push( + reportingObserverIntegration({ + types: ["crash", "deprecation", "intervention"], + }) + ); }; /** * Add browser tracing integrations + * These are conditionally imported as they may not be available in all environments */ - const addBrowserTracingIntegrations = ( - integrations: unknown[], - isProd: boolean - ) => { + const addBrowserTracingIntegrations = (integrations: unknown[]) => { + // Only add browser tracing in browser environment + if (typeof window === "undefined") { + return; + } + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { browserTracingIntegration, browserProfilingIntegration, } = require("@sentry/nextjs"); - integrations.push( - browserTracingIntegration({ - // Filter out health checks and monitoring endpoints - shouldCreateSpanForRequest: (url: string) => { - return !url.match(HEALTH_METRICS_REGEX); - }, - // Ignore noisy resource spans - ignoreResourceSpans: ["resource.css", "resource.font"], - // Enable INP tracking for performance insights - enableInp: true, - // Reduce interaction sampling in production - interactionsSampleRate: isProd ? 0.1 : 1, - }), - browserProfilingIntegration() - ); + if (browserTracingIntegration && browserProfilingIntegration) { + integrations.push( + browserTracingIntegration({ + // Filter out health checks and monitoring endpoints + shouldCreateSpanForRequest: (url: string) => { + return !url.match(HEALTH_METRICS_REGEX); + }, + // Ignore noisy resource spans + ignoreResourceSpans: ["resource.css", "resource.font"], + // Enable INP tracking for performance insights + enableInp: true, + }), + browserProfilingIntegration() + ); + } } catch { - // Fallback if integrations not available + // Browser tracing integrations not available, skip } }; /** * Build integrations array with all required Sentry integrations */ - const buildIntegrations = (isProd: boolean) => { + const buildIntegrations = () => { const integrations = [ + // Note: Global error handlers (window.onerror, unhandled rejections) are + // enabled by default in Sentry's client-side SDK, so we don't need to + // explicitly add globalHandlersIntegration here. + consoleLoggingIntegration({ levels: ["log", "error", "warn"] }), // Zod validation error enhancement @@ -155,19 +530,14 @@ export const initializeSentry = (): ReturnType => { addClientIntegrations(integrations); // Add extra error data integration for richer error context - try { - const { extraErrorDataIntegration } = require("@sentry/nextjs"); - integrations.push( - extraErrorDataIntegration({ - depth: 5, // Capture deeper error object properties - captureErrorCause: true, // Capture error.cause chains - }) - ); - } catch { - // Integration not available - } + integrations.push( + extraErrorDataIntegration({ + depth: 5, // Capture deeper error object properties + captureErrorCause: true, // Capture error.cause chains + }) + ); - addBrowserTracingIntegrations(integrations, isProd); + addBrowserTracingIntegrations(integrations); // Add replay integration at the beginning (important for initialization order) const replay = getReplayIntegration(); @@ -178,7 +548,7 @@ export const initializeSentry = (): ReturnType => { return { integrations, replay }; }; - const { integrations, replay } = buildIntegrations(isProduction); + const { integrations, replay } = buildIntegrations(); // Initialize transaction name sanitization initializeTransactionSanitization(); @@ -227,55 +597,13 @@ export const initializeSentry = (): ReturnType => { ], // Filter sensitive data before sending - beforeSend(event, hint) { - // Remove sensitive user data - if (event.user) { - event.user.email = undefined; - event.user.ip_address = undefined; - } - - // Filter development-only errors in production - if ( - isProduction && - hint?.originalException && - typeof hint.originalException === "object" && - hint.originalException !== null && - "message" in hint.originalException && - typeof hint.originalException.message === "string" && - hint.originalException.message.includes("HMR") - ) { - return null; - } - - return event; - }, + beforeSend: createBeforeSend(isProduction), // Filter transaction data - beforeSendTransaction(event) { - // Remove query parameters that might contain sensitive data - if (event.transaction) { - event.transaction = event.transaction.split("?")[0]; - } - return event; - }, + beforeSendTransaction, // Filter breadcrumbs to reduce noise - beforeBreadcrumb(breadcrumb) { - // Skip console breadcrumbs in production - if (isProduction && breadcrumb.category === "console") { - return null; - } - - // Skip UI clicks on non-interactive elements - if ( - breadcrumb.category === "ui.click" && - breadcrumb.message?.includes("div") - ) { - return null; - } - - return breadcrumb; - }, + beforeBreadcrumb: createBeforeBreadcrumb(isProduction), // Configure trace propagation for distributed tracing tracePropagationTargets: [ "localhost", @@ -286,26 +614,7 @@ export const initializeSentry = (): ReturnType => { API_ROUTE_REGEX, ], // Smart sampling based on transaction importance using portalSampler - tracesSampler: (samplingContext) => { - // Adapt Sentry's TracesSamplerContext to portalSampler's SamplingContext - const adaptedContext = { - name: - samplingContext.transactionContext?.name || - samplingContext.name || - "", - attributes: samplingContext.transactionContext?.data, - parentSampled: samplingContext.parentSampled, - parentSampleRate: samplingContext.parentSampleRate, - inheritOrSampleWith: (fallbackRate: number) => { - if (samplingContext.parentSampled !== undefined) { - return samplingContext.parentSampled ? 1 : 0; - } - return Math.random() < fallbackRate ? 1 : 0; - }, - }; - - return portalSampler(isProduction)(adaptedContext); - }, + tracesSampler: createTracesSampler(isProduction), // Browser profiling sample rate profileSessionSampleRate: isProduction ? 0.1 : 1, ...(replay && { diff --git a/src/lib/observability/enrichment.ts b/src/lib/observability/enrichment.ts deleted file mode 100644 index 8911f64..0000000 --- a/src/lib/observability/enrichment.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * Event data enrichment utilities for Portal - * Adds structured and unstructured data to Sentry events - */ - -interface PortalUserContext { - id?: string; - email?: string; - username?: string; - role?: string; - subscription?: string; - tenant?: string; -} - -interface PortalEventContext { - feature?: string; - component?: string; - action?: string; - route?: string; - userAgent?: string; - viewport?: string; -} - -/** - * Set user context with Portal-specific data - */ -export const setPortalUser = (user: PortalUserContext): void => { - try { - const { setUser, setTag } = require("@sentry/nextjs"); - - // Set user context (structured, searchable) - setUser({ - id: user.id, - email: user.email, // Will be filtered out by beforeSend - username: user.username, - }); - - // Set user-related tags for filtering and alerts - if (user.role) { - setTag("user.role", user.role); - } - if (user.subscription) { - setTag("user.subscription", user.subscription); - } - if (user.tenant) { - setTag("user.tenant", user.tenant); - } - } catch { - // Sentry not available - } -}; - -/** - * Set Portal-specific tags for business context - */ -export const setPortalTags = (tags: Record): void => { - try { - const { setTag } = require("@sentry/nextjs"); - - for (const [key, value] of Object.entries(tags)) { - setTag(`portal.${key}`, value); - } - } catch { - // Sentry not available - } -}; - -/** - * Set feature-specific context - */ -export const setFeatureContext = (context: PortalEventContext): void => { - try { - const { setContext, setTag } = require("@sentry/nextjs"); - - // Set as context for debugging - setContext("portal_feature", { - feature: context.feature, - component: context.component, - action: context.action, - route: context.route, - timestamp: new Date().toISOString(), - }); - - // Set key items as tags for filtering - if (context.feature) { - setTag("portal.feature", context.feature); - } - if (context.component) { - setTag("portal.component", context.component); - } - } catch { - // Sentry not available - } -}; - -/** - * Set browser/device context - */ -export const setBrowserContext = (): void => { - if (typeof window === "undefined") { - return; - } - - try { - const { setContext } = require("@sentry/nextjs"); - - setContext("browser_info", { - viewport: `${window.innerWidth}x${window.innerHeight}`, - screen: `${window.screen.width}x${window.screen.height}`, - pixelRatio: window.devicePixelRatio, - online: navigator.onLine, - cookieEnabled: navigator.cookieEnabled, - language: navigator.language, - platform: navigator.platform, - }); - } catch { - // Sentry not available - } -}; - -/** - * Add breadcrumb for user actions - */ -export const addActionBreadcrumb = ( - action: string, - category = "user", - data?: Record -): void => { - try { - const { addBreadcrumb } = require("@sentry/nextjs"); - - addBreadcrumb({ - message: action, - category: `portal.${category}`, - level: "info", - data: { - timestamp: new Date().toISOString(), - ...data, - }, - }); - } catch { - // Sentry not available - } -}; - -/** - * Set deployment context - */ -export const setDeploymentContext = (): void => { - try { - const { setContext, setTag } = require("@sentry/nextjs"); - - // Set deployment info as context - setContext("deployment", { - buildId: process.env.BUILD_ID, - gitHash: process.env.GIT_HASH, - deployTime: process.env.DEPLOY_TIME, - region: process.env.VERCEL_REGION || process.env.AWS_REGION, - }); - - // Set region as tag for filtering - const region = process.env.VERCEL_REGION || process.env.AWS_REGION; - if (region) { - setTag("deployment.region", region); - } - } catch { - // Sentry not available - } -}; - -/** - * Comprehensive Portal context setup - */ -export const initializePortalContext = (user?: PortalUserContext): void => { - // Set user context if provided - if (user) { - setPortalUser(user); - } - - // Set browser context (client-side only) - setBrowserContext(); - - // Set deployment context - setDeploymentContext(); - - // Set initial Portal tags - const hostname = - typeof window !== "undefined" ? window.location.hostname : "server"; - setPortalTags({ - service: "portal", - domain: hostname, - }); -}; diff --git a/src/lib/observability/error.ts b/src/lib/observability/error.ts deleted file mode 100644 index 2c6175b..0000000 --- a/src/lib/observability/error.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { captureException } from "@sentry/nextjs"; - -import { log } from "@/lib/observability/log"; - -/** - * Parse an error into a human-readable string message. - * Pure function with no side effects. - */ -export const parseError = (error: unknown): string => { - if (error instanceof Error) { - return error.message; - } - - if ( - error && - typeof error === "object" && - "message" in error && - typeof error.message === "string" - ) { - return error.message; - } - - return String(error); -}; - -/** - * Capture an error to Sentry with optional context. - * This function has side effects (captures to Sentry, logs). - */ -export const captureError = ( - error: unknown, - context?: { - tags?: Record; - extra?: Record; - } -): void => { - try { - captureException(error, context); - const message = parseError(error); - log.error(`Error captured: ${message}`); - } catch (sentryError) { - // Fallback error logging when Sentry fails - // eslint-disable-next-line no-console - console.error("Failed to capture error to Sentry:", sentryError); - // eslint-disable-next-line no-console - console.error("Original error:", parseError(error)); - } -}; diff --git a/src/lib/observability/fingerprinting.ts b/src/lib/observability/fingerprinting.ts deleted file mode 100644 index 6f4778e..0000000 --- a/src/lib/observability/fingerprinting.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * Event fingerprinting utilities for better error grouping - */ - -/** - * Set fingerprint for current scope - */ -export const setFingerprint = (fingerprint: string[]): void => { - try { - const { withScope } = require("@sentry/nextjs"); - withScope((scope: { setFingerprint: (fingerprint: string[]) => void }) => { - scope.setFingerprint(fingerprint); - }); - } catch { - // Sentry not available - } -}; - -/** - * Common fingerprinting patterns for Portal - */ -export const fingerprintPatterns = { - // API errors grouped by endpoint and status - apiError: (endpoint: string, method: string, statusCode: number) => [ - "api-error", - endpoint, - method, - String(statusCode), - ], - - // Database errors grouped by operation and table - databaseError: (operation: string, table: string) => [ - "database-error", - operation, - table, - ], - - // Auth errors grouped by provider and type - authError: (provider: string, errorType: string) => [ - "auth-error", - provider, - errorType, - ], - - // Validation errors grouped by field - validationError: (field: string, rule: string) => [ - "validation-error", - field, - rule, - ], - - // External service errors grouped by service - externalServiceError: (service: string, operation: string) => [ - "external-service-error", - service, - operation, - ], - - // Generic errors that should be grouped aggressively - genericError: (errorType: string) => [errorType], - - // Errors that should use default grouping plus context - contextualError: (context: string) => ["{{ default }}", context], -}; - -// Note: @sentry/types is deprecated - all types moved to @sentry/core -// Using inline interface definitions here since @sentry/core is not directly -// available as a dependency (it's transitive through @sentry/nextjs). -// These types are only used internally for type safety in event processors. -interface SentryEvent { - fingerprint?: string[]; - transaction?: string; - type?: string; - [key: string]: unknown; -} - -interface SentryHint { - originalException?: { - name?: string; - statusCode?: number; - status?: number; - endpoint?: string; - url?: string; - method?: string; - operation?: string; - table?: string; - model?: string; - provider?: string; - type?: string; - code?: string; - field?: string; - path?: string; - rule?: string; - constraint?: string; - [key: string]: unknown; - }; - [key: string]: unknown; -} - -/** - * Process API error fingerprinting - */ -const processApiError = ( - event: SentryEvent, - exception: SentryHint["originalException"] -): boolean => { - if (exception?.name === "ApiError" || exception?.statusCode) { - const endpoint = exception.endpoint || exception.url || "unknown"; - const method = exception.method || "unknown"; - const statusCode = exception.statusCode || exception.status || 0; - - event.fingerprint = fingerprintPatterns.apiError( - endpoint, - method, - statusCode - ); - return true; - } - return false; -}; - -/** - * Process database error fingerprinting - */ -const processDatabaseError = ( - event: SentryEvent, - exception: SentryHint["originalException"] -): boolean => { - if ( - exception?.name?.includes("Database") || - exception?.code?.startsWith("P") - ) { - const operation = exception.operation || "unknown"; - const table = exception.table || exception.model || "unknown"; - - event.fingerprint = fingerprintPatterns.databaseError(operation, table); - return true; - } - return false; -}; - -/** - * Process auth error fingerprinting - */ -const processAuthError = ( - event: SentryEvent, - exception: SentryHint["originalException"] -): boolean => { - if (exception?.name?.includes("Auth") || exception?.provider) { - const provider = exception.provider || "unknown"; - const errorType = exception.type || exception.code || "unknown"; - - event.fingerprint = fingerprintPatterns.authError(provider, errorType); - return true; - } - return false; -}; - -/** - * Process validation error fingerprinting - */ -const processValidationError = ( - event: SentryEvent, - exception: SentryHint["originalException"] -): boolean => { - if (exception?.name?.includes("Validation") || exception?.field) { - const field = exception.field || exception.path || "unknown"; - const rule = exception.rule || exception.constraint || "unknown"; - - event.fingerprint = fingerprintPatterns.validationError(field, rule); - return true; - } - return false; -}; - -/** - * Initialize fingerprinting in beforeSend hook - */ -export const initializeFingerprinting = (): void => { - try { - const { addEventProcessor } = require("@sentry/nextjs"); - - addEventProcessor((event: SentryEvent, hint: SentryHint) => { - const exception = hint?.originalException; - - if (!(exception && event)) { - return event; - } - - // Try each error type processor - if (processApiError(event, exception)) { - return event; - } - if (processDatabaseError(event, exception)) { - return event; - } - if (processAuthError(event, exception)) { - return event; - } - if (processValidationError(event, exception)) { - return event; - } - - return event; - }); - } catch { - // Sentry not available - } -}; diff --git a/src/lib/observability/helpers.ts b/src/lib/observability/helpers.ts new file mode 100644 index 0000000..6c2196c --- /dev/null +++ b/src/lib/observability/helpers.ts @@ -0,0 +1,1064 @@ +/** + * Advanced observability helpers + * Consolidated utilities for instrumentation, context management, metrics, and tracing + * These are optional utilities that can be imported when needed + */ + +// ============================================================================ +// Context & Enrichment +// ============================================================================ + +interface PortalUserContext { + id?: string; + email?: string; + username?: string; + role?: string; + subscription?: string; + tenant?: string; +} + +interface PortalEventContext { + feature?: string; + component?: string; + action?: string; + route?: string; + userAgent?: string; + viewport?: string; +} + +/** + * Set user context with Portal-specific data + */ +export const setPortalUser = (user: PortalUserContext): void => { + try { + const { setUser, setTag } = require("@sentry/nextjs"); + setUser({ + id: user.id, + email: user.email, // Will be filtered out by beforeSend + username: user.username, + }); + if (user.role) { + setTag("user.role", user.role); + } + if (user.subscription) { + setTag("user.subscription", user.subscription); + } + if (user.tenant) { + setTag("user.tenant", user.tenant); + } + } catch { + // Sentry not available + } +}; + +/** + * Set Portal-specific tags for business context + */ +export const setPortalTags = (tags: Record): void => { + try { + const { setTag } = require("@sentry/nextjs"); + for (const [key, value] of Object.entries(tags)) { + setTag(`portal.${key}`, value); + } + } catch { + // Sentry not available + } +}; + +/** + * Set feature-specific context + */ +export const setFeatureContext = (context: PortalEventContext): void => { + try { + const { setContext, setTag } = require("@sentry/nextjs"); + setContext("portal_feature", { + feature: context.feature, + component: context.component, + action: context.action, + route: context.route, + timestamp: new Date().toISOString(), + }); + if (context.feature) { + setTag("portal.feature", context.feature); + } + if (context.component) { + setTag("portal.component", context.component); + } + } catch { + // Sentry not available + } +}; + +/** + * Set browser/device context (client-side only) + */ +export const setBrowserContext = (): void => { + if (typeof window === "undefined") { + return; + } + try { + const { setContext } = require("@sentry/nextjs"); + setContext("browser_info", { + viewport: `${window.innerWidth}x${window.innerHeight}`, + screen: `${window.screen.width}x${window.screen.height}`, + pixelRatio: window.devicePixelRatio, + online: navigator.onLine, + cookieEnabled: navigator.cookieEnabled, + language: navigator.language, + platform: navigator.platform, + }); + } catch { + // Sentry not available + } +}; + +/** + * Set deployment context + */ +export const setDeploymentContext = (): void => { + try { + const { setContext, setTag } = require("@sentry/nextjs"); + setContext("deployment", { + buildId: process.env.BUILD_ID, + gitHash: process.env.GIT_HASH, + deployTime: process.env.DEPLOY_TIME, + region: process.env.VERCEL_REGION || process.env.AWS_REGION, + }); + const region = process.env.VERCEL_REGION || process.env.AWS_REGION; + if (region) { + setTag("deployment.region", region); + } + } catch { + // Sentry not available + } +}; + +/** + * Comprehensive Portal context setup + */ +export const initializePortalContext = (user?: PortalUserContext): void => { + if (user) { + setPortalUser(user); + } + setBrowserContext(); + setDeploymentContext(); + const hostname = + typeof window !== "undefined" ? window.location.hostname : "server"; + setPortalTags({ service: "portal", domain: hostname }); +}; + +/** + * Add breadcrumb for user actions + */ +export const addActionBreadcrumb = ( + action: string, + category = "user", + data?: Record +): void => { + try { + const { addBreadcrumb } = require("@sentry/nextjs"); + addBreadcrumb({ + message: action, + category: `portal.${category}`, + level: "info", + data: { timestamp: new Date().toISOString(), ...data }, + }); + } catch { + // Sentry not available + } +}; + +/** + * Set data on global scope (applies to all events) + */ +export const setGlobalData = (data: { + tags?: Record; + extra?: Record; + context?: Record; +}): void => { + try { + const { getGlobalScope } = require("@sentry/nextjs"); + const scope = getGlobalScope(); + if (data.tags) { + for (const [key, value] of Object.entries(data.tags)) { + scope.setTag(key, value); + } + } + if (data.extra) { + scope.setExtras(data.extra); + } + if (data.context) { + for (const [key, value] of Object.entries(data.context)) { + scope.setContext(key, value); + } + } + } catch { + // Sentry not available + } +}; + +/** + * Execute function with isolated scope + */ +export const withIsolatedScope = ( + data: { + user?: { id: string; email?: string; [key: string]: unknown }; + tags?: Record; + extra?: Record; + }, + fn: () => T +): T => { + try { + const { + withIsolationScope, + setUser, + setTag, + setExtras, + } = require("@sentry/nextjs"); + return withIsolationScope(() => { + if (data.user) { + setUser(data.user); + } + if (data.tags) { + for (const [key, value] of Object.entries(data.tags)) { + setTag(key, value); + } + } + if (data.extra) { + setExtras(data.extra); + } + return fn(); + }); + } catch { + return fn(); + } +}; + +/** + * Execute function with local scope + */ +export const withLocalScope = ( + data: { + level?: "fatal" | "error" | "warning" | "log" | "info" | "debug"; + tags?: Record; + extra?: Record; + context?: Record; + }, + fn: () => T +): T => { + try { + const { withScope } = require("@sentry/nextjs"); + return withScope( + (scope: { + setLevel?: (level: string) => void; + setTag: (key: string, value: string) => void; + setExtras: (extras: Record) => void; + setContext: (key: string, value: unknown) => void; + }) => { + if (data.level) { + scope.setLevel?.(data.level); + } + if (data.tags) { + for (const [key, value] of Object.entries(data.tags)) { + scope.setTag(key, value); + } + } + if (data.extra) { + scope.setExtras(data.extra); + } + if (data.context) { + for (const [key, value] of Object.entries(data.context)) { + scope.setContext(key, value); + } + } + return fn(); + } + ); + } catch { + return fn(); + } +}; + +/** + * Common scope patterns for Portal + */ +export const scopePatterns = { + userContext: ( + user: { id: string; email?: string; tier?: string }, + fn: () => T + ): T => withIsolatedScope({ user }, fn), + + apiContext: ( + endpoint: string, + method: string, + fn: () => T, + userId?: string + ): T => + withLocalScope( + { + tags: { endpoint, method, ...(userId && { user_id: userId }) }, + context: { api: { endpoint, method } }, + }, + fn + ), + + jobContext: (jobName: string, jobId: string, fn: () => T): T => + withIsolatedScope( + { + tags: { job_name: jobName, job_id: jobId }, + extra: { job: { name: jobName, id: jobId } }, + }, + fn + ), +}; + +// ============================================================================ +// Cache Instrumentation +// ============================================================================ + +interface CacheOptions { + key: string | string[]; + address?: string; + port?: number; +} + +interface CacheSetOptions extends CacheOptions { + itemSize?: number; +} + +interface CacheGetOptions extends CacheOptions { + hit?: boolean; + itemSize?: number; +} + +const normalizeKey = (key: string | string[]) => { + const keys = Array.isArray(key) ? key : [key]; + return { primaryKey: keys[0], keys }; +}; + +const baseCacheAttributes = (options: CacheOptions) => { + const { keys } = normalizeKey(options.key); + return { + "cache.key": keys, + ...(options.address && { "network.peer.address": options.address }), + ...(options.port && { "network.peer.port": options.port }), + }; +}; + +/** + * Instrument cache set operations + */ +export const instrumentCacheSet = async ( + options: CacheSetOptions, + setter: () => Promise | T +): Promise => { + try { + const { startSpan } = require("@sentry/nextjs"); + const { primaryKey } = normalizeKey(options.key); + return await startSpan( + { + name: `cache.set ${primaryKey}`, + op: "cache.put", + attributes: { + ...baseCacheAttributes(options), + ...(options.itemSize && { "cache.item_size": options.itemSize }), + }, + }, + setter + ); + } catch { + return await setter(); + } +}; + +/** + * Instrument cache get operations + */ +export const instrumentCacheGet = async ( + options: CacheGetOptions, + getter: () => Promise | T +): Promise => { + try { + const { startSpan } = require("@sentry/nextjs"); + const { primaryKey } = normalizeKey(options.key); + return await startSpan( + { + name: `cache.get ${primaryKey}`, + op: "cache.get", + attributes: baseCacheAttributes(options), + }, + async (span: { setAttribute: (key: string, value: unknown) => void }) => { + const result = await getter(); + const hit = options.hit ?? result !== undefined; + span.setAttribute("cache.hit", hit); + if (hit && options.itemSize) { + span.setAttribute("cache.item_size", options.itemSize); + } + return result; + } + ); + } catch { + return await getter(); + } +}; + +/** + * Calculate item size for common data types + */ +export const calculateCacheItemSize = (value: unknown): number => { + if (value === null || value === undefined) { + return 0; + } + if (typeof value === "string") { + return value.length; + } + if (typeof value === "object") { + try { + return JSON.stringify(value).length; + } catch { + return 0; + } + } + return String(value).length; +}; + +/** + * Common cache configurations + */ +export const cacheConfigs = { + redis: (host = "localhost", port = 6379) => ({ address: host, port }), + memory: () => ({ address: "in-memory" }), + nextjs: () => ({ address: "next-cache" }), +}; + +// ============================================================================ +// Queue Instrumentation +// ============================================================================ + +interface TraceHeaders { + "sentry-trace"?: string; + baggage?: string; +} + +interface QueueMessage { + id: string; + body: unknown; + timestamp: number; + retryCount?: number; +} + +interface QueueProducerOptions { + messageId: string; + queueName: string; + messageSize: number; +} + +interface QueueConsumerOptions { + messageId: string; + queueName: string; + messageSize: number; + retryCount?: number; + receiveLatency?: number; +} + +const buildQueueConsumerAttributes = (options: QueueConsumerOptions) => ({ + "messaging.message.id": options.messageId, + "messaging.destination.name": options.queueName, + "messaging.message.body.size": options.messageSize, + ...(options.retryCount !== undefined && { + "messaging.message.retry.count": options.retryCount, + }), + ...(options.receiveLatency !== undefined && { + "messaging.message.receive.latency": options.receiveLatency, + }), +}); + +/** + * Instrument queue message publishing + */ +export const instrumentQueueProducer = async ( + options: QueueProducerOptions, + producer: (traceHeaders: TraceHeaders) => Promise +): Promise => { + try { + const { startSpan, getTraceData } = require("@sentry/nextjs"); + return await startSpan( + { + name: "queue_producer", + op: "queue.publish", + attributes: { + "messaging.message.id": options.messageId, + "messaging.destination.name": options.queueName, + "messaging.message.body.size": options.messageSize, + }, + }, + async () => { + const traceHeaders = getTraceData(); + return await producer(traceHeaders); + } + ); + } catch { + return await producer({}); + } +}; + +/** + * Instrument queue message consumption + */ +export const instrumentQueueConsumer = async ( + options: QueueConsumerOptions, + traceHeaders: TraceHeaders, + consumer: () => Promise +): Promise => { + let consumerExecuted = false; + try { + const { continueTrace, startSpan } = require("@sentry/nextjs"); + return await continueTrace( + { + sentryTrace: traceHeaders["sentry-trace"], + baggage: traceHeaders.baggage, + }, + async () => { + return await startSpan( + { name: "queue_consumer_transaction" }, + async (parent: { + setStatus?: (status: { code: number; message: string }) => void; + }) => { + try { + const result = await startSpan( + { + name: "queue_consumer", + op: "queue.process", + attributes: buildQueueConsumerAttributes(options), + }, + () => { + consumerExecuted = true; + return consumer(); + } + ); + parent.setStatus?.({ code: 1, message: "ok" }); + return result; + } catch (error) { + parent.setStatus?.({ code: 2, message: "error" }); + throw error; + } + } + ); + } + ); + } catch (error) { + if (!consumerExecuted) { + return await consumer(); + } + throw error; + } +}; + +/** + * Create queue message with trace headers + */ +export const createQueueMessage = ( + id: string, + body: unknown, + traceHeaders: TraceHeaders +): QueueMessage & { sentryTrace?: string; sentryBaggage?: string } => ({ + id, + body, + timestamp: Date.now(), + sentryTrace: traceHeaders["sentry-trace"], + sentryBaggage: traceHeaders.baggage, +}); + +/** + * Calculate receive latency + */ +export const calculateReceiveLatency = (message: QueueMessage): number => + Date.now() - message.timestamp; + +// ============================================================================ +// HTTP Instrumentation +// ============================================================================ + +interface HttpRequestOptions { + method: string; + url: string; + requestSize?: number; +} + +const calculateBodySize = (body: unknown): number | undefined => { + try { + return JSON.stringify(body).length; + } catch { + return undefined; + } +}; + +const buildHttpOptions = ( + method: "GET" | "POST" | "PUT" | "DELETE", + url: string, + body?: unknown +): HttpRequestOptions => ({ + method, + url, + ...(body !== undefined && { requestSize: calculateBodySize(body) }), +}); + +/** + * Instrument HTTP requests for custom clients + */ +export const instrumentHttpRequest = async ( + options: HttpRequestOptions, + requester: () => Promise +): Promise => { + try { + const { startSpan } = require("@sentry/nextjs"); + return await startSpan( + { + op: "http.client", + name: `${options.method} ${options.url}`, + attributes: { + "http.request.method": options.method, + ...(options.requestSize && { + "http.request.body.size": options.requestSize, + }), + }, + }, + async (span: { + setAttribute: (key: string, value: unknown) => void; + setStatus: (status: { code: number; message: string }) => void; + }) => { + try { + const parsedURL = new URL( + options.url, + typeof window !== "undefined" ? window.location.origin : undefined + ); + span.setAttribute("server.address", parsedURL.hostname); + if (parsedURL.port) { + span.setAttribute("server.port", Number(parsedURL.port)); + } + const result = await requester(); + if (result && typeof result === "object" && "status" in result) { + const response = result as { + status: number; + headers?: { get?: (name: string) => string | null }; + }; + span.setAttribute("http.response.status_code", response.status); + if (response.headers?.get) { + const contentLength = response.headers.get("content-length"); + if (contentLength) { + span.setAttribute( + "http.response.body.size", + Number(contentLength) + ); + } + } + } + return result; + } catch (error) { + span.setStatus({ code: 2, message: "error" }); + throw error; + } + } + ); + } catch { + return await requester(); + } +}; + +/** + * HTTP client with automatic Sentry instrumentation + */ +export const httpClient = { + get: (url: string, fetcher: () => Promise) => + instrumentHttpRequest(buildHttpOptions("GET", url), fetcher), + + post: (url: string, body: unknown, fetcher: () => Promise) => + instrumentHttpRequest(buildHttpOptions("POST", url, body), fetcher), + + put: (url: string, body: unknown, fetcher: () => Promise) => + instrumentHttpRequest(buildHttpOptions("PUT", url, body), fetcher), + + delete: (url: string, fetcher: () => Promise) => + instrumentHttpRequest(buildHttpOptions("DELETE", url), fetcher), +}; + +// ============================================================================ +// Span Utilities +// ============================================================================ + +interface SpanAttributes { + [key: string]: string | number | boolean | (string | number | boolean)[]; +} + +/** + * Add metrics to the currently active span + */ +export const addSpanMetrics = (attributes: SpanAttributes): void => { + try { + const { getActiveSpan } = require("@sentry/nextjs"); + const span = getActiveSpan(); + if (span) { + span.setAttributes(attributes); + } + } catch { + // Sentry not available + } +}; + +/** + * Create a dedicated span with custom metrics (auto-ending) + */ +export const createMetricSpan = ( + name: string, + op: string, + attributes: SpanAttributes, + fn: () => T +): T => { + try { + const { startSpan } = require("@sentry/nextjs"); + return startSpan({ name, op, attributes }, fn); + } catch { + return fn(); + } +}; + +/** + * Create a manual span that must be ended explicitly + */ +export const createManualSpan = ( + name: string, + op: string, + attributes?: SpanAttributes +) => { + try { + const { startInactiveSpan } = require("@sentry/nextjs"); + return startInactiveSpan({ name, op, attributes }); + } catch { + return { + setAttribute: () => { + // Mock implementation + }, + setAttributes: () => { + // Mock implementation + }, + setStatus: () => { + // Mock implementation + }, + end: () => { + // Mock implementation + }, + }; + } +}; + +interface Span { + setAttribute: (key: string, value: unknown) => void; + setAttributes: (attributes: Record) => void; + setStatus: (status: { code: number; message: string }) => void; + end: () => void; + updateName?: (name: string) => void; +} + +/** + * Update span name + */ +export const updateSpanName = (span: Span, name: string): void => { + try { + const { updateSpanName } = require("@sentry/nextjs"); + updateSpanName(span, name); + } catch { + span?.updateName?.(name); + } +}; + +/** + * Common span metrics for Portal operations + */ +export const spanMetrics = { + database: ( + operation: string, + table: string, + duration: number, + rows?: number + ) => ({ + "db.operation": operation, + "db.table": table, + "db.duration_ms": duration, + ...(rows !== undefined && { "db.rows_affected": rows }), + }), + + api: ( + endpoint: string, + method: string, + duration: number, + status: number + ) => ({ + "http.endpoint": endpoint, + "http.method": method, + "http.duration_ms": duration, + "http.status_code": status, + }), + + auth: ( + operation: string, + provider: string, + duration: number, + success: boolean + ) => ({ + "auth.operation": operation, + "auth.provider": provider, + "auth.duration_ms": duration, + "auth.success": success, + }), +}; + +// ============================================================================ +// Trace Propagation +// ============================================================================ + +/** + * Get trace data for manual propagation (WebSockets, job queues, etc.) + */ +export const getTraceHeaders = (): { + "sentry-trace"?: string; + baggage?: string; +} => { + try { + const { getTraceData } = require("@sentry/nextjs"); + return getTraceData(); + } catch { + return {}; + } +}; + +/** + * Continue a trace from incoming headers + */ +export const continueTrace = ( + headers: { "sentry-trace"?: string; baggage?: string }, + callback: () => T +): T => { + try { + const { continueTrace } = require("@sentry/nextjs"); + return continueTrace( + { + sentryTrace: headers["sentry-trace"], + baggage: headers.baggage, + }, + callback + ); + } catch { + return callback(); + } +}; + +/** + * Start a new isolated trace + */ +export const startNewTrace = (callback: () => T): T => { + try { + const { startNewTrace } = require("@sentry/nextjs"); + return startNewTrace(callback); + } catch { + return callback(); + } +}; + +// ============================================================================ +// Metrics +// ============================================================================ + +interface MetricAttributes { + [key: string]: string | number | boolean; +} + +interface MetricOptions { + attributes?: MetricAttributes; + unit?: string; +} + +/** + * Increment a counter metric + */ +export const incrementCounter = ( + name: string, + value = 1, + options?: MetricOptions +): void => { + try { + const { metrics } = require("@sentry/nextjs"); + metrics.count(name, value, options); + } catch { + // Sentry not available + } +}; + +/** + * Set a gauge metric (value that can go up/down) + */ +export const setGauge = ( + name: string, + value: number, + options?: MetricOptions +): void => { + try { + const { metrics } = require("@sentry/nextjs"); + metrics.gauge(name, value, options); + } catch { + // Sentry not available + } +}; + +/** + * Record a distribution metric (for timing, sizes, etc.) + */ +export const recordDistribution = ( + name: string, + value: number, + options?: MetricOptions +): void => { + try { + const { metrics } = require("@sentry/nextjs"); + metrics.distribution(name, value, options); + } catch { + // Sentry not available + } +}; + +/** + * Common Portal metrics + */ +export const portalMetrics = { + userAction: (action: string, userId?: string) => + incrementCounter("user.action", 1, { + attributes: { action, ...(userId && { user_id: userId }) }, + }), + + apiRequest: ( + endpoint: string, + method: string, + duration: number, + status: number + ) => { + incrementCounter("api.request", 1, { + attributes: { endpoint, method, status: status.toString() }, + }); + recordDistribution("api.duration", duration, { + attributes: { endpoint, method }, + unit: "millisecond", + }); + }, + + dbQuery: ( + table: string, + operation: string, + duration: number, + rows?: number + ) => { + incrementCounter("db.query", 1, { + attributes: { table, operation }, + }); + recordDistribution("db.duration", duration, { + attributes: { table, operation }, + unit: "millisecond", + }); + if (rows !== undefined) { + recordDistribution("db.rows", rows, { + attributes: { table, operation }, + }); + } + }, + + cacheOperation: (operation: "hit" | "miss" | "set", key: string) => + incrementCounter(`cache.${operation}`, 1, { + attributes: { cache_key: key }, + }), + + authEvent: ( + event: "login" | "logout" | "signup" | "failed_login", + provider?: string + ) => + incrementCounter("auth.event", 1, { + attributes: { event, ...(provider && { provider }) }, + }), + + businessEvent: ( + event: string, + value?: number, + attributes?: MetricAttributes + ) => { + incrementCounter("business.event", 1, { + attributes: { event, ...attributes }, + }); + if (value !== undefined) { + recordDistribution("business.value", value, { + attributes: { event, ...attributes }, + }); + } + }, + + systemMetric: (metric: string, value: number, unit?: string) => + setGauge(`system.${metric}`, value, { unit }), +}; + +// ============================================================================ +// Event Levels +// ============================================================================ + +type SentryLevel = "fatal" | "error" | "warning" | "log" | "info" | "debug"; + +/** + * Set level for current scope + */ +export const setLevel = (level: SentryLevel): void => { + try { + const { getCurrentScope } = require("@sentry/nextjs"); + getCurrentScope().setLevel(level); + } catch { + // Sentry not available + } +}; + +/** + * Capture message with specific level + */ +export const captureMessageWithLevel = ( + message: string, + level: SentryLevel +): void => { + try { + const { captureMessage } = require("@sentry/nextjs"); + captureMessage(message, level); + } catch { + // Sentry not available + } +}; + +/** + * Capture exception with specific level + */ +export const captureExceptionWithLevel = ( + error: unknown, + level: SentryLevel +): void => { + try { + const { withScope, captureException } = require("@sentry/nextjs"); + withScope((scope: { setLevel: (level: string) => void }) => { + scope.setLevel(level); + captureException(error); + }); + } catch { + // Sentry not available + } +}; + +/** + * Common level patterns for Portal + */ +export const levelPatterns = { + fatal: (error: unknown) => captureExceptionWithLevel(error, "fatal"), + error: (error: unknown) => captureExceptionWithLevel(error, "error"), + warning: (message: string) => captureMessageWithLevel(message, "warning"), + info: (message: string) => captureMessageWithLevel(message, "info"), + debug: (message: string) => captureMessageWithLevel(message, "debug"), +}; diff --git a/src/lib/observability/http.ts b/src/lib/observability/http.ts deleted file mode 100644 index 356a68f..0000000 --- a/src/lib/observability/http.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * HTTP request instrumentation utilities for custom HTTP clients - */ - -import type { Span } from "@sentry/nextjs"; -import { startSpan } from "@sentry/nextjs"; - -interface HttpRequestOptions { - method: string; - url: string; - requestSize?: number; -} - -/** - * Instrument HTTP requests for custom clients - */ -export const instrumentHttpRequest = async ( - options: HttpRequestOptions, - requester: () => Promise -): Promise => { - try { - return await startSpan( - { - op: "http.client", - name: `${options.method} ${options.url}`, - attributes: { - "http.request.method": options.method, - ...(options.requestSize && { - "http.request.body.size": options.requestSize, - }), - }, - }, - async (span: Span) => { - try { - // Parse URL for server attributes - const parsedURL = new URL( - options.url, - typeof window !== "undefined" ? window.location.origin : undefined - ); - span.setAttribute("server.address", parsedURL.hostname); - if (parsedURL.port) { - span.setAttribute("server.port", Number(parsedURL.port)); - } - - const result = await requester(); - - // If result looks like a Response object, extract status and size - if (result && typeof result === "object" && "status" in result) { - const response = result as { - status: number; - headers?: { get?: (name: string) => string | null }; - }; - span.setAttribute("http.response.status_code", response.status); - - if (response.headers?.get) { - const contentLength = response.headers.get("content-length"); - if (contentLength) { - span.setAttribute( - "http.response.body.size", - Number(contentLength) - ); - } - } - } - - return result; - } catch (error) { - // Use OpenTelemetry span status code for error (2 = ERROR) - span.setStatus({ code: 2, message: "error" }); - throw error; - } - } - ); - } catch { - // Fallback without instrumentation - return await requester(); - } -}; - -/** - * Safely calculate the size of a request body by stringifying it - * @param body - The request body to measure - * @returns The size in bytes, or undefined if serialization fails - */ -const calculateBodySize = (body: unknown): number | undefined => { - try { - return JSON.stringify(body).length; - } catch { - // Body may be circular or non-serializable - return undefined; - } -}; - -/** - * Build HTTP request options for instrumentation - */ -const buildHttpOptions = ( - method: "GET" | "POST" | "PUT" | "DELETE", - url: string, - body?: unknown -): HttpRequestOptions => ({ - method, - url, - ...(body !== undefined && { - requestSize: calculateBodySize(body), - }), -}); - -/** - * Common HTTP methods with automatic Sentry instrumentation - * Each method wraps the provided fetcher function with span tracing, - * error capture, and performance metrics - * - * @example - * ```ts - * const data = await httpClient.get('/api/users', () => fetch('/api/users').then(r => r.json())); - * ``` - */ -export const httpClient = { - /** - * Execute a GET request with instrumentation - * @param url - Request URL for tracing - * @param fetcher - Function that performs the actual HTTP request - * @returns Promise resolving to the response data - */ - get: (url: string, fetcher: () => Promise) => - instrumentHttpRequest(buildHttpOptions("GET", url), fetcher), - - /** - * Execute a POST request with instrumentation - * @param url - Request URL for tracing - * @param body - Request body (used for size calculation) - * @param fetcher - Function that performs the actual HTTP request - * @returns Promise resolving to the response data - */ - post: (url: string, body: unknown, fetcher: () => Promise) => - instrumentHttpRequest(buildHttpOptions("POST", url, body), fetcher), - - /** - * Execute a PUT request with instrumentation - * @param url - Request URL for tracing - * @param body - Request body (used for size calculation) - * @param fetcher - Function that performs the actual HTTP request - * @returns Promise resolving to the response data - */ - put: (url: string, body: unknown, fetcher: () => Promise) => - instrumentHttpRequest(buildHttpOptions("PUT", url, body), fetcher), - - /** - * Execute a DELETE request with instrumentation - * @param url - Request URL for tracing - * @param fetcher - Function that performs the actual HTTP request - * @returns Promise resolving to the response data - */ - delete: (url: string, fetcher: () => Promise) => - instrumentHttpRequest(buildHttpOptions("DELETE", url), fetcher), -}; diff --git a/src/lib/observability/index.ts b/src/lib/observability/index.ts index bc59893..5e8e5db 100644 --- a/src/lib/observability/index.ts +++ b/src/lib/observability/index.ts @@ -1,69 +1,9 @@ -/** biome-ignore-all lint/performance/noBarrelFile: Observability package exports are intentionally centralized */ +/** biome-ignore-all lint/performance/noBarrelFile: Barrel file for @/lib/observability */ -export { - cacheConfigs, - calculateCacheItemSize, - instrumentCacheGet, - instrumentCacheSet, -} from "./cache"; +// Core initialization export { initializeSentry as initializeSentryClient } from "./client"; -export { - addActionBreadcrumb, - initializePortalContext, - setBrowserContext, - setDeploymentContext, - setFeatureContext, - setPortalTags, - setPortalUser, -} from "./enrichment"; -export { captureError, parseError } from "./error"; -export { - fingerprintPatterns, - initializeFingerprinting, - setFingerprint, -} from "./fingerprinting"; -export { httpClient, instrumentHttpRequest } from "./http"; -export { - captureExceptionWithLevel, - captureMessageWithLevel, - levelPatterns, - setLevel, -} from "./levels"; -export { log } from "./log"; -export { - incrementCounter, - portalMetrics, - recordDistribution, - setGauge, -} from "./metrics"; -export { - calculateReceiveLatency, - createQueueMessage, - instrumentQueueConsumer, - instrumentQueueProducer, -} from "./queue"; -export { - errorSamplingRates, - portalSampler, - samplingPatterns, -} from "./sampling"; -export { - scopePatterns, - setGlobalData, - withIsolatedScope, - withLocalScope, -} from "./scopes"; -export { - addSpanMetrics, - createManualSpan, - createMetricSpan, - spanMetrics, - updateSpanName, -} from "./span"; -export { continueTrace, getTraceHeaders, startNewTrace } from "./trace"; -export { - initializeTransactionSanitization, - sanitizeTransactionName, - setLongAttribute, - setUrlAttributes, -} from "./troubleshooting"; +export { initializeSentry as initializeSentryEdge } from "./edge"; +export * from "./helpers"; +export { keys } from "./keys"; +export { initializeSentry as initializeSentryServer } from "./server"; +export { captureError, log, parseError } from "./utils"; diff --git a/src/lib/observability/levels.ts b/src/lib/observability/levels.ts deleted file mode 100644 index f459f17..0000000 --- a/src/lib/observability/levels.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Event level utilities for setting severity levels - */ - -type SentryLevel = "fatal" | "error" | "warning" | "log" | "info" | "debug"; - -/** - * Set level for current scope - */ -export const setLevel = (level: SentryLevel): void => { - try { - const { getCurrentScope } = require("@sentry/nextjs"); - getCurrentScope().setLevel(level); - } catch { - // Sentry not available - } -}; - -/** - * Capture message with specific level - */ -export const captureMessageWithLevel = ( - message: string, - level: SentryLevel -): void => { - try { - const { captureMessage } = require("@sentry/nextjs"); - captureMessage(message, level); - } catch { - // Sentry not available - } -}; - -/** - * Capture exception with specific level - */ -export const captureExceptionWithLevel = ( - error: unknown, - level: SentryLevel -): void => { - try { - const { withScope, captureException } = require("@sentry/nextjs"); - withScope((scope: { setLevel: (level: string) => void }) => { - scope.setLevel(level); - captureException(error); - }); - } catch { - // Sentry not available - } -}; - -/** - * Common level patterns for Portal - */ -export const levelPatterns = { - // Critical system failures - fatal: (error: unknown) => captureExceptionWithLevel(error, "fatal"), - - // Application errors that need attention - error: (error: unknown) => captureExceptionWithLevel(error, "error"), - - // Potential issues or degraded performance - warning: (message: string) => captureMessageWithLevel(message, "warning"), - - // General operational information - info: (message: string) => captureMessageWithLevel(message, "info"), - - // Detailed debugging information - debug: (message: string) => captureMessageWithLevel(message, "debug"), -}; diff --git a/src/lib/observability/log.ts b/src/lib/observability/log.ts deleted file mode 100644 index a46da3f..0000000 --- a/src/lib/observability/log.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Structured logging using Sentry's logger - * Provides consistent, queryable logs across all environments - */ - -interface LogAttributes { - [key: string]: unknown; -} - -const getSentryLogger = () => { - try { - // Dynamic import to handle server/client differences - const Sentry = require("@sentry/nextjs"); - return Sentry.logger; - } catch { - // Fallback to console if Sentry not available - return null; - } -}; - -const createLogger = () => { - const sentryLogger = getSentryLogger(); - - return { - trace: (message: string, attributes?: LogAttributes) => { - if (sentryLogger) { - sentryLogger.trace(message, attributes); - } else { - console.log("[TRACE]", message, attributes); - } - }, - - debug: (message: string, attributes?: LogAttributes) => { - if (sentryLogger) { - sentryLogger.debug(message, attributes); - } else if (process.env.NODE_ENV === "development") { - console.log("[DEBUG]", message, attributes); - } - }, - - info: (message: string, attributes?: LogAttributes) => { - if (sentryLogger) { - sentryLogger.info(message, attributes); - } else { - console.info("[INFO]", message, attributes); - } - }, - - warn: (message: string, attributes?: LogAttributes) => { - if (sentryLogger) { - sentryLogger.warn(message, attributes); - } else { - console.warn("[WARN]", message, attributes); - } - }, - - error: (message: string, attributes?: LogAttributes) => { - if (sentryLogger) { - sentryLogger.error(message, attributes); - } else { - console.error("[ERROR]", message, attributes); - } - }, - - fatal: (message: string, attributes?: LogAttributes) => { - if (sentryLogger) { - sentryLogger.fatal(message, attributes); - } else { - console.error("[FATAL]", message, attributes); - } - }, - }; -}; - -export const log = createLogger(); diff --git a/src/lib/observability/metrics.ts b/src/lib/observability/metrics.ts deleted file mode 100644 index c6447ee..0000000 --- a/src/lib/observability/metrics.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Metrics utilities for tracking application performance and business metrics - */ - -interface MetricAttributes { - [key: string]: string | number | boolean; -} - -interface MetricOptions { - attributes?: MetricAttributes; - unit?: string; -} - -/** - * Increment a counter metric - */ -export const incrementCounter = ( - name: string, - value = 1, - options?: MetricOptions -): void => { - try { - const { metrics } = require("@sentry/nextjs"); - metrics.count(name, value, options); - } catch { - // Sentry not available or metrics disabled - } -}; - -/** - * Set a gauge metric (value that can go up/down) - */ -export const setGauge = ( - name: string, - value: number, - options?: MetricOptions -): void => { - try { - const { metrics } = require("@sentry/nextjs"); - metrics.gauge(name, value, options); - } catch { - // Sentry not available or metrics disabled - } -}; - -/** - * Record a distribution metric (for timing, sizes, etc.) - */ -export const recordDistribution = ( - name: string, - value: number, - options?: MetricOptions -): void => { - try { - const { metrics } = require("@sentry/nextjs"); - metrics.distribution(name, value, options); - } catch { - // Sentry not available or metrics disabled - } -}; - -/** - * Common Portal metrics - */ -export const portalMetrics = { - // User activity metrics - userAction: (action: string, userId?: string) => - incrementCounter("user.action", 1, { - attributes: { action, ...(userId && { user_id: userId }) }, - }), - - // API performance metrics - apiRequest: ( - endpoint: string, - method: string, - duration: number, - status: number - ) => { - incrementCounter("api.request", 1, { - attributes: { endpoint, method, status: status.toString() }, - }); - recordDistribution("api.duration", duration, { - attributes: { endpoint, method }, - unit: "millisecond", - }); - }, - - // Database metrics - dbQuery: ( - table: string, - operation: string, - duration: number, - rows?: number - ) => { - incrementCounter("db.query", 1, { - attributes: { table, operation }, - }); - recordDistribution("db.duration", duration, { - attributes: { table, operation }, - unit: "millisecond", - }); - if (rows !== undefined) { - recordDistribution("db.rows", rows, { - attributes: { table, operation }, - }); - } - }, - - // Cache metrics - cacheOperation: (operation: "hit" | "miss" | "set", key: string) => - incrementCounter(`cache.${operation}`, 1, { - attributes: { cache_key: key }, - }), - - // Auth metrics - authEvent: ( - event: "login" | "logout" | "signup" | "failed_login", - provider?: string - ) => - incrementCounter("auth.event", 1, { - attributes: { event, ...(provider && { provider }) }, - }), - - // Business metrics - businessEvent: ( - event: string, - value?: number, - attributes?: MetricAttributes - ) => { - incrementCounter("business.event", 1, { - attributes: { event, ...attributes }, - }); - if (value !== undefined) { - recordDistribution("business.value", value, { - attributes: { event, ...attributes }, - }); - } - }, - - // System metrics - systemMetric: (metric: string, value: number, unit?: string) => - setGauge(`system.${metric}`, value, { unit }), -}; diff --git a/src/lib/observability/queue.ts b/src/lib/observability/queue.ts deleted file mode 100644 index 60314b6..0000000 --- a/src/lib/observability/queue.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Queue instrumentation utilities for messaging and background jobs - */ - -import type { Span } from "@sentry/nextjs"; -import { continueTrace, getTraceData, startSpan } from "@sentry/nextjs"; - -// Shared type for trace headers -interface TraceHeaders { - "sentry-trace"?: string; - baggage?: string; -} - -interface QueueMessage { - id: string; - body: unknown; - timestamp: number; - retryCount?: number; -} - -interface QueueProducerOptions { - messageId: string; - queueName: string; - messageSize: number; -} - -interface QueueConsumerOptions { - messageId: string; - queueName: string; - messageSize: number; - retryCount?: number; - receiveLatency?: number; -} - -/** - * Build consumer attributes object from options - */ -const buildQueueConsumerAttributes = (options: QueueConsumerOptions) => ({ - "messaging.message.id": options.messageId, - "messaging.destination.name": options.queueName, - "messaging.message.body.size": options.messageSize, - ...(options.retryCount !== undefined && { - "messaging.message.retry.count": options.retryCount, - }), - ...(options.receiveLatency !== undefined && { - "messaging.message.receive.latency": options.receiveLatency, - }), -}); - -/** - * Instrument queue message publishing - */ -export const instrumentQueueProducer = async ( - options: QueueProducerOptions, - producer: (traceHeaders: TraceHeaders) => Promise -): Promise => { - try { - return await startSpan( - { - name: "queue_producer", - op: "queue.publish", - attributes: { - "messaging.message.id": options.messageId, - "messaging.destination.name": options.queueName, - "messaging.message.body.size": options.messageSize, - }, - }, - async () => { - const traceHeaders = getTraceData(); - return await producer(traceHeaders); - } - ); - } catch { - // Fallback without instrumentation - return await producer({}); - } -}; - -/** - * Instrument queue message consumption - */ -export const instrumentQueueConsumer = async ( - options: QueueConsumerOptions, - traceHeaders: TraceHeaders, - consumer: () => Promise -): Promise => { - let consumerExecuted = false; - - try { - return await continueTrace( - { - sentryTrace: traceHeaders["sentry-trace"], - baggage: traceHeaders.baggage, - }, - async () => { - return await startSpan( - { - name: "queue_consumer_transaction", - }, - async (parent: Span) => { - try { - const result = await startSpan( - { - name: "queue_consumer", - op: "queue.process", - attributes: buildQueueConsumerAttributes(options), - }, - () => { - consumerExecuted = true; - return consumer(); - } - ); - - // Use OpenTelemetry span status code for success (1 = OK) - parent.setStatus?.({ code: 1, message: "ok" }); - return result; - } catch (error) { - // Use OpenTelemetry span status code for error (2 = ERROR) - parent.setStatus?.({ code: 2, message: "error" }); - throw error; - } - } - ); - } - ); - } catch (error) { - // Fallback without instrumentation only if consumer hasn't been executed yet - // This handles cases where Sentry instrumentation fails before consumer runs - if (!consumerExecuted) { - return await consumer(); - } - // If consumer was already executed, re-throw the original error - throw error; - } -}; - -/** - * Helper to create queue message with trace headers - */ -export const createQueueMessage = ( - id: string, - body: unknown, - traceHeaders: TraceHeaders -): QueueMessage & { sentryTrace?: string; sentryBaggage?: string } => { - return { - id, - body, - timestamp: Date.now(), - sentryTrace: traceHeaders["sentry-trace"], - sentryBaggage: traceHeaders.baggage, - }; -}; - -/** - * Helper to calculate receive latency - */ -export const calculateReceiveLatency = (message: QueueMessage): number => { - return Date.now() - message.timestamp; -}; diff --git a/src/lib/observability/sampling.ts b/src/lib/observability/sampling.ts deleted file mode 100644 index 9ac77e8..0000000 --- a/src/lib/observability/sampling.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Sampling utilities for managing Sentry sampling decisions - */ - -interface SamplingContext { - name: string; - attributes?: Record; - parentSampled?: boolean; - parentSampleRate?: number; - inheritOrSampleWith: (fallbackRate: number) => number; -} - -/** - * Helper function to check if transaction should be skipped - */ -const shouldSkipTransaction = (name: string): boolean => { - return name.includes("health") || name.includes("metrics"); -}; - -/** - * Helper function to check if transaction is critical auth flow - */ -const isCriticalAuthFlow = (name: string): boolean => { - return ( - name.includes("auth") || name.includes("login") || name.includes("signup") - ); -}; - -/** - * Helper function to get API route sampling rate - */ -const getApiRouteSamplingRate = ( - name: string, - isProduction: boolean -): number | null => { - if (name.includes("/api/")) { - return isProduction ? 0.3 : 1; - } - return null; -}; - -/** - * Helper function to get static asset sampling rate - */ -const getStaticAssetSamplingRate = ( - name: string, - isProduction: boolean -): number | null => { - if (name.includes("/_next/") || name.includes("/favicon")) { - return isProduction ? 0.01 : 0.1; - } - return null; -}; - -/** - * Helper function to get user tier sampling rate - */ -const getUserTierSamplingRate = ( - attributes: Record | undefined, - isProduction: boolean -): number | null => { - if (attributes?.userTier === "premium") { - return isProduction ? 0.5 : 1; - } - return null; -}; - -/** - * Portal's intelligent sampling function - * Used in client configuration - extracted for reusability - */ -export const portalSampler = (isProduction: boolean) => { - return (samplingContext: SamplingContext): number => { - const { name, attributes, inheritOrSampleWith } = samplingContext; - - // Skip health checks and monitoring endpoints - if (shouldSkipTransaction(name)) { - return 0; - } - - // Always sample auth flows (critical user experience) - if (isCriticalAuthFlow(name)) { - return 1; - } - - // High sampling for API routes (important for debugging) - const apiRate = getApiRouteSamplingRate(name, isProduction); - if (apiRate !== null) { - return apiRate; - } - - // Lower sampling for static assets - const staticRate = getStaticAssetSamplingRate(name, isProduction); - if (staticRate !== null) { - return staticRate; - } - - // Sample based on user tier if available - const tierRate = getUserTierSamplingRate(attributes, isProduction); - if (tierRate !== null) { - return tierRate; - } - - // Default rates based on environment - return inheritOrSampleWith(isProduction ? 0.1 : 1); - }; -}; - -/** - * Common sampling patterns for different scenarios - */ -export const samplingPatterns = { - // Always inherit parent decision (recommended for distributed tracing) - inherit: (samplingContext: SamplingContext) => { - if (samplingContext.parentSampled !== undefined) { - return samplingContext.parentSampled ? 1 : 0; - } - return samplingContext.inheritOrSampleWith(0.1); - }, - - // High-value transactions only - highValue: (samplingContext: SamplingContext) => { - const { name, attributes } = samplingContext; - - // Critical business flows - if ( - name.includes("checkout") || - name.includes("payment") || - name.includes("auth") - ) { - return 1; - } - - // Premium users - if (attributes?.userTier === "premium") { - return 0.5; - } - - // Everything else - low sampling - return 0.01; - }, - - // Development-friendly sampling - development: (samplingContext: SamplingContext) => { - const { name } = samplingContext; - - // Skip noisy endpoints - if ( - name.includes("health") || - name.includes("metrics") || - name.includes("favicon") - ) { - return 0; - } - - // Sample everything else in development - return 1; - }, -}; - -/** - * Error sampling rate recommendations - */ -export const errorSamplingRates = { - development: 1.0, // Capture all errors in development - staging: 1.0, // Capture all errors in staging - production: 1.0, // Usually keep at 1.0 for errors (use rate limiting instead) -}; diff --git a/src/lib/observability/scopes.ts b/src/lib/observability/scopes.ts deleted file mode 100644 index 47bded2..0000000 --- a/src/lib/observability/scopes.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Scope utilities for managing Sentry context data - */ - -/** - * Set data on global scope (applies to all events) - */ -export const setGlobalData = (data: { - tags?: Record; - extra?: Record; - context?: Record; -}): void => { - try { - const { getGlobalScope } = require("@sentry/nextjs"); - const scope = getGlobalScope(); - - if (data.tags) { - for (const [key, value] of Object.entries(data.tags)) { - scope.setTag(key, value); - } - } - - if (data.extra) { - scope.setExtras(data.extra); - } - - if (data.context) { - for (const [key, value] of Object.entries(data.context)) { - scope.setContext(key, value); - } - } - } catch { - // Sentry not available - } -}; - -/** - * Execute function with isolated scope - */ -export const withIsolatedScope = ( - data: { - user?: { id: string; email?: string; [key: string]: unknown }; - tags?: Record; - extra?: Record; - }, - fn: () => T -): T => { - try { - const { - withIsolationScope, - setUser, - setTag, - setExtras, - } = require("@sentry/nextjs"); - - return withIsolationScope(() => { - if (data.user) { - setUser(data.user); - } - if (data.tags) { - for (const [key, value] of Object.entries(data.tags)) { - setTag(key, value); - } - } - if (data.extra) { - setExtras(data.extra); - } - - return fn(); - }); - } catch { - // Sentry not available, execute function directly - return fn(); - } -}; - -/** - * Execute function with local scope - */ -export const withLocalScope = ( - data: { - level?: "fatal" | "error" | "warning" | "log" | "info" | "debug"; - tags?: Record; - extra?: Record; - context?: Record; - }, - fn: () => T -): T => { - try { - const { withScope } = require("@sentry/nextjs"); - - return withScope( - (scope: { - setTag: (key: string, value: string) => void; - setContext: (key: string, value: unknown) => void; - setUser: (user: unknown) => void; - setExtras: (extras: Record) => void; - setLevel?: (level: string) => void; - }) => { - if (data.level) { - scope.setLevel?.(data.level); - } - if (data.tags) { - for (const [key, value] of Object.entries(data.tags)) { - scope.setTag(key, value); - } - } - if (data.extra) { - scope.setExtras(data.extra); - } - if (data.context) { - for (const [key, value] of Object.entries(data.context)) { - scope.setContext(key, value); - } - } - - return fn(); - } - ); - } catch { - // Sentry not available, execute function directly - return fn(); - } -}; - -/** - * Common scope patterns for Portal - * These accept work callbacks to properly scope the execution - */ -export const scopePatterns = { - /** - * Set user context for request - */ - userContext: ( - user: { id: string; email?: string; tier?: string }, - fn: () => T - ): T => withIsolatedScope({ user }, fn), - - /** - * Set API request context - */ - apiContext: ( - endpoint: string, - method: string, - fn: () => T, - userId?: string - ): T => - withLocalScope( - { - tags: { endpoint, method, ...(userId && { user_id: userId }) }, - context: { api: { endpoint, method } }, - }, - fn - ), - - /** - * Set background job context - */ - jobContext: (jobName: string, jobId: string, fn: () => T): T => - withIsolatedScope( - { - tags: { job_name: jobName, job_id: jobId }, - extra: { job: { name: jobName, id: jobId } }, - }, - fn - ), -}; diff --git a/src/lib/observability/server.ts b/src/lib/observability/server.ts index 2a7f8cd..c513236 100644 --- a/src/lib/observability/server.ts +++ b/src/lib/observability/server.ts @@ -16,8 +16,11 @@ const FAVICON_PATTERN = /^GET \/favicon/; import { consoleLoggingIntegration, + extraErrorDataIntegration, + globalHandlersIntegration, httpIntegration, init, + nodeContextIntegration, zodErrorsIntegration, } from "@sentry/nextjs"; @@ -76,6 +79,16 @@ export const initializeSentry = (): ReturnType => { const isProduction = process.env.NODE_ENV === "production"; const integrations = [ + // Global error handlers - captures unhandled rejections and uncaught exceptions + // This is critical for catching module evaluation errors and other unhandled errors + globalHandlersIntegration({ + onerror: true, // Capture window.onerror (client-side) + onunhandledrejection: true, // Capture unhandled promise rejections + }), + + // Node.js context integration - adds Node.js-specific context to errors + nodeContextIntegration(), + // Send console.log, console.error, and console.warn calls as logs to Sentry consoleLoggingIntegration({ levels: ["log", "error", "warn"] }), @@ -105,17 +118,12 @@ export const initializeSentry = (): ReturnType => { ]; // Add extra error data integration for richer error context - try { - const { extraErrorDataIntegration } = require("@sentry/nextjs"); - integrations.push( - extraErrorDataIntegration({ - depth: 5, // Capture deeper error object properties - captureErrorCause: true, // Capture error.cause chains - }) - ); - } catch { - // Integration not available - } + integrations.push( + extraErrorDataIntegration({ + depth: 5, // Capture deeper error object properties + captureErrorCause: true, // Capture error.cause chains + }) + ); // Add Node profiling integration try { diff --git a/src/lib/observability/span.ts b/src/lib/observability/span.ts deleted file mode 100644 index d821eb8..0000000 --- a/src/lib/observability/span.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Span metrics utilities for consistent performance tracking - */ - -interface SpanAttributes { - [key: string]: string | number | boolean | (string | number | boolean)[]; -} - -/** - * Add metrics to the currently active span - */ -export const addSpanMetrics = (attributes: SpanAttributes): void => { - try { - const { getActiveSpan } = require("@sentry/nextjs"); - const span = getActiveSpan(); - - if (span) { - span.setAttributes(attributes); - } - } catch { - // Sentry not available, ignore - } -}; - -/** - * Create a dedicated span with custom metrics (auto-ending) - */ -export const createMetricSpan = ( - name: string, - op: string, - attributes: SpanAttributes, - fn: () => T -): T => { - try { - const { startSpan } = require("@sentry/nextjs"); - return startSpan({ name, op, attributes }, fn); - } catch { - // Sentry not available, execute function directly - return fn(); - } -}; - -/** - * Create a manual span that must be ended explicitly - */ -export const createManualSpan = ( - name: string, - op: string, - attributes?: SpanAttributes -) => { - try { - const { startInactiveSpan } = require("@sentry/nextjs"); - return startInactiveSpan({ name, op, attributes }); - } catch { - // Return mock span if Sentry not available - return { - setAttribute: () => { - // Mock implementation - }, - setAttributes: () => { - // Mock implementation - }, - setStatus: () => { - // Mock implementation - }, - end: () => { - // Mock implementation - }, - }; - } -}; - -interface Span { - setAttribute: (key: string, value: unknown) => void; - setAttributes: (attributes: Record) => void; - setStatus: (status: { code: number; message: string }) => void; - end: () => void; - updateName?: (name: string) => void; -} - -/** - * Update span name (recommended over span.updateName) - */ -export const updateSpanName = (span: Span, name: string): void => { - try { - const { updateSpanName } = require("@sentry/nextjs"); - updateSpanName(span, name); - } catch { - // Fallback to direct method - span?.updateName?.(name); - } -}; - -/** - * Common span metrics for Portal operations - */ -export const spanMetrics = { - database: ( - operation: string, - table: string, - duration: number, - rows?: number - ) => ({ - "db.operation": operation, - "db.table": table, - "db.duration_ms": duration, - ...(rows !== undefined && { "db.rows_affected": rows }), - }), - - api: ( - endpoint: string, - method: string, - duration: number, - status: number - ) => ({ - "http.endpoint": endpoint, - "http.method": method, - "http.duration_ms": duration, - "http.status_code": status, - }), - - auth: ( - operation: string, - provider: string, - duration: number, - success: boolean - ) => ({ - "auth.operation": operation, - "auth.provider": provider, - "auth.duration_ms": duration, - "auth.success": success, - }), -}; diff --git a/src/lib/observability/trace.ts b/src/lib/observability/trace.ts deleted file mode 100644 index a1a5b94..0000000 --- a/src/lib/observability/trace.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Manual trace propagation utilities for custom scenarios - */ - -/** - * Get trace data for manual propagation (WebSockets, job queues, etc.) - */ -export const getTraceHeaders = (): { - "sentry-trace"?: string; - baggage?: string; -} => { - try { - const { getTraceData } = require("@sentry/nextjs"); - return getTraceData(); - } catch { - return {}; - } -}; - -/** - * Continue a trace from incoming headers - */ -export const continueTrace = ( - headers: { "sentry-trace"?: string; baggage?: string }, - callback: () => T -): T => { - try { - const { continueTrace } = require("@sentry/nextjs"); - return continueTrace( - { - sentryTrace: headers["sentry-trace"], - baggage: headers.baggage, - }, - callback - ); - } catch { - return callback(); - } -}; - -/** - * Start a new isolated trace - */ -export const startNewTrace = (callback: () => T): T => { - try { - const { startNewTrace } = require("@sentry/nextjs"); - return startNewTrace(callback); - } catch { - return callback(); - } -}; diff --git a/src/lib/observability/troubleshooting.ts b/src/lib/observability/troubleshooting.ts deleted file mode 100644 index f82cbea..0000000 --- a/src/lib/observability/troubleshooting.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Troubleshooting utilities for transaction naming and data handling - */ - -// Regex constants for performance -const MULTIPLE_SLASHES_PATTERN = /\/+/g; -const TRAILING_SLASH_PATTERN = /\/$/; - -/** - * Sanitize transaction names by replacing dynamic segments - */ -export const sanitizeTransactionName = (transactionName?: string): string => { - if (!transactionName) { - return "unknown"; - } - - return ( - transactionName - // Replace UUIDs with placeholder (case-insensitive) - .replace( - /\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/gi, - "/" - ) - // Replace hash-like strings (32+ hex chars, case-insensitive) - .replace(/\/[0-9a-fA-F]{32,}/gi, "/") - // Replace numeric IDs - .replace(/\/\d+/g, "/") - // Replace email addresses - .replace(/\/[^/]+@[^/]+\.[^/]+/g, "/") - // Replace base64 tokens (40+ chars with = padding) - .replace(/\/[A-Za-z0-9+/=]{40,}/g, "/") - // Replace hex tokens (32+ hex chars, case-insensitive) - .replace(/\/[0-9a-fA-F]{32,}/gi, "/") - // Clean up multiple slashes - .replace(MULTIPLE_SLASHES_PATTERN, "/") - // Remove trailing slash - .replace(TRAILING_SLASH_PATTERN, "") - ); -}; - -interface SpanWithAttributes { - setAttribute?: (key: string, value: string) => void; -} - -/** - * Split long data into multiple attributes to avoid truncation - */ -export const setLongAttribute = ( - span: SpanWithAttributes, - key: string, - value: string, - maxLength = 200 -): void => { - if (!span?.setAttribute) { - return; - } - - if (value.length <= maxLength) { - span.setAttribute(key, value); - return; - } - - // Split into chunks - const chunks: string[] = []; - for (let i = 0; i < value.length; i += maxLength) { - chunks.push(value.slice(i, i + maxLength)); - } - - // Set chunked attributes (setAttribute already verified by guard clause) - chunks.forEach((chunk, index) => { - if (span.setAttribute) { - span.setAttribute(`${key}.${index}`, chunk); - } - }); - - // Set metadata about the chunking - span.setAttribute(`${key}._chunks`, chunks.length.toString()); - span.setAttribute(`${key}._total_length`, value.length.toString()); -}; - -/** - * Set URL attributes properly to avoid truncation - */ -export const setUrlAttributes = ( - span: SpanWithAttributes, - url: string -): void => { - if (!span?.setAttribute) { - return; - } - - try { - const parsedUrl = new URL(url); - - span.setAttribute("http.url.base", parsedUrl.origin); - span.setAttribute("http.url.path", parsedUrl.pathname); - - if (parsedUrl.search) { - // Split query parameters to avoid truncation (setAttribute already verified by guard clause) - const params = new URLSearchParams(parsedUrl.search); - params.forEach((value, key) => { - if (span.setAttribute) { - span.setAttribute(`http.url.query.${key}`, value); - } - }); - } - - if (parsedUrl.hash) { - span.setAttribute("http.url.fragment", parsedUrl.hash); - } - } catch { - // Fallback to setting the full URL with chunking - setLongAttribute(span, "http.url", url); - } -}; - -/** - * Initialize transaction name sanitization - */ -export const initializeTransactionSanitization = (): void => { - try { - const { addEventProcessor } = require("@sentry/nextjs"); - - addEventProcessor((event: { transaction?: string; type?: string }) => { - if (event.type === "transaction" && event.transaction) { - event.transaction = sanitizeTransactionName(event.transaction); - } - return event; - }); - } catch { - // Sentry not available - } -}; diff --git a/src/lib/observability/utils.ts b/src/lib/observability/utils.ts new file mode 100644 index 0000000..8cd4939 --- /dev/null +++ b/src/lib/observability/utils.ts @@ -0,0 +1,119 @@ +/** + * Observability utilities for error handling and logging + */ + +import { captureException } from "@sentry/nextjs"; + +/** + * Parse an error into a human-readable string message. + * Pure function with no side effects. + */ +export const parseError = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) { + return error.message; + } + + return String(error); +}; + +/** + * Capture an error to Sentry with optional context. + * This function has side effects (captures to Sentry, logs). + */ +export const captureError = ( + error: unknown, + context?: { + tags?: Record; + extra?: Record; + } +): void => { + try { + captureException(error, context); + const message = parseError(error); + log.error(`Error captured: ${message}`); + } catch (sentryError) { + // Fallback error logging when Sentry fails + // eslint-disable-next-line no-console + console.error("Failed to capture error to Sentry:", sentryError); + // eslint-disable-next-line no-console + console.error("Original error:", parseError(error)); + } +}; + +const getSentryLogger = () => { + try { + const Sentry = require("@sentry/nextjs"); + return Sentry.logger; + } catch { + return null; + } +}; + +const sentryLogger = getSentryLogger(); + +interface LogAttributes { + [key: string]: unknown; +} + +/** + * Structured logging using Sentry's logger + * Provides consistent, queryable logs across all environments + */ +export const log = { + trace: (message: string, attributes?: LogAttributes) => { + if (sentryLogger) { + sentryLogger.trace(message, attributes); + } else { + console.log("[TRACE]", message, attributes); + } + }, + + debug: (message: string, attributes?: LogAttributes) => { + if (sentryLogger) { + sentryLogger.debug(message, attributes); + } else if (process.env.NODE_ENV === "development") { + console.log("[DEBUG]", message, attributes); + } + }, + + info: (message: string, attributes?: LogAttributes) => { + if (sentryLogger) { + sentryLogger.info(message, attributes); + } else { + console.info("[INFO]", message, attributes); + } + }, + + warn: (message: string, attributes?: LogAttributes) => { + if (sentryLogger) { + sentryLogger.warn(message, attributes); + } else { + console.warn("[WARN]", message, attributes); + } + }, + + error: (message: string, attributes?: LogAttributes) => { + if (sentryLogger) { + sentryLogger.error(message, attributes); + } else { + console.error("[ERROR]", message, attributes); + } + }, + + fatal: (message: string, attributes?: LogAttributes) => { + if (sentryLogger) { + sentryLogger.fatal(message, attributes); + } else { + console.error("[FATAL]", message, attributes); + } + }, +}; diff --git a/src/lib/routes/config.ts b/src/lib/routes/config.ts index 5e73aa2..ffd23a1 100644 --- a/src/lib/routes/config.ts +++ b/src/lib/routes/config.ts @@ -3,6 +3,7 @@ import { Bot, HelpCircle, LogOut, + MessageSquare, Settings2, Shield, SquareTerminal, @@ -146,6 +147,18 @@ export const routeConfig = { order: 4, }, }, + { + id: "xmpp", + path: "/app/xmpp", + icon: MessageSquare, + metadata: { + robots: { index: false, follow: false }, + }, + navigation: { + group: "platform", + order: 5, + }, + }, { id: "admin", path: "/app/admin", @@ -155,7 +168,7 @@ export const routeConfig = { }, navigation: { group: "platform", - order: 5, + order: 6, permissions: ["canViewAdmin"], // Role-based access }, }, diff --git a/src/lib/routes/i18n.ts b/src/lib/routes/i18n.ts index a928238..6802656 100644 --- a/src/lib/routes/i18n.ts +++ b/src/lib/routes/i18n.ts @@ -1,3 +1,5 @@ +import { captureException } from "@sentry/nextjs"; + import type { RouteConfig } from "./types"; /** @@ -244,8 +246,33 @@ export function createRouteTranslationResolver( if (translated && translated !== translationKey) { return translated; } - } catch { - // Translation not found, return undefined to fall back to original + } catch (error) { + // Translation not found - capture to Sentry for monitoring + // Only capture if it's a missing message error (not other translation errors) + if ( + error instanceof Error && + (error.message.includes("MISSING_MESSAGE") || + error.message.includes("Could not resolve")) + ) { + captureException(error, { + tags: { + type: "missing_translation", + routeId, + translationKey, + }, + level: "warning", // Missing translations are warnings, not errors + }); + } else { + // For other errors, still capture but as error level + captureException(error, { + tags: { + type: "translation_error", + routeId, + translationKey, + }, + }); + } + // Return undefined to fall back to original } return undefined; diff --git a/src/lib/xmpp/client.ts b/src/lib/xmpp/client.ts new file mode 100644 index 0000000..2fcdc10 --- /dev/null +++ b/src/lib/xmpp/client.ts @@ -0,0 +1,210 @@ +import "server-only"; + +import { xmppConfig } from "./config"; +import type { ProsodyRestAccountResponse, ProsodyRestError } from "./types"; +import { formatJid } from "./utils"; + +/** + * Custom error for Prosody account not found + */ +export class ProsodyAccountNotFoundError extends Error { + constructor(message = "Prosody account not found") { + super(message); + this.name = "ProsodyAccountNotFoundError"; + } +} + +/** + * Escape XML special characters for defense-in-depth + * Username is already validated, but this prevents XML injection if validation fails + */ +function escapeXml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// ============================================================================ +// Prosody REST API Client +// ============================================================================ +// Client for interacting with Prosody's mod_rest API +// Uses HTTP Basic authentication with admin JID and component secret +// +// Documentation: https://modules.prosody.im/mod_rest.html + +/** + * Create Basic Auth header for Prosody REST API + */ +function createAuthHeader(): string { + const { username, password } = xmppConfig.prosody; + const credentials = Buffer.from(`${username}:${password}`).toString("base64"); + return `Basic ${credentials}`; +} + +/** + * Make a request to Prosody REST API + */ +async function prosodyRequest( + endpoint: string, + options: RequestInit = {} +): Promise { + const { restUrl } = xmppConfig.prosody; + const url = `${restUrl}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/xml", + Authorization: createAuthHeader(), + ...options.headers, + }, + }); + + if (!response.ok) { + let errorMessage = `Prosody REST API error: ${response.status} ${response.statusText}`; + try { + const errorData = (await response.json()) as ProsodyRestError; + errorMessage = errorData.error || errorData.message || errorMessage; + } catch { + // If response is not JSON, use status text + } + throw new Error(errorMessage); + } + + // mod_rest returns XML or JSON depending on the request + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + return (await response.json()) as T; + } + + // For XML responses, return the text + const text = await response.text(); + return text as unknown as T; +} + +/** + * Create XMPP account in Prosody + * Note: No password is set - authentication is handled via OAuth + * + * @param username - XMPP localpart (username) + * @returns Success response + */ +export async function createProsodyAccount( + username: string +): Promise { + const jid = formatJid(username, xmppConfig.domain); + + // mod_rest expects XMPP stanzas in XML format + // Create account stanza: + // + // username + // + // + const stanza = ` + + + ${escapeXml(username)} + +`; + + try { + const result = await prosodyRequest( + "/accounts", + { + method: "POST", + body: stanza, + } + ); + + return result; + } catch (error) { + if (error instanceof Error) { + // Check if account already exists + if ( + error.message.includes("exists") || + error.message.includes("409") || + error.message.includes("conflict") + ) { + throw new Error(`XMPP account already exists: ${jid}`); + } + throw error; + } + throw new Error("Failed to create Prosody account"); + } +} + +/** + * Delete XMPP account from Prosody + * + * @param username - XMPP localpart (username) + * @returns Success response + */ +export async function deleteProsodyAccount( + username: string +): Promise { + // Delete account stanza: + // + // + // + // + const stanza = ` + + + + +`; + + try { + const result = await prosodyRequest( + `/accounts/${encodeURIComponent(username)}`, + { + method: "DELETE", + body: stanza, + } + ); + + return result; + } catch (error) { + if (error instanceof Error) { + // Check if account doesn't exist + if ( + error.message.includes("not found") || + error.message.includes("404") + ) { + // Account doesn't exist, but that's okay for deletion + throw new ProsodyAccountNotFoundError(); + } + throw error; + } + throw new Error("Failed to delete Prosody account"); + } +} + +/** + * Check if XMPP account exists in Prosody + * + * @param username - XMPP localpart (username) + * @returns true if account exists, false otherwise + */ +export async function checkProsodyAccountExists( + username: string +): Promise { + try { + await prosodyRequest(`/accounts/${encodeURIComponent(username)}`, { + method: "GET", + }); + return true; + } catch (error) { + if ( + error instanceof Error && + (error.message.includes("not found") || error.message.includes("404")) + ) { + return false; + } + // Re-throw other errors + throw error; + } +} diff --git a/src/lib/xmpp/config.ts b/src/lib/xmpp/config.ts new file mode 100644 index 0000000..bc03b82 --- /dev/null +++ b/src/lib/xmpp/config.ts @@ -0,0 +1,89 @@ +import "server-only"; + +import { captureException } from "@sentry/nextjs"; + +import { keys } from "./keys"; + +// ============================================================================ +// XMPP Configuration +// ============================================================================ +// Configuration for Prosody XMPP server integration +// Uses validated environment variables via keys.ts + +const env = keys(); + +export const xmppConfig = { + // XMPP domain (e.g., "xmpp.atl.chat") + domain: env.XMPP_DOMAIN || "xmpp.atl.chat", + + // Prosody REST API configuration + prosody: { + // REST API endpoint URL + // Use internal Docker network URL for same-network communication + // or public URL if Prosody is on a different network + restUrl: env.PROSODY_REST_URL, + + // Username for REST API authentication (admin JID) + // Format: "admin@xmpp.atl.chat" or from PROSODY_ADMIN_JID + username: + env.PROSODY_REST_USERNAME || + env.PROSODY_ADMIN_JID || + "admin@xmpp.atl.chat", + + // Password/secret for REST API authentication (component secret) + // Same as PROSODY_REST_SECRET in Prosody configuration + password: env.PROSODY_REST_PASSWORD || env.PROSODY_REST_SECRET, + }, +} as const; + +/** + * Validate XMPP configuration + * Called at module load time to catch configuration errors early + */ +function validateXmppConfig(): void { + if (!xmppConfig.prosody.password) { + const error = new Error( + "PROSODY_REST_PASSWORD or PROSODY_REST_SECRET environment variable is required" + ); + // Capture to Sentry before throwing (if Sentry is initialized) + try { + captureException(error, { + tags: { + type: "configuration_error", + module: "xmpp_config", + missing_var: "PROSODY_REST_PASSWORD or PROSODY_REST_SECRET", + }, + level: "error", + }); + } catch { + // Sentry might not be initialized yet, continue to throw + } + throw error; + } + + if (!xmppConfig.prosody.restUrl) { + const error = new Error( + "PROSODY_REST_URL environment variable is required" + ); + // Capture to Sentry before throwing (if Sentry is initialized) + try { + captureException(error, { + tags: { + type: "configuration_error", + module: "xmpp_config", + missing_var: "PROSODY_REST_URL", + }, + level: "error", + }); + } catch { + // Sentry might not be initialized yet, continue to throw + } + throw error; + } +} + +// Validate configuration at module load time +// Errors are captured to Sentry before throwing so they're logged even if +// they occur during module evaluation (before request handling) +// Next.js will catch this in onRequestError if it occurs during request handling +validateXmppConfig(); diff --git a/src/lib/xmpp/keys.ts b/src/lib/xmpp/keys.ts new file mode 100644 index 0000000..2acf13d --- /dev/null +++ b/src/lib/xmpp/keys.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { createEnv } from "@t3-oss/env-nextjs"; + +/** + * Get validated XMPP environment variables + * Uses t3-env for runtime validation and type safety + * @returns Validated environment configuration for XMPP/Prosody integration + */ +export const keys = () => + createEnv({ + server: { + XMPP_DOMAIN: z.string().optional(), + PROSODY_REST_URL: z.url().optional(), + PROSODY_REST_USERNAME: z.string().optional(), + PROSODY_ADMIN_JID: z.string().optional(), + PROSODY_REST_PASSWORD: z.string().optional(), + PROSODY_REST_SECRET: z.string().optional(), + }, + runtimeEnv: { + XMPP_DOMAIN: process.env.XMPP_DOMAIN, + PROSODY_REST_URL: process.env.PROSODY_REST_URL, + PROSODY_REST_USERNAME: process.env.PROSODY_REST_USERNAME, + PROSODY_ADMIN_JID: process.env.PROSODY_ADMIN_JID, + PROSODY_REST_PASSWORD: process.env.PROSODY_REST_PASSWORD, + PROSODY_REST_SECRET: process.env.PROSODY_REST_SECRET, + }, + }); diff --git a/src/lib/xmpp/types.ts b/src/lib/xmpp/types.ts new file mode 100644 index 0000000..12628e0 --- /dev/null +++ b/src/lib/xmpp/types.ts @@ -0,0 +1,56 @@ +// ============================================================================ +// XMPP Types +// ============================================================================ +// TypeScript types for XMPP account management and Prosody REST API + +/** + * XMPP account status + */ +export type XmppAccountStatus = "active" | "suspended" | "deleted"; + +/** + * XMPP account information + */ +export interface XmppAccount { + id: string; + userId: string; + jid: string; // Full JID: username@xmpp.atl.chat + username: string; // XMPP localpart (username) + status: XmppAccountStatus; + createdAt: Date; + updatedAt: Date; + metadata?: Record; +} + +/** + * Create XMPP account request + */ +export interface CreateXmppAccountRequest { + username?: string; // Optional, defaults to email localpart +} + +/** + * Update XMPP account request + */ +export interface UpdateXmppAccountRequest { + username?: string; // Optional, must be unique + status?: XmppAccountStatus; // Optional: "active" | "suspended" | "deleted" + metadata?: Record; // Optional JSONB +} + +/** + * Prosody REST API error response + */ +export interface ProsodyRestError { + error: string; + message?: string; +} + +/** + * Prosody REST API account creation response + */ +export interface ProsodyRestAccountResponse { + success?: boolean; + error?: string; + message?: string; +} diff --git a/src/lib/xmpp/utils.ts b/src/lib/xmpp/utils.ts new file mode 100644 index 0000000..af596d1 --- /dev/null +++ b/src/lib/xmpp/utils.ts @@ -0,0 +1,121 @@ +// ============================================================================ +// XMPP Utilities +// ============================================================================ +// Utility functions for XMPP account management + +import type { XmppAccountStatus } from "./types"; + +/** + * XMPP localpart (username) validation rules: + * - Alphanumeric characters (a-z, A-Z, 0-9) + * - Underscore (_) + * - Hyphen (-) + * - Dot (.) + * - Must start with a letter or number + * - Length: 1-1023 characters (XMPP spec allows up to 1023, but we'll use a reasonable limit) + */ +const XMPP_USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/; +const XMPP_USERNAME_MIN_LENGTH = 1; +const XMPP_USERNAME_MAX_LENGTH = 63; // Reasonable limit for usernames +const XMPP_USERNAME_SANITIZE_REGEX = /[^a-zA-Z0-9._-]/g; +const XMPP_USERNAME_START_REGEX = /^[^a-zA-Z0-9]+/; // Remove ALL leading non-alphanumeric chars + +/** + * Validate XMPP username (localpart) + * @param username - The username to validate + * @returns true if valid, false otherwise + */ +export function isValidXmppUsername(username: string): boolean { + if (!username || typeof username !== "string") { + return false; + } + + if ( + username.length < XMPP_USERNAME_MIN_LENGTH || + username.length > XMPP_USERNAME_MAX_LENGTH + ) { + return false; + } + + return XMPP_USERNAME_REGEX.test(username); +} + +/** + * Generate XMPP username from email address + * Extracts the localpart (part before @) from an email + * @param email - Email address (e.g., "user@example.com") + * @returns Username derived from email localpart + */ +export function generateUsernameFromEmail(email: string): string { + if (!email || typeof email !== "string") { + throw new Error("Invalid email address"); + } + + const localpart = email.split("@")[0]?.toLowerCase() || ""; + + // Sanitize: remove invalid characters, keep only valid XMPP characters + const sanitized = localpart + .replace(XMPP_USERNAME_SANITIZE_REGEX, "") + .replace(XMPP_USERNAME_START_REGEX, "") // Ensure starts with alphanumeric + .slice(0, XMPP_USERNAME_MAX_LENGTH); + + if (!sanitized || sanitized.length < XMPP_USERNAME_MIN_LENGTH) { + throw new Error(`Cannot generate valid XMPP username from email: ${email}`); + } + + return sanitized; +} + +/** + * Format JID from username and domain + * @param username - XMPP localpart (username) + * @param domain - XMPP domain (e.g., "xmpp.atl.chat") + * @returns Full JID (e.g., "username@xmpp.atl.chat") + */ +export function formatJid(username: string, domain: string): string { + if (!isValidXmppUsername(username)) { + throw new Error(`Invalid XMPP username: ${username}`); + } + + if (!domain || typeof domain !== "string") { + throw new Error("Invalid XMPP domain"); + } + + return `${username}@${domain}`; +} + +/** + * Parse JID into username and domain + * @param jid - Full JID (e.g., "username@xmpp.atl.chat") + * @returns Object with username and domain + */ +export function parseJid(jid: string): { username: string; domain: string } { + if (!jid || typeof jid !== "string") { + throw new Error("Invalid JID"); + } + + const parts = jid.split("@"); + if (parts.length !== 2) { + throw new Error(`Invalid JID format: ${jid}`); + } + + const [username, domain] = parts; + if (!(username && domain)) { + throw new Error(`Invalid JID format: ${jid}`); + } + + return { username, domain }; +} + +/** + * Validate XMPP account status + * @param status - Status to validate + * @returns true if valid, false otherwise + */ +export function isValidXmppAccountStatus( + status: string +): status is XmppAccountStatus { + return (["active", "suspended", "deleted"] as const).includes( + status as XmppAccountStatus + ); +}