view mrjunejune/admin_api.c @ 267:a78f5986011f

discover bundled production runtimes Locate Bazel-packaged Copilot and Python binaries by stable suffix instead of hardcoding an external repository prefix. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 12:58:55 -0700
parents 04fee26ecce0
children
line wrap: on
line source

#include "mrjunejune/admin_api.h"
#include "mrjunejune/auth_api.h"
#include "mrjunejune/template_renderer.h"

#include "auth/auth_store.h"
#include "auth/auth_crypto.h"
#include "seobeo/seobeo.h"
#include "dowa/dowa.h"

#include <openssl/crypto.h>

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

/* ------------------------------------------------------------------ */
/* Constants                                                            */
/* ------------------------------------------------------------------ */

#define ADMIN_BODY_MAX_BYTES   4096
#define ADMIN_UUID_SIZE        37

/* ------------------------------------------------------------------ */
/* Internal helpers                                                     */
/* ------------------------------------------------------------------ */

static const char *admin_req_value(
    Seobeo_Request_Entry *p_req,
    const char           *key)
{
  void *p = Dowa_HashMap_Get_Ptr(p_req, (char *)key);
  return p ? ((Seobeo_Request_Entry *)p)->value : NULL;
}

static boolean admin_extract_secret_field(
    Dowa_JSON_Entry *obj,
    const char      *key,
    char            *out_buf,
    size_t           max_len)
{
  char *arena_ptr = Dowa_JSON_Get_String(obj, key);
  if (!arena_ptr || arena_ptr[0] == '\0')
    return FALSE;

  size_t field_len = strlen(arena_ptr);
  if (field_len > max_len)
  {
    OPENSSL_cleanse(arena_ptr, field_len);
    return FALSE;
  }

  memcpy(out_buf, arena_ptr, field_len);
  out_buf[field_len] = '\0';
  OPENSSL_cleanse(arena_ptr, field_len);
  return TRUE;
}

static Seobeo_Request_Entry *admin_json_response(
    Dowa_Arena *p_arena,
    const char *status,
    const char *body)
{
  Seobeo_Request_Entry *resp = NULL;
  Dowa_HashMap_Push_Arena(resp, "status", (char *)status, p_arena);
  Dowa_HashMap_Push_Arena(
      resp, "content-type", "application/json; charset=utf-8", p_arena);
  Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
  Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
  Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
  Dowa_HashMap_Push_Arena(resp, "body", (char *)body, p_arena);
  return resp;
}

static Seobeo_Request_Entry *admin_error(
    Dowa_Arena *p_arena,
    const char *status,
    const char *code,
    const char *message)
{
  char buf[512];
  snprintf(buf, sizeof(buf),
           "{\"error\":{\"code\":\"%s\",\"message\":\"%s\"}}",
           code, message);
  char *body = Dowa_Arena_Allocate(p_arena, strlen(buf) + 1);
  if (body) strcpy(body, buf);
  return admin_json_response(p_arena, status, body ? body : "{}");
}

static Seobeo_Request_Entry *admin_html_redirect(
    Dowa_Arena *p_arena,
    const char *location)
{
  Seobeo_Request_Entry *resp = NULL;
  Dowa_HashMap_Push_Arena(resp, "status",   "302",          p_arena);
  Dowa_HashMap_Push_Arena(resp, "Location", (char *)location, p_arena);
  Dowa_HashMap_Push_Arena(resp, "body",     "",             p_arena);
  return resp;
}

/*
 * Gate: resolve principal, enforce admin+active, reject forced-password.
 * On failure sets *pp_err_resp and returns FALSE.
 * On success fills *p_principal and returns TRUE.
 *
 * For page requests (is_page==TRUE), unauthenticated → 302 redirect.
 * For API requests (is_page==FALSE), unauthenticated → 401 JSON.
 */
static boolean admin_require_admin(
    Seobeo_Request_Entry  *p_req,
    Auth_Principal        *p_principal,
    Dowa_Arena            *p_arena,
    boolean                is_page,
    Seobeo_Request_Entry **pp_err_resp)
{
  Auth_Store *store = Auth_API_Get_Store();
  if (!store)
  {
    *pp_err_resp = admin_error(p_arena, "503", "service_unavailable",
                               "Auth not initialised");
    return FALSE;
  }

  boolean found = FALSE;
  if (!Auth_API_Resolve_Existing_Principal(p_req, p_principal, p_arena, &found))
  {
    *pp_err_resp = admin_error(p_arena, "500", "internal_error",
                               "Session error");
    return FALSE;
  }

  if (!found || p_principal->kind != AUTH_PRINCIPAL_USER)
  {
    *pp_err_resp = is_page
        ? admin_html_redirect(p_arena, "/login")
        : admin_error(p_arena, "401", "unauthenticated",
                      "Authentication required");
    return FALSE;
  }

  if (p_principal->must_change_password)
  {
    *pp_err_resp = is_page
        ? admin_html_redirect(p_arena, "/account/password")
        : admin_error(p_arena, "403", "password_change_required",
                      "Password change required");
    return FALSE;
  }

  if (strcmp(p_principal->role, "admin") != 0)
  {
    *pp_err_resp = admin_error(p_arena, "403", "forbidden",
                               "Admin access required");
    return FALSE;
  }

  *pp_err_resp = NULL;
  return TRUE;
}

/*
 * Validate that `:id` param is a non-empty UUID-shaped string.
 * Fills id_out (capacity >= 37).  Returns FALSE on invalid/missing.
 */
static boolean admin_get_id_param(
    Seobeo_Request_Entry *p_req,
    char                 *id_out,
    size_t                capacity)
{
  void *kv = Dowa_HashMap_Get_Ptr(p_req, ":id");
  if (!kv) return FALSE;
  const char *val = ((Seobeo_Request_Entry *)kv)->value;
  if (!val || val[0] == '\0') return FALSE;
  size_t vlen = strlen(val);
  if (vlen != 36) return FALSE;  /* UUID is 36 chars + NUL */
  if (vlen >= capacity) return FALSE;
  memcpy(id_out, val, vlen);
  id_out[vlen] = '\0';
  return TRUE;
}

/*
 * Append one JSON user object to buf (returns new offset, -1 on error).
 * Never includes password_hash, session digests, or guest IDs.
 * p_arena is used for temporary string escaping.
 */
static int admin_append_user_json(
    char                  *buf,
    size_t                 capacity,
    size_t                 offset,
    boolean                is_first,
    const Auth_User_Record *u,
    Dowa_Arena            *p_arena)
{
  char *safe_id   = Dowa_JSON_Escape_String(u->id,       0, p_arena);
  char *safe_user = Dowa_JSON_Escape_String(u->username, 0, p_arena);
  char *safe_role = Dowa_JSON_Escape_String(u->role,     0, p_arena);
  char *safe_stat = Dowa_JSON_Escape_String(u->status,   0, p_arena);
  if (!safe_id || !safe_user || !safe_role || !safe_stat)
    return -1;

  int n = snprintf(buf + offset, capacity - offset,
                   "%s{"
                   "\"id\":\"%s\","
                   "\"username\":\"%s\","
                   "\"role\":\"%s\","
                   "\"status\":\"%s\","
                   "\"mustChangePassword\":%s,"
                   "\"createdAt\":%lld,"
                   "\"updatedAt\":%lld,"
                   "\"passwordChangedAt\":%lld"
                   "}",
                   is_first ? "" : ",",
                   safe_id, safe_user, safe_role, safe_stat,
                   u->must_change_password ? "true" : "false",
                   (long long)u->created_at,
                   (long long)u->updated_at,
                   (long long)u->password_changed_at);

  if (n < 0 || (size_t)n >= capacity - offset)
    return -1;
  return (int)(offset + (size_t)n);
}

/* ------------------------------------------------------------------ */
/* Route: GET /admin/users  (page)                                      */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *admin_page_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  Auth_Principal principal;
  Seobeo_Request_Entry *err = NULL;
  if (!admin_require_admin(p_req, &principal, p_arena, TRUE, &err))
    return err;

  char *body = Dowa_Arena_Allocate(p_arena, 128 * 1024);
  if (!body || !Mjj_Template_Render_File(body, 128 * 1024, "/admin/users/index.html", p_arena))
  {
    return admin_error(p_arena, "500", "internal_error", "Render failed");
  }

  Seobeo_Request_Entry *resp = NULL;
  Dowa_HashMap_Push_Arena(resp, "status", "200", p_arena);
  Dowa_HashMap_Push_Arena(resp, "content-type", "text/html; charset=utf-8", p_arena);
  Dowa_HashMap_Push_Arena(resp, "cache-control", "no-store", p_arena);
  Dowa_HashMap_Push_Arena(resp, "pragma", "no-cache", p_arena);
  Dowa_HashMap_Push_Arena(resp, "x-content-type-options", "nosniff", p_arena);
  Dowa_HashMap_Push_Arena(resp, "referrer-policy", "no-referrer", p_arena);
  Dowa_HashMap_Push_Arena(
      resp, "content-security-policy", "frame-ancestors 'none'", p_arena);
  Dowa_HashMap_Push_Arena(resp, "body", body, p_arena);
  return resp;
}

static Seobeo_Request_Entry *admin_list_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  Auth_Principal principal;
  Seobeo_Request_Entry *err = NULL;
  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
    return err;

  Auth_Store *store = Auth_API_Get_Store();

  /* Pagination params from query string; defaults applied. */
  int64 page  = 1;
  int64 limit = ADMIN_API_DEFAULT_LIMIT;
  const char *qpage  = admin_req_value(p_req, "Query-page");
  const char *qlimit = admin_req_value(p_req, "Query-limit");
  if (qpage  && qpage[0]  != '\0') page  = atol(qpage);
  if (qlimit && qlimit[0] != '\0') limit = atol(qlimit);
  if (page  < 1)                   page  = 1;
  if (limit < 1)                   limit = 1;
  if (limit > ADMIN_API_MAX_LIMIT) limit = ADMIN_API_MAX_LIMIT;

  Dowa_Arena *list_arena = Dowa_Arena_Create(128 * 1024);
  if (!list_arena)
    return admin_error(p_arena, "500", "internal_error", "OOM");

  Auth_User_Record *records = NULL;
  Auth_Store_Result res = Auth_Store_List_Users(store, &records, list_arena);
  if (res != AUTH_STORE_OK)
  {
    Dowa_Arena_Free(list_arena);
    return admin_error(p_arena, "500", "internal_error", "List users failed");
  }

  int64 total = (int64)Dowa_Array_Length(records);
  int64 offset_start = total;
  if (page - 1 <= total / limit)
    offset_start = (page - 1) * limit;
  if (offset_start > total)
    offset_start = total;
  int64 offset_end = total - offset_start < limit
      ? total
      : offset_start + limit;

  /* Build JSON response in the request arena. */
  size_t resp_size = 64 + (size_t)(total > 0 ? total : 1) * 400;
  char *body = Dowa_Arena_Allocate(p_arena, resp_size);
  if (!body)
  {
    Dowa_Arena_Free(list_arena);
    return admin_error(p_arena, "500", "internal_error", "OOM");
  }

  int n = snprintf(body, resp_size,
                   "{\"total\":%lld,\"page\":%lld,\"limit\":%lld,\"users\":[",
                   (long long)total, (long long)page, (long long)limit);
  if (n < 0)
  {
    Dowa_Arena_Free(list_arena);
    return admin_error(p_arena, "500", "internal_error", "Encode error");
  }
  size_t pos = (size_t)n;
  boolean first = TRUE;

  for (int64 i = offset_start; i < offset_end && pos < resp_size - 2; i++)
  {
    int nw = admin_append_user_json(body, resp_size, pos, first, &records[i], p_arena);
    if (nw < 0)
    {
      Dowa_Arena_Free(list_arena);
      return admin_error(p_arena, "500", "internal_error", "Encode error");
    }
    pos   = (size_t)nw;
    first = FALSE;
  }

  if (pos + 2 >= resp_size)
  {
    Dowa_Arena_Free(list_arena);
    return admin_error(p_arena, "500", "internal_error", "Buffer too small");
  }
  body[pos++] = ']';
  body[pos++] = '}';
  body[pos]   = '\0';

  Dowa_Arena_Free(list_arena);
  return admin_json_response(p_arena, "200", body);
}

/* ------------------------------------------------------------------ */
/* Route: POST /api/admin/users  (create)                               */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *admin_create_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  Auth_Principal principal;
  Seobeo_Request_Entry *err = NULL;
  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
    return err;

  if (!Auth_API_Verify_CSRF(p_req, &principal))
    return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");

  const char *body_str = admin_req_value(p_req, "Body");
  if (!body_str)
    return admin_error(p_arena, "400", "bad_request", "Invalid request body");

  size_t body_len = strlen(body_str);
  if (body_len > ADMIN_BODY_MAX_BYTES)
  {
    OPENSSL_cleanse((char *)body_str, body_len);
    return admin_error(p_arena, "400", "bad_request", "Invalid request body");
  }
  Dowa_JSON_Value jv =
      Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
  OPENSSL_cleanse((char *)body_str, body_len);
  if (jv.type != DOWA_JSON_OBJECT)
    return admin_error(p_arena, "400", "bad_request", "Expected JSON object");

  Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
  char *username_raw  = Dowa_JSON_Get_String(obj, "username");
  char *role_raw      = Dowa_JSON_Get_String(obj, "role");
  char password_buf[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
  memset(password_buf, 0, sizeof(password_buf));
  boolean have_password = admin_extract_secret_field(
      obj, "temporaryPassword", password_buf,
      AUTH_CRYPTO_PASSWORD_MAX_BYTES);
  char *password_raw = password_buf;

  if (!username_raw || username_raw[0] == '\0' ||
      !have_password)
  {
    OPENSSL_cleanse(password_buf, sizeof(password_buf));
    return admin_error(p_arena, "400", "bad_request",
                       "username and temporaryPassword required");
  }

  const char *role = "member";
  if (role_raw && role_raw[0] != '\0')
  {
    if (strcmp(role_raw, "admin") != 0 && strcmp(role_raw, "member") != 0)
    {
      OPENSSL_cleanse(password_raw, strlen(password_raw));
      return admin_error(p_arena, "400", "invalid_role",
                         "role must be admin or member");
    }
    role = role_raw;
  }

  size_t pw_len = strlen(password_raw);
  if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
  {
    OPENSSL_cleanse(password_raw, pw_len);
    return admin_error(p_arena, "400", "password_policy",
                       "Password must be 12 to 1024 characters");
  }

  char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
  Auth_Crypto_Result hr =
      Auth_Crypto_Password_Hash(password_raw, encoded_hash, sizeof(encoded_hash));
  OPENSSL_cleanse(password_raw, pw_len);

  if (hr != AUTH_CRYPTO_OK)
  {
    OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
    return admin_error(p_arena, "500", "internal_error", "Hash error");
  }

  char new_id[ADMIN_UUID_SIZE];
  Auth_Store *store = Auth_API_Get_Store();
  Auth_Store_Result cr = Auth_Store_Create_User_Audited(
      store,
      username_raw,
      encoded_hash,
      role,
      TRUE,   /* must_change_password */
      principal.user_id,
      new_id);
  OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));

  if (cr == AUTH_STORE_CONFLICT)
    return admin_error(p_arena, "409", "conflict", "Username already exists");
  if (cr != AUTH_STORE_OK)
    return admin_error(p_arena, "500", "internal_error", "Create user failed");

  /* Fetch the created record for response. */
  Auth_User_Record rec;
  memset(&rec, 0, sizeof(rec));
  if (Auth_Store_Get_User(store, new_id, &rec) != AUTH_STORE_OK)
    return admin_error(p_arena, "500", "internal_error", "Fetch failed");

  char *safe_id   = Dowa_JSON_Escape_String(rec.id, 0, p_arena);
  char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena);
  char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena);
  char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena);
  if (!safe_id || !safe_user || !safe_role || !safe_stat)
    return admin_error(p_arena, "500", "internal_error", "Encode error");

  char body_buf[512];
  snprintf(body_buf, sizeof(body_buf),
           "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\","
           "\"status\":\"%s\",\"mustChangePassword\":true,"
           "\"createdAt\":%lld}",
           safe_id, safe_user, safe_role, safe_stat,
           (long long)rec.created_at);

  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
  if (!body_copy)
    return admin_error(p_arena, "500", "internal_error", "OOM");
  strcpy(body_copy, body_buf);

  return admin_json_response(p_arena, "201", body_copy);
}

/* ------------------------------------------------------------------ */
/* Route: PATCH /api/admin/users/:id  (update)                          */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *admin_update_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  Auth_Principal principal;
  Seobeo_Request_Entry *err = NULL;
  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
    return err;

  if (!Auth_API_Verify_CSRF(p_req, &principal))
    return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");

  char target_id[ADMIN_UUID_SIZE];
  if (!admin_get_id_param(p_req, target_id, sizeof(target_id)))
    return admin_error(p_arena, "400", "bad_request", "Invalid or missing id");

  const char *body_str = admin_req_value(p_req, "Body");
  if (!body_str)
    return admin_error(p_arena, "400", "bad_request", "Invalid request body");

  size_t body_len = strlen(body_str);
  if (body_len > ADMIN_BODY_MAX_BYTES)
  {
    OPENSSL_cleanse((char *)body_str, body_len);
    return admin_error(p_arena, "400", "bad_request", "Invalid request body");
  }
  Dowa_JSON_Value jv =
      Dowa_JSON_Parse(body_str, (int32)body_len, p_arena);
  OPENSSL_cleanse((char *)body_str, body_len);
  if (jv.type != DOWA_JSON_OBJECT)
    return admin_error(p_arena, "400", "bad_request", "Expected JSON object");

  Dowa_JSON_Entry *obj = (Dowa_JSON_Entry *)jv.object_val;
  char *op_raw         = Dowa_JSON_Get_String(obj, "op");
  char temporary_password[AUTH_CRYPTO_PASSWORD_MAX_BYTES + 1];
  memset(temporary_password, 0, sizeof(temporary_password));
  boolean have_temporary_password = admin_extract_secret_field(
      obj, "temporaryPassword", temporary_password,
      AUTH_CRYPTO_PASSWORD_MAX_BYTES);
  if (!op_raw || op_raw[0] == '\0')
  {
    OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
    return admin_error(p_arena, "400", "bad_request", "op field required");
  }

  Auth_Store *store = Auth_API_Get_Store();
  Auth_Store_Result res = AUTH_STORE_OK;

  if (strcmp(op_raw, "enable") == 0)
  {
    res = Auth_Store_Enable_User(store, target_id, principal.user_id);
  }
  else if (strcmp(op_raw, "disable") == 0)
  {
    res = Auth_Store_Disable_User_And_Revoke_Sessions(
        store, target_id, principal.user_id);
  }
  else if (strcmp(op_raw, "set_role") == 0)
  {
    char *new_role = Dowa_JSON_Get_String(obj, "role");
    if (!new_role || new_role[0] == '\0')
    {
      OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
      return admin_error(p_arena, "400", "bad_request",
                         "role required for set_role op");
    }
    if (strcmp(new_role, "admin") != 0 && strcmp(new_role, "member") != 0)
    {
      OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
      return admin_error(p_arena, "400", "invalid_role",
                         "role must be admin or member");
    }
    res = Auth_Store_Update_Role_And_Revoke_Sessions(
        store, target_id, new_role, principal.user_id);
  }
  else if (strcmp(op_raw, "temp_reset") == 0)
  {
    if (!have_temporary_password)
    {
      OPENSSL_cleanse(
          temporary_password, sizeof(temporary_password));
      return admin_error(p_arena, "400", "bad_request",
                         "temporaryPassword required for temp_reset op");
    }
    char *pw_raw = temporary_password;

    size_t pw_len = strlen(pw_raw);
    if (pw_len < 12 || pw_len > AUTH_CRYPTO_PASSWORD_MAX_BYTES)
    {
      OPENSSL_cleanse(pw_raw, pw_len);
      return admin_error(p_arena, "400", "password_policy",
                         "Password must be 12 to 1024 characters");
    }

    char encoded_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE];
    Auth_Crypto_Result hr =
        Auth_Crypto_Password_Hash(pw_raw, encoded_hash, sizeof(encoded_hash));
    OPENSSL_cleanse(pw_raw, pw_len);

    if (hr != AUTH_CRYPTO_OK)
    {
      OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
      return admin_error(p_arena, "500", "internal_error", "Hash error");
    }

    res = Auth_Store_Admin_Reset_Password(store, target_id, encoded_hash,
                                          principal.user_id);
    OPENSSL_cleanse(encoded_hash, sizeof(encoded_hash));
  }
  else
  {
    OPENSSL_cleanse(temporary_password, sizeof(temporary_password));
    return admin_error(p_arena, "400", "invalid_op",
                       "op must be enable, disable, set_role, or temp_reset");
  }

  OPENSSL_cleanse(temporary_password, sizeof(temporary_password));

  if (res == AUTH_STORE_NOT_FOUND)
    return admin_error(p_arena, "404", "not_found", "User not found");
  if (res == AUTH_STORE_LAST_ADMIN)
    return admin_error(p_arena, "409", "last_admin",
                       "Cannot remove the last active admin");
  if (res == AUTH_STORE_INVALID_ARG)
    return admin_error(p_arena, "400", "bad_request", "Invalid argument");
  if (res != AUTH_STORE_OK)
    return admin_error(p_arena, "500", "internal_error", "Update failed");

  /* Return updated user record. */
  Auth_User_Record rec;
  memset(&rec, 0, sizeof(rec));
  if (Auth_Store_Get_User(store, target_id, &rec) != AUTH_STORE_OK)
    return admin_json_response(p_arena, "200", "{\"ok\":true}");

  char *safe_id   = Dowa_JSON_Escape_String(rec.id, 0, p_arena);
  char *safe_user = Dowa_JSON_Escape_String(rec.username, 0, p_arena);
  char *safe_role = Dowa_JSON_Escape_String(rec.role, 0, p_arena);
  char *safe_stat = Dowa_JSON_Escape_String(rec.status, 0, p_arena);
  if (!safe_id || !safe_user || !safe_role || !safe_stat)
    return admin_error(p_arena, "500", "internal_error", "Encode error");

  char body_buf[512];
  snprintf(body_buf, sizeof(body_buf),
           "{\"id\":\"%s\",\"username\":\"%s\",\"role\":\"%s\","
           "\"status\":\"%s\",\"mustChangePassword\":%s,"
           "\"updatedAt\":%lld}",
           safe_id, safe_user, safe_role, safe_stat,
           rec.must_change_password ? "true" : "false",
           (long long)rec.updated_at);

  char *body_copy = Dowa_Arena_Allocate(p_arena, strlen(body_buf) + 1);
  if (!body_copy)
    return admin_error(p_arena, "500", "internal_error", "OOM");
  strcpy(body_copy, body_buf);

  return admin_json_response(p_arena, "200", body_copy);
}

/* ------------------------------------------------------------------ */
/* Route: DELETE /api/admin/users/:id/sessions  (revoke sessions)       */
/* ------------------------------------------------------------------ */

static Seobeo_Request_Entry *admin_revoke_sessions_handler(
    Seobeo_Request_Entry *p_req,
    Dowa_Arena            *p_arena)
{
  Auth_Principal principal;
  Seobeo_Request_Entry *err = NULL;
  if (!admin_require_admin(p_req, &principal, p_arena, FALSE, &err))
    return err;

  if (!Auth_API_Verify_CSRF(p_req, &principal))
    return admin_error(p_arena, "403", "csrf_invalid", "CSRF check failed");

  char target_id[ADMIN_UUID_SIZE];
  if (!admin_get_id_param(p_req, target_id, sizeof(target_id)))
    return admin_error(p_arena, "400", "bad_request", "Invalid or missing id");

  Auth_Store *store = Auth_API_Get_Store();

  Auth_Store_Result res = Auth_Store_Revoke_All_Sessions_Audited(
      store, target_id, NULL, principal.user_id);
  if (res == AUTH_STORE_NOT_FOUND)
    return admin_error(p_arena, "404", "not_found", "User not found");
  if (res != AUTH_STORE_OK)
    return admin_error(p_arena, "500", "internal_error", "Revoke failed");

  return admin_json_response(p_arena, "200", "{\"ok\":true}");
}

/* ------------------------------------------------------------------ */
/* Public API                                                           */
/* ------------------------------------------------------------------ */

void Admin_API_Register_Routes(void)
{
  Seobeo_Router_Register("GET",    "/admin/users",                   admin_page_handler);
  Seobeo_Router_Register("GET",    "/api/admin/users",               admin_list_handler);
  Seobeo_Router_Register("POST",   "/api/admin/users",               admin_create_handler);
  Seobeo_Router_Register("PATCH",  "/api/admin/users/:id",           admin_update_handler);
  Seobeo_Router_Register("DELETE", "/api/admin/users/:id/sessions",  admin_revoke_sessions_handler);
}

/* ------------------------------------------------------------------ */
/* Test hooks                                                           */
/* ------------------------------------------------------------------ */

#ifdef ADMIN_API_TEST_HOOKS
Seobeo_Request_Entry *Admin_API_Test_Page_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return admin_page_handler(p_req, p_arena); }

Seobeo_Request_Entry *Admin_API_Test_List_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return admin_list_handler(p_req, p_arena); }

Seobeo_Request_Entry *Admin_API_Test_Create_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return admin_create_handler(p_req, p_arena); }

Seobeo_Request_Entry *Admin_API_Test_Update_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return admin_update_handler(p_req, p_arena); }

Seobeo_Request_Entry *Admin_API_Test_Revoke_Sessions_Handler(
    Seobeo_Request_Entry *p_req, Dowa_Arena *p_arena)
{ return admin_revoke_sessions_handler(p_req, p_arena); }
#endif /* ADMIN_API_TEST_HOOKS */