diff --git a/AGENTS.md b/AGENTS.md index 634cc81..4b2e215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ > Scope: Root project (applies to all subdirectories unless overridden) -Centralized identity and hub management system for the AllThingsLinux (ATL) community. One ATL identity provisions access to all services: email, IRC, XMPP, SSH, web hosting, Discord, wiki access, and developer tools across `atl.dev`, `atl.sh`, `atl.tools`, and `atl.chat`. +Centralized identity and hub management system for the AllThingsLinux (ATL) community. One ATL identity provisions access to all services: email, IRC, XMPP, SSH, web hosting, Discord, wiki access, and developer tools across `atl.dev`, `atl.sh`, `[REDACTED]`, and `atl.chat`. ## Quick Facts @@ -131,6 +131,36 @@ Each module has its own `keys.ts` file using `@t3-oss/env-nextjs`. The central ` - [Code Standards](.agents/code-standards.md) — Rules beyond what Biome enforces - [Project Skills](.agents/skills.md) — Available agent skills index +## Cursor Cloud specific instructions + +### Services + +| Service | How to start | Port | Required? | +|---------|-------------|------|-----------| +| PostgreSQL 18 | `docker compose up -d portal-db` | 5432 | Yes | +| Next.js dev server | `pnpm dev` | 3000 | Yes | + +### Environment setup + +A `.env` file at the project root is required with at minimum `DATABASE_URL`, `BETTER_AUTH_SECRET`, and `BETTER_AUTH_URL`. See the README "Database Setup" and "Getting Started" sections for connection details and defaults. + +All integration env vars (Discord, IRC, XMPP, Mailcow, Sentry) are optional — the app starts without them. + +### Startup sequence + +1. Ensure Docker daemon is running (`sudo dockerd` if not already started) +2. `docker compose up -d portal-db` — start PostgreSQL +3. Wait for healthy: `docker compose ps` should show `(healthy)` +4. `pnpm db:push` — sync schema to dev DB (safe for dev; use `pnpm db:migrate` for prod-like flow) +5. `pnpm dev` — start the dev server on port 3000 + +### Gotchas + +- **`pnpm typegen` before `pnpm type-check`**: Next.js 16 generates `RouteContext` types via `next typegen`. Running `tsc --noEmit` without it first produces `TS2304: Cannot find name 'RouteContext'` errors. The `pnpm build` script runs typegen automatically, but `pnpm type-check` does not. +- **Docker-in-Docker**: The Cloud VM runs inside a container. Docker requires `fuse-overlayfs` storage driver, `iptables-legacy`, and the daemon started via `sudo dockerd`. See the environment snapshot for pre-installed Docker. +- **`pg-native` build script**: The `libpq` native build is not in `onlyBuiltDependencies`. The app falls back to the pure-JS `pg` driver, which works fine for development. +- **Pre-existing test failures**: 2 test files (`tests/app/api/admin/irc-accounts/route.test.ts` and `tests/app/api/bridge/identity/route.test.ts`) fail due to `vi.mock` hoisting issues with t3-env server-side variable access. These are not environment issues — all 118 individual tests pass. + ## Finish the Task - [ ] Run `pnpm fix` before committing. diff --git a/src/app/api/admin/irc-accounts/route.ts b/src/app/api/admin/irc-accounts/route.ts index 633b187..d2d116b 100644 --- a/src/app/api/admin/irc-accounts/route.ts +++ b/src/app/api/admin/irc-accounts/route.ts @@ -43,26 +43,24 @@ export async function GET(request: NextRequest) { } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - const rows = await db - .select({ - ircAccount, - user: { - id: user.id, - email: user.email, - name: user.name, - }, - }) - .from(ircAccount) - .leftJoin(user, eq(ircAccount.userId, user.id)) - .where(whereClause) - .orderBy(desc(ircAccount.createdAt)) - .limit(limit) - .offset(offset); - - const [totalResult] = await db - .select({ count: count() }) - .from(ircAccount) - .where(whereClause); + const [rows, [totalResult]] = await Promise.all([ + db + .select({ + ircAccount, + user: { + id: user.id, + email: user.email, + name: user.name, + }, + }) + .from(ircAccount) + .leftJoin(user, eq(ircAccount.userId, user.id)) + .where(whereClause) + .orderBy(desc(ircAccount.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(ircAccount).where(whereClause), + ]); const total = Number(totalResult?.count ?? 0); diff --git a/src/app/api/admin/mailcow-accounts/route.ts b/src/app/api/admin/mailcow-accounts/route.ts index 8ea4a0d..c52fe3c 100644 --- a/src/app/api/admin/mailcow-accounts/route.ts +++ b/src/app/api/admin/mailcow-accounts/route.ts @@ -36,26 +36,24 @@ export async function GET(request: NextRequest) { } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - const rows = await db - .select({ - mailcowAccount, - user: { - id: user.id, - email: user.email, - name: user.name, - }, - }) - .from(mailcowAccount) - .leftJoin(user, eq(mailcowAccount.userId, user.id)) - .where(whereClause) - .orderBy(desc(mailcowAccount.createdAt)) - .limit(limit) - .offset(offset); - - const [totalResult] = await db - .select({ count: count() }) - .from(mailcowAccount) - .where(whereClause); + const [rows, [totalResult]] = await Promise.all([ + db + .select({ + mailcowAccount, + user: { + id: user.id, + email: user.email, + name: user.name, + }, + }) + .from(mailcowAccount) + .leftJoin(user, eq(mailcowAccount.userId, user.id)) + .where(whereClause) + .orderBy(desc(mailcowAccount.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(mailcowAccount).where(whereClause), + ]); const total = Number(totalResult?.count ?? 0); diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts index 88b104b..7d1e8ca 100644 --- a/src/app/api/admin/stats/route.ts +++ b/src/app/api/admin/stats/route.ts @@ -13,39 +13,35 @@ export async function GET(request: NextRequest) { try { await requireAdminOrStaff(request); - // Get user stats - const [userStats] = await db - .select({ - total: count(user.id), - admins: sql`COUNT(*) FILTER (WHERE ${user.role} = 'admin')`, - staff: sql`COUNT(*) FILTER (WHERE ${user.role} = 'staff')`, - banned: sql`COUNT(*) FILTER (WHERE ${user.banned} = true)`, - }) - .from(user); - - // Get active sessions count - const [sessionStats] = await db - .select({ - total: count(session.id), - active: sql`COUNT(*) FILTER (WHERE ${session.expiresAt} > NOW())`, - }) - .from(session); - - // Get API keys count - const [apiKeyStats] = await db - .select({ - total: count(apikey.id), - enabled: sql`COUNT(*) FILTER (WHERE ${apikey.enabled} = true)`, - }) - .from(apikey); - - // Get OAuth clients count - const [oauthClientStats] = await db - .select({ - total: count(oauthClient.id), - disabled: sql`COUNT(*) FILTER (WHERE ${oauthClient.disabled} = true)`, - }) - .from(oauthClient); + const [[userStats], [sessionStats], [apiKeyStats], [oauthClientStats]] = + await Promise.all([ + db + .select({ + total: count(user.id), + admins: sql`COUNT(*) FILTER (WHERE ${user.role} = 'admin')`, + staff: sql`COUNT(*) FILTER (WHERE ${user.role} = 'staff')`, + banned: sql`COUNT(*) FILTER (WHERE ${user.banned} = true)`, + }) + .from(user), + db + .select({ + total: count(session.id), + active: sql`COUNT(*) FILTER (WHERE ${session.expiresAt} > NOW())`, + }) + .from(session), + db + .select({ + total: count(apikey.id), + enabled: sql`COUNT(*) FILTER (WHERE ${apikey.enabled} = true)`, + }) + .from(apikey), + db + .select({ + total: count(oauthClient.id), + disabled: sql`COUNT(*) FILTER (WHERE ${oauthClient.disabled} = true)`, + }) + .from(oauthClient), + ]); return Response.json({ users: { diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 36099a3..eba2dc7 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -37,18 +37,19 @@ export async function GET(request: NextRequest) { const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - const users = await db - .select() - .from(user) - .where(whereClause) - .orderBy(desc(user.createdAt)) - .limit(limit) - .offset(offset); - - const [{ count }] = await db - .select({ count: db.$count(user, whereClause) }) - .from(user) - .limit(1); + const [users, [{ count }]] = await Promise.all([ + db + .select() + .from(user) + .where(whereClause) + .orderBy(desc(user.createdAt)) + .limit(limit) + .offset(offset), + db + .select({ count: db.$count(user, whereClause) }) + .from(user) + .limit(1), + ]); return Response.json({ users, diff --git a/src/app/api/admin/xmpp-accounts/route.ts b/src/app/api/admin/xmpp-accounts/route.ts index 3dba3fa..3f9ac59 100644 --- a/src/app/api/admin/xmpp-accounts/route.ts +++ b/src/app/api/admin/xmpp-accounts/route.ts @@ -38,26 +38,24 @@ export async function GET(request: NextRequest) { } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - const rows = await db - .select({ - xmppAccount, - user: { - id: user.id, - email: user.email, - name: user.name, - }, - }) - .from(xmppAccount) - .leftJoin(user, eq(xmppAccount.userId, user.id)) - .where(whereClause) - .orderBy(desc(xmppAccount.createdAt)) - .limit(limit) - .offset(offset); - - const [totalResult] = await db - .select({ count: count() }) - .from(xmppAccount) - .where(whereClause); + const [rows, [totalResult]] = await Promise.all([ + db + .select({ + xmppAccount, + user: { + id: user.id, + email: user.email, + name: user.name, + }, + }) + .from(xmppAccount) + .leftJoin(user, eq(xmppAccount.userId, user.id)) + .where(whereClause) + .orderBy(desc(xmppAccount.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(xmppAccount).where(whereClause), + ]); const total = Number(totalResult?.count ?? 0); diff --git a/src/features/integrations/lib/irc/atheme/client.ts b/src/features/integrations/lib/irc/atheme/client.ts index 3a928e3..e7408b5 100644 --- a/src/features/integrations/lib/irc/atheme/client.ts +++ b/src/features/integrations/lib/irc/atheme/client.ts @@ -3,33 +3,33 @@ import "server-only"; import { ircConfig } from "../config"; import type { AnyAthemeFaultCode, AthemeFault } from "../types"; -/** JSON-RPC 2.0 request */ +/** JSON-RPC 2.0 request — Atheme requires `id` as a string (not number) */ interface JsonRpcRequest { jsonrpc: "2.0"; method: string; params: string[]; - id: number; + id: string; } /** JSON-RPC 2.0 success response — result is always a string for atheme.command */ interface JsonRpcSuccess { jsonrpc: "2.0"; result: string; - id: number; + id: string; } /** JSON-RPC 2.0 success response with object result (atheme.ison) */ interface JsonRpcObjectSuccess { jsonrpc: "2.0"; result: T; - id: number; + id: string; } /** JSON-RPC 2.0 error response (Atheme fault) */ interface JsonRpcError { jsonrpc: "2.0"; error: { code: number; message: string }; - id: number; + id: string; } /** @@ -60,7 +60,7 @@ async function athemeRpc( jsonrpc: "2.0", method, params, - id: 1, + id: "1", }; const controller = new AbortController(); @@ -113,7 +113,8 @@ async function athemeRpc( } /** - * Call atheme.command (unauthenticated — cookie ".", account ""). + * Call atheme.command (unauthenticated — cookie ".", account "."). + * Atheme's JSONRPC rejects empty-string params, so use "." as placeholder. * Params: authcookie, account, sourceip, service, command, ...commandParams */ function athemeCommand(params: string[]): Promise { @@ -136,7 +137,7 @@ export async function registerNick( ): Promise { await athemeCommand([ ".", - "", + ".", sourceIp, "NickServ", "REGISTER", @@ -158,7 +159,7 @@ export async function dropNick( ): Promise { await athemeCommand([ ".", - "", + ".", sourceIp, "NickServ", "DROP", diff --git a/src/features/integrations/lib/xmpp/client.ts b/src/features/integrations/lib/xmpp/client.ts index c0dc03d..c6823c6 100644 --- a/src/features/integrations/lib/xmpp/client.ts +++ b/src/features/integrations/lib/xmpp/client.ts @@ -27,12 +27,17 @@ export class ProsodyAccountNotFoundError extends Error { // Body: { "password": "..." } /** - * Create Basic Auth header for Prosody REST API + * Create Authorization header for Prosody REST API. + * mod_http_admin_api (Prosody 13+) requires Bearer token auth via mod_tokenauth. */ function createAuthHeader(): string { - const { username, password } = xmppConfig.prosody; - const credentials = Buffer.from(`${username}:${password}`).toString("base64"); - return `Basic ${credentials}`; + const { token } = xmppConfig.prosody; + if (!token) { + throw new Error( + "PROSODY_REST_TOKEN is required for mod_http_admin_api Bearer auth" + ); + } + return `Bearer ${token}`; } /** diff --git a/src/features/integrations/lib/xmpp/config.ts b/src/features/integrations/lib/xmpp/config.ts index fd935fe..9b096ac 100644 --- a/src/features/integrations/lib/xmpp/config.ts +++ b/src/features/integrations/lib/xmpp/config.ts @@ -22,10 +22,13 @@ export const xmppConfig = { // Use internal Docker network URL for same-network communication restUrl: env.PROSODY_REST_URL, - // Username for REST API authentication (admin JID, e.g. admin@atl.chat) - username: env.PROSODY_REST_USERNAME || "admin@atl.chat", + // Bearer token for mod_http_admin_api (mod_tokenauth in Prosody 13+) + // Generate via prosodyctl or OAuth2 client credentials grant + token: env.PROSODY_REST_TOKEN, - // Password for REST API authentication + // Legacy: username/password kept for backward-compat but unused by + // mod_http_admin_api in Prosody 13+ (it requires Bearer tokens) + username: env.PROSODY_REST_USERNAME || "admin@atl.chat", password: env.PROSODY_REST_PASSWORD, }, } as const; @@ -36,17 +39,16 @@ export const xmppConfig = { * This prevents blocking the entire application if XMPP is not configured */ export function validateXmppConfig(): void { - if (!xmppConfig.prosody.password) { + if (!xmppConfig.prosody.token) { const error = new Error( - "PROSODY_REST_PASSWORD environment variable is required" + "PROSODY_REST_TOKEN environment variable is required (Bearer token for mod_http_admin_api)" ); - // Capture to Sentry before throwing (if Sentry is initialized) try { captureException(error, { tags: { type: "configuration_error", module: "xmpp_config", - missing_var: "PROSODY_REST_PASSWORD", + missing_var: "PROSODY_REST_TOKEN", }, level: "error", }); @@ -60,7 +62,6 @@ export function validateXmppConfig(): void { const error = new Error( "PROSODY_REST_URL environment variable is required" ); - // Capture to Sentry before throwing (if Sentry is initialized) try { captureException(error, { tags: { @@ -81,5 +82,5 @@ export function validateXmppConfig(): void { * Check if XMPP is configured (non-throwing) */ export function isXmppConfigured(): boolean { - return !!(xmppConfig.prosody.password && xmppConfig.prosody.restUrl); + return !!(xmppConfig.prosody.token && xmppConfig.prosody.restUrl); } diff --git a/src/features/integrations/lib/xmpp/keys.ts b/src/features/integrations/lib/xmpp/keys.ts index 8be2ff2..73895b5 100644 --- a/src/features/integrations/lib/xmpp/keys.ts +++ b/src/features/integrations/lib/xmpp/keys.ts @@ -4,6 +4,12 @@ import { createEnv } from "@t3-oss/env-nextjs"; /** * Get validated XMPP environment variables * Uses t3-env for runtime validation and type safety + * + * Auth: Prosody's mod_http_admin_api requires Bearer token auth (mod_tokenauth). + * Set PROSODY_REST_TOKEN to a token generated via prosodyctl or OAuth2. + * Legacy PROSODY_REST_USERNAME/PASSWORD are kept for backward-compat but unused + * by mod_http_admin_api in Prosody 13+. + * * @returns Validated environment configuration for XMPP/Prosody integration */ export const keys = () => @@ -11,12 +17,14 @@ export const keys = () => server: { XMPP_DOMAIN: z.string().optional(), PROSODY_REST_URL: z.url().optional(), + PROSODY_REST_TOKEN: z.string().optional(), PROSODY_REST_USERNAME: z.string().optional(), PROSODY_REST_PASSWORD: z.string().optional(), }, runtimeEnv: { XMPP_DOMAIN: process.env.XMPP_DOMAIN, PROSODY_REST_URL: process.env.PROSODY_REST_URL, + PROSODY_REST_TOKEN: process.env.PROSODY_REST_TOKEN, PROSODY_REST_USERNAME: process.env.PROSODY_REST_USERNAME, PROSODY_REST_PASSWORD: process.env.PROSODY_REST_PASSWORD, }, diff --git a/src/features/routing/lib/permissions.ts b/src/features/routing/lib/permissions.ts index 8b49725..2e2a2e5 100644 --- a/src/features/routing/lib/permissions.ts +++ b/src/features/routing/lib/permissions.ts @@ -73,11 +73,11 @@ export function getNavigationItems( // Group routes by navigation group return resolvedConfig.navigationGroups - .sort((a, b) => a.order - b.order) + .toSorted((a, b) => a.order - b.order) .map((group) => { const items = accessibleRoutes .filter((route) => route.navigation?.group === group.id) - .sort( + .toSorted( (a, b) => (a.navigation?.order ?? 0) - (b.navigation?.order ?? 0) ); diff --git a/tests/lib/integrations/irc/atheme/client.test.ts b/tests/lib/integrations/irc/atheme/client.test.ts index e0455ab..675d24f 100644 --- a/tests/lib/integrations/irc/atheme/client.test.ts +++ b/tests/lib/integrations/irc/atheme/client.test.ts @@ -49,7 +49,7 @@ describe("Atheme Client", () => { const ip = "1.2.3.4"; mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => ({ jsonrpc: "2.0", result: "Success", id: 1 }), + json: async () => ({ jsonrpc: "2.0", result: "Success", id: "1" }), }); // Act @@ -65,7 +65,7 @@ describe("Atheme Client", () => { method: "atheme.command", params: [ ".", - "", + ".", ip, "NickServ", "REGISTER", @@ -73,7 +73,7 @@ describe("Atheme Client", () => { password, email, ], - id: 1, + id: "1", }), }) ); @@ -90,7 +90,7 @@ describe("Atheme Client", () => { json: async () => ({ jsonrpc: "2.0", error: { code: 8, message: "Nick already registered" }, - id: 1, + id: "1", }), }); @@ -128,7 +128,7 @@ describe("Atheme Client", () => { try { mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => ({ jsonrpc: "2.0", result: "Success", id: 1 }), + json: async () => ({ jsonrpc: "2.0", result: "Success", id: "1" }), }); // Act