de6b466df7
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>
446 lines
14 KiB
JavaScript
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,
|
|
}
|