refactor(api): refactor API module with route definitions and response utilities

Restructure the core API module to improve clarity, consistency, and
maintainability:

- Introduce `defineApiRoutes()` helper for declarative route definitions
  with built-in config validation at startup
- Add `apiSuccess()` / `apiError()` response utilities; enforce their
  use across all handlers (core and modules)
- Move auth enforcement to route definitions (`auth: 'public' | 'user' |
  'admin'`), removing manual auth checks from handlers
- Extract core routes into `core-routes.js`; router now has no knowledge
  of specific features
- Rename `nx-route.js` to `route-handler.js` and update package.json
  export accordingly
- Update ARCHITECTURE.md to reflect new API conventions and point to
  `src/core/api/README.md` for details
This commit is contained in:
2026-04-13 15:13:03 -04:00
parent 89741d4460
commit 4ddf834990
25 changed files with 1261 additions and 1185 deletions
+356
View File
@@ -0,0 +1,356 @@
/**
* Auth Feature — API Routes
*
* User management endpoints (profile, admin CRUD).
* Auth is enforced by the router before any handler is called — no manual
* session validation is needed here. The validated session is injected via
* the context argument: (request, params, { session }).
*/
import { query, updateById } from '@zen/core/database';
import { updateUser } from './lib/auth.js';
import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
import { fail, info } from '../../shared/lib/logger.js';
import { defineApiRoutes } from '../../core/api/define.js';
import { apiSuccess, apiError } from '../../core/api/respond.js';
/** Maximum number of users returned per paginated request */
const MAX_PAGE_LIMIT = 100;
/**
* Extension → MIME type map derived from the validated file extension.
* The client-supplied file.type is NEVER trusted.
*/
const EXTENSION_TO_MIME = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
};
/**
* Log the raw error server-side and return an opaque fallback.
* Never forward internal error details to the client.
*/
function logAndObscureError(error, fallback) {
fail(`Internal handler error: ${error.message}`);
return fallback;
}
// ---------------------------------------------------------------------------
// GET /zen/api/users/me
// ---------------------------------------------------------------------------
async function handleGetCurrentUser(_request, _params, { session }) {
return apiSuccess({
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
role: session.user.role,
image: session.user.image,
emailVerified: session.user.email_verified,
createdAt: session.user.created_at
}
});
}
// ---------------------------------------------------------------------------
// GET /zen/api/users/:id (admin only)
// ---------------------------------------------------------------------------
async function handleGetUserById(_request, { id: userId }) {
const result = await query(
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
return apiError('Not Found', 'User not found');
}
return apiSuccess({ user: result.rows[0] });
}
// ---------------------------------------------------------------------------
// PUT /zen/api/users/:id (admin only)
// ---------------------------------------------------------------------------
async function handleUpdateUserById(request, { id: userId }) {
try {
const body = await request.json();
const allowedFields = ['name', 'role', 'email_verified'];
const updateData = { updated_at: new Date() };
for (const field of allowedFields) {
if (body[field] !== undefined) {
if (field === 'email_verified') {
updateData[field] = Boolean(body[field]);
} else if (field === 'role') {
const role = String(body[field]).toLowerCase();
if (['admin', 'user'].includes(role)) {
updateData[field] = role;
}
} else if (field === 'name' && body[field] != null) {
updateData[field] = String(body[field]).trim() || null;
}
}
}
const updated = await updateById('zen_auth_users', userId, updateData);
if (!updated) {
return apiError('Not Found', 'User not found');
}
const result = await query(
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
[userId]
);
return apiSuccess({
success: true,
user: result.rows[0],
message: 'User updated successfully'
});
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to update user');
}
}
// ---------------------------------------------------------------------------
// GET /zen/api/users (admin only)
// ---------------------------------------------------------------------------
async function handleListUsers(request) {
// Both page and limit are clamped server-side; client-supplied values
// cannot force full-table scans or negative offsets.
const url = new URL(request.url);
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1);
const limit = Math.min(
Math.max(1, parseInt(url.searchParams.get('limit') || '10', 10) || 10),
MAX_PAGE_LIMIT
);
const offset = (page - 1) * limit;
const sortBy = url.searchParams.get('sortBy') || 'created_at';
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
// Whitelist allowed sort columns to prevent SQL injection via identifier injection.
const allowedSortColumns = ['id', 'email', 'name', 'role', 'email_verified', 'created_at'];
const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
// Wrap the whitelisted column in double-quotes to enforce identifier boundaries.
const quotedSortColumn = `"${sortColumn}"`;
const result = await query(
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY ${quotedSortColumn} ${order} LIMIT $1 OFFSET $2`,
[limit, offset]
);
const countResult = await query('SELECT COUNT(*) FROM zen_auth_users');
const total = parseInt(countResult.rows[0].count);
return apiSuccess({
users: result.rows,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
});
}
// ---------------------------------------------------------------------------
// PUT /zen/api/users/profile
// ---------------------------------------------------------------------------
async function handleUpdateProfile(request, _params, { session }) {
try {
const body = await request.json();
const { name } = body;
if (!name || !name.trim()) {
return apiError('Bad Request', 'Name is required');
}
const updatedUser = await updateUser(session.user.id, { name: name.trim() });
return apiSuccess({
success: true,
user: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
role: updatedUser.role,
image: updatedUser.image,
email_verified: updatedUser.email_verified,
created_at: updatedUser.created_at
},
message: 'Profile updated successfully'
});
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to update profile');
}
}
// ---------------------------------------------------------------------------
// POST /zen/api/users/profile/picture
// ---------------------------------------------------------------------------
async function handleUploadProfilePicture(request, _params, { session }) {
try {
const formData = await request.formData();
const file = formData.get('file');
if (!file) {
return apiError('Bad Request', 'No file provided');
}
// Read the buffer once — used for both content inspection and the upload.
const buffer = Buffer.from(await file.arrayBuffer());
// Validate file — passes buffer for magic-byte / content-pattern inspection.
const validation = validateUpload({
filename: file.name,
size: file.size,
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
maxSize: FILE_SIZE_LIMITS.AVATAR,
buffer,
});
if (!validation.valid) {
return apiError('Bad Request', validation.errors.join(', '));
}
// Check for an existing profile picture to replace later.
const currentUser = await query(
'SELECT image FROM zen_auth_users WHERE id = $1',
[session.user.id]
);
const oldImageKey = currentUser.rows[0]?.image ?? null;
// Generate storage path.
const uniqueFilename = generateUniqueFilename(file.name, 'avatar');
const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename);
// Derive content-type from the validated extension — never from file.type,
// which is fully attacker-controlled.
const ext = getFileExtension(file.name).toLowerCase();
const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
const uploadResult = await uploadImage({
key,
body: buffer,
contentType,
metadata: { userId: session.user.id, originalName: file.name }
});
if (!uploadResult.success) {
return apiError('Internal Server Error', 'Failed to upload image');
}
// Commit the DB write first. Only on success do we remove the old object —
// an orphaned old object is preferable to a broken DB reference.
let updatedUser;
try {
updatedUser = await updateUser(session.user.id, { image: key });
} catch (dbError) {
// Roll back the newly uploaded object so storage stays clean.
try {
await deleteFile(key);
} catch (rollbackError) {
fail(`Rollback delete of newly uploaded object failed: ${rollbackError.message}`);
}
throw dbError;
}
if (oldImageKey) {
try {
await deleteFile(oldImageKey);
info(`Deleted old profile picture: ${oldImageKey}`);
} catch (deleteError) {
// Non-fatal: log for operator cleanup; the DB reference is consistent.
fail(`Failed to delete old profile picture (orphaned object): ${deleteError.message}`);
}
}
return apiSuccess({
success: true,
user: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
role: updatedUser.role,
image: updatedUser.image,
email_verified: updatedUser.email_verified,
created_at: updatedUser.created_at
},
message: 'Profile picture uploaded successfully'
});
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to upload profile picture');
}
}
// ---------------------------------------------------------------------------
// DELETE /zen/api/users/profile/picture
// ---------------------------------------------------------------------------
async function handleDeleteProfilePicture(_request, _params, { session }) {
try {
const currentUser = await query(
'SELECT image FROM zen_auth_users WHERE id = $1',
[session.user.id]
);
if (currentUser.rows.length === 0) {
return apiError('Not Found', 'User not found');
}
const imageKey = currentUser.rows[0].image;
if (!imageKey) {
return apiError('Bad Request', 'No profile picture to delete');
}
const updatedUser = await updateUser(session.user.id, { image: null });
try {
await deleteFile(imageKey);
info(`Deleted profile picture: ${imageKey}`);
} catch (deleteError) {
// Non-fatal: the DB is already updated; log for operator cleanup.
fail(`Failed to delete profile picture from storage: ${deleteError.message}`);
}
return apiSuccess({
success: true,
user: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
role: updatedUser.role,
image: updatedUser.image,
email_verified: updatedUser.email_verified,
created_at: updatedUser.created_at
},
message: 'Profile picture deleted successfully'
});
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Failed to delete profile picture');
}
}
// ---------------------------------------------------------------------------
// Route definitions
// ---------------------------------------------------------------------------
//
// Order matters: specific paths (/users/me, /users/profile) must come before
// parameterised paths (/users/:id) so they match first.
export const routes = defineApiRoutes([
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
]);