Files
ZachariahSharma de6b466df7 feat: add managed api users and keys
Implements lib/api-users-store.cjs with full CRUD + auth, 7 admin REST
endpoints in server.cjs, and integration tests for all store functions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 01:47:54 -06:00

446 lines
14 KiB
JavaScript

"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,
}