Skip to content
Closed
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 8 additions & 7 deletions docker/nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ http {
proxy_pass http://betterbase_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
Expand All @@ -62,13 +62,14 @@ http {
proxy_pass http://betterbase_server;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}

location /health {
proxy_pass http://betterbase_server;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand All @@ -78,7 +79,7 @@ http {
proxy_pass http://minio;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100m;
}
Expand All @@ -88,7 +89,7 @@ http {
proxy_pass http://betterbase_dashboard;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# SPA fallback
proxy_intercept_errors on;
Expand All @@ -98,7 +99,7 @@ http {
location @dashboard_fallback {
proxy_pass http://betterbase_dashboard;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}

Expand All @@ -110,7 +111,7 @@ http {
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/server/migrations/017_revoked_admin_tokens.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS betterbase_meta.revoked_admin_tokens (
jti TEXT PRIMARY KEY,
admin_user_id TEXT REFERENCES betterbase_meta.admin_users(id) ON DELETE SET NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ
);

CREATE INDEX IF NOT EXISTS idx_revoked_admin_tokens_expires_at
ON betterbase_meta.revoked_admin_tokens (expires_at);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
2 changes: 1 addition & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ app.use("*", async (c, next) => {

const projectId = c.req.header("X-Project-ID") ?? null;
const userAgent = c.req.header("User-Agent")?.slice(0, 255) ?? null;
const ip = c.req.header("X-Forwarded-For")?.split(",")[0] ?? null;
const ip = c.req.header("X-Real-IP") ?? null;

// Fire-and-forget log insert (don't await, don't fail requests on log error)
getPool()
Expand Down
4 changes: 1 addition & 3 deletions packages/server/src/lib/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,5 @@ export async function writeAuditLog(entry: AuditEntry): Promise<void> {

// Helper: extract IP from Hono context
export function getClientIp(headers: Headers): string {
return (
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? headers.get("x-real-ip") ?? "unknown"
);
return headers.get("x-real-ip") ?? headers.get("x-forwarded-for")?.split(",").pop()?.trim() ?? "unknown";
}
56 changes: 52 additions & 4 deletions packages/server/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import bcrypt from "bcryptjs";
import { randomUUID } from "crypto";
import { SignJWT, jwtVerify } from "jose";
import type { Pool } from "pg";
import { getPool } from "./db";
import { validateEnv } from "./env";

const getSecret = () => {
const env = validateEnv();
return new TextEncoder().encode(env.BETTERBASE_JWT_SECRET);
};

const TOKEN_EXPIRY = "30d";
const TOKEN_EXPIRY = "8h";
const BCRYPT_ROUNDS = 12;

// --- Password ---
Expand All @@ -24,21 +26,67 @@ export async function verifyPassword(password: string, hash: string): Promise<bo
// --- JWT for admin sessions ---

export async function signAdminToken(adminUserId: string): Promise<string> {
const env = validateEnv();
return new SignJWT({ sub: adminUserId, type: "admin" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(TOKEN_EXPIRY)
.setIssuer(env.BETTERBASE_JWT_ISSUER)
.setAudience(env.BETTERBASE_JWT_AUDIENCE)
.setJti(randomUUID())
.sign(getSecret());
}

export async function verifyAdminToken(token: string): Promise<{ sub: string } | null> {
export async function verifyAdminToken(
token: string,
): Promise<{ sub: string; jti: string | undefined; exp?: number } | null> {
// Step 1: Verify JWT signature and payload validity
let payload: any;
try {
const { payload } = await jwtVerify(token, getSecret());
const env = validateEnv();
const verified = await jwtVerify(token, getSecret(), {
issuer: env.BETTERBASE_JWT_ISSUER,
audience: env.BETTERBASE_JWT_AUDIENCE,
});
payload = verified.payload;

if (payload.type !== "admin") return null;
return { sub: payload.sub as string };
if (!payload.sub) return null;

// Log warning if jti is missing
if (!payload.jti) {
console.warn(`[auth] Token missing jti claim (sub=${payload.sub})`);
}
} catch {
// Cryptographic verification failed - invalid token
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Step 2: Check revocation list (fail-closed on DB errors)
// Skip revocation check if jti is missing
if (payload.jti) {
try {
const pool = getPool();
const { rows } = await pool.query(
"SELECT 1 FROM betterbase_meta.revoked_admin_tokens WHERE jti = $1 LIMIT 1",
[payload.jti],
);
if (rows.length > 0) return null;
} catch (err) {
// DB/query error - log and fail closed
console.error(
`[auth] DB error checking token revocation (jti=${payload.jti}, sub=${payload.sub}):`,
err,
);
return null;
}
}

return {
sub: payload.sub as string,
jti: payload.jti as string | undefined,
exp: payload.exp as number | undefined
};
}

// --- Middleware helper: extract + verify token from Authorization header ---
Expand Down
11 changes: 11 additions & 0 deletions packages/server/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const EnvSchema = z.object({
INNGEST_SIGNING_KEY: z.string().optional(),
INNGEST_EVENT_KEY: z.string().optional(),
PORT: z.string().default("3000"),
BETTERBASE_JWT_ISSUER: z.string().default("betterbase"),
BETTERBASE_JWT_AUDIENCE: z.string().default("betterbase-admin"),
});

export type Env = z.infer<typeof EnvSchema>;
Expand Down Expand Up @@ -53,6 +55,15 @@ export function validateEnv(): Env {
result.data.INNGEST_EVENT_KEY = "betterbase-dev-event-key";
}

if (result.data.STORAGE_ENDPOINT) {
if (!result.data.STORAGE_ACCESS_KEY || !result.data.STORAGE_SECRET_KEY) {
console.error(
"[env] STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY are required when STORAGE_ENDPOINT is set",
);
process.exit(1);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

validatedEnv = result.data;
return validatedEnv;
}
41 changes: 25 additions & 16 deletions packages/server/src/lib/inngest.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,6 @@
import { createHash } from "node:crypto";
import { EventSchemas, Inngest } from "inngest";

// ─── CSV Escaping Helper ───────────────────────────────────────────────────────
// Helper to escape CSV values - prevents CSV injection and handles special characters
const escapeCSVValue = (value: unknown): string => {
if (value === null || value === undefined) return "";
const str = String(value);
// Prefix formula injection characters (=, +, -, @, \t, \r, \n) with single quote
if (str.match(/^[=\+\-@\t\r\n]/)) {
return `"${str.replace(/"/g, '""')}"`;
}
// Wrap in quotes if contains comma, quote, or newline
if (str.includes(",") || str.includes('"') || str.includes("\n") || str.includes("\r")) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
import { escapeCSVValue } from "./csv";

// Helper to validate schema name - prevents SQL injection
const validateSchemaName = (slug: string): string => {
Expand Down Expand Up @@ -556,11 +541,35 @@ export const pollNotificationRules = inngest.createFunction(
},
);

// ─── Function: Cleanup Expired Revoked Tokens (Cron) ─────────────────────────

export const cleanupExpiredTokens = inngest.createFunction(
{
id: "cleanup-expired-revoked-tokens",
retries: 1,
},
// Runs every hour
{ cron: "0 * * * *" },
async ({ step }) => {
const deleted = await step.run("delete-expired-tokens", async () => {
const { getPool } = await import("./db");
const pool = getPool();
const { rowCount } = await pool.query(
"DELETE FROM betterbase_meta.revoked_admin_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()",
);
return rowCount ?? 0;
});

return { deleted };
},
);

// ─── All functions (used in serve() registration) ────────────────────────────

export const allInngestFunctions = [
deliverWebhook,
evaluateNotificationRule,
exportProjectUsers,
pollNotificationRules,
cleanupExpiredTokens,
];
37 changes: 35 additions & 2 deletions packages/server/src/routes/admin/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { getClientIp, writeAuditLog } from "../../lib/audit";
import {
extractBearerToken,
signAdminToken,
Expand Down Expand Up @@ -62,8 +63,40 @@ authRoutes.get("/me", async (c) => {
return c.json({ admin: rows[0] });
});

// POST /admin/auth/logout (client-side token discard — stateless)
authRoutes.post("/logout", (c) => c.json({ success: true }));
// POST /admin/auth/logout
authRoutes.post("/logout", async (c) => {
const token = extractBearerToken(c.req.header("Authorization"));
if (!token) return c.json({ success: true });

const payload = await verifyAdminToken(token);
if (!payload) return c.json({ success: true });

const pool = getPool();
// Only revoke if jti is present
if (payload.jti && payload.exp) {
await pool.query(
`INSERT INTO betterbase_meta.revoked_admin_tokens (jti, admin_user_id, expires_at)
VALUES ($1, $2, to_timestamp($3))
ON CONFLICT (jti) DO NOTHING`,
[payload.jti, payload.sub, payload.exp],
);
}

const { rows } = await pool.query("SELECT id, email FROM betterbase_meta.admin_users WHERE id = $1", [
payload.sub,
]);
if (rows.length > 0) {
await writeAuditLog({
actorId: rows[0].id,
actorEmail: rows[0].email,
action: "admin.logout",
ipAddress: getClientIp(c.req.raw.headers),
userAgent: c.req.header("User-Agent") ?? undefined,
});
}

return c.json({ success: true });
});
Comment on lines +67 to +99
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Logout handler can throw on DB errors and awaits the audit log.

Two issues against the coding guidelines:

  1. Audit log is awaited (line 89). As per coding guidelines: "Audit log writes: fire-and-forget. Never await."

  2. Unhandled exceptions at lines 77 and 85: a Postgres failure on either the revocation INSERT or the admin SELECT propagates out of the handler, hits the global onError, and returns 500. The client perceives logout failure even though the token may already have been revoked (or worse — believes they're logged out when revocation actually failed). As per coding guidelines: "Route handlers must not throw — errors should be caught and return c.json({error}). The global onError handler catches the rest but shouldn't be the primary mechanism."

Wrap the DB calls in try/catch and decide deliberately: fail-closed (return 500 with explicit error if revocation fails — admin must know their token is still valid) or fail-open (return success but log the revocation failure for ops).

Proposed shape
-	const pool = getPool();
-	// Only revoke if jti is present
-	if (payload.jti && payload.exp) {
-		await pool.query(
-			`INSERT INTO betterbase_meta.revoked_admin_tokens (jti, admin_user_id, expires_at)
-			 VALUES ($1, $2, to_timestamp($3))
-			 ON CONFLICT (jti) DO NOTHING`,
-			[payload.jti, payload.sub, payload.exp],
-		);
-	}
-
-	const { rows } = await pool.query("SELECT id, email FROM betterbase_meta.admin_users WHERE id = $1", [
-		payload.sub,
-	]);
-	if (rows.length > 0) {
-		await writeAuditLog({
+	const pool = getPool();
+	if (payload.jti && payload.exp) {
+		try {
+			await pool.query(
+				`INSERT INTO betterbase_meta.revoked_admin_tokens (jti, admin_user_id, expires_at)
+				 VALUES ($1, $2, to_timestamp($3))
+				 ON CONFLICT (jti) DO NOTHING`,
+				[payload.jti, payload.sub, payload.exp],
+			);
+		} catch (err) {
+			console.error("[auth] failed to revoke token", err);
+			return c.json({ error: "Logout failed; please retry." }, 500);
+		}
+	}
+
+	let rows: any[] = [];
+	try {
+		({ rows } = await pool.query("SELECT id, email FROM betterbase_meta.admin_users WHERE id = $1", [payload.sub]));
+	} catch (err) {
+		console.error("[auth] failed to load admin for audit", err);
+	}
+	if (rows.length > 0) {
+		writeAuditLog({
 			actorId: rows[0].id,
 			actorEmail: rows[0].email,
 			action: "admin.logout",
 			ipAddress: getClientIp(c.req.raw.headers),
 			userAgent: c.req.header("User-Agent") ?? undefined,
-		});
+		}).catch(() => {});
 	}

As per coding guidelines: "Audit log writes: fire-and-forget. Never await." and "Route handlers must not throw — errors should be caught and return c.json({error})."

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

In `@packages/server/src/routes/admin/auth.ts` around lines 67 - 99, The logout
handler (authRoutes.post("/logout")) currently performs DB queries (revocation
INSERT and admin SELECT) without error handling and awaits writeAuditLog; wrap
the DB calls that use getPool().query (the INSERT into
betterbase_meta.revoked_admin_tokens and the SELECT from
betterbase_meta.admin_users) in a try/catch so the route does not throw: decide
fail-closed for revocation (if the INSERT fails return c.json({ error:
"revocation_failed" }) and do not proceed) or fail-open if you prefer (log the
error and continue to return success); ensure the SELECT is similarly guarded
and returns a handled error instead of throwing; change the writeAuditLog call
to fire-and-forget (call writeAuditLog(...) without await and attach a
.catch(...) to log failures) so audit writes are never awaited.


// GET /admin/auth/setup-status — check if admin exists (no body validation)
authRoutes.get("/setup-status", async (c) => {
Expand Down
7 changes: 5 additions & 2 deletions packages/server/src/routes/admin/project-scoped/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import { getClientIp, writeAuditLog } from "../../../lib/audit";
import { escapeCSVValue } from "../../../lib/csv";
import { getPool } from "../../../lib/db";

export const projectUserRoutes = new Hono();
Expand Down Expand Up @@ -233,14 +234,16 @@ projectUserRoutes.post("/export", async (c) => {
header +
rows
.map(
(r) => `${r.id},"${r.name}","${r.email}",${r.email_verified},${r.created_at},${r.banned}`,
(r) =>
`${escapeCSVValue(r.id)},${escapeCSVValue(r.name)},${escapeCSVValue(r.email)},${r.email_verified},${escapeCSVValue(r.created_at)},${r.banned}`,
)
.join("\n");

return new Response(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="users-${project.slug}-${Date.now()}.csv"`,
"Content-Security-Policy": "default-src 'none'",
},
});
});
Loading
Loading