Document Directus production contract and repair auth

This commit is contained in:
makearmy 2026-07-10 10:51:04 -04:00
parent 0a7ee5ff35
commit 92ce8bce97
32 changed files with 24821 additions and 65 deletions

View file

@ -1,13 +1,12 @@
// app/api/auth/register/route.ts
import { NextResponse } from "next/server";
import { rateLimit, requestIp } from "@/lib/rate-limit";
import {
createDirectusUser,
directusUserExists,
loginDirectus,
} from "@/lib/directus";
const DIRECTUS = (process.env.DIRECTUS_URL || process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
const SERVICE_TOKEN =
process.env.DIRECTUS_SERVICE_TOKEN ||
process.env.DIRECTUS_ADMIN_TOKEN ||
process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || "";
const DEFAULT_ROLE = process.env.DIRECTUS_DEFAULT_ROLE || undefined;
const SECURE = process.env.NODE_ENV === "production";
function bad(message: string, status = 400) {
@ -15,16 +14,18 @@ function bad(message: string, status = 400) {
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
async function directusLogin(email: string, password: string) {
const r = await fetch(`${DIRECTUS}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ email, password }),
cache: "no-store",
function isConflict(error: any): boolean {
const code = error?.detail?.errors?.[0]?.extensions?.code;
const message = error?.detail?.errors?.[0]?.message || error?.message || "";
return error?.status === 409 || code === "RECORD_NOT_UNIQUE" || /unique|already exists/i.test(message);
}
function logDirectusFailure(stage: string, error: any) {
console.error(`[auth/register] Directus ${stage} failed`, {
status: error?.status,
code: error?.detail?.errors?.[0]?.extensions?.code,
message: error?.detail?.errors?.[0]?.message || error?.message,
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j?.errors?.[0]?.message || j?.message || `Login failed (${r.status})`);
return j?.data || j;
}
export async function POST(req: Request) {
@ -32,9 +33,6 @@ export async function POST(req: Request) {
if (!rateLimit(`register:${requestIp(req)}`, 5, 60 * 60_000)) {
return bad("Too many registration attempts. Please try again later.", 429);
}
if (!DIRECTUS) return bad("Missing DIRECTUS_URL/NEXT_PUBLIC_API_BASE_URL", 500);
if (!SERVICE_TOKEN) return bad("Missing DIRECTUS_SERVICE_TOKEN / admin token", 500);
const body = await req.json().catch(() => ({} as any));
const email = String(body?.email ?? "").trim().toLowerCase();
const username = String(body?.username ?? "").trim();
@ -46,53 +44,35 @@ export async function POST(req: Request) {
if (password.length < 8) return bad("Password must be at least 8 characters");
if (password !== confirm) return bad("Passwords do not match");
// Optional pre-check to return a friendly 409 instead of a generic Directus error
const existsRes = await fetch(
`${DIRECTUS}/users?filter[_or][0][email][_eq]=${encodeURIComponent(email)}` +
`&filter[_or][1][username][_eq]=${encodeURIComponent(username)}` +
`&fields=id,email,username&limit=1`,
{
headers: {
Authorization: `Bearer ${SERVICE_TOKEN}`,
Accept: "application/json",
},
cache: "no-store",
try {
if (await directusUserExists(email, username)) {
return bad("Email or username already in use", 409);
}
);
const existsJson = await existsRes.json().catch(() => ({}));
if (Array.isArray(existsJson?.data) && existsJson.data.length > 0) {
return bad("Email or username already in use", 409);
} catch (error: any) {
logDirectusFailure("duplicate lookup", error);
return bad("Sign-up is temporarily unavailable.", 503);
}
// Create user with sane defaults
const createPayload: any = {
email,
username,
password,
};
if (DEFAULT_ROLE) createPayload.role = DEFAULT_ROLE;
const createRes = await fetch(`${DIRECTUS}/users`, {
method: "POST",
headers: {
Authorization: `Bearer ${SERVICE_TOKEN}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(createPayload),
cache: "no-store",
});
const cj = await createRes.json().catch(() => ({}));
if (!createRes.ok) {
const msg = cj?.errors?.[0]?.message || cj?.message || `User create failed (${createRes.status})`;
return bad(msg, createRes.status || 500);
let user: { id: string };
try {
// Resolves the production Users role through the Registration Bot
// policy, then creates an active local-auth account in that role.
user = await createDirectusUser({ email, username, password });
} catch (error: any) {
if (isConflict(error)) return bad("Email or username already in use", 409);
logDirectusFailure("user creation", error);
return bad("Sign-up is temporarily unavailable.", 503);
}
// Auto-login (email-based; directus expects "email" even though it's an identifier)
const tokens = await directusLogin(email, password);
let tokens: any;
try {
tokens = await loginDirectus(email, password);
} catch (error: any) {
logDirectusFailure("post-registration login", error);
return bad("Your account was created, but automatic sign-in failed. Please sign in.", 502);
}
const res = NextResponse.json({ ok: true, id: cj?.data?.id || null }, { status: 201 });
const res = NextResponse.json({ ok: true, id: user.id }, { status: 201 });
if (tokens?.access_token) {
res.cookies.set("ma_at", tokens.access_token, {
path: "/",

View file

@ -4,14 +4,28 @@
// ⚠️ NOTE: Do NOT import `headers()` / `cookies()` from "next/headers" here.
// Next 15's types can make those async and break build-time type checks in shared libs.
const BASE = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const TOKEN_ADMIN_REGISTER = process.env.DIRECTUS_TOKEN_ADMIN_REGISTER || ""; // server-only
const BASE = (
process.env.DIRECTUS_URL ||
process.env.NEXT_PUBLIC_API_BASE_URL ||
""
).replace(/\/$/, "");
// DIRECTUS_TOKEN_ADMIN_REGISTER is the canonical credential for the production
// Registration Bot policy. Keep the older aliases as fallbacks so an existing
// deployment does not silently lose authentication during migration.
const TOKEN_ADMIN_REGISTER =
process.env.DIRECTUS_TOKEN_ADMIN_REGISTER ||
process.env.DIRECTUS_SERVICE_TOKEN ||
process.env.DIRECTUS_ADMIN_TOKEN ||
""; // server-only
const ROLE_MEMBER_NAME = process.env.DIRECTUS_ROLE_MEMBER_NAME || "Users";
const PROJECTS_COLLECTION = process.env.DIRECTUS_PROJECTS_COLLECTION || "projects";
if (!BASE) console.warn("[directus] Missing DIRECTUS_URL");
if (!BASE) console.warn("[directus] Missing DIRECTUS_URL / NEXT_PUBLIC_API_BASE_URL");
if (!TOKEN_ADMIN_REGISTER)
console.warn("[directus] Missing DIRECTUS_TOKEN_ADMIN_REGISTER (used for registration and username login)");
console.warn(
"[directus] Missing DIRECTUS_TOKEN_ADMIN_REGISTER (or legacy service-token alias) " +
"used for registration and username login"
);
export function bytesFromMB(mb: number) {
return Math.round(mb * 1024 * 1024);
@ -119,7 +133,8 @@ export async function dxDELETE<T = any>(path: string, bearer: string): Promise<T
// ─────────────────────────────────────────────────────────────
export async function directusAdminFetch<T = any>(path: string, init?: RequestInit): Promise<T> {
if (!TOKEN_ADMIN_REGISTER) throw new Error("Missing DIRECTUS_TOKEN_ADMIN_REGISTER");
if (!BASE) throw new Error("Missing Directus base URL");
if (!TOKEN_ADMIN_REGISTER) throw new Error("Missing Directus registration credential");
const res = await fetch(`${BASE}${path}`, {
...init,
headers: {
@ -231,13 +246,25 @@ export async function createDirectusUser(input: {
}
export async function emailForUsername(username: string): Promise<string | null> {
const q = `/users?filter[username][_eq]=${encodeURIComponent(username)}&fields=email&limit=1`;
const clean = username.trim();
if (!clean) return null;
const q = `/users?filter[username][_eq]=${encodeURIComponent(clean)}&fields=email&limit=1`;
const { data } = await directusAdminFetch<{ data: Array<{ email?: string }> }>(q);
const em = data?.[0]?.email;
return em ? String(em) : null;
}
export async function directusUserExists(email: string, username: string): Promise<boolean> {
const q =
`/users?filter[_or][0][email][_eq]=${encodeURIComponent(email)}` +
`&filter[_or][1][username][_eq]=${encodeURIComponent(username)}` +
`&fields=id&limit=1`;
const { data } = await directusAdminFetch<{ data: Array<{ id: string }> }>(q);
return Array.isArray(data) && data.length > 0;
}
export async function loginDirectus(email: string, password: string) {
if (!BASE) throw new Error("Missing Directus base URL");
const res = await fetch(`${BASE}/auth/login`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },