Merge branch 'feature/drizzle-rls-real-auth'

# Conflicts:
#	CLAUDE.md
#	package.json
#	src/app/api/auth/[...nextauth]/route.ts
#	src/app/login/LoginClient.tsx
#	src/auth.config.ts
#	src/components/admin/AdminSidebar.tsx
#	src/lib/admin-permissions-types.ts
#	src/lib/admin-permissions.ts
#	src/lib/auth.ts
#	src/middleware.ts
This commit is contained in:
2026-06-07 01:55:06 +00:00
84 changed files with 4165 additions and 2881 deletions
+56 -11
View File
@@ -1,19 +1,48 @@
// Shared AdminUser type — safe to import from both server and client components
//
// `brand_id` is the active brand (one of `brand_ids`, or null for platform_admin).
// `brand_ids` is the full list of brands the admin can act in.
// - platform_admin: `brand_id = null`, `brand_ids = []` (in dev) or all brands
// (resolved by `listBrandsForAdmin`).
// - multi_brand_admin: `brand_id` = selected/cookie brand, `brand_ids` = 2+.
// - brand_admin / store_employee / staff: `brand_id` = their single brand,
// `brand_ids = [that one]`.
// Shared AdminUser type — safe to import from both server and client
// components. The shape mirrors what `getAdminUser()` returns and
// includes both the user's role and the tenant they belong to.
export type AdminRole = "platform_admin" | "brand_admin" | "store_employee";
export type AdminUser = {
id?: string;
/** user.id from the `users` table — or "dev" for dev_session cookies */
id: string;
/** user_id (same as id) — kept for legacy callers */
user_id: string;
/** email from the `users` table, or null for dev shims */
email: string | null;
/** display name */
display_name: string | null;
/** tenant id from `tenant_users`, or null for platform_admin */
tenant_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* call sites that haven't been migrated yet. Always mirrors
* `tenant_id`; will be removed in a later cleanup pass.
*/
brand_id: string | null;
/**
* @deprecated Use `tenant_id` instead. Kept for backward compat with
* multi-brand admin code (`AdminSidebar`, `setActiveBrand`, etc.).
* In our schema an admin belongs to at most one tenant, so this is
* always `[]` for platform_admin or `[tenant_id]` for everyone else.
* Will be removed when the multi-brand admin UI is re-thought.
*/
brand_ids: string[];
role: "platform_admin" | "brand_admin" | "multi_brand_admin" | "store_employee" | "staff";
/** tenant slug (for storefronts) */
tenant_slug: string | null;
/** role within the tenant (or platform-wide for platform_admin) */
role: AdminRole;
/** is the user active? */
active: boolean;
/** auth provider */
auth_provider: "dev" | "google" | "email" | null;
// ── Permission flags ────────────────────────────────────────────
// Derived from the role, but exposed as individual booleans so
// existing consumer code (forms, sidebar, etc.) can read them
// directly without doing role math. See `permissionsForRole()` in
// admin-permissions.ts for the source of truth.
can_manage_products: boolean;
can_manage_stops: boolean;
can_manage_orders: boolean;
@@ -24,5 +53,21 @@ export type AdminUser = {
can_manage_water_log: boolean;
can_manage_reports: boolean;
can_manage_settings: boolean;
can_manage_billing: boolean;
can_manage_branding: boolean;
can_manage_marketing: boolean;
can_manage_team: boolean;
/** must the user change their password? (legacy; unused) */
must_change_password?: boolean;
};
export type TenantContext = {
tenant: {
id: string;
name: string;
slug: string;
status: string;
};
user: AdminUser;
};
+197 -157
View File
@@ -1,185 +1,225 @@
import { cookies } from "next/headers";
import type { AdminUser } from "./admin-permissions-types";
export type { AdminUser } from "./admin-permissions-types";
import "server-only";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { withPlatformAdmin } from "@/db/client";
import { users, tenants, tenantUsers } from "@/db/schema";
import type { AdminRole, AdminUser, TenantContext } from "@/lib/admin-permissions-types";
/**
* Returns the current admin user, or `null` if not authenticated.
* Source of truth for the current admin user.
*
* Resolution order:
* 1. Mock data mode (NEXT_PUBLIC_USE_MOCK_DATA=true) → platform_admin dev.
* 2. `dev_session` cookie → dev admin (platform_admin/brand_admin/store_employee).
* 3. Real auth (rc_auth_uid or rc_uid cookie) → load admin_users + brand_ids.
* Looks up the Auth.js v5 session, then resolves the user + tenant
* from the `users` and `tenant_users` tables.
*
* `brand_id` is the active brand; `brand_ids` is the full membership list.
* For dev sessions without a real DB, `brand_ids` is populated by:
* - platform_admin: `[]` (listBrandsForAdmin resolves against the brands table)
* - store_employee: `[<first real brand>]` if a brand exists, else `[]`
* - brand_admin: `[]` (legacy dev; we don't have a way to scope brand in dev)
* Returns `null` if:
* - No Auth.js session (caller not signed in)
* - The session email doesn't match any `users.email`
* - The user has no `tenant_users` row (not provisioned yet)
*
* Provisioning: an admin must run
* INSERT INTO users (email, ...) VALUES (...)
* INSERT INTO tenant_users (tenant_id, user_id, role) VALUES (...)
* to grant a Google-sign-in user admin access. Until provisioned, the
* layout shows "Access Denied" — correct behavior.
*
* The previous `dev_session` cookie bypass has been removed. The only
* way into the admin is through real Auth.js (Google in production;
* for local dev, configure `AUTH_GOOGLE_ID` / `AUTH_GOOGLE_SECRET`).
*/
export async function getAdminUser(): Promise<AdminUser | null> {
const cookieStore = await cookies();
// ── Mock data mode for UI review ─────────────────────────────────
if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === "true") {
return buildDevAdmin("platform_admin");
}
// ── Dev session bypass (enabled for testing on all envs) ──────────────
const dev = cookieStore.get("dev_session")?.value;
if (dev === "platform_admin" || dev === "brand_admin" || dev === "store_employee") {
return buildDevAdmin(dev);
}
// ── Main auth: rc_auth_uid (new) or rc_uid (legacy) cookie set by /api/login ─
const uid = cookieStore.get("rc_auth_uid")?.value ?? cookieStore.get("rc_uid")?.value;
if (!uid) return null;
if (!process.env.SUPABASE_SERVICE_ROLE_KEY || !process.env.NEXT_PUBLIC_SUPABASE_URL) {
return null;
}
// Lookup admin_users by Supabase auth user id
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
let adminUsers: unknown[] = [];
let sessionEmail: string | null = null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_users?user_id=eq.${uid}&limit=1`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (res.ok) {
const data = await res.json().catch(() => []);
adminUsers = Array.isArray(data) ? data : [];
}
} catch (e) {
// fetch failed silently
}
// First login — auto-create platform_admin via SECURITY DEFINER RPC
if (adminUsers.length === 0) {
// Check if uid is a valid UUID before trying to insert
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_REGEX.test(uid)) return null;
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/rpc/upsert_admin_user`,
{
method: "POST",
headers: { apikey: serviceKey, "Content-Type": "application/json", Prefer: "return=representation" },
body: JSON.stringify({ p_user_id: uid }),
}
);
if (res.ok) {
const inserted = await res.json().catch(() => null);
if (inserted && inserted.length > 0) {
return buildAdminUser(inserted[0] as Record<string, unknown>, []);
}
}
} catch (e) {
// RPC failed silently
}
const session = await auth();
sessionEmail = session?.user?.email ?? null;
} catch (err) {
console.error("[admin-permissions] auth() failed:", err);
return null;
}
const admin = adminUsers[0] as Record<string, unknown>;
if (!admin.active) return null;
if (!sessionEmail) return null;
// Load brand_ids from the admin_user_brands junction
const brandIds = await fetchAdminUserBrandIds(supabaseUrl, serviceKey, admin.id as string);
return await withPlatformAdmin(async (db) => {
const userRows = await db
.select()
.from(users)
.where(eq(users.email, sessionEmail))
.limit(1);
const user = userRows[0];
if (!user) return null;
return buildAdminUser(admin, brandIds);
const membershipRows = await db
.select({
tenantId: tenants.id,
tenantName: tenants.name,
tenantSlug: tenants.slug,
tenantStatus: tenants.status,
role: tenantUsers.role,
})
.from(tenantUsers)
.innerJoin(tenants, eq(tenants.id, tenantUsers.tenantId))
.where(eq(tenantUsers.userId, user.id))
.limit(1);
if (membershipRows.length === 0) {
// Signed in but not provisioned for any tenant.
return null;
}
const m = membershipRows[0];
const role = m.role as AdminRole;
return buildAdminUser({
id: user.id,
email: user.email,
displayName: user.name,
authProvider: user.authProvider,
tenantId: m.tenantId,
tenantSlug: m.tenantSlug,
tenantName: m.tenantName,
role,
active: true,
});
});
}
/**
* Load `brand_ids` from the admin_user_brands junction for the given admin row.
* Returns an empty array on any failure (e.g. before migration 207 is applied).
* Resolves the current admin user AND their tenant. Returns `null` if
* the user is not signed in or has no tenant. For platform_admin (no
* tenant), `tenant` is `null` and callers should use `withPlatformAdmin`
* to query across all tenants.
*/
async function fetchAdminUserBrandIds(
supabaseUrl: string,
serviceKey: string,
adminRowId: string
): Promise<string[]> {
try {
const res = await fetch(
`${supabaseUrl}/rest/v1/admin_user_brands?admin_user_id=eq.${adminRowId}&select=brand_id`,
{ headers: { apikey: serviceKey, "Content-Type": "application/json" } }
);
if (!res.ok) return [];
const data = await res.json().catch(() => []);
if (!Array.isArray(data)) return [];
return data
.map((row: Record<string, unknown>) => row.brand_id as string)
.filter((id): id is string => typeof id === "string");
} catch {
return [];
export async function getCurrentTenant(): Promise<TenantContext | null> {
const user = await getAdminUser();
if (!user) return null;
if (!user.tenant_id) {
// platform_admin — no specific tenant
return null;
}
return {
user,
tenant: {
id: user.tenant_id,
name: user.display_name ?? user.tenant_slug ?? "Unknown",
slug: user.tenant_slug ?? "unknown",
status: "active",
},
};
}
function buildDevAdmin(role: string): AdminUser {
// For dev sessions we don't have an admin_user_brands junction row to load.
// - platform_admin: `brand_ids = []` (listBrandsForAdmin resolves against brands).
// - store_employee: `brand_ids = []` (dev AdminAccessDenied is acceptable;
// this is the documented limitation — re-read spec section on getAdminUser
// step 1. We skip the spec's "fetch first real brand" complexity here in
// favour of keeping dev session cheap and DB-independent).
// - brand_admin: `brand_ids = []` (same rationale).
// `role` is narrowed to the strict union — we know the dev callers pass
// only valid values.
const base = {
// ────────────────────────────────────────────────────────────────────────
// Re-exports for backward compat
// ────────────────────────────────────────────────────────────────────────
export type { AdminUser, AdminRole, TenantContext } from "@/lib/admin-permissions-types";
/**
* @deprecated Kept for unit tests that exercise the dev shim path.
* Production code should never call this — `getAdminUser()` only reads
* the Auth.js session now.
*/
export function buildDevAdmin(role: AdminRole): AdminUser {
const isPlatform = role === "platform_admin";
const tenantId = isPlatform ? null : "dev-tenant";
return {
id: "dev",
user_id: "dev",
brand_id: null,
brand_ids: [] as string[],
role: role as AdminUser["role"],
email: null,
display_name: "Demo Admin",
tenant_id: tenantId,
brand_id: tenantId, // legacy alias
brand_ids: tenantId ? [tenantId] : [], // legacy array alias
tenant_slug: isPlatform ? null : "tuxedo",
role,
active: true,
auth_provider: "dev",
...permissionsForRole(role),
must_change_password: false,
};
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
}
function buildAdminUser(r: Record<string, unknown>, brandIds: string[]): AdminUser {
// The DB column is TEXT (per CLAUDE.md) so the runtime value is a string.
// We narrow it to the known union here. If the DB has an unknown role
// (e.g. a future role), the migration's CHECK constraint will reject it
// before it ever reaches this function.
const role = r.role as AdminUser["role"];
// `brand_id` is the *legacy* single-brand column — preserved here as-is.
// The canonical "active brand" is resolved by `getActiveBrandId` on each
// page/action, which considers URL params, the active_brand_id cookie,
// and this legacy fallback. Setting `brand_id` here to a sensible default
// (legacy → first of brand_ids) keeps the AdminUser shape useful even
// for callers that haven't migrated to `getActiveBrandId` yet.
const legacyBrandId = (r.brand_id as string | null) ?? null;
const base = {
id: r.id as string,
user_id: r.user_id as string,
brand_id: legacyBrandId ?? brandIds[0] ?? null,
brand_ids: brandIds,
role,
active: r.active as boolean,
must_change_password: Boolean(r.must_change_password),
function buildAdminUser(input: {
id: string;
email: string | null;
displayName: string | null;
authProvider: "dev" | "google" | "email" | null;
tenantId: string;
tenantSlug: string;
tenantName: string;
role: AdminRole;
active: boolean;
}): AdminUser {
return {
id: input.id,
user_id: input.id,
email: input.email,
display_name: input.displayName,
tenant_id: input.tenantId,
brand_id: input.tenantId, // legacy alias
brand_ids: [input.tenantId], // legacy array alias
tenant_slug: input.tenantSlug,
role: input.role,
active: input.active,
auth_provider: input.authProvider,
...permissionsForRole(input.role),
};
}
/**
* Single source of truth for "what can a role do". Used by both the
* dev shim and the real user lookup so the demo and the real thing
* behave identically.
*/
export function permissionsForRole(role: AdminRole) {
if (role === "platform_admin") {
return {
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: true,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
if (role === "brand_admin") {
return {
can_manage_products: true,
can_manage_stops: true,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: true,
can_manage_refunds: true,
can_manage_users: false,
can_manage_water_log: true,
can_manage_reports: true,
can_manage_settings: true,
can_manage_billing: true,
can_manage_branding: true,
can_manage_marketing: true,
can_manage_team: true,
};
}
// store_employee
return {
can_manage_products: false,
can_manage_stops: false,
can_manage_orders: true,
can_manage_pickup: true,
can_manage_messages: false,
can_manage_refunds: false,
can_manage_users: false,
can_manage_water_log: false,
can_manage_reports: false,
can_manage_settings: false,
can_manage_billing: false,
can_manage_branding: false,
can_manage_marketing: false,
can_manage_team: false,
};
if (role === "platform_admin") {
return { ...base, can_manage_products: true, can_manage_stops: true, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: true, can_manage_refunds: true,
can_manage_users: true, can_manage_water_log: true, can_manage_reports: true, can_manage_settings: true };
}
if (role === "store_employee") {
return { ...base, can_manage_products: false, can_manage_stops: false, can_manage_orders: true,
can_manage_pickup: true, can_manage_messages: false, can_manage_refunds: false,
can_manage_users: false, can_manage_water_log: false, can_manage_reports: false, can_manage_settings: false };
}
return { ...base, can_manage_products: Boolean(r.can_manage_products), can_manage_stops: Boolean(r.can_manage_stops),
can_manage_orders: Boolean(r.can_manage_orders), can_manage_pickup: Boolean(r.can_manage_pickup),
can_manage_messages: Boolean(r.can_manage_messages), can_manage_refunds: Boolean(r.can_manage_refunds),
can_manage_users: Boolean(r.can_manage_users), can_manage_water_log: Boolean(r.can_manage_water_log),
can_manage_reports: Boolean(r.can_manage_reports), can_manage_settings: Boolean(r.can_manage_settings) };
}
+73 -120
View File
@@ -1,136 +1,89 @@
import NextAuth from "next-auth";
import PostgresAdapter from "@auth/pg-adapter";
import { Pool } from "pg";
import Credentials from "next-auth/providers/credentials";
import {
authConfig,
isDevLoginEnabled,
} from "@/auth.config";
import "server-only";
/**
* Build the dev Credentials provider. Lives here (Node-only) because
* `next-auth/providers/credentials` cannot be loaded in the edge runtime
* that the middleware uses.
* Auth.js (NextAuth v5) — server-side configuration.
*
* This file is Node-only. It is imported by:
* - `src/app/api/auth/[...nextauth]/route.ts` (the OAuth + credentials handlers)
* - Server actions that call `signIn` / `signOut`
* - `src/lib/admin-permissions.ts` (reads `auth()` for the current user)
*
* The middleware imports a separate, edge-safe instance built from
* `src/auth.config.ts`. Both instances share the same JWT cookie, so the
* middleware can read sessions minted here.
*
* Providers:
* - Google OAuth — active when AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set.
* - Email + password (Credentials) — active in dev only; backed by the
* `users.password_hash` column. In production, set ALLOW_DEV_LOGIN=false
* (the default) and the provider is omitted entirely.
*
* For local dev, run `npm run db:seed` to create the seeded admin user
* (`admin@route-commerce.local` / `admin`). The `authorize` function
* looks up the user by email, verifies the password against the stored
* hash, and returns the real user record. No `dev_session` cookie
* bypass; this is real Auth.js sign-in.
*/
function buildDevCredentialsProvider() {
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { eq } from "drizzle-orm";
import { authConfig, isDevLoginEnabled } from "@/auth.config";
import { withDb } from "@/db/client";
import { users } from "@/db/schema";
import { verifyPassword } from "@/lib/passwords";
function buildCredentialsProvider() {
return Credentials({
id: "dev-login",
name: "Dev login",
id: "credentials",
name: "Email + password",
credentials: {
username: { label: "Username", type: "text" },
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
/**
* Returns the user on success, or `null` on any failure. Auth.js
* never throws from `authorize` — a throw is treated as a 500.
*/
async authorize(creds) {
if (!isDevLoginEnabled()) return null;
// Any non-empty username/password combo is accepted; this is purely a
// local convenience for smoke testing without Google OAuth.
const username = String(creds?.username ?? "").trim();
const email = String(creds?.email ?? "").trim().toLowerCase();
const password = String(creds?.password ?? "");
if (!username || !password) return null;
if (!email || !password) return null;
return {
id: `dev-${username.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`,
name: username,
email: `${username}@dev.local`,
// Custom field surfaced via `jwt` callback if needed
devRole: "platform_admin",
} as unknown as { id: string; name: string; email: string };
},
});
}
/**
* Shared Postgres pool for Auth.js. Reuses the same database the rest of
* the app talks to (via `pg`). Lives behind a module-level singleton so
* Next.js hot reload doesn't open a new pool on every request.
*
* Note: in production, `DATABASE_URL` should be the only DB env var. The
* Supabase project URL / service role key are no longer required for auth
* (they are still used elsewhere until the rest of the app is migrated off
* the @supabase client — see CLAUDE.md).
*/
const globalForPool = globalThis as unknown as { __pgPool?: Pool };
function getPool(): Pool {
if (globalForPool.__pgPool) return globalForPool.__pgPool;
const connectionString =
process.env.DATABASE_URL ??
process.env.SUPABASE_DB_URL ??
process.env.POSTGRES_URL;
if (!connectionString) {
// Don't throw at module load — let route handlers return a clean 500
// if env is missing. The smoke test instructions tell the user to
// set DATABASE_URL.
// eslint-disable-next-line no-console
console.warn(
"[auth] No DATABASE_URL / SUPABASE_DB_URL / POSTGRES_URL set — Auth.js database adapter will not be wired up."
);
}
const pool = new Pool({
connectionString,
// Reasonable defaults; override via connection string if you need more
max: 10,
idleTimeoutMillis: 30_000,
});
globalForPool.__pgPool = pool;
return pool;
}
/**
* Final server-side Auth.js config.
*
* Builds on `authConfig` (edge-safe) and layers on:
* 1. The Postgres database adapter
* 2. The dev Credentials provider (only in development)
*
* Note: when using a database adapter the session strategy is fixed to
* "database" — Auth.js will persist sessions in the `sessions` table.
*/
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
// Use JWT sessions to match the edge-friendly config in `authConfig`.
// The middleware (running on the edge) cannot reach the database, so it
// must use JWT. The Postgres adapter is still wired up so that user
// records are created/updated when a new OAuth sign-in happens — but
// the session itself is stored in the cookie as an encrypted JWT.
adapter: PostgresAdapter(getPool()),
// `session.strategy` is inherited from `authConfig` ("jwt")
providers: [
// Re-declare the providers from authConfig and append the dev
// credentials provider if dev login is enabled. (NextAuth merges by
// provider id, so this overrides the edge stubs.)
...authConfig.providers,
...(isDevLoginEnabled() ? [buildDevCredentialsProvider()] : []),
],
events: {
/**
* First-time sign-in: auto-create a `platform_admin` row in
* `admin_users` keyed to this auth.js user id, mirroring the legacy
* `rc_auth_uid` flow. This is the seam between the new auth layer
* and the existing admin authorization model.
*/
async signIn({ user }) {
try {
const pool = getPool();
const userId = user.id;
if (!userId) return;
// Fire and forget — don't block sign-in on a missing admin_users row.
await pool.query(
`SELECT id FROM admin_users WHERE user_id = $1 LIMIT 1`,
[userId]
);
// Note: we don't auto-create here; the existing `getAdminUser()`
// in `src/lib/admin-permissions.ts` is the source of truth for
// role lookups and is unchanged. After this migration the user
// is authenticated; the existing `dev_session` demo path still
// works for the smoke test.
} catch (e) {
// The `users` table is global (not tenant-scoped), so we use
// `withDb` rather than `withTenant` — no GUC to set.
const u = await withDb(async (db) => {
const rows = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return rows[0] ?? null;
});
if (!u || !u.passwordHash) return null;
if (!verifyPassword(password, u.passwordHash)) return null;
return {
id: u.id,
name: u.name ?? undefined,
email: u.email,
};
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[auth] signIn event error (non-fatal):", e);
console.error("[auth] credentials authorize failed:", err);
return null;
}
},
},
});
}
const providers = [
...authConfig.providers,
...(isDevLoginEnabled() ? [buildCredentialsProvider()] : []),
];
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers,
});
-18
View File
@@ -1,18 +0,0 @@
// Clerk Auth Helper Functions - Stub implementation
// Replace with actual Clerk auth implementation when Clerk is set up
export async function getClerkAuth() {
return { userId: null, sessionId: null };
}
export async function requireAuth() {
throw new Error("Unauthorized");
}
export function getUserId(): string | null {
return null;
}
export async function getSession() {
return { userId: null, sessionId: null };
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Shared Postgres connection pool.
*
* The app connects to Postgres directly via the `pg` driver — no Supabase
* platform, JS client, or REST gateway. Server actions and API routes
* import `pool` (or the typed `query` helper below) and call SECURITY
* DEFINER PL/pgSQL functions.
*
* Usage:
* import { pool, query } from "@/lib/db";
* const { rows } = await query<MyRow>("SELECT * FROM my_table WHERE id = $1", [id]);
*
* Configuration:
* - DATABASE_URL (required) — full Postgres connection string. Same env var
* is used by `supabase/push-migrations.js` and any external migration
* tooling. Format: `postgres://user:pass@host:port/dbname`.
*
* Notes:
* - This module is server-only. It must never be imported from a Client
* Component. The `import "server-only"` line below makes Next.js fail
* the build if a client import is attempted.
* - The pool is created lazily on first use. If `DATABASE_URL` is missing
* at import time, the first query throws a clear error pointing at the
* missing env var. This keeps local builds (e.g. `next build` static
* analysis, lint) from failing just because the DB isn't configured.
* - SSL is enabled for non-localhost connections; `pg` reads `?sslmode=`
* from the URL automatically.
*/
import "server-only";
import { Pool, type PoolConfig, type QueryResult, type QueryResultRow } from "pg";
let _pool: Pool | null = null;
let _poolError: Error | null = null;
function buildPool(): Pool {
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error(
"DATABASE_URL is not set. Add it to .env.local (see .env.example).",
);
}
const config: PoolConfig = {
connectionString,
// Conservative defaults for a serverless environment (Vercel, Lambda).
// Adjust via env vars if you need more headroom:
// PG_POOL_MAX (default 10)
// PG_POOL_IDLE_MS (default 30s)
// PG_POOL_CONN_TIMEOUT_MS (default 10s)
max: parseInt(process.env.PG_POOL_MAX ?? "10", 10),
idleTimeoutMillis: parseInt(process.env.PG_POOL_IDLE_MS ?? "30000", 10),
connectionTimeoutMillis: parseInt(
process.env.PG_POOL_CONN_TIMEOUT_MS ?? "10000",
10,
),
// Vercel/serverless recycling: keep the pool hot for warm invocations.
allowExitOnIdle: false,
};
const pool = new Pool(config);
// Surface connection errors loudly. Without these handlers, `pg` swallows
// backend disconnects (e.g. idle TCP RSTs from Vercel's network) and the
// pool goes silently dead.
pool.on("error", (err) => {
console.error("[db] idle client error", err);
});
return pool;
}
/**
* The shared connection pool. Lazy-initialized; throws a clear error on
* first use if `DATABASE_URL` is not set.
*/
export function getPool(): Pool {
if (_pool) return _pool;
if (_poolError) throw _poolError;
try {
_pool = buildPool();
return _pool;
} catch (err) {
_poolError = err instanceof Error ? err : new Error(String(err));
throw _poolError;
}
}
/**
* Convenience alias matching the previous Supabase client shape so call
* sites read naturally: `pool.query(...)`. Lazy.
*/
export const pool = new Proxy({} as Pool, {
get(_target, prop, receiver) {
return Reflect.get(getPool(), prop, receiver);
},
});
/**
* Typed query helper. Use this everywhere a `SELECT` / simple `INSERT/UPDATE`
* is enough. For transactions or `LISTEN/NOTIFY`, use `getPool()` directly.
*
* Example:
* const { rows } = await query<AdminUserRow>(
* "SELECT * FROM admin_users WHERE user_id = $1 LIMIT 1",
* [uid]
* );
*/
export async function query<T extends QueryResultRow = QueryResultRow>(
text: string,
params?: ReadonlyArray<unknown>,
): Promise<QueryResult<T>> {
return getPool().query<T>(text, params as unknown[] | undefined);
}
/**
* Run `fn` inside a single transaction. Commits on success, rolls back on
* any thrown error. The provided client must be used for all queries inside
* `fn` to keep them on the same connection.
*
* Example:
* const result = await withTx(async (client) => {
* await client.query("INSERT INTO foo ...", [...]);
* const { rows } = await client.query<Bar>("SELECT ...", [...]);
* return rows[0];
* });
*/
export async function withTx<T>(
fn: (client: import("pg").PoolClient) => Promise<T>,
): Promise<T> {
const client = await getPool().connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (err) {
try {
await client.query("ROLLBACK");
} catch {
// ignore secondary rollback failure
}
throw err;
} finally {
client.release();
}
}
+67
View File
@@ -0,0 +1,67 @@
import "server-only";
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Password hashing + verification.
*
* Format: `scrypt$N$salt$hash` (hex-encoded).
*
* - scrypt: algorithm identifier (future-proof — easy to migrate to
* argon2id by adding a new branch in `verifyPassword`)
* - N: scrypt cost parameter (CPU/memory). 2^14 (16384) is a
* reasonable default for an interactive login on modern
* hardware (~50ms per hash on a typical server)
* - salt: 16 random bytes, hex-encoded
* - hash: 64-byte derived key, hex-encoded
*
* Why scrypt and not bcrypt / argon2?
* - No extra dependency. Node's `crypto` module has it built in.
* - Argon2id is the modern recommendation but requires a native module
* (`argon2` or `@node-rs/argon2`) which complicates the install.
* When we add a native build step, we should switch to argon2id.
* - bcrypt is fine but not better than scrypt for our use case, and
* also requires a native module.
*
* All comparisons are constant-time (`timingSafeEqual`) to defeat
* timing-based side-channel attacks.
*/
const KEY_LEN = 64;
const SALT_LEN = 16;
const DEFAULT_N = 16384; // 2^14
const ALGO = "scrypt";
export function hashPassword(plain: string): string {
if (typeof plain !== "string" || plain.length === 0) {
throw new Error("hashPassword: password must be a non-empty string");
}
const salt = randomBytes(SALT_LEN).toString("hex");
const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex");
return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`;
}
export function verifyPassword(plain: string, stored: string): boolean {
if (typeof plain !== "string" || typeof stored !== "string") return false;
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== ALGO) return false;
const n = Number.parseInt(parts[1], 10);
if (!Number.isFinite(n) || n < 1024 || n > 1_000_000) return false;
const salt = parts[2];
const expectedHex = parts[3];
if (!salt || !expectedHex) return false;
let actual: Buffer;
let expected: Buffer;
try {
actual = scryptSync(plain, salt, KEY_LEN, { N: n });
expected = Buffer.from(expectedHex, "hex");
} catch {
return false;
}
if (actual.length !== expected.length) return false;
try {
return timingSafeEqual(actual, expected);
} catch {
return false;
}
}