"use strict" const crypto = require("crypto") const { withTransaction } = require("./db.cjs") const { createApiKey, hashSecret } = require("./security.cjs") // ─── Validation helpers ─────────────────────────────────────────────────────── function validateInput(input, requireFields = false) { const errors = [] if (requireFields || input.displayName !== undefined) { if (!input.displayName || typeof input.displayName !== "string" || !input.displayName.trim()) { errors.push("displayName must be a non-empty string") } } // At least one of allowPre/allowPost must be true — only enforce on create or if both are being set if (requireFields) { if (!input.allowPre && !input.allowPost) { errors.push("at least one of allowPre or allowPost must be true") } } else if (input.allowPre !== undefined || input.allowPost !== undefined) { // If patching permissions, validate the combined intent isn't both false const pre = input.allowPre !== undefined ? input.allowPre : true // unknown; can't validate without DB const post = input.allowPost !== undefined ? input.allowPost : true if (input.allowPre !== undefined && input.allowPost !== undefined) { if (!input.allowPre && !input.allowPost) { errors.push("at least one of allowPre or allowPost must be true") } } } if (requireFields || input.requestsPerMinute !== undefined) { if (input.requestsPerMinute !== undefined) { if (typeof input.requestsPerMinute !== "number" || !Number.isInteger(input.requestsPerMinute) || input.requestsPerMinute <= 0) { errors.push("requestsPerMinute must be a positive integer") } } } if (requireFields || input.monthlyTokenLimit !== undefined) { if (input.monthlyTokenLimit !== undefined) { if (typeof input.monthlyTokenLimit !== "number" || input.monthlyTokenLimit <= 0) { errors.push("monthlyTokenLimit must be a positive number") } } } if (input.expiresAt !== undefined && input.expiresAt !== null) { const exp = input.expiresAt instanceof Date ? input.expiresAt : new Date(input.expiresAt) if (isNaN(exp.getTime())) { errors.push("expiresAt must be a valid date") } else if (exp.getTime() <= Date.now()) { errors.push("expiresAt must be in the future") } } return errors } // ─── createApiUser ───────────────────────────────────────────────────────────── async function createApiUser(pool, input) { const errors = validateInput(input, true) if (errors.length) { const err = new Error(errors[0]) err.validationErrors = errors err.status = 400 throw err } const userId = crypto.randomUUID() const keyId = crypto.randomUUID() const { plaintext, hash, suffix } = createApiKey() const expiresAt = input.expiresAt ? (input.expiresAt instanceof Date ? input.expiresAt : new Date(input.expiresAt)) : null const result = await withTransaction(pool, async (client) => { const { rows: [userRow] } = await client.query( `INSERT INTO api_users (id, display_name, status, allow_pre, allow_post, requests_per_minute, monthly_token_limit, expires_at) VALUES ($1, $2, 'active', $3, $4, $5, $6, $7) RETURNING *`, [ userId, input.displayName.trim(), Boolean(input.allowPre), Boolean(input.allowPost), input.requestsPerMinute, input.monthlyTokenLimit, expiresAt, ] ) const { rows: [keyRow] } = await client.query( `INSERT INTO api_keys (id, api_user_id, key_hash, key_suffix) VALUES ($1, $2, $3, $4) RETURNING *`, [keyId, userId, hash, suffix] ) return { userRow, keyRow } }) return { user: { ...result.userRow, keySuffix: result.keyRow.key_suffix }, plaintextKey: plaintext, } } // ─── listApiUsers ───────────────────────────────────────────────────────────── async function listApiUsers(pool) { const { rows } = await pool.query( `SELECT u.*, k.key_suffix, k.created_at AS key_created_at FROM api_users u LEFT JOIN api_keys k ON k.api_user_id = u.id AND k.revoked_at IS NULL WHERE u.deleted_at IS NULL ORDER BY u.created_at DESC` ) return rows } // ─── updateApiUser ───────────────────────────────────────────────────────────── async function updateApiUser(pool, id, patch) { const errors = validateInput(patch, false) if (errors.length) { const err = new Error(errors[0]) err.validationErrors = errors err.status = 400 throw err } // Build dynamic SET clause const sets = ["updated_at = now()"] const values = [] let idx = 1 if (patch.displayName !== undefined) { sets.push(`display_name = $${idx++}`) values.push(patch.displayName.trim()) } if (patch.allowPre !== undefined) { sets.push(`allow_pre = $${idx++}`) values.push(Boolean(patch.allowPre)) } if (patch.allowPost !== undefined) { sets.push(`allow_post = $${idx++}`) values.push(Boolean(patch.allowPost)) } if (patch.requestsPerMinute !== undefined) { sets.push(`requests_per_minute = $${idx++}`) values.push(patch.requestsPerMinute) } if (patch.monthlyTokenLimit !== undefined) { sets.push(`monthly_token_limit = $${idx++}`) values.push(patch.monthlyTokenLimit) } if (patch.expiresAt !== undefined) { sets.push(`expires_at = $${idx++}`) values.push( patch.expiresAt === null ? null : (patch.expiresAt instanceof Date ? patch.expiresAt : new Date(patch.expiresAt)) ) } values.push(id) const { rows } = await pool.query( `UPDATE api_users SET ${sets.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING *`, values ) if (!rows.length) { const err = new Error("API user not found or deleted") err.status = 404 throw err } return rows[0] } // ─── rotateApiUserKey ───────────────────────────────────────────────────────── async function rotateApiUserKey(pool, id) { const result = await withTransaction(pool, async (client) => { const { rows: [user] } = await client.query( "SELECT * FROM api_users WHERE id = $1", [id] ) if (!user) { const err = new Error("API user not found") err.status = 404 throw err } if (user.deleted_at) { const err = new Error("API user is deleted") err.status = 400 throw err } if (user.status === "revoked") { const err = new Error("API user is revoked") err.status = 400 throw err } // Revoke current active key(s) await client.query( "UPDATE api_keys SET revoked_at = now() WHERE api_user_id = $1 AND revoked_at IS NULL", [id] ) // Generate new key const newKeyId = crypto.randomUUID() const { plaintext, hash, suffix } = createApiKey() const { rows: [keyRow] } = await client.query( `INSERT INTO api_keys (id, api_user_id, key_hash, key_suffix) VALUES ($1, $2, $3, $4) RETURNING *`, [newKeyId, id, hash, suffix] ) // Update updated_at on user const { rows: [updatedUser] } = await client.query( "UPDATE api_users SET updated_at = now() WHERE id = $1 RETURNING *", [id] ) return { user: updatedUser, keyRow, plaintext } }) return { user: { ...result.user, keySuffix: result.keyRow.key_suffix }, plaintextKey: result.plaintext, } } // ─── revokeApiUser ──────────────────────────────────────────────────────────── async function revokeApiUser(pool, id) { await withTransaction(pool, async (client) => { const { rows: [user] } = await client.query( "SELECT * FROM api_users WHERE id = $1", [id] ) if (!user) { const err = new Error("API user not found") err.status = 404 throw err } if (user.deleted_at) { const err = new Error("API user is deleted") err.status = 400 throw err } // Revoke active key(s) await client.query( "UPDATE api_keys SET revoked_at = now() WHERE api_user_id = $1 AND revoked_at IS NULL", [id] ) // Set user status to revoked await client.query( "UPDATE api_users SET status = 'revoked', revoked_at = now(), updated_at = now() WHERE id = $1", [id] ) }) } // ─── reactivateApiUser ──────────────────────────────────────────────────────── async function reactivateApiUser(pool, id) { const result = await withTransaction(pool, async (client) => { const { rows: [user] } = await client.query( "SELECT * FROM api_users WHERE id = $1", [id] ) if (!user) { const err = new Error("API user not found") err.status = 404 throw err } if (user.deleted_at) { const err = new Error("API user is deleted") err.status = 400 throw err } if (user.status === "active") { const err = new Error("API user is already active") err.status = 400 throw err } // Generate new key const newKeyId = crypto.randomUUID() const { plaintext, hash, suffix } = createApiKey() const { rows: [keyRow] } = await client.query( `INSERT INTO api_keys (id, api_user_id, key_hash, key_suffix) VALUES ($1, $2, $3, $4) RETURNING *`, [newKeyId, id, hash, suffix] ) // Reactivate user const { rows: [updatedUser] } = await client.query( `UPDATE api_users SET status = 'active', revoked_at = null, updated_at = now() WHERE id = $1 RETURNING *`, [id] ) return { user: updatedUser, keyRow, plaintext } }) return { user: { ...result.user, keySuffix: result.keyRow.key_suffix }, plaintextKey: result.plaintext, } } // ─── deleteApiUser ──────────────────────────────────────────────────────────── async function deleteApiUser(pool, id) { await withTransaction(pool, async (client) => { const { rows: [user] } = await client.query( "SELECT * FROM api_users WHERE id = $1", [id] ) if (!user) { const err = new Error("API user not found") err.status = 404 throw err } if (user.deleted_at) { const err = new Error("API user is already deleted") err.status = 400 throw err } // Revoke any active key(s) await client.query( "UPDATE api_keys SET revoked_at = now() WHERE api_user_id = $1 AND revoked_at IS NULL", [id] ) // Soft-delete await client.query( `UPDATE api_users SET deleted_at = now(), status = 'deleted', updated_at = now() WHERE id = $1`, [id] ) }) } // ─── authenticateApiKey ─────────────────────────────────────────────────────── async function authenticateApiKey(pool, plaintextKey, route, now = new Date()) { const keyHash = hashSecret(plaintextKey) let keyRow, userRow try { const { rows } = await pool.query( `SELECT k.*, u.id AS user_id, u.display_name, u.status, u.allow_pre, u.allow_post, u.requests_per_minute, u.monthly_token_limit, u.expires_at, u.created_at AS user_created_at, u.updated_at AS user_updated_at, u.last_used_at, u.revoked_at AS user_revoked_at, u.deleted_at FROM api_keys k JOIN api_users u ON u.id = k.api_user_id WHERE k.key_hash = $1`, [keyHash] ) if (!rows.length) return { ok: false, reason: "invalid" } keyRow = rows[0] } catch (err) { throw err } // Check key revocation if (keyRow.revoked_at) return { ok: false, reason: "revoked" } // Check user status if (keyRow.status === "revoked" || keyRow.deleted_at) { return { ok: false, reason: "revoked" } } // Check expiration if (keyRow.expires_at && new Date(keyRow.expires_at) <= now) { return { ok: false, reason: "expired" } } // Check route permissions if (route === "pre" && !keyRow.allow_pre) return { ok: false, reason: "forbidden" } if (route === "post" && !keyRow.allow_post) return { ok: false, reason: "forbidden" } // Update last_used_at (fire-and-forget style — don't fail auth if update fails) pool.query( "UPDATE api_users SET last_used_at = $1 WHERE id = $2", [now, keyRow.api_user_id] ).catch(() => {}) // Reconstruct userRow from joined columns const user = { id: keyRow.api_user_id, display_name: keyRow.display_name, status: keyRow.status, allow_pre: keyRow.allow_pre, allow_post: keyRow.allow_post, requests_per_minute: keyRow.requests_per_minute, monthly_token_limit: keyRow.monthly_token_limit, expires_at: keyRow.expires_at, created_at: keyRow.user_created_at, updated_at: keyRow.user_updated_at, last_used_at: keyRow.last_used_at, revoked_at: keyRow.user_revoked_at, deleted_at: keyRow.deleted_at, } return { ok: true, user, keyId: keyRow.id } } module.exports = { createApiUser, listApiUsers, updateApiUser, rotateApiUserKey, revokeApiUser, reactivateApiUser, deleteApiUser, authenticateApiKey, }