Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hyphenate the compound modifier in the heading.

Use Cursor Cloud-specific instructions on Line 134.

🧰 Tools
🪛 LanguageTool

[grammar] ~134-~134: Use a hyphen to join words.
Context: ...able agent skills index ## Cursor Cloud specific instructions ### Services | S...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` at line 134, Update the heading "Cursor Cloud specific
instructions" to use a hyphenated compound modifier by changing it to "Cursor
Cloud-specific instructions" so the title follows correct compound adjective
formatting; locate the heading text in AGENTS.md and replace the existing
unhyphenated phrase.


### 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.
Expand Down
38 changes: 18 additions & 20 deletions src/app/api/admin/irc-accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { db } from "@/db";
import { user } from "@/db/schema/auth";
import { ircAccount } from "@/db/schema/irc";
import { handleAPIError, requireAdminOrStaff } from "@/shared/api/utils";

Check failure on line 7 in src/app/api/admin/irc-accounts/route.ts

View workflow job for this annotation

GitHub Actions / Test

tests/app/api/admin/irc-accounts/route.test.ts

Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/vi.html#vi-mock ❯ src/app/api/admin/irc-accounts/route.ts:7:1 ❯ tests/app/api/admin/irc-accounts/route.test.ts:21:1 Caused by: Caused by: Error: ❌ Attempted to access a server-side environment variable on the client ❯ onInvalidAccess node_modules/.pnpm/@t3-oss+env-core@0.13.10_typescript@5.9.3_zod@4.3.6/node_modules/@t3-oss/env-core/dist/index.js:35:9 ❯ Object.get node_modules/.pnpm/@t3-oss+env-core@0.13.10_typescript@5.9.3_zod@4.3.6/node_modules/@t3-oss/env-core/dist/index.js:55:42 ❯ src/features/integrations/lib/mailcow/config.ts:10:15 ❯ src/features/integrations/lib/mailcow/index.ts:3:1

const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
Expand Down Expand Up @@ -43,26 +43,24 @@
}
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);

Expand Down
38 changes: 18 additions & 20 deletions src/app/api/admin/mailcow-accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
62 changes: 29 additions & 33 deletions src/app/api/admin/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>`COUNT(*) FILTER (WHERE ${user.role} = 'admin')`,
staff: sql<number>`COUNT(*) FILTER (WHERE ${user.role} = 'staff')`,
banned: sql<number>`COUNT(*) FILTER (WHERE ${user.banned} = true)`,
})
.from(user);

// Get active sessions count
const [sessionStats] = await db
.select({
total: count(session.id),
active: sql<number>`COUNT(*) FILTER (WHERE ${session.expiresAt} > NOW())`,
})
.from(session);

// Get API keys count
const [apiKeyStats] = await db
.select({
total: count(apikey.id),
enabled: sql<number>`COUNT(*) FILTER (WHERE ${apikey.enabled} = true)`,
})
.from(apikey);

// Get OAuth clients count
const [oauthClientStats] = await db
.select({
total: count(oauthClient.id),
disabled: sql<number>`COUNT(*) FILTER (WHERE ${oauthClient.disabled} = true)`,
})
.from(oauthClient);
const [[userStats], [sessionStats], [apiKeyStats], [oauthClientStats]] =
await Promise.all([
db
.select({
total: count(user.id),
admins: sql<number>`COUNT(*) FILTER (WHERE ${user.role} = 'admin')`,
staff: sql<number>`COUNT(*) FILTER (WHERE ${user.role} = 'staff')`,
banned: sql<number>`COUNT(*) FILTER (WHERE ${user.banned} = true)`,
})
.from(user),
db
.select({
total: count(session.id),
active: sql<number>`COUNT(*) FILTER (WHERE ${session.expiresAt} > NOW())`,
})
.from(session),
db
.select({
total: count(apikey.id),
enabled: sql<number>`COUNT(*) FILTER (WHERE ${apikey.enabled} = true)`,
})
.from(apikey),
db
.select({
total: count(oauthClient.id),
disabled: sql<number>`COUNT(*) FILTER (WHERE ${oauthClient.disabled} = true)`,
})
.from(oauthClient),
]);

return Response.json({
users: {
Expand Down
25 changes: 13 additions & 12 deletions src/app/api/admin/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
38 changes: 18 additions & 20 deletions src/app/api/admin/xmpp-accounts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
19 changes: 10 additions & 9 deletions src/features/integrations/lib/irc/atheme/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
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;
}

/**
Expand Down Expand Up @@ -60,7 +60,7 @@ async function athemeRpc<T = string>(
jsonrpc: "2.0",
method,
params,
id: 1,
id: "1",
};

const controller = new AbortController();
Expand Down Expand Up @@ -113,7 +113,8 @@ async function athemeRpc<T = string>(
}

/**
* 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<string> {
Expand All @@ -136,7 +137,7 @@ export async function registerNick(
): Promise<void> {
await athemeCommand([
".",
"",
".",
sourceIp,
"NickServ",
"REGISTER",
Expand All @@ -158,7 +159,7 @@ export async function dropNick(
): Promise<void> {
await athemeCommand([
".",
"",
".",
sourceIp,
"NickServ",
"DROP",
Expand Down
13 changes: 9 additions & 4 deletions src/features/integrations/lib/xmpp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}

/**
Expand Down
Loading
Loading